feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import time
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Annotated, Literal
|
||||
from typing import Annotated, Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
|
@ -25,7 +25,14 @@ from ..auth_sessions import (
|
|||
upsert_managed_user,
|
||||
)
|
||||
from ..config import settings
|
||||
from ..contracts.engine_gateway import ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL
|
||||
from ..contracts.engine_gateway import (
|
||||
ENGINE_PROVIDER_DEFAULTS,
|
||||
ENGINE_PROVIDERS,
|
||||
ENGINE_REASONING_EFFORTS,
|
||||
EngineCapabilitiesResponse,
|
||||
EngineProvider,
|
||||
ReasoningEffort,
|
||||
)
|
||||
from ..db import acquire, get_pool, healthcheck
|
||||
from ..deps import Principal, require_admin_access
|
||||
from ..engine_client import engine_client
|
||||
|
|
@ -269,9 +276,10 @@ class AdminTicketsResponse(BaseModel):
|
|||
|
||||
|
||||
class AdminEngineConfigResponse(BaseModel):
|
||||
engine_mode: str
|
||||
engine_mode: EngineProvider
|
||||
engine_url: str
|
||||
model: str
|
||||
reasoning_effort: ReasoningEffort | None = None
|
||||
updated_by: str | None = None
|
||||
updated_at: float | None = None
|
||||
durable: bool = False
|
||||
|
|
@ -282,6 +290,7 @@ class AdminEngineConfigPatch(BaseModel):
|
|||
engine_mode: str | None = None
|
||||
engine_url: str | None = None
|
||||
model: str | None = None
|
||||
reasoning_effort: str | None = None
|
||||
|
||||
|
||||
class AdminTicketPatch(BaseModel):
|
||||
|
|
@ -977,7 +986,7 @@ class AdminUserDeleteResponse(BaseModel):
|
|||
|
||||
|
||||
_ENGINE_CONFIG: AdminEngineConfigResponse | None = None
|
||||
ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"}
|
||||
ENGINE_MODES = set(ENGINE_PROVIDERS)
|
||||
ENGINE_MODE_ALIASES = {"messages_api": "claude_api"}
|
||||
|
||||
|
||||
|
|
@ -992,23 +1001,37 @@ def _normalize_email(value: str) -> str:
|
|||
|
||||
|
||||
def _default_engine_config() -> AdminEngineConfigResponse:
|
||||
default_model, default_effort = ENGINE_PROVIDER_DEFAULTS[settings.engine_mode]
|
||||
return AdminEngineConfigResponse(
|
||||
engine_mode=settings.engine_mode,
|
||||
engine_url=settings.engine_url,
|
||||
model=ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL,
|
||||
model=default_model,
|
||||
reasoning_effort=default_effort,
|
||||
durable=False,
|
||||
source="runtime_default",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_engine_mode(value: str) -> str:
|
||||
def _normalize_engine_mode(value: str) -> EngineProvider:
|
||||
mode = ENGINE_MODE_ALIASES.get(value.strip(), value.strip())
|
||||
if mode not in ENGINE_MODES:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"unsupported engine mode {value}",
|
||||
)
|
||||
return mode
|
||||
return cast(EngineProvider, mode)
|
||||
|
||||
|
||||
def _normalize_reasoning_effort(value: str | None) -> ReasoningEffort | None:
|
||||
effort = (value or "").strip().lower()
|
||||
if not effort:
|
||||
return None
|
||||
if effort not in ENGINE_REASONING_EFFORTS:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"unsupported reasoning effort {value}",
|
||||
)
|
||||
return cast(ReasoningEffort, effort)
|
||||
|
||||
|
||||
def _normalize_engine_url(value: str) -> str:
|
||||
|
|
@ -1071,6 +1094,7 @@ def _engine_config_from_row(row) -> AdminEngineConfigResponse:
|
|||
engine_mode=_normalize_engine_mode(row["engine_mode"]),
|
||||
engine_url=_normalize_engine_url(row["engine_url"]),
|
||||
model=row["model"],
|
||||
reasoning_effort=_normalize_reasoning_effort(row.get("reasoning_effort")),
|
||||
updated_by=row["updated_by"],
|
||||
updated_at=_updated_at_ts(row["updated_at"]),
|
||||
durable=True,
|
||||
|
|
@ -1266,7 +1290,7 @@ async def _current_engine_config() -> AdminEngineConfigResponse:
|
|||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT engine_mode, engine_url, model, updated_by, updated_at
|
||||
SELECT engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
|
||||
FROM app.admin_engine_config
|
||||
WHERE id = TRUE
|
||||
"""
|
||||
|
|
@ -1286,6 +1310,7 @@ async def apply_engine_config_from_store() -> AdminEngineConfigResponse:
|
|||
base_url=config.engine_url,
|
||||
engine_mode=config.engine_mode,
|
||||
default_model=config.model,
|
||||
default_reasoning_effort=config.reasoning_effort,
|
||||
)
|
||||
return config
|
||||
|
||||
|
|
@ -1752,6 +1777,74 @@ async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigRespo
|
|||
return await _current_engine_config()
|
||||
|
||||
|
||||
@router.get("/engine-capabilities", response_model=EngineCapabilitiesResponse)
|
||||
async def get_engine_capabilities(
|
||||
principal: AdminPrincipal,
|
||||
engine_mode: str | None = Query(default=None),
|
||||
engine_url: str | None = Query(default=None),
|
||||
force: bool = Query(default=False),
|
||||
) -> EngineCapabilitiesResponse:
|
||||
"""Return gateway-discovered models and reasoning levels for one provider."""
|
||||
|
||||
current = await _current_engine_config()
|
||||
provider = _normalize_engine_mode(engine_mode or current.engine_mode)
|
||||
capability_url = (
|
||||
_normalize_engine_url(engine_url)
|
||||
if engine_url is not None
|
||||
else current.engine_url
|
||||
)
|
||||
try:
|
||||
return await engine_client.capabilities(
|
||||
provider=provider,
|
||||
base_url=capability_url,
|
||||
force=force,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"engine capabilities unavailable: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
async def _validate_engine_selection(
|
||||
*,
|
||||
provider: EngineProvider,
|
||||
engine_url: str,
|
||||
model: str,
|
||||
reasoning_effort: ReasoningEffort | None,
|
||||
) -> None:
|
||||
try:
|
||||
capabilities = await engine_client.capabilities(
|
||||
provider=provider,
|
||||
base_url=engine_url,
|
||||
force=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"선택한 엔진의 모델 목록을 검증할 수 없습니다: {exc}",
|
||||
) from exc
|
||||
if not capabilities.available:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=capabilities.detail or "선택한 엔진을 사용할 수 없습니다.",
|
||||
)
|
||||
selected = next((option for option in capabilities.models if option.id == model), None)
|
||||
if selected is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{provider}에서 사용할 수 없는 모델입니다: {model}",
|
||||
)
|
||||
if (
|
||||
reasoning_effort is not None
|
||||
and reasoning_effort not in selected.reasoning_efforts
|
||||
):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{model}에서 사용할 수 없는 추론 강도입니다: {reasoning_effort}",
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/engine-config", response_model=AdminEngineConfigResponse)
|
||||
async def patch_engine_config(
|
||||
body: AdminEngineConfigPatch,
|
||||
|
|
@ -1763,10 +1856,28 @@ async def patch_engine_config(
|
|||
current = await _current_engine_config()
|
||||
next_mode = _normalize_engine_mode(body.engine_mode or current.engine_mode)
|
||||
next_url = _normalize_engine_url(body.engine_url or current.engine_url)
|
||||
next_model = (body.model or current.model).strip()
|
||||
if not next_model:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="model is required",
|
||||
)
|
||||
next_effort = _normalize_reasoning_effort(
|
||||
body.reasoning_effort
|
||||
if "reasoning_effort" in body.model_fields_set
|
||||
else current.reasoning_effort
|
||||
)
|
||||
await _validate_engine_selection(
|
||||
provider=next_mode,
|
||||
engine_url=next_url,
|
||||
model=next_model,
|
||||
reasoning_effort=next_effort,
|
||||
)
|
||||
next_config = AdminEngineConfigResponse(
|
||||
engine_mode=next_mode,
|
||||
engine_url=next_url,
|
||||
model=(body.model or current.model).strip(),
|
||||
model=next_model,
|
||||
reasoning_effort=next_effort,
|
||||
updated_by=principal.email,
|
||||
updated_at=time.time(),
|
||||
durable=False,
|
||||
|
|
@ -1778,20 +1889,22 @@ async def patch_engine_config(
|
|||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO app.admin_engine_config (
|
||||
id, engine_mode, engine_url, model, updated_by, updated_at
|
||||
id, engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
|
||||
)
|
||||
VALUES (TRUE, $1, $2, $3, $4, now())
|
||||
VALUES (TRUE, $1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
engine_mode = EXCLUDED.engine_mode,
|
||||
engine_url = EXCLUDED.engine_url,
|
||||
model = EXCLUDED.model,
|
||||
reasoning_effort = EXCLUDED.reasoning_effort,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = now()
|
||||
RETURNING engine_mode, engine_url, model, updated_by, updated_at
|
||||
RETURNING engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
|
||||
""",
|
||||
next_config.engine_mode,
|
||||
next_config.engine_url,
|
||||
next_config.model,
|
||||
next_config.reasoning_effort,
|
||||
principal.email,
|
||||
)
|
||||
next_config = _engine_config_from_row(row)
|
||||
|
|
@ -1806,6 +1919,7 @@ async def patch_engine_config(
|
|||
base_url=next_config.engine_url,
|
||||
engine_mode=next_config.engine_mode,
|
||||
default_model=next_config.model,
|
||||
default_reasoning_effort=next_config.reasoning_effort,
|
||||
)
|
||||
return next_config
|
||||
|
||||
|
|
|
|||
|
|
@ -71,12 +71,13 @@ from ..session_read_model import (
|
|||
session_share_payload as _session_share_payload,
|
||||
stage_label as _stage_label,
|
||||
)
|
||||
from ..store import InProcSession, store
|
||||
from ..store import InProcSession, TurnRecord, store
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
||||
logger = logging.getLogger(__name__)
|
||||
_SESSION_EVALUATION_IN_FLIGHT: set[str] = set()
|
||||
_SESSION_EVALUATION_RECOVERY_TASK: asyncio.Task[int] | None = None
|
||||
_STREAM_TURN_EVALUATION_TASKS: set[asyncio.Task[None]] = set()
|
||||
|
||||
TheoryMode = Literal["humanistic", "cbt", "integrative"]
|
||||
EndStateValue = str | int | float | bool | None | dict[str, float]
|
||||
|
|
@ -670,9 +671,11 @@ async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -
|
|||
if _should_schedule_session_digest_worker(carry):
|
||||
asyncio.create_task(_run_session_digest_worker_for_session(sess.session_id))
|
||||
asyncio.create_task(_write_episodic_embeddings(sess))
|
||||
asyncio.create_task(engine_client.close_session(sess.session_id))
|
||||
return
|
||||
require_runtime_fallback_allowed("session end")
|
||||
store.end(sess.session_id)
|
||||
asyncio.create_task(engine_client.close_session(sess.session_id))
|
||||
|
||||
|
||||
def _should_schedule_session_digest_worker(carry: memory.CarryOver) -> bool:
|
||||
|
|
@ -770,6 +773,84 @@ async def _evaluate_stream_turn(
|
|||
return orchestrator.turn_evaluation_error_payload(ctx, exc)
|
||||
|
||||
|
||||
async def _evaluate_and_persist_stream_turn(
|
||||
*,
|
||||
sess: InProcSession,
|
||||
ctx: orchestrator.TurnContext,
|
||||
final_reply: str,
|
||||
result: orchestrator.TurnResult,
|
||||
learner_turn: TurnRecord,
|
||||
) -> None:
|
||||
"""응답 완료 뒤 fast-loop 평가를 저장해 다음 발화의 임계 경로에서 분리한다."""
|
||||
evaluation = await _evaluate_stream_turn(ctx, final_reply)
|
||||
if evaluation is None:
|
||||
return
|
||||
|
||||
if learner_turn.turn_id is not None:
|
||||
saved = await session_persistence.replace_turn_evaluation(
|
||||
turn_id=learner_turn.turn_id,
|
||||
evaluation=evaluation,
|
||||
)
|
||||
if not saved:
|
||||
logger.warning(
|
||||
"turn fast-loop evaluation was not saved: session_id=%s turn_id=%s",
|
||||
ctx.session_id,
|
||||
learner_turn.turn_id,
|
||||
)
|
||||
return
|
||||
|
||||
learner_turn.evaluation = evaluation
|
||||
cached = store.get(ctx.session_id)
|
||||
if cached is not None:
|
||||
for turn in cached.turns:
|
||||
if learner_turn.turn_id and turn.turn_id == learner_turn.turn_id:
|
||||
turn.evaluation = evaluation
|
||||
break
|
||||
if (
|
||||
learner_turn.turn_id is None
|
||||
and turn.speaker == "counselor"
|
||||
and turn.turn_seq == learner_turn.turn_seq
|
||||
):
|
||||
turn.evaluation = evaluation
|
||||
break
|
||||
|
||||
result.evaluation = evaluation
|
||||
await turn_runtime.maybe_recharge_live_coach_credit(sess, ctx, result)
|
||||
|
||||
|
||||
def _observe_stream_turn_evaluation_task(task: asyncio.Task[None]) -> None:
|
||||
_STREAM_TURN_EVALUATION_TASKS.discard(task)
|
||||
try:
|
||||
task.result()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("turn fast-loop evaluation background task cancelled")
|
||||
except Exception:
|
||||
logger.exception("turn fast-loop evaluation background task crashed")
|
||||
|
||||
|
||||
def _schedule_stream_turn_evaluation(
|
||||
*,
|
||||
sess: InProcSession,
|
||||
ctx: orchestrator.TurnContext,
|
||||
final_reply: str,
|
||||
result: orchestrator.TurnResult,
|
||||
learner_turn: TurnRecord,
|
||||
) -> asyncio.Task[None]:
|
||||
task = asyncio.create_task(
|
||||
_evaluate_and_persist_stream_turn(
|
||||
sess=sess,
|
||||
ctx=ctx,
|
||||
final_reply=final_reply,
|
||||
result=result,
|
||||
learner_turn=learner_turn,
|
||||
),
|
||||
name=f"turn-evaluation:{ctx.session_id}:{result.turn_seq}",
|
||||
)
|
||||
_STREAM_TURN_EVALUATION_TASKS.add(task)
|
||||
task.add_done_callback(_observe_stream_turn_evaluation_task)
|
||||
return task
|
||||
|
||||
|
||||
def _stream_result_from_done(
|
||||
ctx: orchestrator.TurnContext,
|
||||
final_reply: str,
|
||||
|
|
@ -1649,15 +1730,22 @@ async def stream_turn(
|
|||
).model_dump(),
|
||||
}
|
||||
if not finalized_turn:
|
||||
evaluation = await _evaluate_stream_turn(ctx, final_reply)
|
||||
result = _stream_result_from_done(
|
||||
ctx, final_reply, data, evaluation
|
||||
ctx, final_reply, data, None
|
||||
)
|
||||
await turn_runtime.finalize_completed_turn(
|
||||
learner_turn = await turn_runtime.finalize_completed_turn(
|
||||
sess,
|
||||
ctx,
|
||||
result,
|
||||
context_prefix="session",
|
||||
recharge_live_coach=False,
|
||||
)
|
||||
_schedule_stream_turn_evaluation(
|
||||
sess=sess,
|
||||
ctx=ctx,
|
||||
final_reply=final_reply,
|
||||
result=result,
|
||||
learner_turn=learner_turn,
|
||||
)
|
||||
finalized_turn = True
|
||||
yield {
|
||||
|
|
|
|||
|
|
@ -158,14 +158,26 @@ _PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label")
|
|||
async def voice_health() -> JSONResponse:
|
||||
"""Return voice service readiness."""
|
||||
available = voice_service.is_available()
|
||||
stt_available = voice_service.stt_available()
|
||||
tts_available = voice_service.tts_available()
|
||||
tts_provider = voice_service.tts_provider()
|
||||
body = {
|
||||
"status": "ok" if available else "degraded",
|
||||
"available": available,
|
||||
"stt_available": stt_available,
|
||||
"tts_available": tts_available,
|
||||
"stt_model": voice_svc.STT_MODEL,
|
||||
"tts_model": voice_svc.TTS_MODEL,
|
||||
"tts_model": (
|
||||
voice_svc.HIGGS_TTS_MODEL
|
||||
if tts_provider == "higgs"
|
||||
else voice_svc.TTS_MODEL
|
||||
),
|
||||
"tts_provider": tts_provider,
|
||||
"reason": None if available else "OPENAI_API_KEY is not configured",
|
||||
"reason": (
|
||||
None
|
||||
if available
|
||||
else "STT 또는 TTS provider가 준비되지 않았습니다"
|
||||
),
|
||||
}
|
||||
return JSONResponse(body, status_code=200 if available else 503)
|
||||
|
||||
|
|
@ -192,12 +204,6 @@ async def voice_speech(
|
|||
access_error = await _practice_access_error(learner)
|
||||
if access_error is not None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=access_error)
|
||||
if not voice_service.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="OPENAI_API_KEY is not configured",
|
||||
)
|
||||
|
||||
sess, err = await turn_runtime.load_owned_session(
|
||||
body.session_id,
|
||||
learner,
|
||||
|
|
@ -224,6 +230,11 @@ async def voice_speech(
|
|||
persona_code=sess.persona.code,
|
||||
explicit_preset=None,
|
||||
)
|
||||
if not voice_service.tts_available(voice_preset):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="TTS provider is not configured",
|
||||
)
|
||||
try:
|
||||
chunks = [
|
||||
chunk.audio
|
||||
|
|
@ -248,11 +259,11 @@ async def voice_speech(
|
|||
)
|
||||
return Response(
|
||||
content=audio,
|
||||
media_type="audio/mpeg",
|
||||
media_type=voice_service.tts_media_type_for_voice(voice_preset),
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"X-Vignette-TTS-Model": voice_svc.TTS_MODEL,
|
||||
"X-Vignette-TTS-Provider": voice_service.tts_provider(),
|
||||
"X-Vignette-TTS-Model": voice_service.tts_model_for_voice(voice_preset),
|
||||
"X-Vignette-TTS-Provider": voice_service.tts_provider_for_voice(voice_preset),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -314,7 +325,7 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
"session_id": session_id,
|
||||
"voice": voice_preset.openai_voice,
|
||||
"preset": voice_preset.preset,
|
||||
"tts_provider": voice_service.tts_provider(),
|
||||
"tts_provider": voice_service.tts_provider_for_voice(voice_preset),
|
||||
"state": "idle",
|
||||
**bind_meta,
|
||||
},
|
||||
|
|
@ -751,7 +762,7 @@ async def _run_turn_and_speak(
|
|||
"type": "state",
|
||||
"state": "speaking",
|
||||
"voice": context.voice_preset.openai_voice,
|
||||
"tts_provider": voice_service.tts_provider(),
|
||||
"tts_provider": voice_service.tts_provider_for_voice(context.voice_preset),
|
||||
},
|
||||
)
|
||||
try:
|
||||
|
|
@ -787,15 +798,35 @@ async def _load_voice_session(
|
|||
|
||||
|
||||
def _client_turn_text_for_speech(sess: InProcSession, turn_seq: int) -> str | None:
|
||||
"""Return the persisted client-visible reply for one completed turn."""
|
||||
for turn in reversed(sess.turns):
|
||||
if (
|
||||
turn.turn_seq == turn_seq
|
||||
and turn.speaker == "client"
|
||||
and turn.is_visible_to("client")
|
||||
):
|
||||
text = (turn.text_masked or turn.text).strip()
|
||||
return text or None
|
||||
"""완료된 상담 턴 번호에 대응하는 client-visible 응답을 반환한다.
|
||||
|
||||
degraded 인메모리 미러는 상담자/내담자 한 쌍이 같은 논리 turn_seq를 쓰지만,
|
||||
DB의 app.turns.seq는 발화마다 1씩 증가한다. DB 스냅샷에서 논리 1턴을
|
||||
그대로 seq=1로 찾으면 상담자 발화만 잡혀 TTS가 404가 되므로 두 저장 형태를
|
||||
명시적으로 구분한다.
|
||||
"""
|
||||
visible_clients = [
|
||||
turn
|
||||
for turn in sess.turns
|
||||
if turn.speaker == "client" and turn.is_visible_to("client")
|
||||
]
|
||||
counselor_sequences = {
|
||||
turn.turn_seq for turn in sess.turns if turn.speaker == "counselor"
|
||||
}
|
||||
paired_sequences = counselor_sequences.intersection(
|
||||
turn.turn_seq for turn in visible_clients
|
||||
)
|
||||
if paired_sequences or not counselor_sequences:
|
||||
match = next(
|
||||
(turn for turn in reversed(visible_clients) if turn.turn_seq == turn_seq),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
index = turn_seq - 1
|
||||
match = visible_clients[index] if 0 <= index < len(visible_clients) else None
|
||||
if match is not None:
|
||||
text = (match.text_masked or match.text).strip()
|
||||
return text or None
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue