전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -51,6 +51,70 @@ TicketPriority = Literal["low", "normal", "high", "urgent"]
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
NotificationDeliveryStatus = Literal["queued", "sending", "sent", "failed", "skipped"]
METERED_CLIENT_TURN_FILTER_SQL = """
speaker = 'client'
AND (
llm_provider IS NOT NULL OR model IS NOT NULL
OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
OR cost_usd IS NOT NULL
)
"""
USAGE_AGGREGATE_COLUMNS_SQL = """
COUNT(*) AS turns,
COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in,
COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out,
COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
"""
SUPPORT_TICKET_DETAIL_FROM_SQL = """
SELECT
t.id,
t.reporter_id,
t.reporter_email,
t.reporter_name,
t.reporter_role,
t.category,
t.priority,
t.status,
t.subject,
t.body,
t.source_path,
t.fingerprint,
t.parent_ticket_id,
t.assigned_group,
t.resolution_note,
t.created_at,
t.updated_at,
t.resolved_at,
COALESCE(dup.duplicate_count, 0) AS duplicate_count,
dup.parent_candidate_id AS duplicate_parent_candidate_id,
COALESCE(child.child_ticket_count, 0) AS child_ticket_count,
COALESCE(ev.event_count, 0) AS event_count,
ev.last_event_at
FROM app.support_ticket AS t
LEFT JOIN LATERAL (
SELECT
count(*) FILTER (WHERE id <> t.id)::int AS duplicate_count,
(array_agg(id::text ORDER BY created_at ASC, id ASC))[1] AS parent_candidate_id
FROM app.support_ticket
WHERE fingerprint <> ''
AND fingerprint = t.fingerprint
) AS dup ON TRUE
LEFT JOIN LATERAL (
SELECT count(*)::int AS child_ticket_count
FROM app.support_ticket
WHERE parent_ticket_id = t.id
) AS child ON TRUE
LEFT JOIN LATERAL (
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
FROM audit.audit_log
WHERE action = 'support_ticket_update'
AND target_kind = 'support_ticket'
AND target_id = t.id::text
) AS ev ON TRUE
"""
class AdminServiceHealth(BaseModel):
key: str
@ -450,17 +514,10 @@ async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics:
async def _usage_from_database(window_days: int) -> AdminUsageResponse:
async with acquire(role="admin") as conn:
total_row = await conn.fetchrow(
"""
f"""
SELECT
COUNT(*) FILTER (WHERE speaker = 'client') AS total_turns,
COUNT(*) FILTER (
WHERE speaker = 'client'
AND (
llm_provider IS NOT NULL OR model IS NOT NULL
OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
OR cost_usd IS NOT NULL
)
) AS metered_turns,
COUNT(*) FILTER (WHERE {METERED_CLIENT_TURN_FILTER_SQL}) AS metered_turns,
COALESCE(SUM(tokens_in) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_in,
COALESCE(SUM(tokens_out) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_out,
COALESCE(SUM(cost_usd) FILTER (WHERE speaker = 'client'), 0)::numeric AS cost_usd
@ -470,22 +527,14 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
window_days,
)
rows = await conn.fetch(
"""
f"""
SELECT
COALESCE(llm_provider, 'unknown') AS provider,
COALESCE(model, 'unknown') AS model,
COUNT(*) AS turns,
COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in,
COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out,
COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
{USAGE_AGGREGATE_COLUMNS_SQL}
FROM app.turns
WHERE created_at >= now() - ($1::int * interval '1 day')
AND speaker = 'client'
AND (
llm_provider IS NOT NULL OR model IS NOT NULL
OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
OR cost_usd IS NOT NULL
)
AND {METERED_CLIENT_TURN_FILTER_SQL}
GROUP BY 1, 2
ORDER BY
cost_usd DESC,
@ -496,21 +545,13 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
window_days,
)
daily_rows = await conn.fetch(
"""
f"""
SELECT
to_char(date_trunc('day', created_at), 'YYYY-MM-DD') AS day,
COUNT(*) AS turns,
COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in,
COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out,
COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
{USAGE_AGGREGATE_COLUMNS_SQL}
FROM app.turns
WHERE created_at >= now() - ($1::int * interval '1 day')
AND speaker = 'client'
AND (
llm_provider IS NOT NULL OR model IS NOT NULL
OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
OR cost_usd IS NOT NULL
)
AND {METERED_CLIENT_TURN_FILTER_SQL}
GROUP BY 1
ORDER BY 1
""",
@ -761,52 +802,8 @@ async def _tickets_from_database(
search_filter = search.strip().lower()
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT
t.id,
t.reporter_id,
t.reporter_email,
t.reporter_name,
t.reporter_role,
t.category,
t.priority,
t.status,
t.subject,
t.body,
t.source_path,
t.fingerprint,
t.parent_ticket_id,
t.assigned_group,
t.resolution_note,
t.created_at,
t.updated_at,
t.resolved_at,
COALESCE(dup.duplicate_count, 0) AS duplicate_count,
dup.parent_candidate_id AS duplicate_parent_candidate_id,
COALESCE(child.child_ticket_count, 0) AS child_ticket_count,
COALESCE(ev.event_count, 0) AS event_count,
ev.last_event_at
FROM app.support_ticket AS t
LEFT JOIN LATERAL (
SELECT
count(*) FILTER (WHERE id <> t.id)::int AS duplicate_count,
(array_agg(id::text ORDER BY created_at ASC, id ASC))[1] AS parent_candidate_id
FROM app.support_ticket
WHERE fingerprint <> ''
AND fingerprint = t.fingerprint
) AS dup ON TRUE
LEFT JOIN LATERAL (
SELECT count(*)::int AS child_ticket_count
FROM app.support_ticket
WHERE parent_ticket_id = t.id
) AS child ON TRUE
LEFT JOIN LATERAL (
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
FROM audit.audit_log
WHERE action = 'support_ticket_update'
AND target_kind = 'support_ticket'
AND target_id = t.id::text
) AS ev ON TRUE
SUPPORT_TICKET_DETAIL_FROM_SQL
+ """
WHERE ($1::text IS NULL OR status = $1)
AND ($2::text IS NULL OR category = $2)
AND ($3::text IS NULL OR priority = $3)
@ -1736,54 +1733,7 @@ async def patch_ticket(
detail=detail,
)
row = await conn.fetchrow(
"""
SELECT
t.id,
t.reporter_id,
t.reporter_email,
t.reporter_name,
t.reporter_role,
t.category,
t.priority,
t.status,
t.subject,
t.body,
t.source_path,
t.fingerprint,
t.parent_ticket_id,
t.assigned_group,
t.resolution_note,
t.created_at,
t.updated_at,
t.resolved_at,
COALESCE(dup.duplicate_count, 0) AS duplicate_count,
dup.parent_candidate_id AS duplicate_parent_candidate_id,
COALESCE(child.child_ticket_count, 0) AS child_ticket_count,
COALESCE(ev.event_count, 0) AS event_count,
ev.last_event_at
FROM app.support_ticket AS t
LEFT JOIN LATERAL (
SELECT
count(*) FILTER (WHERE id <> t.id)::int AS duplicate_count,
(array_agg(id::text ORDER BY created_at ASC, id ASC))[1] AS parent_candidate_id
FROM app.support_ticket
WHERE fingerprint <> ''
AND fingerprint = t.fingerprint
) AS dup ON TRUE
LEFT JOIN LATERAL (
SELECT count(*)::int AS child_ticket_count
FROM app.support_ticket
WHERE parent_ticket_id = t.id
) AS child ON TRUE
LEFT JOIN LATERAL (
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
FROM audit.audit_log
WHERE action = 'support_ticket_update'
AND target_kind = 'support_ticket'
AND target_id = t.id::text
) AS ev ON TRUE
WHERE t.id = $1::uuid
""",
SUPPORT_TICKET_DETAIL_FROM_SQL + " WHERE t.id = $1::uuid",
ticket_id,
)
except Exception as exc:

View file

@ -967,7 +967,7 @@ async def callback(
cohort_ids=cohort_ids,
external_id=external_id,
)
except InactiveUserError as exc:
except InactiveUserError:
_log_oauth_callback_failure(
request,
"inactive_user",

View file

@ -33,7 +33,7 @@ RoleLiteral = Literal["client", "counselor", "evaluator"]
class KBSearchRequest(BaseModel):
query: str = Field(..., min_length=1) # PII 마스킹된 질의(마스킹은 가드레일 책임)
role: RoleLiteral = "evaluator" # 검색 주체(정보비대칭 정책 선택)
role: RoleLiteral = "evaluator" # 검색 주체(정보비대칭 정책 선택)
k: int = Field(default=5, ge=1, le=50)
rerank: bool = True
# 정책 화이트리스트를 *좁히는* 추가 필터만 허용(넓히지 못함 — 정보비대칭 보존)
@ -51,9 +51,9 @@ class ChunkOut(BaseModel):
kb_kind: str
heading_path: Optional[str] = None
context_prefix: Optional[str] = None
body: Optional[str] = None # expose_body=True(상담사/평가) 정책에서만
behavior_cue: Optional[str] = None # 내담자 정책: 본문 비노출, 행동단서만(M6)
label_id: Optional[int] = None # 평가 정책에서만
body: Optional[str] = None # expose_body=True(상담사/평가) 정책에서만
behavior_cue: Optional[str] = None # 내담자 정책: 본문 비노출, 행동단서만(M6)
label_id: Optional[int] = None # 평가 정책에서만
meta: dict[str, Any] = Field(default_factory=dict)
source_id: Optional[str] = None
@ -62,9 +62,9 @@ class KBSearchResponse(BaseModel):
chunks: list[ChunkOut]
policy: str
top1_score: float
crag_pass: bool # top1 >= 임계(F-06: 미달 시 관찰 프레이밍)
crag_pass: bool # top1 >= 임계(F-06: 미달 시 관찰 프레이밍)
latency_ms: int
degraded: bool = False # reranker/embed 폴백 투명성
degraded: bool = False # reranker/embed 폴백 투명성
class MemoryRecallRequest(BaseModel):
@ -79,11 +79,11 @@ class IndexChunkIn(BaseModel):
seq: int
chunk_text: str = Field(..., min_length=1)
heading_path: Optional[str] = None
context_prefix: Optional[str] = None # Contextual Retrieval 프리픽스(색인 대상)
context_prefix: Optional[str] = None # Contextual Retrieval 프리픽스(색인 대상)
kb_kind: Optional[str] = None
visible_to: Optional[list[str]] = None # 미지정 시 {client,counselor,evaluator}
visible_to: Optional[list[str]] = None # 미지정 시 {client,counselor,evaluator}
sensitivity: Optional[int] = Field(default=None, ge=0, le=3)
label_id: Optional[int] = None # taxonomy 정답 라벨 FK
label_id: Optional[int] = None # taxonomy 정답 라벨 FK
meta: Optional[dict[str, Any]] = None
token_count: Optional[int] = None
@ -99,8 +99,8 @@ class IndexRequestIn(BaseModel):
class IndexResponse(BaseModel):
doc_id: Optional[int]
chunks_indexed: int
skipped_unchanged: bool # content_hash 동일 → 증분 스킵
embedded: bool # 임베딩 적재 여부(모델 미가용 시 False)
skipped_unchanged: bool # content_hash 동일 → 증분 스킵
embedded: bool # 임베딩 적재 여부(모델 미가용 시 False)
degraded: bool = False
@ -211,16 +211,11 @@ async def search(body: KBSearchRequest) -> KBSearchResponse:
)
except RuntimeError as e:
# DB 풀 미초기화(lifespan 밖) — 시연/테스트 degraded
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}")
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}"
)
return KBSearchResponse(
chunks=[_to_chunk_out(c) for c in result.chunks],
policy=result.policy_name,
top1_score=round(result.top1_score, 6),
crag_pass=result.top1_score >= rag.CRAG_TOP1_THRESHOLD,
latency_ms=result.latency_ms,
degraded=result.degraded,
)
return _search_response(result)
# ════════════════════════════════════════════════════════════════════════════
@ -254,18 +249,15 @@ async def eval_grounding(body: KBSearchRequest) -> KBSearchResponse:
except Exception:
pass
except rag.NotConfigured as e:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}")
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}"
)
except RuntimeError as e:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}")
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}"
)
return KBSearchResponse(
chunks=[_to_chunk_out(c) for c in result.chunks],
policy=result.policy_name,
top1_score=round(result.top1_score, 6),
crag_pass=result.top1_score >= rag.CRAG_TOP1_THRESHOLD,
latency_ms=result.latency_ms,
degraded=result.degraded,
)
return _search_response(result)
# ════════════════════════════════════════════════════════════════════════════
@ -287,9 +279,19 @@ async def persona_memory(body: MemoryRecallRequest) -> KBSearchResponse:
k=body.k,
)
except rag.NotConfigured as e:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}")
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}"
)
except RuntimeError as e:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}")
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}"
)
return _search_response(result)
def _search_response(result: rag.SearchResult) -> KBSearchResponse:
"""Project every RAG policy result through the same browser-facing contract."""
return KBSearchResponse(
chunks=[_to_chunk_out(c) for c in result.chunks],
@ -304,7 +306,9 @@ async def persona_memory(body: MemoryRecallRequest) -> KBSearchResponse:
# ════════════════════════════════════════════════════════════════════════════
# 인덱싱 트리거 — 관리자 전용(content_hash 증분, 오프라인 배치)
# ════════════════════════════════════════════════════════════════════════════
@router.post("/index", response_model=IndexResponse, status_code=status.HTTP_202_ACCEPTED)
@router.post(
"/index", response_model=IndexResponse, status_code=status.HTTP_202_ACCEPTED
)
async def index_document(
body: IndexRequestIn,
principal: Annotated[Principal, Depends(require_role(Role.ADMIN))],
@ -329,9 +333,13 @@ async def index_document(
except rag.IndexPolicyViolation as e:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) from e
except rag.NotConfigured as e:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}")
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}"
)
except RuntimeError as e:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}")
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}"
)
return IndexResponse(
doc_id=result.doc_id,
@ -356,7 +364,9 @@ async def sync_live_coach_source_packs(
evaluator RAG 검색에도 올린다. source row를 먼저 upsert한 content_hash 기반 증분 색인을
수행한다. 임베딩 모델 미가용 BM25-only degraded 색인으로 이어진다.
"""
source_rows, index_payloads = source_pack_sync.build_repo_source_pack_manifest(refresh=True)
source_rows, index_payloads = source_pack_sync.build_repo_source_pack_manifest(
refresh=True
)
if not source_rows or not index_payloads:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
@ -365,11 +375,17 @@ async def sync_live_coach_source_packs(
try:
async with acquire() as conn:
result = await source_pack_sync.sync_repo_source_packs(conn, apply=True, refresh=True)
result = await source_pack_sync.sync_repo_source_packs(
conn, apply=True, refresh=True
)
except rag.NotConfigured as exc:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {exc}") from exc
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {exc}"
) from exc
except RuntimeError as exc:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}"
) from exc
return LiveCoachSourcePackSyncResponse(
sources_upserted=result.sources_upserted,

View file

@ -8,7 +8,7 @@ import re
import uuid
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
from ..db import acquire
from ..deps import CurrentPrincipal, Principal, Role, require_role
@ -52,6 +52,7 @@ from ..persona_read_model import (
from ..engine_client import EngineError, EngineMessage, GenerateRequest, engine_client
from ..services import rag
from ..services.guardrail import mask_pii
from ..services.tabular_ingest import TabularIngestError, extract_tabular_text
router = APIRouter(prefix="/personas", tags=["personas"])
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
@ -555,6 +556,42 @@ async def create_persona_source_route(
return await _register_persona_source_document(request, principal)
@router.post(
"/sources/upload",
response_model=PersonaSourceDocumentResponse,
status_code=status.HTTP_201_CREATED,
)
async def upload_persona_source_route(
principal: TeacherOrAdmin,
file: UploadFile = File(...),
source_kind: PersonaSourceKind = Form("mixed_notes"),
title: str | None = Form(None),
source_note: str = Form(""),
) -> PersonaSourceDocumentResponse:
"""자유 양식 엑셀/CSV 업로드 → 텍스트 변환 → 기존 source 등록 경로 재사용 (P4).
업로드 원본 바이트는 핸들러 메모리에서만 파싱하고 저장하지 않는다(원본 파기).
파생 텍스트만 기존 마스킹·hash-only 증거·sanitized chunk 경로로 등록된다.
"""
_ensure_teacher_or_admin(principal)
data = await file.read()
try:
text = extract_tabular_text(filename=file.filename or "", data=data)
except TabularIngestError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
finally:
# 원본 바이트 참조를 즉시 놓는다 — 파생 텍스트 외에는 남기지 않는다.
del data
request = PersonaSourceDocumentRequest(
filename=(file.filename or "uploaded-table.xlsx")[:240],
source_kind=source_kind,
text=text,
title=(title or None),
source_note=source_note[:800],
)
return await _register_persona_source_document(request, principal)
@router.post("/drafts/generate", response_model=PersonaDraftGenerateResponse)
async def generate_persona_draft_route(
request: PersonaDraftGenerateRequest,

View file

@ -12,11 +12,12 @@ import asyncio
import json
import logging
import secrets
import time
from datetime import datetime
from typing import Literal, Optional
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from sse_starlette.sse import EventSourceResponse
from .. import db, session_persistence, turn_runtime
@ -46,15 +47,17 @@ from ..session_read_model import (
MISSING_SESSION_EVALUATION_GRACE_SECONDS,
ReviewCaseWorksheet,
ReviewCaseWorksheetSaveRequest,
ReviewWorksheetItem,
ReviewWorksheetSection,
ReviewWorksheetItem as ReviewWorksheetItem,
ReviewWorksheetSection as ReviewWorksheetSection,
SessionArchiveResponse,
SessionDetailResponse,
SessionReviewReadInput,
SessionReviewResponse,
SessionProgress,
SessionShareDeleteResponse,
SessionShareResponse,
StageLabel,
build_session_progress,
build_session_review,
dashboard_achievements as _dashboard_achievements,
dashboard_feedback as _dashboard_feedback,
@ -68,7 +71,7 @@ from ..session_read_model import (
session_share_payload as _session_share_payload,
stage_label as _stage_label,
)
from ..store import InProcSession, TurnRecord, store
from ..store import InProcSession, store
router = APIRouter(prefix="/sessions", tags=["sessions"])
logger = logging.getLogger(__name__)
@ -82,6 +85,19 @@ EndStateValue = str | int | float | bool | None | dict[str, float]
class SessionStartRequest(BaseModel):
persona_code: str = Field(..., examples=["P1"])
theory_mode: TheoryMode = "humanistic"
# 이번 회기 목표 단계(2026-07-13 회의 P1). 회의 권장은 2개 수준이지만
# 소유자 지시(2026-07-15)로 1~4개까지 자유 선택을 허용한다.
# 빈 리스트는 구계약 클라이언트 호환용 — 준비 페이지는 항상 1개 이상을 보낸다.
goal_stages: list[StageLabel] = Field(default_factory=list, max_length=4)
@field_validator("goal_stages")
@classmethod
def _dedupe_goal_stages(cls, value: list[StageLabel]) -> list[StageLabel]:
seen: list[StageLabel] = []
for stage in value:
if stage not in seen:
seen.append(stage)
return seen[:4]
class SessionStartResponse(BaseModel):
@ -92,6 +108,11 @@ class SessionStartResponse(BaseModel):
effective_openness: float
recall_summary: Optional[str] = None
degraded: bool = False
started_at: str = ""
goal_stages: list[StageLabel] = Field(default_factory=list)
# 시간 기반 회기 종료 계약(회의 P1): 프론트 타이머·10분 전 알람의 기준값.
duration_limit_seconds: int = 0
warning_before_end_seconds: int = 0
class TurnRequest(BaseModel):
@ -132,6 +153,8 @@ class TurnResponse(BaseModel):
crisis_resource: Optional[CrisisResourceResponse] = None
conversation_stopped: bool = False
output_error: Optional[str] = None
# P2 단계 누적 게이지·상세 수치 — 턴마다 갱신된 파생값.
progress: Optional[SessionProgress] = None
class SessionEndResponse(BaseModel):
@ -146,6 +169,20 @@ _RECALL_CACHE: dict[str, memory.RecallContext] = {}
_KB_CUES_CACHE: dict[str, list[str]] = {}
_RAG_WARM_SEMAPHORE = asyncio.Semaphore(1)
def cached_kb_cues(session_id: str) -> list[str]:
"""Return a defensive copy of the session-scoped, process-lifetime KB cues."""
return list(_KB_CUES_CACHE.get(session_id) or [])
def invalidate_session_context_cache(session_id: str) -> None:
"""Invalidate all derived turn context when a session reaches its terminal state."""
_RECALL_CACHE.pop(session_id, None)
_KB_CUES_CACHE.pop(session_id, None)
# ────────────────────────────────────────────────────────────────────────────
# RAG 배선 헬퍼 — 내담자(CLIENT) 뷰. 임베더/KB/DB 풀 미가용 시 빈 값으로 graceful
# degradation: 상담 루프를 절대 막지 않는다(라이브 루프 비차단이 계약). routes/kb.py가
@ -213,7 +250,13 @@ async def _retrieve_live_coach_grounding(
conn,
query=query,
k=4,
kinds=("theory", "technique", "supervisor_pattern", "microskill", "taxonomy"),
kinds=(
"theory",
"technique",
"supervisor_pattern",
"microskill",
"taxonomy",
),
)
try:
await rag.log_retrieval(
@ -233,9 +276,16 @@ async def _retrieve_live_coach_grounding(
if not body:
continue
meta = chunk.meta if isinstance(chunk.meta, dict) else {}
title = str(meta.get("source_title") or meta.get("title") or chunk.source_id or "Vignette KB").strip()
title = str(
meta.get("source_title")
or meta.get("title")
or chunk.source_id
or "Vignette KB"
).strip()
source_type = str(meta.get("source_type") or "").strip()
source_version = str(meta.get("source_version") or meta.get("version") or "").strip()
source_version = str(
meta.get("source_version") or meta.get("version") or ""
).strip()
citation = str(meta.get("citation") or "").strip()
out.append(
live_coach.LiveCoachGrounding(
@ -252,7 +302,9 @@ async def _retrieve_live_coach_grounding(
return out
def _latest_turn_evaluation(sess: InProcSession, turn_seq: int | None) -> Optional[dict]:
def _latest_turn_evaluation(
sess: InProcSession, turn_seq: int | None
) -> Optional[dict]:
"""방금 상담자 발화에 붙은 fast-loop 평가를 찾는다."""
for turn in reversed(sess.turns):
if turn.speaker != "counselor":
@ -322,7 +374,8 @@ async def _load_case_memory(case_id: str) -> dict:
"end_state": dict(summary_row["end_state"] or {}),
}
return {
"case_digest": (case_row["case_digest"] if case_row is not None else None) or None,
"case_digest": (case_row["case_digest"] if case_row is not None else None)
or None,
"prev_summary": prev_summary,
"pinned_facts": [row["value"] for row in fact_rows if row["value"]],
}
@ -361,7 +414,10 @@ async def _episodic_recall_snippets(case_id: str, query: str) -> list[str]:
try:
async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:
result = await rag.retrieve_persona_memory(
conn, case_id=case_id, query=query, k=_RAG_RECALL_K,
conn,
case_id=case_id,
query=query,
k=_RAG_RECALL_K,
)
return await _hydrate_episodic_text(conn, result)
except Exception:
@ -412,14 +468,39 @@ async def ensure_recall_context(sess: InProcSession) -> memory.RecallContext:
return recall
def session_time_over(sess: InProcSession) -> bool:
"""시간 기반 회기 종료(회의 P1): 제한 + 마무리 유예까지 지난 세션인지 판정.
제한 시간(기본 60) 도달 자체는 프론트가 정리 유도·자동 종료로 처리하고,
서버는 유예(기본 +10)까지 지난 뒤의 턴만 거부한다(마무리 인사 허용).
"""
if settings.session_duration_minutes <= 0:
return False
limit_seconds = (
settings.session_duration_minutes + settings.session_overtime_grace_minutes
) * 60
return (time.time() - sess.created_at) > limit_seconds
def _ensure_turn_time_allowed(sess: InProcSession) -> None:
if session_time_over(sess):
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="session_time_over",
)
async def _prepare_turn_context(
*,
session_id: str,
sess: InProcSession,
learner_text: str,
) -> orchestrator.TurnContext:
_ensure_turn_time_allowed(sess)
recall = await ensure_recall_context(sess)
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
kb_cues = (
_KB_CUES_CACHE.get(session_id) or []
) # 비차단: warm 전이면 빈 단서(graceful)
ctx = orchestrator.prepare_turn(
session_id=session_id,
case_id=sess.case_id,
@ -446,7 +527,9 @@ async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
"""
async with _RAG_WARM_SEMAPHORE:
try:
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
_RECALL_CACHE[session_id] = await _build_start_recall(
case_id=case_id, card=card
)
except Exception:
pass
try:
@ -460,7 +543,9 @@ def _ensure_learner(principal: Principal) -> Principal:
return principal
if principal.can_access_role(Role.LEARNER):
return principal.with_role(Role.LEARNER)
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions")
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="only learners can use sessions"
)
async def _ensure_practice_consent(principal: Principal) -> None:
@ -495,7 +580,9 @@ async def _load_session_or_404(
if err == turn_runtime.SessionAccessError.NOT_FOUND:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
if err == turn_runtime.SessionAccessError.FORBIDDEN:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session does not belong to user")
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="session does not belong to user"
)
if err == turn_runtime.SessionAccessError.ENDED:
raise HTTPException(status.HTTP_409_CONFLICT, detail="session already ended")
assert sess is not None
@ -562,7 +649,9 @@ async def _load_review_session_or_404(
supervisor = _review_supervisor_principal(principal)
if supervisor is None:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session review access denied")
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="session review access denied"
)
return (
await _load_supervisor_review_session_or_404(
session_id,
@ -587,7 +676,9 @@ async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -
def _should_schedule_session_digest_worker(carry: memory.CarryOver) -> bool:
return bool(settings.session_digest_worker_enabled and carry.compression_job is not None)
return bool(
settings.session_digest_worker_enabled and carry.compression_job is not None
)
async def _run_session_digest_worker_for_session(session_id: str) -> None:
@ -600,7 +691,9 @@ async def _run_session_digest_worker_for_session(session_id: str) -> None:
try:
db.get_pool()
async with db.acquire(role="admin") as conn:
loaded = await session_digest_worker.load_session_digest_job(conn, session_id)
loaded = await session_digest_worker.load_session_digest_job(
conn, session_id
)
if loaded is None:
return
model = settings.session_digest_worker_model.strip() or None
@ -620,7 +713,9 @@ async def _run_session_digest_worker_for_session(session_id: str) -> None:
learner_id=loaded.learner_id,
)
except Exception:
logger.warning("session digest worker failed for session_id=%s", session_id, exc_info=True)
logger.warning(
"session digest worker failed for session_id=%s", session_id, exc_info=True
)
return
@ -653,7 +748,10 @@ def _public_share_url(request: Request, token: str) -> str:
base = str(request.base_url).rstrip("/")
return f"{base}/share/session/{token}"
async def _evaluate_stream_turn(ctx: orchestrator.TurnContext, final_reply: str) -> Optional[dict]:
async def _evaluate_stream_turn(
ctx: orchestrator.TurnContext, final_reply: str
) -> Optional[dict]:
"""stream 경로 완료 후 fast-loop 평가를 계산한다. 실패는 턴 저장을 막지 않는다."""
if not final_reply:
return None
@ -689,7 +787,9 @@ def _stream_result_from_done(
state_after=ctx.state_after,
evaluation=evaluation,
crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none",
crisis_resource=data.get("crisis_resource") if isinstance(data.get("crisis_resource"), dict) else None,
crisis_resource=data.get("crisis_resource")
if isinstance(data.get("crisis_resource"), dict)
else None,
conversation_stopped=bool(data.get("conversation_stopped")),
llm_provider=str(data.get("llm_provider") or "") or None,
model=str(data.get("model") or "") or None,
@ -780,9 +880,13 @@ def _observe_session_evaluation_task(task: asyncio.Task[None], session_id: str)
try:
task.result()
except asyncio.CancelledError:
logger.warning("session evaluation background task cancelled: session_id=%s", session_id)
logger.warning(
"session evaluation background task cancelled: session_id=%s", session_id
)
except Exception:
logger.exception("session evaluation background task crashed: session_id=%s", session_id)
logger.exception(
"session evaluation background task crashed: session_id=%s", session_id
)
def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | None:
@ -800,20 +904,26 @@ def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | No
name=f"session-evaluation:{sess.session_id}",
)
task.add_done_callback(
lambda done, session_id=sess.session_id: _observe_session_evaluation_task(done, session_id)
lambda done, session_id=sess.session_id: _observe_session_evaluation_task(
done, session_id
)
)
return task
async def recover_missing_session_evaluations(*, limit: int | None = None) -> int:
recovery_limit = settings.session_evaluation_recovery_limit if limit is None else limit
recovery_limit = (
settings.session_evaluation_recovery_limit if limit is None else limit
)
if recovery_limit <= 0:
return 0
stale_after_seconds = (
_session_evaluation_timeout_seconds()
+ MISSING_SESSION_EVALUATION_GRACE_SECONDS
_session_evaluation_timeout_seconds() + MISSING_SESSION_EVALUATION_GRACE_SECONDS
)
candidates, durable = await session_persistence.list_sessions_missing_session_evaluation(
(
candidates,
durable,
) = await session_persistence.list_sessions_missing_session_evaluation(
older_than_seconds=stale_after_seconds,
limit=recovery_limit,
)
@ -892,9 +1002,7 @@ async def _load_learner_sessions(
if not durable:
require_runtime_fallback_allowed("session list")
sessions = [
sess
for sess in store.list()
if sess.learner_id == principal.user_id
sess for sess in store.list() if sess.learner_id == principal.user_id
]
sessions.sort(key=lambda sess: sess.created_at, reverse=True)
return sessions, durable
@ -917,7 +1025,9 @@ async def _review_ready_map(
sessions: list[InProcSession],
principal: Principal,
) -> dict[str, bool]:
results = await asyncio.gather(*[_review_ready(sess, principal) for sess in sessions])
results = await asyncio.gather(
*[_review_ready(sess, principal) for sess in sessions]
)
return {sess.session_id: ready for sess, ready in zip(sessions, results)}
@ -970,7 +1080,9 @@ async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsR
sess,
review_ready=await _review_ready(sess, principal),
archived=archive_record is not None,
archived_at=_iso(float(archived_at)) if isinstance(archived_at, (int, float)) else None,
archived_at=_iso(float(archived_at))
if isinstance(archived_at, (int, float))
else None,
)
)
@ -1042,7 +1154,9 @@ async def archive_session(
allow_ended=True,
)
if not sess.ended:
raise HTTPException(status.HTTP_409_CONFLICT, detail="active sessions cannot be archived")
raise HTTPException(
status.HTTP_409_CONFLICT, detail="active sessions cannot be archived"
)
record, durable = await session_persistence.set_session_archived(
session_id=sess.session_id,
learner_id=principal.user_id,
@ -1053,7 +1167,9 @@ async def archive_session(
sess,
principal,
archived=True,
archived_at=_iso(float(archived_at)) if isinstance(archived_at, (int, float)) else None,
archived_at=_iso(float(archived_at))
if isinstance(archived_at, (int, float))
else None,
source="database" if durable else "runtime",
)
@ -1084,7 +1200,9 @@ async def restore_archived_session(
)
@router.post("", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED
)
async def start_session(
body: SessionStartRequest,
principal: CurrentPrincipal,
@ -1102,14 +1220,18 @@ async def start_session(
detail="persona catalog database unavailable",
) from exc
if catalog_persona is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}")
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}"
)
card = catalog_persona.card
case_context = await session_persistence.get_case_context(
learner_id=principal.user_id,
persona_id=catalog_persona.persona_id,
)
recall = await _build_seed_recall(case_id=case_context.case_id if case_context else None)
recall = await _build_seed_recall(
case_id=case_context.case_id if case_context else None
)
session_no = (case_context.last_session_no + 1) if case_context else 1
st = state_machine.init_state(
params=card.openness_params(),
@ -1117,6 +1239,7 @@ async def start_session(
)
carry_rapport = st.rapport_credit
goal_stages = [str(stage) for stage in body.goal_stages]
sess = await session_persistence.create_session(
learner_id=principal.user_id,
card=card,
@ -1127,6 +1250,7 @@ async def start_session(
persona_id=catalog_persona.persona_id,
persona_version=catalog_persona.version,
case_id=case_context.case_id if case_context else None,
goal_stages=goal_stages,
)
degraded = catalog_persona.degraded or sess is None
if sess is None:
@ -1138,6 +1262,7 @@ async def start_session(
state=st,
session_no=session_no,
carry_rapport=carry_rapport,
goal_stages=goal_stages,
)
else:
store.put(sess)
@ -1155,6 +1280,10 @@ async def start_session(
effective_openness=round(st.effective_openness, 4),
recall_summary=recall.recall_summary,
degraded=degraded,
started_at=_iso(sess.created_at) or "",
goal_stages=body.goal_stages,
duration_limit_seconds=settings.session_duration_minutes * 60,
warning_before_end_seconds=settings.session_warning_minutes * 60,
)
@ -1169,7 +1298,10 @@ async def get_session_review(
principal,
include_turn_evaluation=True,
)
evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation(
(
evaluation_record,
evaluation_durable,
) = await session_persistence.load_session_evaluation(
session_id,
review_principal,
)
@ -1196,6 +1328,7 @@ async def get_session_review(
)
)
@router.post("/{session_id}/share", response_model=SessionShareResponse)
async def create_session_share(
session_id: str,
@ -1211,7 +1344,9 @@ async def create_session_share(
include_turn_evaluation=True,
)
if not sess.ended:
raise HTTPException(status.HTTP_409_CONFLICT, detail="session must be ended before sharing")
raise HTTPException(
status.HTTP_409_CONFLICT, detail="session must be ended before sharing"
)
review = await get_session_review(session_id, principal)
token = secrets.token_urlsafe(32)
@ -1228,7 +1363,14 @@ async def create_session_share(
detail="session share persistence unavailable",
)
created_at = saved.get("created_at")
created_label = _iso(created_at if isinstance(created_at, (int, float)) else datetime.now().timestamp()) or ""
created_label = (
_iso(
created_at
if isinstance(created_at, (int, float))
else datetime.now().timestamp()
)
or ""
)
return SessionShareResponse(
shareUrl=_public_share_url(request, token),
title=str(payload["title"]),
@ -1334,6 +1476,11 @@ async def submit_turn(
crisis_resource=result.crisis_resource,
conversation_stopped=result.conversation_stopped,
output_error=result.output_error,
progress=build_session_progress(
result.state_after,
prev_rapport_credit=sess.prev_rapport_credit,
goal_stages=list(sess.goal_stages or []),
),
)
@ -1345,17 +1492,28 @@ async def list_live_coach_history(
"""현재 회기에서 학습자에게 실제로 전달된 라이브 코칭 이력을 반환한다."""
principal = _ensure_learner(principal)
await _load_session_or_404(session_id, principal)
events, durable = await session_persistence.list_live_coach_events(session_id, principal)
quota, quota_durable = await session_persistence.get_live_coach_quota(session_id, principal)
credit_events, credit_durable = await session_persistence.list_live_coach_credit_events(
events, durable = await session_persistence.list_live_coach_events(
session_id, principal
)
quota, quota_durable = await session_persistence.get_live_coach_quota(
session_id, principal
)
(
credit_events,
credit_durable,
) = await session_persistence.list_live_coach_credit_events(
session_id,
principal,
)
return LiveCoachHistoryResponse(
source="database" if durable and quota_durable and credit_durable else "runtime",
source="database"
if durable and quota_durable and credit_durable
else "runtime",
quota=live_coach.LiveCoachQuota(**quota),
events=[live_coach.LiveCoachEvent(**event) for event in events],
credit_events=[live_coach.LiveCoachCreditEvent(**event) for event in credit_events],
credit_events=[
live_coach.LiveCoachCreditEvent(**event) for event in credit_events
],
)
@ -1382,6 +1540,16 @@ async def live_coach_turn(
stage=stage,
theory_mode=sess.theory_mode,
)
prior_events, _ = await session_persistence.list_live_coach_events(
session_id, principal
)
prior_coach = [
{
"title": str((event.get("suggestion") or {}).get("title") or ""),
"focus": str((event.get("suggestion") or {}).get("focus") or ""),
}
for event in prior_events[-2:]
]
item = live_coach.LiveCoachInput(
session_id=sess.session_id,
turn_seq=turn_seq,
@ -1394,6 +1562,8 @@ async def live_coach_turn(
client_reply=body.client_reply,
recent_turns=sess.recent_turns(k=8, visible_to=LEARNER_VISIBLE_AI_ROLE),
evaluation=_latest_turn_evaluation(sess, body.turn_seq),
goal_stages=list(sess.goal_stages or []),
prior_coach=prior_coach,
)
suggestion = await live_coach.generate_live_coaching(
item,
@ -1416,8 +1586,13 @@ async def live_coach_turn(
status_code=status.HTTP_409_CONFLICT,
detail="live coach credit exhausted",
) from exc
quota_after, quota_durable = await session_persistence.get_live_coach_quota(session_id, principal)
credit_events, credit_durable = await session_persistence.list_live_coach_credit_events(session_id, principal)
quota_after, quota_durable = await session_persistence.get_live_coach_quota(
session_id, principal
)
(
credit_events,
credit_durable,
) = await session_persistence.list_live_coach_credit_events(session_id, principal)
turn_credit_events = [
live_coach.LiveCoachCreditEvent(**event)
for event in credit_events
@ -1425,7 +1600,9 @@ async def live_coach_turn(
]
return suggestion.model_copy(
update={
"persistence_source": "database" if coach_event_durable and quota_durable and credit_durable else "runtime",
"persistence_source": "database"
if coach_event_durable and quota_durable and credit_durable
else "runtime",
"quota": live_coach.LiveCoachQuota(**quota_after),
"credit_events": turn_credit_events[-2:],
}
@ -1462,10 +1639,20 @@ async def stream_turn(
final_reply += text
yield {"event": "token", "data": text}
elif ev.event == "done":
data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)}
data = {
**ev.data,
"stage": _stage_label(ctx.state_after.stage),
"progress": build_session_progress(
ctx.state_after,
prev_rapport_credit=sess.prev_rapport_credit,
goal_stages=list(sess.goal_stages or []),
).model_dump(),
}
if not finalized_turn:
evaluation = await _evaluate_stream_turn(ctx, final_reply)
result = _stream_result_from_done(ctx, final_reply, data, evaluation)
result = _stream_result_from_done(
ctx, final_reply, data, evaluation
)
await turn_runtime.finalize_completed_turn(
sess,
ctx,
@ -1473,7 +1660,10 @@ async def stream_turn(
context_prefix="session",
)
finalized_turn = True
yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
yield {
"event": "done",
"data": json.dumps(data, ensure_ascii=False),
}
elif (
ev.event == "safety"
and bool(ev.data.get("conversation_stopped"))
@ -1484,14 +1674,18 @@ async def stream_turn(
safety_data = {
"session_id": ctx.session_id,
"stage": _stage_label(ctx.state_after.stage),
"effective_openness": round(ctx.state_after.effective_openness, 4),
"effective_openness": round(
ctx.state_after.effective_openness, 4
),
"turn_seq": ctx.state_after.turn_seq,
"safety_flagged": True,
"crisis_kind": ctx.crisis.kind.value,
"crisis_resource": ev.data.get("crisis_resource"),
"conversation_stopped": True,
}
result = _stream_result_from_done(ctx, final_reply, safety_data, None)
result = _stream_result_from_done(
ctx, final_reply, safety_data, None
)
await turn_runtime.finalize_completed_turn(
sess,
ctx,
@ -1499,16 +1693,25 @@ async def stream_turn(
context_prefix="session",
)
finalized_turn = True
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
yield {
"event": ev.event,
"data": json.dumps(ev.data, ensure_ascii=False),
}
else:
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
yield {
"event": ev.event,
"data": json.dumps(ev.data, ensure_ascii=False),
}
now = asyncio.get_running_loop().time()
if now - last_beat >= settings.sse_heartbeat_seconds:
yield {"event": "ping", "data": "{}"}
last_beat = now
except Exception as exc:
yield {"event": "error", "data": json.dumps({"detail": str(exc)}, ensure_ascii=False)}
yield {
"event": "error",
"data": json.dumps({"detail": str(exc)}, ensure_ascii=False),
}
return
return EventSourceResponse(event_generator())
@ -1536,8 +1739,7 @@ async def end_session(
)
await _end_persisted_session(sess, carry)
_RECALL_CACHE.pop(session_id, None)
_KB_CUES_CACHE.pop(session_id, None)
invalidate_session_context_cache(session_id)
if not was_ended:
_schedule_session_evaluation(sess)

View file

@ -22,6 +22,7 @@ from ..config import settings
from ..db import acquire, get_pool
from ..deps import CurrentPrincipal, Role
from ..runtime_policy import require_runtime_fallback_allowed
from ..services.phase3_kpi_contract import PREPOST_MEASURE_NAMES, PREPOST_TIMEPOINTS
from ..services.support_tickets import support_ticket_fingerprint
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
@ -37,13 +38,6 @@ AVATAR_CONTENT_TYPES = {
}
DEFAULT_PREPOST_PILOT_ID = "phase3-pilot-draft"
DEFAULT_PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28"
PREPOST_MEASURE_NAMES = (
"self_efficacy",
"skill_proficiency",
"training_satisfaction",
)
PREPOST_TIMEPOINTS = ("pre", "post")
TERMS_BODY = """Vignette 서비스 이용약관 초안
1. 목적
@ -193,7 +187,9 @@ class UserPreferencesResponse(BaseModel):
theme: str = "system"
voice_preset_id: str = "soft-young-fem"
voice_rate: float = 1.0
notifications: NotificationPreferences = Field(default_factory=NotificationPreferences)
notifications: NotificationPreferences = Field(
default_factory=NotificationPreferences
)
class UserPreferencesPatch(BaseModel):
@ -221,7 +217,9 @@ TicketCategory = Literal[
]
TicketPriority = Literal["low", "normal", "high", "urgent"]
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
PrepostMeasureName = Literal["self_efficacy", "skill_proficiency", "training_satisfaction"]
PrepostMeasureName = Literal[
"self_efficacy", "skill_proficiency", "training_satisfaction"
]
PrepostTimepoint = Literal["pre", "post"]
@ -386,7 +384,9 @@ def _preferences_from_row(row) -> UserPreferencesResponse:
theme=row["theme"],
voice_preset_id=_normalize_voice_preset(row["voice_preset_id"]),
voice_rate=float(row["voice_rate"]),
notifications=NotificationPreferences.model_validate(row["notifications"] or {}),
notifications=NotificationPreferences.model_validate(
row["notifications"] or {}
),
)
@ -420,7 +420,9 @@ def _validated_avatar_extension(content_type: str, content: bytes) -> str:
ext, magic = AVATAR_CONTENT_TYPES[normalized]
if normalized == "image/webp":
if not (content.startswith(magic) and content[8:12] == b"WEBP"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file"
)
elif not content.startswith(magic):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
return ext
@ -484,16 +486,28 @@ async def get_me(principal: CurrentPrincipal) -> UserProfileResponse:
@router.patch("/me", response_model=UserProfileResponse)
async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserProfileResponse:
async def patch_me(
body: UserProfilePatch, principal: CurrentPrincipal
) -> UserProfileResponse:
profile = await _profile_for(principal)
updated = await update_managed_user(
principal.user_id,
ManagedUserPatch(
display_name=body.display_name if body.display_name is not None else profile.display_name,
affiliation=body.affiliation if body.affiliation is not None else profile.affiliation,
legal_name=body.legal_name if body.legal_name is not None else profile.legal_name,
department=body.department if body.department is not None else profile.department,
grade_level=body.grade_level if body.grade_level is not None else profile.grade_level,
display_name=body.display_name
if body.display_name is not None
else profile.display_name,
affiliation=body.affiliation
if body.affiliation is not None
else profile.affiliation,
legal_name=body.legal_name
if body.legal_name is not None
else profile.legal_name,
department=body.department
if body.department is not None
else profile.department,
grade_level=body.grade_level
if body.grade_level is not None
else profile.grade_level,
phone=body.phone if body.phone is not None else profile.phone,
contact_address=(
body.contact_address
@ -506,7 +520,9 @@ async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserP
if body.self_introduction is not None
else profile.self_introduction
),
avatar_url=body.avatar_url if body.avatar_url is not None else profile.avatar_url,
avatar_url=body.avatar_url
if body.avatar_url is not None
else profile.avatar_url,
),
)
if updated is None:
@ -525,7 +541,9 @@ async def upload_my_avatar(
if not content:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty_avatar_file")
if len(content) > AVATAR_MAX_BYTES:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="avatar_too_large")
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="avatar_too_large"
)
ext = _validated_avatar_extension(content_type, content)
root = _upload_root()
@ -597,7 +615,9 @@ async def create_support_ticket(
) -> UserSupportTicketResponse:
profile = await _profile_for(principal)
try:
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
async with acquire(
role=principal.role.value, user_id=principal.user_id
) as conn:
subject = body.subject.strip()
ticket_body = body.body.strip()
source_path = body.source_path.strip()
@ -725,7 +745,9 @@ async def _support_tickets_for_user(
resolution_note=row["resolution_note"] or "",
created_at=float(row["created_at"] or 0.0),
updated_at=float(row["updated_at"] or 0.0),
resolved_at=float(row["resolved_at"]) if row["resolved_at"] is not None else None,
resolved_at=float(row["resolved_at"])
if row["resolved_at"] is not None
else None,
)
for row in rows
],
@ -741,11 +763,13 @@ async def list_my_support_tickets(
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="support ticket persistence unavailable",
detail="support ticket persistence unavailable",
) from exc
def _normalized_prepost_score(raw_score: float, min_score: float, max_score: float) -> float:
def _normalized_prepost_score(
raw_score: float, min_score: float, max_score: float
) -> float:
if max_score <= min_score:
return 0.0
return round(((raw_score - min_score) / (max_score - min_score)) * 100.0, 3)
@ -803,7 +827,8 @@ async def _prepost_measures_for_user(
pairs = {
item.measure_name
for item in measures
if {m.timepoint for m in measures if m.measure_name == item.measure_name} == {"pre", "post"}
if {m.timepoint for m in measures if m.measure_name == item.measure_name}
== {"pre", "post"}
}
return UserPrepostMeasuresResponse(
source="database",
@ -820,7 +845,9 @@ async def _prepost_measures_for_user(
@router.get("/me/prepost-measures", response_model=UserPrepostMeasuresResponse)
async def list_my_prepost_measures(
principal: CurrentPrincipal,
pilot_id: str = Query(default=DEFAULT_PREPOST_PILOT_ID, min_length=1, max_length=80),
pilot_id: str = Query(
default=DEFAULT_PREPOST_PILOT_ID, min_length=1, max_length=80
),
) -> UserPrepostMeasuresResponse:
try:
return await _prepost_measures_for_user(principal, pilot_id=pilot_id)
@ -837,9 +864,13 @@ async def upsert_my_prepost_measure(
principal: CurrentPrincipal,
) -> UserPrepostMeasureItem:
pilot_id = body.pilot_id.strip() or DEFAULT_PREPOST_PILOT_ID
instrument_version = body.instrument_version.strip() or DEFAULT_PREPOST_INSTRUMENT_VERSION
instrument_version = (
body.instrument_version.strip() or DEFAULT_PREPOST_INSTRUMENT_VERSION
)
try:
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
async with acquire(
role=principal.role.value, user_id=principal.user_id
) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.learner_prepost_measure (
@ -982,7 +1013,9 @@ async def patch_preferences(
if body.voice_preset_id is not None
else current_prefs.voice_preset_id
),
voice_rate=body.voice_rate if body.voice_rate is not None else current_prefs.voice_rate,
voice_rate=body.voice_rate
if body.voice_rate is not None
else current_prefs.voice_rate,
notifications=(
body.notifications
if body.notifications is not None

View file

@ -16,6 +16,7 @@ from __future__ import annotations
import json
import hashlib
import time
from dataclasses import dataclass, field as dataclass_field
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, status
@ -49,6 +50,42 @@ class VoiceSpeechRequest(BaseModel):
session_id: str = Field(min_length=1, max_length=80)
turn_seq: int = Field(ge=1)
@dataclass(frozen=True, slots=True)
class VoiceSessionContext:
session_id: str
principal: Principal
voice_preset: VoicePreset
@dataclass(frozen=True, slots=True)
class VoiceProsody:
audio_ref: str | None = None
duration_s: float | None = None
silence_ms: int | None = None
speech_rate: float | None = None
barge_in: bool | None = None
provider_events: list[dict[str, object]] = dataclass_field(default_factory=list)
@dataclass(frozen=True, slots=True)
class VoiceTurnInput:
learner_text: str
prosody: VoiceProsody = dataclass_field(default_factory=VoiceProsody)
@dataclass(frozen=True, slots=True)
class VoiceAudioInput:
audio: bytes
fmt: str | None
sample_rate: int | None = None
channels: int | None = None
sample_width: int | None = None
audio_started_at: float | None = None
audio_ended_at: float | None = None
prosody: VoiceProsody = dataclass_field(default_factory=VoiceProsody)
# WebSocket close codes.
WS_CLOSE_DEGRADED = 1011
WS_CLOSE_BAD_REQUEST = 1008
@ -112,6 +149,8 @@ def _is_turn_persistence_unavailable(exc: Exception) -> bool:
return False
detail = str(exc.detail or "")
return "turn append" in detail and "persistence unavailable" in detail
_PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label")
@ -132,7 +171,9 @@ async def voice_health() -> JSONResponse:
@router.post("/speech")
async def voice_speech(body: VoiceSpeechRequest, principal: CurrentPrincipal) -> Response:
async def voice_speech(
body: VoiceSpeechRequest, principal: CurrentPrincipal
) -> Response:
"""Synthesize the persisted client reply for a completed text turn.
The browser sends only session/turn identifiers. The server reloads the
@ -167,7 +208,9 @@ async def voice_speech(body: VoiceSpeechRequest, principal: CurrentPrincipal) ->
turn_runtime.SessionAccessError.FORBIDDEN: status.HTTP_403_FORBIDDEN,
turn_runtime.SessionAccessError.ENDED: status.HTTP_409_CONFLICT,
}.get(err, status.HTTP_404_NOT_FOUND)
raise HTTPException(status_code=status_code, detail=f"voice session {err or 'not_found'}")
raise HTTPException(
status_code=status_code, detail=f"voice session {err or 'not_found'}"
)
text = _client_turn_text_for_speech(sess, body.turn_seq)
if text is None:
@ -222,13 +265,17 @@ async def voice_ws(websocket: WebSocket) -> None:
# Authenticate the same server-side browser session used by REST routes.
principal = await _principal_from_websocket(websocket)
if principal is None:
await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"})
await _safe_send_json(
websocket, {"type": "error", "detail": "not authenticated"}
)
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
return
if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER):
principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER:
await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"})
await _safe_send_json(
websocket, {"type": "error", "detail": "only learners can use voice"}
)
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
return
@ -244,7 +291,9 @@ async def voice_ws(websocket: WebSocket) -> None:
websocket,
{
"type": "degraded",
"reason": bind_meta.get("degraded_reason", "voice session binding degraded"),
"reason": bind_meta.get(
"degraded_reason", "voice session binding degraded"
),
**bind_meta,
},
)
@ -294,12 +343,17 @@ async def voice_ws(websocket: WebSocket) -> None:
receiving = True
audio_started_at = time.monotonic()
audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
await _safe_send_json(
websocket, {"type": "state", "state": "listening"}
)
audio_buf.extend(msg["bytes"])
if len(audio_buf) > _MAX_AUDIO_BYTES:
await _safe_send_json(
websocket,
{"type": "error", "detail": "audio too large; please send a shorter utterance"},
{
"type": "error",
"detail": "audio too large; please send a shorter utterance",
},
)
audio_buf.clear()
receiving = False
@ -312,7 +366,9 @@ async def voice_ws(websocket: WebSocket) -> None:
try:
ctrl = json.loads(text)
except (json.JSONDecodeError, TypeError):
await _safe_send_json(websocket, {"type": "error", "detail": "invalid control json"})
await _safe_send_json(
websocket, {"type": "error", "detail": "invalid control json"}
)
continue
ctype = ctrl.get("type")
@ -324,30 +380,44 @@ async def voice_ws(websocket: WebSocket) -> None:
audio_channels = _safe_int(ctrl.get("channels"))
audio_sample_width = _safe_int(ctrl.get("sample_width"))
audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
await _safe_send_json(
websocket, {"type": "state", "state": "listening"}
)
elif ctype == "audio_end":
receiving = False
audio_ended_at = time.monotonic()
silence_ms = _safe_int(ctrl.get("silence_ms"))
if silence_ms is None and last_audio_end_at is not None and audio_started_at is not None:
silence_ms = max(0, int((audio_started_at - last_audio_end_at) * 1000))
if (
silence_ms is None
and last_audio_end_at is not None
and audio_started_at is not None
):
silence_ms = max(
0, int((audio_started_at - last_audio_end_at) * 1000)
)
end_format = _safe_str(ctrl.get("format")) or audio_format
await _handle_utterance(
websocket,
session_id=session_id,
principal=principal,
voice_preset=voice_preset,
audio=bytes(audio_buf),
fmt=end_format,
sample_rate=_safe_int(ctrl.get("sample_rate")) or audio_sample_rate,
channels=_safe_int(ctrl.get("channels")) or audio_channels,
sample_width=_safe_int(ctrl.get("sample_width")) or audio_sample_width,
audio_started_at=audio_started_at,
audio_ended_at=audio_ended_at,
silence_ms=silence_ms,
barge_in=_safe_bool(ctrl.get("barge_in")),
provider_events=_safe_provider_events(ctrl.get("provider_events")),
VoiceSessionContext(session_id, principal, voice_preset),
VoiceAudioInput(
audio=bytes(audio_buf),
fmt=end_format,
sample_rate=_safe_int(ctrl.get("sample_rate"))
or audio_sample_rate,
channels=_safe_int(ctrl.get("channels")) or audio_channels,
sample_width=_safe_int(ctrl.get("sample_width"))
or audio_sample_width,
audio_started_at=audio_started_at,
audio_ended_at=audio_ended_at,
prosody=VoiceProsody(
silence_ms=silence_ms,
barge_in=_safe_bool(ctrl.get("barge_in")),
provider_events=_safe_provider_events(
ctrl.get("provider_events")
),
),
),
)
last_audio_end_at = audio_ended_at
audio_started_at = None
@ -365,10 +435,8 @@ async def voice_ws(websocket: WebSocket) -> None:
if learner_text:
await _run_turn_and_speak(
websocket,
session_id=session_id,
principal=principal,
voice_preset=voice_preset,
learner_text=learner_text,
VoiceSessionContext(session_id, principal, voice_preset),
VoiceTurnInput(learner_text=learner_text),
)
elif ctype == "stt_result":
@ -412,7 +480,9 @@ async def voice_ws(websocket: WebSocket) -> None:
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
else:
await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"})
await _safe_send_json(
websocket, {"type": "error", "detail": f"voice ws error: {e}"}
)
finally:
await _safe_close(websocket)
@ -431,7 +501,11 @@ async def _handle_stt_result_control(
learner_text = str(ctrl.get("text") or "").strip()
transcript_final = _safe_bool(ctrl.get("final"))
silence_ms = _safe_int(ctrl.get("silence_ms"))
if silence_ms is None and last_audio_end_at is not None and audio_started_at is not None:
if (
silence_ms is None
and last_audio_end_at is not None
and audio_started_at is not None
):
silence_ms = max(0, int((audio_started_at - last_audio_end_at) * 1000))
provider_events = _safe_provider_events(ctrl.get("provider_events"))
decision = voice_svc.assess_end_of_turn(
@ -456,52 +530,49 @@ async def _handle_stt_result_control(
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
await _safe_send_json(
websocket,
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
{
"type": "transcript",
"text": learner_text,
"final": True,
"speaker": "counselor",
},
)
await _run_turn_and_speak(
websocket,
session_id=session_id,
principal=principal,
voice_preset=voice_preset,
learner_text=learner_text,
duration_s=_elapsed_seconds(audio_started_at, audio_ended_at),
silence_ms=decision.silence_ms,
barge_in=_safe_bool(ctrl.get("barge_in")),
provider_events=provider_events,
VoiceSessionContext(session_id, principal, voice_preset),
VoiceTurnInput(
learner_text=learner_text,
prosody=VoiceProsody(
duration_s=_elapsed_seconds(audio_started_at, audio_ended_at),
silence_ms=decision.silence_ms,
barge_in=_safe_bool(ctrl.get("barge_in")),
provider_events=provider_events,
),
),
)
async def _handle_utterance(
websocket: WebSocket,
*,
session_id: str,
principal: Principal,
voice_preset: VoicePreset,
audio: bytes,
fmt: Optional[str],
sample_rate: int | None = None,
channels: int | None = None,
sample_width: int | None = None,
audio_started_at: float | None = None,
audio_ended_at: float | None = None,
silence_ms: int | None = None,
barge_in: bool | None = None,
provider_events: list[dict[str, object]] | None = None,
context: VoiceSessionContext,
utterance: VoiceAudioInput,
) -> None:
"""Transcribe one utterance, generate the client reply, then synthesize TTS."""
if not audio:
await _safe_send_json(websocket, {"type": "transcript", "text": "", "final": True})
if not utterance.audio:
await _safe_send_json(
websocket, {"type": "transcript", "text": "", "final": True}
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
# STT begins after the learner stops speaking.
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
upload_audio, upload_fmt = _normalize_audio_upload(
audio,
fmt=fmt,
sample_rate=sample_rate,
channels=channels,
sample_width=sample_width,
utterance.audio,
fmt=utterance.fmt,
sample_rate=utterance.sample_rate,
channels=utterance.channels,
sample_width=utterance.sample_width,
)
filename, content_type = _audio_meta(upload_fmt)
try:
@ -513,18 +584,30 @@ async def _handle_utterance(
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
except Exception as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"STT failed: {e}"})
await _safe_send_json(
websocket, {"type": "error", "detail": f"STT failed: {e}"}
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
learner_text = stt.text
audio_ref = _voice_audio_ref(upload_audio, upload_fmt)
duration_s = stt.duration or _elapsed_seconds(audio_started_at, audio_ended_at)
duration_s = stt.duration or _elapsed_seconds(
utterance.audio_started_at, utterance.audio_ended_at
)
speech_rate = _estimate_speech_rate(learner_text, duration_s)
provider_events = _merge_provider_events(provider_events, getattr(stt, "provider_events", []))
provider_events = _merge_provider_events(
utterance.prosody.provider_events,
getattr(stt, "provider_events", []),
)
await _safe_send_json(
websocket,
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
{
"type": "transcript",
"text": learner_text,
"final": True,
"speaker": "counselor",
},
)
if not learner_text:
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
@ -532,47 +615,54 @@ async def _handle_utterance(
await _run_turn_and_speak(
websocket,
session_id=session_id,
principal=principal,
voice_preset=voice_preset,
learner_text=learner_text,
audio_ref=audio_ref,
silence_ms=silence_ms,
speech_rate=speech_rate,
barge_in=barge_in,
provider_events=provider_events,
context,
VoiceTurnInput(
learner_text=learner_text,
prosody=VoiceProsody(
audio_ref=audio_ref,
duration_s=duration_s,
silence_ms=utterance.prosody.silence_ms,
speech_rate=speech_rate,
barge_in=utterance.prosody.barge_in,
provider_events=provider_events,
),
),
)
async def _run_turn_and_speak(
websocket: WebSocket,
*,
session_id: str,
principal: Principal,
voice_preset: VoicePreset,
learner_text: str,
audio_ref: str | None = None,
duration_s: float | None = None,
silence_ms: int | None = None,
speech_rate: float | None = None,
barge_in: bool | None = None,
provider_events: list[dict[str, object]] | None = None,
context: VoiceSessionContext,
turn: VoiceTurnInput,
) -> None:
"""Run one counseling turn and stream synthesized client speech."""
learner_text = turn.learner_text
prosody = turn.prosody
speech_rate = prosody.speech_rate
if speech_rate is None:
speech_rate = _estimate_speech_rate(learner_text, duration_s)
sess, err = await _load_voice_session(session_id, principal)
speech_rate = _estimate_speech_rate(learner_text, prosody.duration_s)
sess, err = await _load_voice_session(context.session_id, context.principal)
if sess is None:
await _safe_send_json(websocket, {"type": "error", "detail": err or "session not found or ended"})
await _safe_send_json(
websocket, {"type": "error", "detail": err or "session not found or ended"}
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
from . import sessions as session_routes
if session_routes.session_time_over(sess):
await _safe_send_json(
websocket,
{"type": "error", "detail": "session_time_over"},
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
recall = await session_routes.ensure_recall_context(sess)
kb_cues = session_routes._KB_CUES_CACHE.get(session_id) or []
kb_cues = session_routes.cached_kb_cues(context.session_id)
ctx = orchestrator.prepare_turn(
session_id=session_id,
session_id=context.session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
@ -599,7 +689,9 @@ async def _run_turn_and_speak(
audit_hook=session_persistence.record_llm_call_audit,
)
except EngineError as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"engine unavailable: {e}"})
await _safe_send_json(
websocket, {"type": "error", "detail": f"engine unavailable: {e}"}
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
@ -617,11 +709,11 @@ async def _run_turn_and_speak(
stage=turn_runtime.stage_label(ctx.state_after.stage),
text=learner_text,
text_masked=ctx.learner_text_masked,
audio_ref=audio_ref,
silence_ms=silence_ms,
audio_ref=prosody.audio_ref,
silence_ms=prosody.silence_ms,
speech_rate=speech_rate,
barge_in=barge_in,
provider_events=provider_events or [],
barge_in=prosody.barge_in,
provider_events=prosody.provider_events,
evaluation=result.evaluation,
),
)
@ -640,6 +732,11 @@ async def _run_turn_and_speak(
"crisis_kind": result.crisis_kind,
"crisis_resource": result.crisis_resource,
"conversation_stopped": result.conversation_stopped,
"progress": session_routes.build_session_progress(
result.state_after,
prev_rapport_credit=sess.prev_rapport_credit,
goal_stages=list(sess.goal_stages or []),
).model_dump(),
},
)
@ -653,13 +750,13 @@ async def _run_turn_and_speak(
{
"type": "state",
"state": "speaking",
"voice": voice_preset.openai_voice,
"voice": context.voice_preset.openai_voice,
"tts_provider": voice_service.tts_provider(),
},
)
try:
n = 0
async for ck in voice_service.synthesize_stream(reply, voice_preset):
async for ck in voice_service.synthesize_stream(reply, context.voice_preset):
# 바이너리 오디오 청크만 송신(프론트가 Web Audio AnalyserNode로 립싱크 자체 산출).
await _safe_send_bytes(websocket, ck.audio)
n += 1
@ -667,7 +764,9 @@ async def _run_turn_and_speak(
except VoiceUnavailable as e:
await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)})
except Exception as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"TTS failed: {e}"})
await _safe_send_json(
websocket, {"type": "error", "detail": f"TTS failed: {e}"}
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
@ -732,9 +831,8 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
async def _practice_access_error(principal: Principal) -> str | None:
if (
principal.profile_completed_at is None
and not await user_onboarding_complete(principal.user_id)
if principal.profile_completed_at is None and not await user_onboarding_complete(
principal.user_id
):
return "onboarding_required"
if principal.consent_at is None and not await user_has_consent(principal.user_id):
@ -763,7 +861,12 @@ async def _bind_session(
persona_code=sess.persona.code,
explicit_preset=explicit_preset,
)
return session_id, vp, None, {"degraded": False, "persona_catalog_source": "session"}
return (
session_id,
vp,
None,
{"degraded": False, "persona_catalog_source": "session"},
)
# persona_code session creation is local-dev only. Production uses REST start.
if settings.environment != "dev":
@ -815,9 +918,13 @@ async def _bind_session(
)
degraded_reasons: list[str] = []
if catalog_persona.degraded:
degraded_reasons.append("카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다")
degraded_reasons.append(
"카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다"
)
if session_source == "runtime":
degraded_reasons.append("세션 저장소 연결 전까지 비영구 개발 런타임 기록을 사용합니다")
degraded_reasons.append(
"세션 저장소 연결 전까지 비영구 개발 런타임 기록을 사용합니다"
)
bind_meta = {
"degraded": bool(degraded_reasons),
"degraded_reason": "; ".join(degraded_reasons) if degraded_reasons else None,
@ -906,7 +1013,9 @@ def _normalize_audio_upload(
raise ValueError("pcm sample_width must be 2 bytes")
return _wav_from_pcm16(
audio,
sample_rate=_bounded_int(sample_rate, default=48000, minimum=8000, maximum=96000),
sample_rate=_bounded_int(
sample_rate, default=48000, minimum=8000, maximum=96000
),
channels=_bounded_int(channels, default=1, minimum=1, maximum=2),
), "wav"