전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue