feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG

web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

View file

@ -17,7 +17,10 @@ from .config import settings
from .db import close_pool, healthcheck, init_pool
from .engine_client import engine_client
from .routes import auth as auth_routes
from .routes import eval as eval_routes
from .routes import kb as kb_routes
from .routes import sessions as session_routes
from .routes import voice as voice_routes
@asynccontextmanager
@ -52,6 +55,10 @@ app.add_middleware(
app.include_router(auth_routes.router)
app.include_router(session_routes.router)
# Features 트랙 스텁 라우터(evaluator/voice/rag 가 채움). 등록만 — import 가능 보장.
app.include_router(eval_routes.router)
app.include_router(voice_routes.router)
app.include_router(kb_routes.router)
@app.get("/health", tags=["meta"])

205
apps/api/app/routes/eval.py Normal file
View file

@ -0,0 +1,205 @@
"""평가 라우트 — 교수자/관리자용 평가 조회 + 재평가 트리거 (Features: evaluator).
services/evaluator.py 2-loop 평가(fast/deep) 교수자(TEACHER)·관리자(ADMIN)에게 노출한다.
학습자(LEARNER)에겐 평가 결과가 직접 노출되지 않는다(설계서 §4.1 2-레이어: RBAC × AIView).
라우터는 RBAC(레이어2) 강제한다 require_role(TEACHER, ADMIN). 평가 AI 전부 봐도 되므로
(레이어1 AIView.EVALUATOR) CCD/정답 누설 걱정은 client AI 책임이고 여기선 무관.
엔드포인트:
GET /eval/health 헬스(소유 트랙 전환 확인)
POST /eval/sessions/{id}/turn 단일 fast-loop 재평가 트리거
POST /eval/sessions/{id}/reevaluate 회기 deep-loop 재평가 트리거(전체 축어록)
GET /eval/sessions/{id}/evaluation 회기 평가 조회(분포 + 최근 deep 결과)
DB(feedback_scores/supervisor_comment) SoR 적재는 Phase 2. 현재는 in-proc store + 엔진 직접 호출
(degraded). DB 붙으면 조회 경로를 turns.evaluation / supervisor_comment 조인으로 교체한다.
"""
from __future__ import annotations
from typing import Annotated, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..deps import Principal, Role, require_role
from ..engine_client import EngineError, engine_client
from ..services import evaluator
from ..services.evaluator import SessionEvaluation, TurnEvaluation
from ..store import store
router = APIRouter(prefix="/eval", tags=["eval"])
# 교수자/관리자만 평가 조회·트리거 (학습자 비노출)
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
# ── 요청/응답 모델 ──────────────────────────────────────
class ReevaluateRequest(BaseModel):
scope: str = Field("session_end", description="'session_end' | 'stage_transition'")
class TurnReevaluateRequest(BaseModel):
turn_seq: int = Field(..., ge=0, description="재평가할 상담자 발화의 turn_seq")
class EvaluationSummary(BaseModel):
"""회기 평가 조회 응답(분포 + deep 결과 합본)."""
session_id: str
stage: str
deep: Optional[dict[str, Any]] = None
distribution: dict[str, Any] = Field(default_factory=dict)
# ── in-proc 평가 결과 캐시 (DB 적재 전 degraded 보관) ───────────────────────
# DB 가 붙으면 turns.evaluation / supervisor_comment 로 대체. 지금은 트리거 결과를 보관해
# 조회 GET 이 재호출 없이 마지막 deep 결과를 돌려주게 한다.
_DEEP_CACHE: dict[str, SessionEvaluation] = {}
def _load_session_or_404(session_id: str):
sess = store.get(session_id)
if sess is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
return sess
def _theory_mode_of(sess) -> Optional[str]:
tt = getattr(sess.persona, "theory_target", None)
if isinstance(tt, (list, tuple)) and tt:
return ", ".join(str(x) for x in tt)
# store 가 theory_mode 문자열도 보유(InProcSession.theory_mode)
return getattr(sess, "theory_mode", None)
@router.get("/health")
async def eval_health() -> dict[str, str]:
"""평가 라우터 헬스 — Features:evaluator 로 전환됨."""
return {"status": "ok", "owner": "features:evaluator", "loops": "fast,deep"}
# ════════════════════════════════════════════════════════════════════════════
# 회기 deep-loop 재평가 트리거 (교수자/관리자)
# ════════════════════════════════════════════════════════════════════════════
@router.post("/sessions/{session_id}/reevaluate", response_model=SessionEvaluation)
async def reevaluate_session(
session_id: str,
body: ReevaluateRequest,
principal: TeacherOrAdmin,
) -> SessionEvaluation:
"""회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화).
in-proc store 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다.
엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출).
"""
sess = _load_session_or_404(session_id)
masked = sess.masked_turns()
# 발화 seq 보강(deep 프롬프트 가독성 — store 가 seq 미포함이라 인덱스로 부여)
enriched: list[dict[str, Any]] = []
for i, t in enumerate(masked):
item = dict(t)
item.setdefault("seq", i)
enriched.append(item)
# 누적 기법 코드 — DB 미가용이라 fast 결과가 없으면 빈 분포(deep LLM 정성 평가는 그대로 유효).
technique_codes: list[str] = []
try:
result = await evaluator.evaluate_session(
session_id=session_id,
stage=sess.state.stage.value,
masked_turns=enriched,
engine=engine_client,
technique_codes=technique_codes,
theory_mode=_theory_mode_of(sess),
scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end",
)
except EngineError as e:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {e}")
if result.error and result.error.startswith("engine_error"):
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error)
_DEEP_CACHE[session_id] = result
return result
# ════════════════════════════════════════════════════════════════════════════
# 단일 턴 fast-loop 재평가 트리거 (교수자/관리자)
# ════════════════════════════════════════════════════════════════════════════
@router.post("/sessions/{session_id}/turn", response_model=TurnEvaluation)
async def reevaluate_turn(
session_id: str,
body: TurnReevaluateRequest,
principal: TeacherOrAdmin,
) -> TurnEvaluation:
"""단일 상담자 발화 fast-loop 재평가(기법/내담자상태/적절성/의도이탈).
store 축어록에서 해당 turn_seq 상담자 발화 + 직후 내담자 응답을 재구성해
경량 TurnContext evaluator.evaluate_turn 호출한다.
"""
sess = _load_session_or_404(session_id)
# 대상 상담자 발화 + 직후 내담자 응답 찾기
target_idx: Optional[int] = None
for i, tr in enumerate(sess.turns):
if tr.speaker == "counselor" and tr.turn_seq == body.turn_seq:
target_idx = i
break
if target_idx is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail=f"counselor turn_seq {body.turn_seq} not found"
)
learner = sess.turns[target_idx]
client_reply = ""
if target_idx + 1 < len(sess.turns) and sess.turns[target_idx + 1].speaker == "client":
client_reply = sess.turns[target_idx + 1].text_masked
# 평가용 경량 TurnContext 재구성(prepare_turn 의 결정론 산출과 동형). 엔진 호출 없음.
from ..services.orchestrator import TurnContext # 지연 import(소유권 경계)
recent = [
{"speaker": tr.speaker, "text": tr.text_masked} for tr in sess.turns[max(0, target_idx - 4):target_idx]
]
ctx = TurnContext(
session_id=session_id,
case_id=sess.case_id,
persona=sess.persona,
state_before=sess.state,
learner_text_raw=learner.text,
learner_text_masked=learner.text_masked,
state_after=sess.state, # 조회 시점 상태(정밀 재현은 DB 스냅샷 도입 시)
recent_turns=recent,
)
result = await evaluator.evaluate_turn(ctx, client_reply, engine=engine_client)
if result.error and result.error.startswith("engine_error"):
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error)
return result
# ════════════════════════════════════════════════════════════════════════════
# 회기 평가 조회 (교수자/관리자) — 마지막 deep 결과 + 분포
# ════════════════════════════════════════════════════════════════════════════
@router.get("/sessions/{session_id}/evaluation", response_model=EvaluationSummary)
async def get_session_evaluation(
session_id: str,
principal: TeacherOrAdmin,
) -> EvaluationSummary:
"""회기 평가 조회(읽기) — 마지막 deep 재평가 결과 + 기법 분포.
DB 적재 degraded: deep 결과는 reevaluate 트리거가 보관한 캐시에서, 분포는 결과에서.
아직 평가 트리거가 없었다면 deep=None + 분포.
"""
_load_session_or_404(session_id)
cached = _DEEP_CACHE.get(session_id)
if cached is None:
return EvaluationSummary(session_id=session_id, stage="", deep=None, distribution={})
return EvaluationSummary(
session_id=session_id,
stage=cached.stage,
deep=cached.to_dict(),
distribution=cached.distribution.model_dump(),
)

321
apps/api/app/routes/kb.py Normal file
View file

@ -0,0 +1,321 @@
"""지식베이스(KB) 라우트 — 하이브리드 검색 + 인덱싱 트리거(관리자).
설계서 §3.6·§4.3 / MASTERPLAN §3.6:
GET /kb/health 라우터 + RAG 구성요소 readiness
POST /kb/search 정적 지식 하이브리드 검색(정책 4-튜플, visible_to DB 강제)
POST /kb/eval-grounding 평가 AI 채점 근거(evaluator 정책, label_id 동봉)
POST /kb/index 문서 인덱싱 트리거(관리자, content_hash 증분, 오프라인 배치)
정보비대칭은 *DB WHERE* 강제한다(services/rag.py POLICIES). 라우트는 role
요청 컨텍스트로만 결정하고, 검색 함수가 정책 화이트리스트를 고정한다(코드경로 부재 1차방어).
DB/임베딩 모델 미가용(Docker off, rag 의존성 미설치)이면 rag.NotConfigured 503 변환.
무거운 import(FlagEmbedding/torch) services/rag.py 함수 내부로 가둔다(이식성).
"""
from __future__ import annotations
from typing import Annotated, Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..db import acquire, get_pool
from ..deps import AIView, Principal, Role, require_role
from ..services import rag
router = APIRouter(prefix="/kb", tags=["kb"])
# ── 요청/응답 모델 ──────────────────────────────────────
RoleLiteral = Literal["client", "counselor", "evaluator"]
class KBSearchRequest(BaseModel):
query: str = Field(..., min_length=1) # PII 마스킹된 질의(마스킹은 가드레일 책임)
role: RoleLiteral = "evaluator" # 검색 주체(정보비대칭 정책 선택)
k: int = Field(default=5, ge=1, le=50)
rerank: bool = True
# 정책 화이트리스트를 *좁히는* 추가 필터만 허용(넓히지 못함 — 정보비대칭 보존)
kb_kind: Optional[list[str]] = None
source_id: Optional[list[str]] = None
sensitivity_max: Optional[int] = Field(default=None, ge=0, le=3)
# 감사 귀속(선택)
session_id: Optional[str] = None
turn_id: Optional[str] = None
class ChunkOut(BaseModel):
chunk_id: int
score: float
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 # 평가 정책에서만
meta: dict[str, Any] = Field(default_factory=dict)
source_id: Optional[str] = None
class KBSearchResponse(BaseModel):
chunks: list[ChunkOut]
policy: str
top1_score: float
crag_pass: bool # top1 >= 임계(F-06: 미달 시 관찰 프레이밍)
latency_ms: int
degraded: bool = False # reranker/embed 폴백 투명성
class MemoryRecallRequest(BaseModel):
case_id: str = Field(..., min_length=1) # UUID — 학습자별 케이스 스코프(M5/T4)
query: str = Field(..., min_length=1)
k: int = Field(default=5, ge=1, le=20)
session_id: Optional[str] = None
turn_id: Optional[str] = None
class IndexChunkIn(BaseModel):
seq: int
chunk_text: str = Field(..., min_length=1)
heading_path: Optional[str] = None
context_prefix: Optional[str] = None # Contextual Retrieval 프리픽스(색인 대상)
kb_kind: Optional[str] = None
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
meta: Optional[dict[str, Any]] = None
token_count: Optional[int] = None
class IndexRequestIn(BaseModel):
source_id: str = Field(..., min_length=1)
doc_uri: str = Field(..., min_length=1)
version: int = 1
content_hash: Optional[str] = None
chunks: list[IndexChunkIn]
class IndexResponse(BaseModel):
doc_id: Optional[int]
chunks_indexed: int
skipped_unchanged: bool # content_hash 동일 → 증분 스킵
embedded: bool # 임베딩 적재 여부(모델 미가용 시 False)
degraded: bool = False
# ── 헬퍼: rag.NotConfigured → 503 ───────────────────────
def _to_chunk_out(c: rag.RetrievedChunk) -> ChunkOut:
return ChunkOut(
chunk_id=c.chunk_id,
score=round(c.score, 6),
kb_kind=c.kb_kind,
heading_path=c.heading_path,
context_prefix=c.context_prefix,
body=c.body,
behavior_cue=c.behavior_cue,
label_id=c.label_id,
meta=c.meta,
source_id=c.source_id,
)
# ════════════════════════════════════════════════════════════════════════════
# 헬스 — 라우터 + RAG readiness (모델/DB 미가용도 정직하게 보고)
# ════════════════════════════════════════════════════════════════════════════
@router.get("/health")
async def kb_health() -> dict[str, object]:
"""KB 라우터 + RAG 구성요소 readiness.
DB /임베딩 모델 가용 여부를 *크래시 없이* 점검(미가용=degraded). 부트/디버그용.
"""
db_ready = False
try:
get_pool()
db_ready = True
except RuntimeError:
db_ready = False
# 임베딩 모델은 무거우므로 *로드하지 않고* 설치 가능성만 가볍게 확인(import 시도 X).
return {
"status": "ok" if db_ready else "degraded",
"owner": "features:rag",
"db_pool": db_ready,
"crag_threshold": rag.CRAG_TOP1_THRESHOLD,
"policies": [r.value for r in rag.POLICIES],
}
# ════════════════════════════════════════════════════════════════════════════
# 지식 검색 — 정책 4-튜플(role)로 분기, visible_to DB 강제
# ════════════════════════════════════════════════════════════════════════════
@router.post("/search", response_model=KBSearchResponse)
async def search(body: KBSearchRequest) -> KBSearchResponse:
"""정적 지식 KB 하이브리드 검색(dense pgvector cosine + sparse tsvector + 리랭킹).
role 정책 4-튜플(사전필터·가중치·본문노출·라벨) 고정한다 호출부가 넓힌다.
AI RLS 컨텍스트(app.current_ai_view) 커넥션에 주입해 visible_to 2 강제.
"""
ai_role = rag.AIRole(body.role)
filters: dict[str, Any] = {}
if body.kb_kind:
filters["kb_kind"] = body.kb_kind
if body.source_id:
filters["source_id"] = body.source_id
if body.sensitivity_max is not None:
filters["sensitivity_max"] = body.sensitivity_max
# RLS 컨텍스트(레이어1): app.current_ai_view = role → visible_to WHERE DB 강제
try:
async with acquire(ai_view=AIView(body.role).value) as conn:
result = await rag.search_kb(
conn,
query=body.query,
role=ai_role,
k=body.k,
filters=filters or None,
rerank=body.rerank,
)
# 감사 적재(best-effort — 로그 실패가 검색을 막지 않음)
try:
await rag.log_retrieval(
conn,
result=result,
ai_role=body.role,
session_id=body.session_id,
turn_id=body.turn_id,
)
except Exception:
pass
except rag.NotConfigured as e:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"RAG not configured: {e}",
)
except RuntimeError as e:
# DB 풀 미초기화(lifespan 밖) — 시연/테스트 degraded
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,
)
# ════════════════════════════════════════════════════════════════════════════
# 평가 근거 — evaluator 정책 래퍼(label_id 동봉, CRAG 게이트)
# ════════════════════════════════════════════════════════════════════════════
@router.post("/eval-grounding", response_model=KBSearchResponse)
async def eval_grounding(body: KBSearchRequest) -> KBSearchResponse:
"""평가 AI 채점 근거 회수(DSM/이론/taxonomy 정답라벨 + 논평).
role 무시하고 evaluator 정책 고정(평가 전용 경로). crag_pass=False 호출부가
'관찰 프레이밍'으로 다운그레이드(F-06).
"""
try:
async with acquire(ai_view=AIView.EVALUATOR.value) as conn:
result = await rag.retrieve_eval_grounding(
conn,
query=body.query,
k=body.k,
kinds=body.kb_kind,
rerank=body.rerank,
)
try:
await rag.log_retrieval(
conn,
result=result,
ai_role="evaluator",
session_id=body.session_id,
turn_id=body.turn_id,
)
except Exception:
pass
except rag.NotConfigured as 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}")
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,
)
# ════════════════════════════════════════════════════════════════════════════
# 페르소나 메모리 회상 — 내담자 연속성(case 스코프, episodic)
# ════════════════════════════════════════════════════════════════════════════
@router.post("/persona-memory", response_model=KBSearchResponse)
async def persona_memory(body: MemoryRecallRequest) -> KBSearchResponse:
"""회기 시작 episodic recall(app.turn_embedding, case_id 스코프 강제).
CCD/정답/평가는 경로에 구조적으로 부재(코드경로 부재 1차방어). 반환은 turn_id+점수만
(본문은 호출부 memory.build_recall_context turns 조인). 내담자 RLS 주입.
"""
try:
async with acquire(ai_view=AIView.CLIENT.value) as conn:
result = await rag.retrieve_persona_memory(
conn,
case_id=body.case_id,
query=body.query,
k=body.k,
)
except rag.NotConfigured as 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}")
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,
)
# ════════════════════════════════════════════════════════════════════════════
# 인덱싱 트리거 — 관리자 전용(content_hash 증분, 오프라인 배치)
# ════════════════════════════════════════════════════════════════════════════
@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))],
) -> IndexResponse:
"""문서 인덱싱(관리자, RBAC ADMIN 강제). content_hash 증분 + 청크 임베딩 적재.
임베딩은 무거운 작업 본래 BackgroundTasks/배치 워커 위임 권장(202 Accepted).
DSM verbatim 저작권(license C/D) source 등록 시점 external_llm_ok 가드 책임.
모델 미가용 embedding NULL 폴백(BM25 , degraded=True) 크래시 X.
"""
req = rag.IndexRequest(
source_id=body.source_id,
doc_uri=body.doc_uri,
version=body.version,
content_hash=body.content_hash,
chunks=[c.model_dump() for c in body.chunks],
)
try:
# 관리자 인덱싱은 RLS 미적용(쓰기 — kb 스키마 직접). role 주입 없이 acquire.
async with acquire() as conn:
result = await rag.index_document(conn, req)
except rag.NotConfigured as 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}")
return IndexResponse(
doc_id=result.doc_id,
chunks_indexed=result.chunks_indexed,
skipped_unchanged=result.skipped_unchanged,
embedded=result.embedded,
degraded=result.degraded,
)

View file

@ -1,13 +1,15 @@
"""상담 세션 라우트 — 시작 / 턴 / 종료 + SSE 스트림 스텁.
"""상담 세션 라우트 — 시작 / 턴 / 스트림 / 종료 (services 실호출).
흐름 (설계서 §2 회기 라이프사이클 + 마스터플랜 §2.2 사이클):
POST /sessions 회기 시작 (case_profile/summary 회상 + session_state 초기화)
POST /sessions/{id}/turn 수련생 발화 1 (가드레일상태머신내담자AI평가)
GET /sessions/{id}/stream 내담자 AI 응답 SSE 스트림 (Cloudflare 우회 heartbeat)
POST /sessions/{id}/end 회기 종료 (무손실 carry-over + LLM 압축 트리거)
POST /sessions 회기 시작 (페르소나 + 회상 + 상태머신 init)
POST /sessions/{id}/turn 수련생 발화 1 (가드레일상태머신내담자AI출력가드)
GET /sessions/{id}/stream 내담자 AI 응답 SSE 스트림 (heartbeat 포함)
POST /sessions/{id}/end 회기 종료 (무손실 carry-over + 압축 트리거)
DB(NAS Postgres) SoR 이지만 Docker off 에서도 엔진만 있으면 1턴이 돌도록
**store(in-memory)** 폴백을 둔다(degraded). 인증도 dev 폴백을 허용한다(개발 편의).
상태머신(라포탐색개입정리) 백엔드가 결정론적으로 소유(LLM 아님, 마스터플랜 §0).
파일은 핸들러 시그니처 + 계약 + TODO. 실제 상태머신/가드레일/압축은 Phase 1~2a 트랙 A.
"""
from __future__ import annotations
@ -15,19 +17,41 @@ from __future__ import annotations
import asyncio
import json
from typing import Annotated, Literal, Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from fastapi import Cookie
from ..config import settings
from ..deps import CurrentPrincipal, HumanDB
from ..engine_client import EngineMessage, StreamRequest, engine_client, EngineError
from ..deps import Principal, Role
from ..engine_client import EngineError, engine_client
from ..services import memory, orchestrator, persona, state_machine
from ..store import TurnRecord, store
router = APIRouter(prefix="/sessions", tags=["sessions"])
Stage = Literal["라포", "탐색", "개입", "정리"]
StageLiteral = Literal["라포", "탐색", "개입", "정리"]
# ── 인증 — dev 폴백 허용 (쿠키 없으면 dev learner) ───────────────────────────
async def get_principal_dev(
session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None,
) -> Principal:
"""세션 쿠키 → Principal. 미인증(쿠키 없음)이면 dev learner 폴백.
개발/시연(쿠키 없음, DB off)에서도 상담 루프가 돌게 한다.
prod 에선 auth.py BFF + Redis 세션이 완성되면 deps.get_current_principal 교체.
TODO: Redis 세션 룩업으로 user_id/role/cohort 복원.
"""
if not session_cookie:
return Principal(user_id="dev-learner", role=Role.LEARNER, cohort_ids=[])
# TODO: Redis 세션 검증. 현재는 쿠키 존재만으로 dev learner.
return Principal(user_id="dev-user", role=Role.LEARNER, cohort_ids=[])
DevPrincipal = Annotated[Principal, Depends(get_principal_dev)]
# ── 요청/응답 모델 ──────────────────────────────────────
@ -37,12 +61,13 @@ class SessionStartRequest(BaseModel):
class SessionStartResponse(BaseModel):
session_id: UUID
case_id: UUID
session_id: str
case_id: str
session_no: int
stage: Stage
# 회기 시작 회상 요약 (큰그림→세부, UI 카드용. CCD/정답은 절대 미포함)
stage: StageLiteral
effective_openness: float
recall_summary: Optional[str] = None
degraded: bool = False # DB 미가용 in-proc 모드 여부(시연 투명성)
class TurnRequest(BaseModel):
@ -51,138 +76,269 @@ class TurnRequest(BaseModel):
class TurnResponse(BaseModel):
turn_seq: int
stage: Stage
stage: StageLiteral
effective_openness: float
# 내담자 응답은 스트림(GET /stream)으로 받는 게 기본. 동기 응답은 폴백/테스트용.
client_reply: Optional[str] = None
safety_flagged: bool = False
crisis_kind: str = "none"
class SessionEndResponse(BaseModel):
session_id: UUID
session_id: str
session_no: int
digest_pending: bool # 압축은 비동기 비블로킹 (설계서 §2-C)
end_state: dict
# ── 핸들러 ──────────────────────────────────────────────
# ════════════════════════════════════════════════════════════════════════════
# 회기 시작
# ════════════════════════════════════════════════════════════════════════════
@router.post("", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED)
async def start_session(
body: SessionStartRequest,
principal: CurrentPrincipal,
conn: HumanDB,
principal: DevPrincipal,
) -> SessionStartResponse:
"""회기 시작 — 회상 + 상태 복원 (설계서 §2-A).
"""회기 시작 — 페르소나 핀 + 회상 + 결정론 상태 init (설계서 §2-A).
절차:
1. persona_card(approved) 조회 + (persona_id, learner_id) -> case_profile upsert
2. case_digest + 직전 session_summary + episodic recall (Phase 2a, 1차는 단일회기)
3. session_state 초기화: stage='라포', carry-over (rapport×0.7, ideation 보수적 유지) [P2]
4. sessions insert
TODO: persona 조회/회상/상태머신 init 구현 (트랙 A). 현재 스텁 응답.
DB 가용 : persona_card(approved) 조회 + case_profile/직전 summary 회상.
DB 미가용(degraded): 시드 페르소나(persona.SEED) + 회상( 회기)으로 in-proc.
"""
# TODO: SELECT persona_id FROM app.persona_card WHERE code=$1 AND status='approved'
# TODO: init_session_state_from_history() — 결정론 carry-over
session_id = uuid4()
case_id = uuid4()
return SessionStartResponse(
session_id=session_id,
case_id=case_id,
card = persona.get_seed_persona(body.persona_code)
if card is None:
# TODO: DB app.persona_card WHERE code=$1 AND status='approved' 조회 경로
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}")
# 회상 — DB/RAG 미가용 시 빈 컨텍스트(첫 회기). 가용 시 case_digest/summary/episodic 주입.
# TODO(Phase 2a): memory.build_recall_context(case_digest=..., prev_summary=..., episodic_snippets=...)
recall = memory.build_recall_context()
# 결정론 상태 init (carry-over 가 있으면 이월; 첫 회기는 None)
st = state_machine.init_state(
base_resistance=card.base_resistance(),
unlock_rate=card.unlock_rate(),
decay_floor=card.decay_floor(),
ideation_baseline=card.ideation_baseline(),
carry=recall.carry,
)
sess = store.create(
learner_id=principal.user_id,
persona=card,
theory_mode=body.theory_mode,
state=st,
session_no=1,
stage="라포",
recall_summary=None, # Phase 2a 회상 채움
)
# 회상 핀(pinned facts)을 세션에 묶어 둔다(턴마다 재조립). store 는 간단히 state 만 보유하므로
# recall_summary/pinned 는 in-proc 캐시로 별도 보관.
_RECALL_CACHE[sess.session_id] = recall
return SessionStartResponse(
session_id=sess.session_id,
case_id=sess.case_id,
session_no=sess.session_no,
stage=st.stage.value, # type: ignore[arg-type]
effective_openness=round(st.effective_openness, 4),
recall_summary=recall.recall_summary,
degraded=True, # 현재 in-proc 경로(DB 붙으면 False 분기)
)
# 회상 컨텍스트 in-proc 캐시 (회기 내 재사용, recall_context). DB 붙으면 session_state.recall_context.
_RECALL_CACHE: dict[str, memory.RecallContext] = {}
def _load_session_or_404(session_id: str):
sess = store.get(session_id)
if sess is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
if sess.ended:
raise HTTPException(status.HTTP_409_CONFLICT, detail="session already ended")
return sess
# ════════════════════════════════════════════════════════════════════════════
# 턴 (동기 폴백 — 기본 UX 는 /stream)
# ════════════════════════════════════════════════════════════════════════════
@router.post("/{session_id}/turn", response_model=TurnResponse)
async def submit_turn(
session_id: UUID,
session_id: str,
body: TurnRequest,
principal: CurrentPrincipal,
conn: HumanDB,
principal: DevPrincipal,
) -> TurnResponse:
"""수련생 발화 1턴 (마스터플랜 §2.2 / 설계서 §2-B).
파이프라인 (전부 백엔드 결정론 게이트):
1. [입력 가드레일] Presidio PII 마스킹 + 위기분류(실제위기 vs 페르소나 연기) [R7/F-03]
2. [상태머신] effective_openness = clamp(stage.openness
+ rapport_credit*unlock_rate - resistance*decay, 0, 1) [P2, 결정론]
3. [모순 검사] pinned_fact locked 모순 -> 차단·재생성 (설계서 §2-B)
4. [내담자 AI] engine_client.stream/generate (CCD 직접노출 금지, Structured Outputs)
5. [출력 가드레일] 자살수단 차단, ideation_stage <= 3 상한 [R5]
6. [평가 AI] fast-loop 4차원 태깅 (deep-loop 단계전환/회기말)
7. [working 갱신] session_state UPSERT (체크포인트)
8. [로깅] turns insert + 임베딩 (재귀학습 원천)
TODO: 1~8 구현 (트랙 A). 현재 스텁: 발화 검증만.
오케스트레이터로 1~8단계 결정론 파이프라인 실행. 내담자 응답은 동기로 번에 받는다
(기본 UX GET /stream 토큰 스트리밍; 경로는 폴백/테스트).
"""
# TODO: load session_state, run guardrail + state machine deterministically
# 동기 응답은 폴백. 기본 UX 는 GET /stream 으로 토큰 스트리밍.
sess = _load_session_or_404(session_id)
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
ctx = orchestrator.prepare_turn(
session_id=session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
learner_text=body.text,
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(),
)
# 수련생 발화 로깅(② episodic 미러) — 마스킹본 저장
assert ctx.state_after is not None
store.append_turn(
session_id,
TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="counselor",
stage=ctx.state_after.stage.value,
text=body.text,
text_masked=ctx.learner_text_masked,
),
)
try:
result = await orchestrator.run_turn_generate(ctx, engine_client)
except EngineError as e:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {e}")
# 내담자 응답 로깅 + 상태 체크포인트(① working UPSERT 미러)
if result.client_reply:
store.append_turn(
session_id,
TurnRecord(
turn_seq=result.turn_seq,
speaker="client",
stage=result.stage,
text=result.client_reply,
text_masked=result.client_reply, # 내담자 응답은 합성(원문PII 없음)
),
)
store.update_state(session_id, result.state_after)
return TurnResponse(
turn_seq=0,
stage="라포",
effective_openness=0.15,
client_reply=None,
safety_flagged=False,
turn_seq=result.turn_seq,
stage=result.stage, # type: ignore[arg-type]
effective_openness=round(result.effective_openness, 4),
client_reply=result.client_reply,
safety_flagged=result.safety_flagged,
crisis_kind=result.crisis_kind,
)
@router.get("/{session_id}/stream")
async def stream_client_reply(
session_id: UUID,
principal: CurrentPrincipal,
# ════════════════════════════════════════════════════════════════════════════
# 스트림 (기본 UX — SSE 토큰)
# ════════════════════════════════════════════════════════════════════════════
@router.post("/{session_id}/stream")
async def stream_turn(
session_id: str,
body: TurnRequest,
principal: DevPrincipal,
):
"""내담자 AI 응답 SSE 스트림 (마스터플랜 §1.1 SSE 분리경로).
"""수련생 발화 1턴을 받아 내담자 AI 응답을 SSE 토큰 스트림으로 흘린다.
- Cloudflare 100 timeout 회피: settings.sse_heartbeat_seconds 마다 ping 이벤트 [R2]
- 게이트웨이 SSE(engine_client.stream) 프록시해 토큰을 재방출
- 이벤트: {event: "token"|"done"|"safety"|"ping", data: ...}
TODO: 게이트웨이와 StreamRequest 바디 결합(현재 stage 회상 컨텍스트 없이 placeholder).
상태머신 컨텍스트(L3 stage/openness) + 마스킹된 최근 N턴 주입.
- Cloudflare 100 timeout 회피: settings.sse_heartbeat_seconds 마다 ping [R2]
- 오케스트레이터 run_turn_stream(가드레일·상태머신·페르소나·출력가드 적용) 프록시
- 이벤트: token | ping | safety | done | error
"""
sess = _load_session_or_404(session_id)
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
ctx = orchestrator.prepare_turn(
session_id=session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
learner_text=body.text,
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(),
)
assert ctx.state_after is not None
# 수련생 발화 로깅 + 상태 체크포인트(스트림은 응답 전 상태 갱신 — 결정론이라 무방)
store.append_turn(
session_id,
TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="counselor",
stage=ctx.state_after.stage.value,
text=body.text,
text_masked=ctx.learner_text_masked,
),
)
store.update_state(session_id, ctx.state_after)
async def event_generator():
# heartbeat 와 엔진 스트림을 병행 (Cloudflare 버퍼링/타임아웃 회피)
last_beat = asyncio.get_event_loop().time()
# TODO: 실제 StreamRequest 조립 — session_state 에서 stage/openness/최근턴 로드
req = StreamRequest(
ai_role="client",
tier="client",
messages=[
EngineMessage(role="system", content="<persona L0~L2 cache_control 주입 TODO>", cache=True),
EngineMessage(role="user", content="<masked latest learner turn TODO>"),
],
)
final_reply = ""
try:
async for chunk in engine_client.stream(req):
yield {"event": "token", "data": chunk}
async for ev in orchestrator.run_turn_stream(ctx, engine_client):
if ev.event == "token":
final_reply += ev.data.get("text", "")
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
now = asyncio.get_event_loop().time()
if now - last_beat >= settings.sse_heartbeat_seconds:
yield {"event": "ping", "data": "{}"}
last_beat = now
yield {"event": "done", "data": json.dumps({"session_id": str(session_id)})}
except EngineError as e:
yield {"event": "error", "data": json.dumps({"detail": str(e)})}
except Exception as e: # 방어 — 어떤 예외도 SSE error 프레임으로
yield {"event": "error", "data": json.dumps({"detail": str(e)}, ensure_ascii=False)}
return
# 내담자 응답 로깅(② episodic) — 스트림 종료 후
if final_reply:
store.append_turn(
session_id,
TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="client",
stage=ctx.state_after.stage.value,
text=final_reply,
text_masked=final_reply,
),
)
return EventSourceResponse(event_generator())
# ════════════════════════════════════════════════════════════════════════════
# 회기 종료
# ════════════════════════════════════════════════════════════════════════════
@router.post("/{session_id}/end", response_model=SessionEndResponse)
async def end_session(
session_id: UUID,
principal: CurrentPrincipal,
conn: HumanDB,
session_id: str,
principal: DevPrincipal,
) -> SessionEndResponse:
"""회기 종료 — carry-over + 압축 트리거 (설계서 §2-C, 비동기 비블로킹).
"""회기 종료 — 무손실 carry-over + 압축 트리거 (설계서 §2-C, 비동기 비블로킹).
절차:
(A) 무손실 carry-over: end_state = session_state 종료 snapshot (코드 복사, LLM 미경유) [P4]
(B) salience 산출 -> 망각/유지
(C) narrative 압축 (LLM, 상주 claude -p 재사용) 비동기
(D~G) digest 임베딩 / case_profile 병합 / pinned 모순처리 / RAG 동기화
+ 상주 프로세스 회수 (말투표류 회기경계 차단)
TODO: (A) 동기 수행 (B~G) BackgroundTasks/큐로 비블로킹. 현재 스텁.
(A) 무손실 carry-over: end_state = state.snapshot() (코드 복사, LLM 미경유) [P4]
(C) narrative 압축(LLM) CompressionJob 으로 큐잉(여기선 페이로드만; 실제 호출은 후속 워커)
"""
# TODO: UPDATE app.sessions SET ended_at=now(); copy end_state; enqueue compression
return SessionEndResponse(session_id=session_id, session_no=1, digest_pending=True)
sess = store.get(session_id)
if sess is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
carry = memory.make_carry_over(
state=sess.state,
session_id=session_id,
case_id=sess.case_id,
session_no=sess.session_no,
masked_turns=sess.masked_turns(),
prev_rapport_credit=sess.prev_rapport_credit,
open_threads=recall.open_threads,
)
# TODO(Phase 2a): BackgroundTasks 로 carry.compression_job 을
# engine_client.generate(GenerateRequest(ai_role='evaluator', tier='feedback',
# messages=memory.build_compression_messages(job))) 호출 → session_summary UPSERT + 임베딩.
# 현재는 큐잉만(digest_pending=True). DB 없으면 압축 결과 적재 생략.
store.end(session_id)
_RECALL_CACHE.pop(session_id, None)
return SessionEndResponse(
session_id=session_id,
session_no=sess.session_no,
digest_pending=carry.compression_job is not None,
end_state=carry.end_state,
)

View file

@ -0,0 +1,437 @@
"""음성 라우트 — OpenAI STT/TTS 캐스케이드 + WSS 실시간 턴테이킹.
한신대 요구 '음성 필수'. 학습자가 마이크로 말하면 STT orchestrator 상담 1
내담자 텍스트 TTS 오디오 + 립싱크 힌트(설계 §4.3 RMS) 역방향으로 흘린다.
캐스케이드(설계 §5.2 음성 오브 4상태 listeningthinkingspeakingidle):
[클라] audio_start(JSON) 바이너리 오디오 청크들 audio_end(JSON)
[서버] state(listening) STT transcript(JSON) state(thinking)
orchestrator.run_turn(가드레일·상태머신·페르소나·내담자AI·출력가드)
reply(JSON, 내담자 텍스트 + stage/openness) state(speaking)
[tts_chunk(JSON: seq/rms) + 바이너리 오디오] × N tts_end(JSON) state(idle)
프로토콜(JSON 제어 + 바이너리 오디오 혼합, 단일 WS):
- 클라서버 텍스트 = JSON 제어({"type": ...}); 클라서버 바이너리 = 오디오 청크
- 서버클라 텍스트 = JSON 이벤트; 서버클라 바이너리 = TTS 오디오 청크
- TTS 바이너리 청크 *직전* 메타 JSON(tts_chunk: seq, rms) 보내 프론트가 짝짓는다.
음성 미설정(OPENAI_API_KEY 없음): GET /voice/health 503 degraded,
WS 핸드셰이크 직후 degraded 이벤트 + close(1011). 절대 크래시 금지.
"""
from __future__ import annotations
import json
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from starlette.websockets import WebSocketState
from ..engine_client import EngineError, engine_client
from ..services import memory, orchestrator, persona
from ..services import voice as voice_svc
from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service
from ..store import TurnRecord, store
router = APIRouter(prefix="/voice", tags=["voice"])
# WS close 코드(섹션별 의미 명시)
WS_CLOSE_DEGRADED = 1011 # 서버측 음성 미설정/장애
WS_CLOSE_BAD_REQUEST = 1008 # 프로토콜 위반(세션 누락 등)
# 한 발화당 누적 오디오 상한(메모리 방어, ~10MB)
_MAX_AUDIO_BYTES = 10 * 1024 * 1024
# ════════════════════════════════════════════════════════════════════════════
# 헬스 — 음성 가용성(키 설정) 노출
# ════════════════════════════════════════════════════════════════════════════
@router.get("/health")
async def voice_health() -> JSONResponse:
"""음성 라우터 헬스. 키 미설정이면 503 degraded(시연 투명성)."""
available = voice_service.is_available()
body = {
"status": "ok" if available else "degraded",
"available": available,
"stt_model": voice_svc.STT_MODEL,
"tts_model": voice_svc.TTS_MODEL,
"reason": None if available else "OPENAI_API_KEY 미설정",
}
return JSONResponse(body, status_code=200 if available else 503)
# ════════════════════════════════════════════════════════════════════════════
# WebSocket — 실시간 음성 캐스케이드
# ════════════════════════════════════════════════════════════════════════════
@router.websocket("/ws")
async def voice_ws(websocket: WebSocket) -> None:
"""음성 실시간 턴 캐스케이드.
쿼리: ?session_id=<hex> (없으면 persona_code 일회용 in-proc 세션 생성 시연용)
오디오 in(바이너리) STT 상담 1 TTS out(바이너리) + 립싱크 힌트.
"""
await websocket.accept()
# 1) 음성 미설정 → degraded 알리고 정상 종료(크래시 금지)
if not voice_service.is_available():
await _safe_send_json(
websocket,
{"type": "degraded", "reason": "OPENAI_API_KEY 미설정 — 음성 기능 비활성"},
)
await _safe_close(websocket, WS_CLOSE_DEGRADED)
return
# 2) 세션 바인딩 — session_id 우선, 없으면 persona_code 로 시연 세션 생성
session_id, voice_preset, err = _bind_session(websocket)
if err is not None:
await _safe_send_json(websocket, {"type": "error", "detail": err})
await _safe_close(websocket, WS_CLOSE_BAD_REQUEST)
return
assert session_id is not None and voice_preset is not None
await _safe_send_json(
websocket,
{
"type": "ready",
"session_id": session_id,
"voice": voice_preset.openai_voice,
"preset": voice_preset.preset,
"state": "idle",
},
)
audio_buf = bytearray()
receiving = False
try:
while True:
msg = await websocket.receive()
mtype = msg.get("type")
if mtype == "websocket.disconnect":
break
# ── 바이너리 = 오디오 청크 누적 ──
if msg.get("bytes") is not None:
if not receiving:
# audio_start 없이 들어온 바이너리 — 관용적으로 자동 시작
receiving = True
audio_buf.clear()
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 — 발화를 짧게 끊어 주세요"},
)
audio_buf.clear()
receiving = False
continue
# ── 텍스트 = JSON 제어 ──
text = msg.get("text")
if text is None:
continue
try:
ctrl = json.loads(text)
except (json.JSONDecodeError, TypeError):
await _safe_send_json(websocket, {"type": "error", "detail": "invalid control json"})
continue
ctype = ctrl.get("type")
if ctype == "audio_start":
receiving = True
audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
elif ctype == "audio_end":
receiving = False
await _handle_utterance(
websocket,
session_id=session_id,
voice_preset=voice_preset,
audio=bytes(audio_buf),
fmt=ctrl.get("format"),
)
audio_buf.clear()
elif ctype == "text_turn":
# 음성 없이 텍스트만 보내는 경로(접근성/디버그): STT 건너뛰고 바로 턴.
receiving = False
audio_buf.clear()
learner_text = (ctrl.get("text") or "").strip()
if learner_text:
await _run_turn_and_speak(
websocket,
session_id=session_id,
voice_preset=voice_preset,
learner_text=learner_text,
)
elif ctype == "ping":
await _safe_send_json(websocket, {"type": "pong"})
elif ctype == "close":
break
except WebSocketDisconnect:
pass
except Exception as e: # 어떤 예외도 WS 를 깨끗이 닫고 알린다(크래시 금지)
await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"})
finally:
await _safe_close(websocket)
# ════════════════════════════════════════════════════════════════════════════
# 발화 1건 처리 — STT → 턴 → TTS
# ════════════════════════════════════════════════════════════════════════════
async def _handle_utterance(
websocket: WebSocket,
*,
session_id: str,
voice_preset: VoicePreset,
audio: bytes,
fmt: Optional[str],
) -> None:
"""오디오 1발화 → STT → 상담 턴 → TTS 캐스케이드."""
if not audio:
await _safe_send_json(websocket, {"type": "transcript", "text": "", "final": True})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
# 1) STT (thinking 진입)
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
filename, content_type = _audio_meta(fmt)
try:
stt = await voice_service.transcribe(
audio, filename=filename, content_type=content_type
)
except VoiceUnavailable as e:
await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
except Exception as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"STT 실패: {e}"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
learner_text = stt.text
await _safe_send_json(
websocket,
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
)
if not learner_text:
# 무음/인식 실패 — 턴 진행 안 함
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
await _run_turn_and_speak(
websocket,
session_id=session_id,
voice_preset=voice_preset,
learner_text=learner_text,
)
async def _run_turn_and_speak(
websocket: WebSocket,
*,
session_id: str,
voice_preset: VoicePreset,
learner_text: str,
) -> None:
"""상담 1턴(orchestrator) → 내담자 텍스트 → TTS 오디오/립싱크 힌트 역방향 전송."""
sess = store.get(session_id)
if sess is None or sess.ended:
await _safe_send_json(websocket, {"type": "error", "detail": "세션 없음/종료됨"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
recall = memory.RecallContext()
ctx = orchestrator.prepare_turn(
session_id=session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
learner_text=learner_text,
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(),
)
assert ctx.state_after is not None
# 학습자 발화 로깅(마스킹본) — sessions.py 패턴과 동일
store.append_turn(
session_id,
TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="counselor",
stage=ctx.state_after.stage.value,
text=learner_text,
text_masked=ctx.learner_text_masked,
),
)
# 2) 내담자 AI 1턴(동기 — 음성은 TTS 전 전체 텍스트가 필요)
try:
result = await orchestrator.run_turn_generate(ctx, engine_client)
except EngineError as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"engine unavailable: {e}"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
reply = result.client_reply or ""
# 내담자 응답 로깅 + 상태 체크포인트
if reply:
store.append_turn(
session_id,
TurnRecord(
turn_seq=result.turn_seq,
speaker="client",
stage=result.stage,
text=reply,
text_masked=reply,
),
)
store.update_state(session_id, result.state_after)
# 내담자 텍스트 이벤트(설계 §5.3 자막 — partial 없이 final)
await _safe_send_json(
websocket,
{
"type": "reply",
"text": reply,
"speaker": "client",
"stage": result.stage,
"effective_openness": round(result.effective_openness, 4),
"turn_seq": result.turn_seq,
"safety_flagged": result.safety_flagged,
"crisis_kind": result.crisis_kind,
},
)
if not reply:
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
# 3) TTS (speaking) — 청크별 메타 JSON(립싱크 rms) + 바이너리 오디오
await _safe_send_json(
websocket,
{"type": "state", "state": "speaking", "voice": voice_preset.openai_voice},
)
try:
n = 0
async for ck in voice_service.synthesize_stream(reply, voice_preset):
# 메타 먼저(프론트가 직후 바이너리와 짝지음) — 설계 §4.3 RMS 1채널
await _safe_send_json(
websocket, {"type": "tts_chunk", "seq": ck.seq, "rms": round(ck.rms, 4)}
)
await _safe_send_bytes(websocket, ck.audio)
n += 1
await _safe_send_json(websocket, {"type": "tts_end", "chunks": n})
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 실패: {e}"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
# ════════════════════════════════════════════════════════════════════════════
# 세션 바인딩 / 메타 헬퍼
# ════════════════════════════════════════════════════════════════════════════
def _bind_session(
websocket: WebSocket,
) -> tuple[Optional[str], Optional[VoicePreset], Optional[str]]:
"""쿼리에서 세션을 바인딩(또는 시연 세션 생성)하고 voice preset 을 해석.
우선순위:
?session_id=<hex> 기존 세션(REST 시작된) 음성 부착
?persona_code=P1[&preset=] in-proc 시연 세션 생성(DB off 폴백)
반환 (session_id, voice_preset, error).
"""
qp = websocket.query_params
explicit_preset = qp.get("preset")
session_id = qp.get("session_id")
if session_id:
sess = store.get(session_id)
if sess is None:
return None, None, f"unknown session {session_id}"
if sess.ended:
return None, None, "session already ended"
vp = resolve_voice(persona_code=sess.persona.code, preset=explicit_preset)
return session_id, vp, None
# persona_code 로 시연 세션 생성(REST 미경유 음성 단독 데모)
persona_code = qp.get("persona_code")
if not persona_code:
return None, None, "session_id 또는 persona_code 쿼리 필요"
card = persona.get_seed_persona(persona_code)
if card is None:
return None, None, f"unknown persona {persona_code}"
from ..services import state_machine
st = state_machine.init_state(
base_resistance=card.base_resistance(),
unlock_rate=card.unlock_rate(),
decay_floor=card.decay_floor(),
ideation_baseline=card.ideation_baseline(),
)
sess = store.create(
learner_id="dev-learner-voice",
persona=card,
theory_mode="humanistic",
state=st,
session_no=1,
)
vp = resolve_voice(persona_code=card.code, preset=explicit_preset)
return sess.session_id, vp, None
def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
"""클라가 알려준 포맷 → (filename, content_type). 기본 webm/opus."""
f = (fmt or "webm").lower().lstrip(".")
table = {
"webm": ("audio.webm", "audio/webm"),
"ogg": ("audio.ogg", "audio/ogg"),
"opus": ("audio.ogg", "audio/ogg"),
"wav": ("audio.wav", "audio/wav"),
"mp3": ("audio.mp3", "audio/mpeg"),
"mp4": ("audio.mp4", "audio/mp4"),
"m4a": ("audio.m4a", "audio/mp4"),
"pcm": ("audio.wav", "audio/wav"),
}
return table.get(f, ("audio.webm", "audio/webm"))
# ── 안전 송수신(연결 끊김 시 조용히 무시) ───────────────────────────────────
async def _safe_send_json(websocket: WebSocket, payload: dict) -> None:
if websocket.client_state != WebSocketState.CONNECTED:
return
try:
await websocket.send_text(json.dumps(payload, ensure_ascii=False))
except Exception:
pass
async def _safe_send_bytes(websocket: WebSocket, data: bytes) -> None:
if websocket.client_state != WebSocketState.CONNECTED:
return
try:
await websocket.send_bytes(data)
except Exception:
pass
async def _safe_close(websocket: WebSocket, code: int = 1000) -> None:
if websocket.client_state == WebSocketState.DISCONNECTED:
return
try:
await websocket.close(code=code)
except Exception:
pass
__all__ = ["router"]

View file

@ -0,0 +1,23 @@
"""상담 루프 도메인 서비스.
레이어 분리 (소유 트랙 A API 파운데이션):
- persona.py : 페르소나 시스템프롬프트 빌더(L0~L6) + 시드 페르소나 P1/P2/P3
- state_machine.py : 결정론 상태 전이(stage·effective_openness, 순수함수)
- guardrail.py : 입출력 가드레일(PII 마스킹·위기분류·자살수단 차단)
- orchestrator.py : 파이프라인 1~8단계 조립(평가/로깅은 주입형 )
- memory.py : 회기 라이프사이클(시작 회상·종료 carry-over) 4계층 매핑
- store.py(상위) : Docker 없이도 도는 in-memory 세션/ 스토어
evaluator.py / voice.py / rag.py Features 트랙 소유 여기서 import 하지 않는다
(lazy import 금지. 라우터 스텁만 등록해 둔다).
"""
from __future__ import annotations
__all__ = [
"persona",
"state_machine",
"guardrail",
"orchestrator",
"memory",
]

View file

@ -0,0 +1,701 @@
"""평가 AI(슈퍼바이저/교수 AI) — 학습자 발화 2-loop 평가. 한신대 AI 3종 중 ③.
MASTERPLAN §2.3 (평가 AI 2-tier 루프):
- fast-loop : 직후 가벼운 4차원 태깅(기법/내담자상태 읽기/적절성 신호/의도이탈 여부).
콜드스타트 지연·비용 통제. tier='feedback' Opus 라우팅(게이트웨이가 최종 결정).
- deep-loop : 단계전환/회기말 정밀(기법 분포·잘한 순간·개선점 최대3·슈퍼바이저 rationale/critique).
전체 회기 + 골든라벨 후보 enum 으로 0-5 채점 근거 + 대안 발화.
설계 원칙 (소유권 분리):
- 평가 AI *전부 봐도 된다*(CCD/정답/상태수치 포함). 비노출은 client AI 책임이고,
학습자에겐 RBAC×AIView 차단(deps.AIView.EVALUATOR). 모듈은 평가 신호만 산출한다.
- 정답 라벨 enum taxonomy.py 단일 원천(SoT). 프롬프트는 TECHNIQUE_KO/CLIENT_STATE_KO
후보로 제시하고 *근거(rationale)* 요구한다. LLM 출력은 enum 으로 안전 파싱(미지값은 버림).
- intent_deviation('의도와 다른 부분') 1 시민 SupervisorComment(critique) 형식과 정합:
{dimension, expected, actual, severity}.
- engine_client.generate(tier='feedback', structured_schema=...) LLM 평가. 엔진 장애·파싱
실패는 *비치명적* orchestrator eval_hook None 으로 흡수(상담 루프를 막지 않음).
- 결과는 pydantic 모델로 반환. orchestrator 주입형으로 부르는 async 함수
evaluate_turn(...) / evaluate_session(...) export.
taxonomy / engine_client / orchestrator / state_machine / persona *읽기 전용* 의존이다.
모듈만 평가 로직을 소유한다(라우트는 routes/eval.py).
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, Optional
from pydantic import BaseModel, Field
from ..engine_client import (
EngineClient,
EngineError,
EngineMessage,
GenerateRequest,
GenerateResponse,
)
from ..taxonomy import (
CLIENT_STATE_KO,
TECHNIQUE_CATEGORY,
TECHNIQUE_KO,
ClientState,
CommentKind,
Technique,
TechniqueCategory,
)
if TYPE_CHECKING: # 런타임 import 회피(순환·소유권 경계). 타입 힌트 전용.
from .orchestrator import TurnContext
# ════════════════════════════════════════════════════════════════════════════
# 0. enum 역인덱스 (LLM 한글/코드 출력 → taxonomy enum 안전 파싱)
# ════════════════════════════════════════════════════════════════════════════
# LLM 은 후보로 한글 라벨을 받지만, 코드값(value)을 돌려줄 수도 있어 둘 다 받는다.
_TECHNIQUE_BY_KO: dict[str, Technique] = {ko: t for t, ko in TECHNIQUE_KO.items()}
_TECHNIQUE_BY_CODE: dict[str, Technique] = {t.value: t for t in Technique}
_CLIENT_STATE_BY_KO: dict[str, ClientState] = {ko: s for s, ko in CLIENT_STATE_KO.items()}
_CLIENT_STATE_BY_CODE: dict[str, ClientState] = {s.value: s for s in ClientState}
# 적절성 신호 — fast-loop 의 경량 판단(상태머신 라포 추정과 별개 차원).
_APPROPRIATENESS = ("pos", "warn", "neutral")
# 의도이탈 심각도 (taxonomy.SupervisorComment.severity 와 동일 어휘).
_SEVERITY = ("minor", "moderate", "major")
def _parse_technique(raw: str) -> Optional[Technique]:
s = (raw or "").strip()
return _TECHNIQUE_BY_KO.get(s) or _TECHNIQUE_BY_CODE.get(s)
def _parse_client_state(raw: str) -> Optional[ClientState]:
s = (raw or "").strip()
return _CLIENT_STATE_BY_KO.get(s) or _CLIENT_STATE_BY_CODE.get(s)
def _coerce_str_list(val: Any) -> list[str]:
if val is None:
return []
if isinstance(val, str):
return [val]
if isinstance(val, (list, tuple)):
return [str(x) for x in val if x is not None]
return []
# ════════════════════════════════════════════════════════════════════════════
# 1. 결과 모델 (pydantic) — orchestrator/route 반환 + intent_deviation 1급 시민
# ════════════════════════════════════════════════════════════════════════════
class IntentDeviation(BaseModel):
"""'의도와 다른 부분'(윤찬 1급 시민). taxonomy.SupervisorComment(critique) intent_deviation 정합.
{dimension, expected, actual, severity} 구조화. dimension 평가 차원
(: 'reflection', 'self_disclosure', 'pacing', 'risk_assessment').
"""
dimension: str = Field(..., description="관련 평가 차원(기법/페이싱/위험사정 등)")
expected: str = Field(..., description="권장된 반응/의도")
actual: str = Field(..., description="실제 나타난 반응")
severity: str = Field("minor", description="minor | moderate | major")
class TechniqueTag(BaseModel):
"""fast-loop 기법 태그 1건 — taxonomy.Technique 코드 + 한글 + 군집 + 근거."""
code: str # taxonomy.Technique.value
label_ko: str # TECHNIQUE_KO
category: str # TechniqueCategory.value (분포 집계축)
rationale: Optional[str] = None # 왜 이 기법으로 봤는지(근거 요구)
class ClientStateRead(BaseModel):
"""내담자 상태 '읽기' — 학습자 발화 직후 내담자 응답에서 관측된 상태(읽기 채점 근거)."""
code: str # taxonomy.ClientState.value
label_ko: str # CLIENT_STATE_KO
rationale: Optional[str] = None
class TurnEvaluation(BaseModel):
"""fast-loop 턴 평가 결과(턴 직후 경량 4차원).
4차원:
technique[] : 학습자(상담자) 발화에 부착된 기법 라벨(복수)
client_state_read[] : 내담자 응답에서 읽은 상태(복수)
appropriateness : 적절성 신호 pos|warn|neutral (경량)
intent_deviation : '의도와 다른 부분' 있으면 구조화(없으면 None)
"""
loop: str = "fast"
turn_seq: int
stage: str
techniques: list[TechniqueTag] = Field(default_factory=list)
client_state_read: list[ClientStateRead] = Field(default_factory=list)
appropriateness: str = "neutral" # pos | warn | neutral
appropriateness_note: Optional[str] = None
intent_deviation: Optional[IntentDeviation] = None # 있을 때만(1급 시민)
rapport_signal: Optional[float] = None # 평가 AI 가 본 라포 신호(1~+1, 상태머신 주입 가능)
theory_mode: Optional[str] = None
error: Optional[str] = None # 평가 실패 시 사유(비치명적; None 이면 정상)
def to_hook_dict(self) -> dict[str, Any]:
"""orchestrator.EvalHook 가 기대하는 평가 dict(turns.evaluation 적재용)."""
return self.model_dump(exclude_none=True)
class TechniqueDistribution(BaseModel):
"""deep-loop 기법 분포 — 군집별 카운트 + 과다/과소 진단."""
by_category: dict[str, int] = Field(default_factory=dict) # category.value -> count
by_technique: dict[str, int] = Field(default_factory=dict) # technique.value -> count
total: int = 0
overused: list[str] = Field(default_factory=list) # 과다 사용 군집(category.value)
underused: list[str] = Field(default_factory=list) # 과소/미사용 군집
class SessionEvaluation(BaseModel):
"""deep-loop 회기말/단계전환 정밀 평가 결과.
기법분포 + 잘한 순간 + 개선점(최대3) + 슈퍼바이저 rationale/critique + 의도이탈 집계.
"""
loop: str = "deep"
session_id: str
stage: str # 평가 시점 단계(전환 트리거면 from-stage)
scope: str = "session_end" # 'session_end' | 'stage_transition'
turns_evaluated: int = 0
distribution: TechniqueDistribution = Field(default_factory=TechniqueDistribution)
strengths: list[str] = Field(default_factory=list) # 잘한 순간(근거 포함 문장)
improvements: list[str] = Field(default_factory=list) # 개선점(최대 3)
intent_deviations: list[IntentDeviation] = Field(default_factory=list)
supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합
supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합
alternative_utterances: list[str] = Field(default_factory=list) # 대안 발화 제시
theory_mode: Optional[str] = None
error: Optional[str] = None
def to_dict(self) -> dict[str, Any]:
return self.model_dump(exclude_none=True)
# ════════════════════════════════════════════════════════════════════════════
# 2. structured_schema (게이트웨이 Structured Outputs 강제 — CCD/형식 안전)
# ════════════════════════════════════════════════════════════════════════════
def _technique_enum_values() -> list[str]:
return [t.value for t in Technique]
def _client_state_enum_values() -> list[str]:
return [s.value for s in ClientState]
def _fast_schema() -> dict[str, Any]:
"""fast-loop 구조화 출력 스키마. enum 후보를 코드값으로 강제(파싱 안정)."""
return {
"type": "object",
"additionalProperties": False,
"properties": {
"techniques": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"code": {"type": "string", "enum": _technique_enum_values()},
"rationale": {"type": "string"},
},
"required": ["code"],
},
},
"client_state_read": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"code": {"type": "string", "enum": _client_state_enum_values()},
"rationale": {"type": "string"},
},
"required": ["code"],
},
},
"appropriateness": {"type": "string", "enum": list(_APPROPRIATENESS)},
"appropriateness_note": {"type": "string"},
"rapport_signal": {"type": "number", "minimum": -1, "maximum": 1},
"intent_deviation": {
"type": ["object", "null"],
"additionalProperties": False,
"properties": {
"dimension": {"type": "string"},
"expected": {"type": "string"},
"actual": {"type": "string"},
"severity": {"type": "string", "enum": list(_SEVERITY)},
},
"required": ["dimension", "expected", "actual", "severity"],
},
},
"required": ["techniques", "client_state_read", "appropriateness"],
}
def _deep_schema() -> dict[str, Any]:
"""deep-loop 구조화 출력 스키마. 분포는 코드로 재집계하므로 LLM 엔 정성 평가만 요구."""
return {
"type": "object",
"additionalProperties": False,
"properties": {
"strengths": {"type": "array", "items": {"type": "string"}},
"improvements": {"type": "array", "items": {"type": "string"}, "maxItems": 3},
"intent_deviations": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"dimension": {"type": "string"},
"expected": {"type": "string"},
"actual": {"type": "string"},
"severity": {"type": "string", "enum": list(_SEVERITY)},
},
"required": ["dimension", "expected", "actual", "severity"],
},
},
"supervisor_rationale": {"type": "string"},
"supervisor_critique": {"type": "string"},
"alternative_utterances": {"type": "array", "items": {"type": "string"}},
},
"required": ["strengths", "improvements"],
}
# ════════════════════════════════════════════════════════════════════════════
# 3. 프롬프트 빌더 — 정답 라벨 enum 후보 제시 + 근거 요구
# ════════════════════════════════════════════════════════════════════════════
_EVAL_ROLE = (
"당신은 상담 수련생을 지도하는 슈퍼바이저(평가 AI)다. 학습자(상담자) 발화를 임상적으로 평가한다.\n"
"당신은 내담자의 내부 설정(핵심신념·진단 차원·정답 라벨·상태 수치)을 *전부 볼 수 있다*. "
"이 정보는 평가 근거로만 쓰고, 평가 결과 자체는 학습자에게 직접 노출되지 않는다(시스템이 차단).\n"
"반드시 제시된 후보 라벨(code) 중에서만 고르고, 각 판단에 한국어 근거(rationale)를 붙인다. "
"추측·과잉 라벨링을 피하고, 근거가 약하면 부착하지 않는다."
)
def _technique_candidates_block() -> str:
"""기법 후보(군집별 그룹핑) — code: 한글 형식으로 제시."""
lines: list[str] = ["[기법 후보 — code: 라벨(군집)]"]
# 군집 순서대로 보기 좋게 그룹핑(평가자가 분포를 의식하도록).
by_cat: dict[TechniqueCategory, list[Technique]] = {}
for t, cat in TECHNIQUE_CATEGORY.items():
by_cat.setdefault(cat, []).append(t)
for cat, techs in by_cat.items():
items = ", ".join(f"{t.value}:{TECHNIQUE_KO[t]}" for t in techs)
lines.append(f"- {cat.value}: {items}")
return "\n".join(lines)
def _client_state_candidates_block() -> str:
items = ", ".join(f"{s.value}:{ko}" for s, ko in CLIENT_STATE_KO.items())
return f"[내담자 상태 후보 — code:라벨]\n- {items}"
def _theory_mode(ctx: "TurnContext") -> Optional[str]:
"""페르소나 theory_target 에서 이론 모드 힌트(이론부합 평가용). 없으면 None."""
tt = getattr(ctx.persona, "theory_target", None)
if isinstance(tt, (list, tuple)) and tt:
return ", ".join(str(x) for x in tt)
return None
def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMessage]:
"""fast-loop 평가 프롬프트(L0 역할 + 후보 라벨 + 이번 턴 맥락)."""
st = ctx.state_after or ctx.state_before
theory = _theory_mode(ctx)
recent = "\n".join(
f"{('상담자' if t.get('speaker') == 'counselor' else '내담자')}: {t.get('text', '')}"
for t in (ctx.recent_turns or [])[-4:]
) or "(직전 맥락 없음)"
crisis_note = ""
if ctx.crisis is not None and getattr(ctx.crisis, "escalate", False):
crisis_note = (
"\n[안전] 학습자 발화에서 실제 위기 신호가 감지됨 — 위험사정(risk_assessment) "
"적절성과 안전 페이싱을 특히 살펴라."
)
system = "\n\n".join(
[
_EVAL_ROLE,
_technique_candidates_block(),
_client_state_candidates_block(),
(
"[평가 4차원]\n"
"① technique: 이번 *상담자(학습자)* 발화에 부착되는 기법(복수 가능, 후보 code 만).\n"
"② client_state_read: 이어진 *내담자* 응답에서 읽히는 상태(복수, 후보 code 만).\n"
"③ appropriateness: 이번 상담자 반응의 적절성 — pos(적절)/warn(주의)/neutral(중립).\n"
"④ intent_deviation: '의도와 다른 부분'이 있으면 {dimension, expected, actual, severity}로. "
"없으면 null. 이 항목은 가장 중요하다 — 무리한 생성 금지, 진짜 이탈만.\n"
"추가로 rapport_signal(1~+1): 이 발화가 라포에 끼친 방향(공감·반영=+, 조언점프·평가=)."
),
]
)
user = (
f"[단계] {st.stage.value} [effective_openness] {st.effective_openness:.2f} "
f"[ideation_stage] {st.ideation_stage}"
+ (f" [이론모드] {theory}" if theory else "")
+ crisis_note
+ f"\n\n[직전 맥락]\n{recent}\n\n"
f"[평가 대상 — 상담자(학습자) 발화]\n{ctx.learner_text_masked}\n\n"
f"[이어진 내담자 응답]\n{client_reply}\n\n"
"위 4차원으로 구조화 평가하라. 후보 code 외 라벨 금지, 각 판단에 rationale 첨부."
)
return [
EngineMessage(role="system", content=system, cache=True),
EngineMessage(role="user", content=user, cache=False),
]
def build_deep_messages(
*,
stage: str,
scope: str,
theory_mode: Optional[str],
masked_turns: list[dict[str, str]],
distribution: "TechniqueDistribution",
) -> list[EngineMessage]:
"""deep-loop 평가 프롬프트(전체 회기 + 코드 집계 분포 + 골든라벨 후보)."""
transcript = "\n".join(
f"{t.get('seq', '')}{('상담자' if t.get('speaker') == 'counselor' else '내담자')}: {t.get('text', '')}"
for t in masked_turns
) or "(축어록 없음)"
dist_lines = ", ".join(f"{k}:{v}" for k, v in distribution.by_category.items()) or "(없음)"
over = ", ".join(distribution.overused) or "(없음)"
under = ", ".join(distribution.underused) or "(없음)"
system = "\n\n".join(
[
_EVAL_ROLE,
_technique_candidates_block(),
(
"[deep-loop 지시]\n"
"전체 회기를 보고 정밀 평가한다. 다음을 산출하라:\n"
"- strengths: 학습자가 잘한 구체적 순간(근거 포함, 발화 인용 가능).\n"
"- improvements: 개선점 최대 3개(우선순위 순, 실행가능한 코칭).\n"
"- intent_deviations: '의도와 다른 부분' 전부 {dimension, expected, actual, severity}로.\n"
"- supervisor_rationale: 회기 전반에서 적절했던 개입의 근거(rationale) 종합.\n"
"- supervisor_critique: 과도/부족/평가적 시각 등 주의점(critique) 종합.\n"
"- alternative_utterances: 핵심 장면에 더 나은 대안 상담자 발화 1~3개."
),
]
)
user = (
f"[평가 시점] {scope} [단계] {stage}"
+ (f" [이론모드] {theory_mode}" if theory_mode else "")
+ f"\n[기법 군집 분포(코드 집계)] {dist_lines}\n"
f"[과다 군집] {over} [과소/미사용 군집] {under}\n\n"
f"[마스킹 축어록]\n{transcript}\n\n"
"위를 근거로 deep-loop 평가를 구조화 산출하라. improvements 는 최대 3개."
)
return [
EngineMessage(role="system", content=system, cache=True),
EngineMessage(role="user", content=user, cache=False),
]
# ════════════════════════════════════════════════════════════════════════════
# 4. 응답 파싱 — structured 우선, 없으면 text(JSON) 폴백, 실패는 빈 결과
# ════════════════════════════════════════════════════════════════════════════
def _structured_payload(resp: GenerateResponse) -> Optional[dict[str, Any]]:
"""게이트웨이 structured 우선, 없으면 text 에서 JSON 추출(코드펜스/잡텍스트 관용)."""
if resp.structured is not None and isinstance(resp.structured, dict):
return resp.structured
raw = (resp.text or "").strip()
if not raw:
return None
# ```json ... ``` 펜스 제거
if raw.startswith("```"):
raw = raw.split("```", 2)[1] if raw.count("```") >= 2 else raw.strip("`")
if raw.lstrip().lower().startswith("json"):
raw = raw.lstrip()[4:]
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else None
except (json.JSONDecodeError, ValueError):
# 본문 안에 묻힌 첫 객체만 시도
start, end = raw.find("{"), raw.rfind("}")
if 0 <= start < end:
try:
obj = json.loads(raw[start : end + 1])
return obj if isinstance(obj, dict) else None
except (json.JSONDecodeError, ValueError):
return None
return None
def _parse_intent_deviation(d: Any) -> Optional[IntentDeviation]:
if not isinstance(d, dict):
return None
dim = str(d.get("dimension") or "").strip()
exp = str(d.get("expected") or "").strip()
act = str(d.get("actual") or "").strip()
if not (dim and exp and act):
return None
sev = str(d.get("severity") or "minor").strip()
if sev not in _SEVERITY:
sev = "minor"
return IntentDeviation(dimension=dim, expected=exp, actual=act, severity=sev)
def _parse_fast(payload: dict[str, Any], *, turn_seq: int, stage: str,
theory: Optional[str]) -> TurnEvaluation:
techniques: list[TechniqueTag] = []
for item in payload.get("techniques") or []:
if not isinstance(item, dict):
continue
t = _parse_technique(str(item.get("code", "")))
if t is None:
continue
techniques.append(
TechniqueTag(
code=t.value,
label_ko=TECHNIQUE_KO[t],
category=TECHNIQUE_CATEGORY[t].value,
rationale=(str(item.get("rationale")).strip() or None) if item.get("rationale") else None,
)
)
states: list[ClientStateRead] = []
for item in payload.get("client_state_read") or []:
if not isinstance(item, dict):
continue
s = _parse_client_state(str(item.get("code", "")))
if s is None:
continue
states.append(
ClientStateRead(
code=s.value,
label_ko=CLIENT_STATE_KO[s],
rationale=(str(item.get("rationale")).strip() or None) if item.get("rationale") else None,
)
)
appro = str(payload.get("appropriateness") or "neutral").strip()
if appro not in _APPROPRIATENESS:
appro = "neutral"
rapport = payload.get("rapport_signal")
rapport_val: Optional[float] = None
if isinstance(rapport, (int, float)):
rapport_val = max(-1.0, min(1.0, float(rapport)))
return TurnEvaluation(
loop="fast",
turn_seq=turn_seq,
stage=stage,
techniques=techniques,
client_state_read=states,
appropriateness=appro,
appropriateness_note=(str(payload.get("appropriateness_note")).strip() or None)
if payload.get("appropriateness_note")
else None,
intent_deviation=_parse_intent_deviation(payload.get("intent_deviation")),
rapport_signal=rapport_val,
theory_mode=theory,
)
# ════════════════════════════════════════════════════════════════════════════
# 5. 분포 집계 (코드 결정론 — LLM 미경유, 무손실)
# ════════════════════════════════════════════════════════════════════════════
def aggregate_distribution(technique_codes: list[str]) -> TechniqueDistribution:
"""부착된 기법 코드 리스트 → 군집/기법 분포 + 과다·과소 진단(결정론).
과다/과소는 회기 전체 5 군집 균형 기준의 *경량 휴리스틱*이다(정밀 채점은 deep LLM).
"""
by_tech: dict[str, int] = {}
by_cat: dict[str, int] = {}
for code in technique_codes:
t = _TECHNIQUE_BY_CODE.get(code)
if t is None:
continue
by_tech[t.value] = by_tech.get(t.value, 0) + 1
cat = TECHNIQUE_CATEGORY[t].value
by_cat[cat] = by_cat.get(cat, 0) + 1
total = sum(by_cat.values())
all_cats = [c.value for c in TechniqueCategory]
overused: list[str] = []
underused: list[str] = []
if total > 0:
# 균등 기대치 = total / 군집수. 1.6배↑=과다, 미사용=과소.
expected = total / len(all_cats)
for c in all_cats:
cnt = by_cat.get(c, 0)
if cnt == 0:
underused.append(c)
elif cnt >= max(2, expected * 1.6):
overused.append(c)
return TechniqueDistribution(
by_category=by_cat,
by_technique=by_tech,
total=total,
overused=overused,
underused=underused,
)
# ════════════════════════════════════════════════════════════════════════════
# 6. 공개 평가 함수 (orchestrator 주입형) — async
# ════════════════════════════════════════════════════════════════════════════
async def evaluate_turn(
ctx: "TurnContext",
client_reply: str,
*,
engine: EngineClient,
) -> TurnEvaluation:
"""fast-loop 턴 평가 — 턴 직후 경량 4차원 태깅(비치명적).
engine 장애·파싱 실패 평가(error 사유 기록) 반환한다. 절대 raise 하지 않는다
(orchestrator.run_turn_generate eval_hook None 으로 흡수하지만, 여기서 1 흡수).
"""
st = ctx.state_after or ctx.state_before
theory = _theory_mode(ctx)
base = TurnEvaluation(loop="fast", turn_seq=st.turn_seq, stage=st.stage.value, theory_mode=theory)
try:
req = GenerateRequest(
ai_role="evaluator",
tier="feedback",
messages=build_fast_messages(ctx, client_reply),
structured_schema=_fast_schema(),
max_tokens=900,
temperature=0.2, # 평가는 보수적·재현적으로
session_id=ctx.session_id,
metadata={"loop": "fast", "stage": st.stage.value, "turn_seq": st.turn_seq},
)
resp = await engine.generate(req)
except EngineError as e:
base.error = f"engine_error: {e}"
return base
except Exception as e: # 방어 — 어떤 예외도 상담 루프를 막지 않게
base.error = f"eval_error: {e}"
return base
payload = _structured_payload(resp)
if payload is None:
base.error = "no_structured_output"
return base
try:
return _parse_fast(payload, turn_seq=st.turn_seq, stage=st.stage.value, theory=theory)
except Exception as e: # 파싱 방어
base.error = f"parse_error: {e}"
return base
async def evaluate_session(
*,
session_id: str,
stage: str,
masked_turns: list[dict[str, Any]],
engine: EngineClient,
technique_codes: Optional[list[str]] = None,
theory_mode: Optional[str] = None,
scope: str = "session_end",
) -> SessionEvaluation:
"""deep-loop 정밀 평가 — 단계전환/회기말. 전체 축어록 + 코드 집계 분포 + LLM 정성 평가.
technique_codes: fast-loop 들에서 누적된 부착 기법 코드(없으면 분포).
엔진/파싱 실패는 비치명적(error 기록 + 분포는 코드로 채움).
"""
distribution = aggregate_distribution(technique_codes or [])
counselor_turns = sum(1 for t in masked_turns if t.get("speaker") == "counselor")
base = SessionEvaluation(
loop="deep",
session_id=session_id,
stage=stage,
scope=scope,
turns_evaluated=counselor_turns,
distribution=distribution,
theory_mode=theory_mode,
)
try:
req = GenerateRequest(
ai_role="evaluator",
tier="feedback",
messages=build_deep_messages(
stage=stage,
scope=scope,
theory_mode=theory_mode,
masked_turns=[{k: v for k, v in t.items()} for t in masked_turns],
distribution=distribution,
),
structured_schema=_deep_schema(),
max_tokens=2048,
temperature=0.3,
session_id=session_id,
metadata={"loop": "deep", "scope": scope, "stage": stage},
)
resp = await engine.generate(req)
except EngineError as e:
base.error = f"engine_error: {e}"
return base
except Exception as e:
base.error = f"eval_error: {e}"
return base
payload = _structured_payload(resp)
if payload is None:
base.error = "no_structured_output"
return base
base.strengths = _coerce_str_list(payload.get("strengths"))
base.improvements = _coerce_str_list(payload.get("improvements"))[:3] # 최대 3
base.alternative_utterances = _coerce_str_list(payload.get("alternative_utterances"))
rationale = payload.get("supervisor_rationale")
critique = payload.get("supervisor_critique")
base.supervisor_rationale = str(rationale).strip() if rationale else None
base.supervisor_critique = str(critique).strip() if critique else None
for d in payload.get("intent_deviations") or []:
dev = _parse_intent_deviation(d)
if dev is not None:
base.intent_deviations.append(dev)
return base
# ════════════════════════════════════════════════════════════════════════════
# 7. orchestrator EvalHook 어댑터 — 주입형 클로저(엔진 바인딩)
# ════════════════════════════════════════════════════════════════════════════
def make_eval_hook(engine: EngineClient):
"""orchestrator.EvalHook(Callable[[TurnContext, str], Awaitable[Optional[dict]]]) 호환 클로저.
sessions 라우트가 run_turn_generate(ctx, engine, eval_hook=make_eval_hook(engine_client))
주입한다. 평가 실패는 None 으로(상담 루프 비차단).
"""
async def _hook(ctx: "TurnContext", client_reply: str) -> Optional[dict[str, Any]]:
ev = await evaluate_turn(ctx, client_reply, engine=engine)
d = ev.to_hook_dict()
return d if d else None
return _hook
__all__ = [
"IntentDeviation",
"TechniqueTag",
"ClientStateRead",
"TurnEvaluation",
"TechniqueDistribution",
"SessionEvaluation",
"aggregate_distribution",
"evaluate_turn",
"evaluate_session",
"make_eval_hook",
"build_fast_messages",
"build_deep_messages",
]

View file

@ -0,0 +1,230 @@
"""입출력 가드레일 — PII 마스킹 · 위기분류 · 출력 안전레일.
MASTERPLAN §2.2 / §3.3 / R5 / R7 / F-03, MEMORY_DESIGN §B:
[입력] Presidio PII 마스킹(미설치 정규식 폴백) + 위기분류(실제위기 vs 페르소나 연기)
[출력] 자살수단/방법 정보 차단, ideation_stage 상한(<=3) 강제.
설계 원칙:
- 모듈 경계 명확: 입력 가드레일(mask_pii / classify_crisis) 출력 가드레일(sanitize_client_reply)
순수함수에 가깝게 분리. IO·LLM·DB 의존 없음(테스트·재사용 용이).
- 외부 LLM 경로 진입 *하드 게이트*: 마스킹 원문은 게이트웨이로 절대 나간다(F-03).
- Presidio 선택 의존(미설치 환경에서도 import 가능해야 ) 지연 로드 + 정규식 폴백.
TODO(Phase 2): Presidio MedicalNERRecognizer + 한국어 자살콘텐츠 분류기(JMIR few-shot 5단계, R8)
교체. 현재 정규식/키워드 폴백은 1 안전망(재현율 우선).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
# ── 출력 가드레일 상한 (R5) ──────────────────────────────
IDEATION_STAGE_CAP = 3 # 내담자 발화/상태가 넘을 수 없는 자살사고 단계 상한
# ════════════════════════════════════════════════════════════════════════════
# 1. PII 마스킹 (입력 — 저장·외부전송 전 하드 게이트, F-03)
# ════════════════════════════════════════════════════════════════════════════
# 정규식 폴백 패턴 (Presidio 미설치 시). 한국 맥락 우선.
# TODO: Presidio + MedicalNERRecognizer 로 정밀화(이름/주소/기관 NER).
_PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
# 주민등록번호 (6자리-7자리)
("RRN", re.compile(r"\b\d{6}[-\s]?\d{7}\b")),
# 휴대폰 (010-1234-5678 등)
("PHONE", re.compile(r"\b01[016789][-\s]?\d{3,4}[-\s]?\d{4}\b")),
# 일반 전화
("PHONE", re.compile(r"\b0\d{1,2}[-\s]?\d{3,4}[-\s]?\d{4}\b")),
# 이메일
("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")),
# 카드/계좌 유사 긴 숫자열 (12자리 이상)
("NUMID", re.compile(r"\b\d{12,}\b")),
]
# Presidio 지연 로드 캐시 (-1=미시도, None=미설치, 객체=설치됨)
_PRESIDIO_ANALYZER: object = -1
_PRESIDIO_ANONYMIZER: object = -1
def _try_load_presidio():
"""Presidio (analyzer, anonymizer) 지연 로드. 미설치면 (None, None)."""
global _PRESIDIO_ANALYZER, _PRESIDIO_ANONYMIZER
if _PRESIDIO_ANALYZER != -1:
return _PRESIDIO_ANALYZER, _PRESIDIO_ANONYMIZER
try:
from presidio_analyzer import AnalyzerEngine # type: ignore
from presidio_anonymizer import AnonymizerEngine # type: ignore
_PRESIDIO_ANALYZER = AnalyzerEngine()
_PRESIDIO_ANONYMIZER = AnonymizerEngine()
except Exception:
_PRESIDIO_ANALYZER = None
_PRESIDIO_ANONYMIZER = None
return _PRESIDIO_ANALYZER, _PRESIDIO_ANONYMIZER
@dataclass(slots=True)
class MaskResult:
text_masked: str
entities: list[str] = field(default_factory=list) # 탐지된 엔티티 타입들
used_presidio: bool = False
def mask_pii(text: str) -> MaskResult:
"""PII 마스킹. Presidio 가용 시 우선, 아니면 정규식 폴백.
반환 text_masked 저장(turns.text_masked)·외부 LLM 전송에 사용한다(F-03).
"""
if not text:
return MaskResult(text_masked=text, entities=[], used_presidio=False)
analyzer, anonymizer = _try_load_presidio()
if analyzer is not None and anonymizer is not None:
try:
results = analyzer.analyze(text=text, language="en") # TODO: ko 모델 등록 시 language="ko"
ents = sorted({r.entity_type for r in results})
anonymized = anonymizer.anonymize(text=text, analyzer_results=results)
return MaskResult(text_masked=anonymized.text, entities=ents, used_presidio=True)
except Exception:
pass # 폴백으로
# 정규식 폴백
masked = text
found: list[str] = []
for label, pat in _PII_PATTERNS:
if pat.search(masked):
found.append(label)
masked = pat.sub(f"[{label}]", masked)
return MaskResult(text_masked=masked, entities=sorted(set(found)), used_presidio=False)
# ════════════════════════════════════════════════════════════════════════════
# 2. 위기 분류 (입력 — 실제위기 vs 페르소나 연기 구분, R8)
# ════════════════════════════════════════════════════════════════════════════
class CrisisKind(str, Enum):
NONE = "none"
PERSONA_PLAY = "persona_play" # 페르소나 연기 맥락의 위기 표현(시뮬레이션 정상)
LEARNER_REAL = "learner_real" # 수련생 본인의 실제 위기 신호(에스컬레이션 대상)
@dataclass(slots=True)
class CrisisResult:
kind: CrisisKind
risk_level: int = 0 # 0~5 (한국어 자살콘텐츠 5단계 자리; 현재 휴리스틱)
matched: list[str] = field(default_factory=list)
escalate: bool = False # safety_events 적재 + 교수자 알림 트리거 여부
# 위기 표현 키워드(한국어 우선). TODO: JMIR 한국어 벤치 few-shot 분류기로 교체(R8).
_CRISIS_TERMS = [
"죽고 싶", "죽고싶", "자살", "목숨", "사라지고 싶", "없어지고 싶",
"자해", "끝내고 싶", "살기 싫", "살아서 뭐", "죽어야",
]
# 실제 위기로 가중되는 1인칭 현재 단서(수련생 본인 신호일 가능성)
_FIRST_PERSON_NOW = ["지금 나", "나 진짜", "제가 지금", "저 지금", "real", "도와주세요"]
def classify_crisis(text: str, *, speaker_is_persona_context: bool = True) -> CrisisResult:
"""위기 분류.
Args:
speaker_is_persona_context: True 상담 시뮬레이션 발화(수련생가상내담자) 맥락.
경우 위기 표현은 기본 PERSONA_PLAY 본다(연기). 1인칭 실제 단서가 강하면
LEARNER_REAL 승격해 에스컬레이션(보수적, 재현율 우선).
NOTE: 가상내담자(P1) 자살사고 연기는 시뮬레이션 핵심이라 차단 대상이 아니다.
진짜 위험은 *수련생 본인* 실제 위기 신호다.
"""
if not text:
return CrisisResult(kind=CrisisKind.NONE)
matched = [t for t in _CRISIS_TERMS if t in text]
if not matched:
return CrisisResult(kind=CrisisKind.NONE)
risk = min(5, 2 + len(matched)) # 거친 단계화(휴리스틱)
real_cues = [c for c in _FIRST_PERSON_NOW if c in text]
if not speaker_is_persona_context or real_cues:
# 실제 위기 가능 → 에스컬레이션(보수적)
return CrisisResult(
kind=CrisisKind.LEARNER_REAL,
risk_level=max(risk, 4),
matched=matched + real_cues,
escalate=True,
)
# 시뮬레이션 연기 맥락
return CrisisResult(
kind=CrisisKind.PERSONA_PLAY,
risk_level=risk,
matched=matched,
escalate=False,
)
# ════════════════════════════════════════════════════════════════════════════
# 3. 출력 가드레일 (내담자 AI 응답 — 자살수단 차단, ideation 상한 R5)
# ════════════════════════════════════════════════════════════════════════════
# 자살/자해 '수단·방법' 정보 패턴 (절대 출력 금지). 정서 표현은 허용.
_MEANS_TERMS = [
"약을 얼마나", "몇 알", "몇 정", "투신", "목을 매", "목 매", "번개탄", "농약",
"손목을", "칼로", "뛰어내리", "수면제 ", "", "방법은", "어떻게 죽",
]
@dataclass(slots=True)
class OutputGuardResult:
text: str # 정제된(또는 원본) 응답
blocked: bool = False # 차단/치환 발생 여부
needs_regeneration: bool = False # 재생성 필요(수단정보 누출 시)
reasons: list[str] = field(default_factory=list)
def sanitize_client_reply(text: str, *, ideation_stage: int) -> OutputGuardResult:
"""내담자 AI 응답 출력 가드레일.
1. 자살/자해 수단·방법 정보가 있으면 needs_regeneration=True(차단, 재생성 신호).
2. ideation_stage > IDEATION_STAGE_CAP(3) 이면 안전 상한 위반으로 기록(상태머신이 상한 강제하나
이중방어). 응답 자체 텍스트는 정서 표현이면 유지.
"""
reasons: list[str] = []
blocked = False
needs_regen = False
if text:
for term in _MEANS_TERMS:
if term in text:
reasons.append(f"means_info:{term}")
blocked = True
needs_regen = True
break
if ideation_stage > IDEATION_STAGE_CAP:
reasons.append(f"ideation_over_cap:{ideation_stage}>{IDEATION_STAGE_CAP}")
blocked = True
return OutputGuardResult(
text=text,
blocked=blocked,
needs_regeneration=needs_regen,
reasons=reasons,
)
def clamp_ideation(stage: int) -> int:
"""ideation_stage 를 안전 상한(3)으로 클램프 (R5)."""
return max(1, min(IDEATION_STAGE_CAP, stage))
__all__ = [
"IDEATION_STAGE_CAP",
"MaskResult",
"mask_pii",
"CrisisKind",
"CrisisResult",
"classify_crisis",
"OutputGuardResult",
"sanitize_client_reply",
"clamp_ideation",
]

View file

@ -0,0 +1,180 @@
"""회기 라이프사이클 메모리 — 시작 회상 + 종료 carry-over (4계층 매핑).
MEMORY_KNOWLEDGE_PERSONA_DESIGN §1·§2·§8 + 4 대원칙(P1~P4):
WORKING : 상태머신 수치(state_machine.SessionState) 체크포인트
EPISODIC : 발화(turns) append-only 회상 검색 대상
SUMMARY : 회기종료 압축(end_state 무손실 + digest 서사 LLM 압축)
SEMANTIC : case_profile 누적 + pinned_fact
핵심 원칙:
- P2/P4: 숫자(상태 수치) 코드가 무손실 복사(carry-over). 서사(narrative) LLM 압축.
- P3: 회상은 큰그림세부 순서(case_digest 직전 summary episodic recall) 토큰 예산 배분.
- 회상 요약(recall_summary)에는 CCD·정답·평가가 절대 들어가지 않는다(M6, 내담자 ).
모듈은 *순수 조립/ 로직* + (선택) LLM 압축 *트리거 * 담당한다.
실제 임베딩/하이브리드 검색은 RAG(Features) 소유 여기선 인터페이스(주입형) 추상화한다.
DB 미가용(Docker off) 시에도 동작하도록 입력은 plain dict/list 받는다.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from .state_machine import SessionState
# ════════════════════════════════════════════════════════════════════════════
# 회기 시작 — 회상 (큰그림 → 세부)
# ════════════════════════════════════════════════════════════════════════════
@dataclass(slots=True)
class RecallContext:
"""회기 시작 회상 결과. 내담자 AI system L2 주입용.
CCD/정답/평가 미포함(내담자 ). digest/open_threads "자기 기억" 표면만.
"""
recall_summary: Optional[str] = None # UI 카드 + L2 주입(큰그림→세부 합본)
pinned_facts: list[str] = field(default_factory=list) # L4 hard-pin(무손실)
open_threads: list[str] = field(default_factory=list)
carry: Optional[dict] = None # 직전 end_state(결정론 carry-over 입력)
def build_recall_context(
*,
case_digest: Optional[str] = None, # ④ 큰그림 (~400토큰 예산)
prev_summary: Optional[dict] = None, # ③ 직전 session_summary {digest, open_threads, homework, end_state}
episodic_snippets: Optional[list[str]] = None, # ② recall top-k 세부 (RAG 주입형)
pinned_facts: Optional[list[str]] = None, # ④ pinned_fact value[]
) -> RecallContext:
"""회상 컨텍스트 조립 (P3: 큰그림→세부 순서로 토큰 예산 배분).
DB/RAG 없으면 인자들이 None RecallContext( 회기·in-proc fallback).
호출부(sessions.start) DB/RAG 가용 채워 넣는다.
"""
lines: list[str] = []
if case_digest:
lines.append(f"[케이스 큰그림]\n{case_digest}")
prev_end_state: Optional[dict] = None
open_threads: list[str] = []
if prev_summary:
digest = prev_summary.get("digest")
if digest:
lines.append(f"[직전 회기 요약]\n{digest}")
open_threads = list(prev_summary.get("open_threads") or [])
if open_threads:
ot = "\n".join(f"- {t}" for t in open_threads)
lines.append(f"[미해결 주제]\n{ot}")
homework = prev_summary.get("homework")
if homework:
lines.append(f"[지난 과제]\n{homework}")
prev_end_state = prev_summary.get("end_state")
if episodic_snippets:
snips = "\n".join(f"- {s}" for s in episodic_snippets)
lines.append(f"[지난 대화 단편(세부)]\n{snips}")
recall_summary = "\n\n".join(lines) if lines else None
return RecallContext(
recall_summary=recall_summary,
pinned_facts=list(pinned_facts or []),
open_threads=open_threads,
carry=prev_end_state,
)
# ════════════════════════════════════════════════════════════════════════════
# 회기 종료 — carry-over (무손실 수치 복사 + 서사 압축 트리거)
# ════════════════════════════════════════════════════════════════════════════
@dataclass(slots=True)
class CarryOver:
"""회기 종료 무손실 carry-over (P4: 코드 복사, LLM 미경유).
end_state = 상태머신 종료 snapshot(다음 회기 init_state 입력).
compression_job = 서사 digest LLM 압축이 *필요한* 입력 묶음(비동기 대상).
"""
end_state: dict
rapport_delta: float = 0.0
compression_job: Optional["CompressionJob"] = None
@dataclass(slots=True)
class CompressionJob:
"""회기종료 narrative 압축 작업(LLM, 비동기 비블로킹). 큐에 적재될 페이로드.
입력은 *마스킹된 발화*(F-03). 실제 LLM 호출/임베딩/DB UPSERT
orchestrator/background task engine_client + RAG 수행한다(여기선 페이로드만).
"""
session_id: str
case_id: Optional[str]
session_no: int
masked_turns: list[dict[str, str]] # [{speaker, text}] (text_masked)
end_state: dict
open_threads: list[str] = field(default_factory=list)
def make_carry_over(
*,
state: SessionState,
session_id: str,
case_id: Optional[str],
session_no: int,
masked_turns: list[dict[str, str]],
prev_rapport_credit: float = 0.0,
open_threads: Optional[list[str]] = None,
) -> CarryOver:
"""회기 종료 carry-over 생성.
(A) 무손실: end_state = state.snapshot() (코드 복사) [P4]
(B) rapport_delta = 종료 rapport_credit 이전 회기 rapport_credit
(C) 서사 압축은 CompressionJob 으로 큐잉(LLM, 비동기) 여기선 페이로드만 만든다
"""
end_state = state.snapshot()
rapport_delta = round(state.rapport_credit - prev_rapport_credit, 4)
job = CompressionJob(
session_id=session_id,
case_id=case_id,
session_no=session_no,
masked_turns=masked_turns,
end_state=end_state,
open_threads=list(open_threads or []),
)
return CarryOver(end_state=end_state, rapport_delta=rapport_delta, compression_job=job)
def build_compression_messages(job: CompressionJob) -> list[dict[str, str]]:
"""CompressionJob → 서사 압축용 EngineMessage 평문(dict) 리스트.
실제 호출은 orchestrator/background engine_client.generate(GenerateRequest(
ai_role='evaluator', tier='feedback', ...)) 수행. 여기선 프롬프트만 조립(IO 없음).
pinned 사실 보존·정답 미포함 지시 포함.
"""
transcript = "\n".join(
f"{('상담자' if t.get('speaker') == 'counselor' else '내담자')}: {t.get('text', '')}"
for t in job.masked_turns
)
threads = "\n".join(f"- {t}" for t in job.open_threads) or "(없음)"
system = (
"당신은 상담 회기 종료 요약기다. 아래 마스킹된 축어록을 6~10문장 digest 로 압축한다.\n"
"규칙: ① 사실·정서 궤적·미해결 주제를 보존한다. ② 평가/점수/정답 라벨은 절대 포함하지 않는다.\n"
"③ 내담자가 실제로 말한 사실은 바꾸지 않는다(무손실). ④ 한국어, 간결한 임상 서술체."
)
user = (
f"[회기 번호] {job.session_no}\n"
f"[종료 상태(수치, 참고)] {job.end_state}\n"
f"[미해결 주제]\n{threads}\n\n"
f"[마스킹된 축어록]\n{transcript}\n\n"
"위를 digest 6~10문장으로 압축하라."
)
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
__all__ = [
"RecallContext",
"build_recall_context",
"CarryOver",
"CompressionJob",
"make_carry_over",
"build_compression_messages",
]

View file

@ -0,0 +1,329 @@
"""턴 오케스트레이터 — 상담 1턴 파이프라인 1~8단계 조립.
MASTERPLAN §2.2 / MEMORY_DESIGN §2-B 사이클:
1. [입력 가드레일] PII 마스킹 + 위기분류(실제위기 vs 연기) (guardrail)
2. [상태머신] effective_openness 결정론 계산 + 단계전이 (state_machine)
3. [페르소나 컨텍스트] L0~L6 messages 조립 (persona)
4. [내담자 AI] engine_client.stream/generate (CCD 비노출) (engine_client)
5. [출력 가드레일] 자살수단 차단, ideation 상한 (guardrail)
6. [평가 ] 주입형 평가 함수는 *인자로 받는다*(Features 소유) (hook)
7. [상태 갱신] working state 반영(체크포인트는 호출부가 DB/store UPSERT)
8. [로깅 ] 주입형 turns insert/임베딩은 호출부가 주입
설계 원칙:
- 평가(evaluator) 함수와 로깅 함수는 *주입*받는다( 모듈은 evaluator.py import 하지 않음).
- DB 인터페이스로 추상화하되 asyncpg conn 받을 있게 했다(현재는 hook 으로만 사용).
- generate(동기, 폴백/테스트) + stream(SSE 토큰) 경로 모두 제공.
- 엔진 장애는 EngineError 전파 라우트가 503/SSE error 프레임으로 변환.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
from ..engine_client import (
EngineClient,
EngineError,
EngineMessage,
GenerateRequest,
GenerateResponse,
StreamRequest,
)
from . import guardrail, persona, state_machine
from .persona import PersonaCard, PersonaStateContext
from .state_machine import SessionState, Stage
# 평가 훅 타입: U_t(수련생 마스킹 발화) + 내담자응답 + 상태 → 평가 결과(dict)
# Features evaluator 가 이 시그니처에 맞춰 함수를 주입한다(여기선 호출만).
EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]]
# 로깅 훅: TurnContext + 내담자응답 → None (turns insert/임베딩은 주입측 책임)
LogHook = Callable[["TurnContext", str], Awaitable[None]]
@dataclass(slots=True)
class TurnContext:
"""한 턴 파이프라인을 관통하는 컨텍스트(가드레일/상태/페르소나 산출 집약)."""
session_id: str
case_id: Optional[str]
persona: PersonaCard
state_before: SessionState
learner_text_raw: str
learner_text_masked: str = ""
crisis: Optional[guardrail.CrisisResult] = None
state_after: Optional[SessionState] = None
messages: list[EngineMessage] = field(default_factory=list)
# 회상/메모리 주입(memory.RecallContext 에서 옴)
recall_summary: Optional[str] = None
pinned_facts: list[str] = field(default_factory=list)
recent_turns: list[dict[str, str]] = field(default_factory=list)
kb_behavior_cues: list[str] = field(default_factory=list)
def to_state_context(self) -> PersonaStateContext:
st = self.state_after or self.state_before
return PersonaStateContext(
stage=st.stage.value,
effective_openness=st.effective_openness,
resistance=st.resistance,
rapport_credit=st.rapport_credit,
ideation_stage=st.ideation_stage,
affect_state=st.affect_state,
)
@dataclass(slots=True)
class TurnResult:
"""동기(generate) 턴 결과."""
turn_seq: int
stage: str
effective_openness: float
client_reply: Optional[str]
safety_flagged: bool
state_after: SessionState
evaluation: Optional[dict] = None
crisis_kind: str = "none"
# ════════════════════════════════════════════════════════════════════════════
# 1~3단계 — 입력 가드레일 + 상태머신 + 페르소나 컨텍스트 (엔진 호출 전 결정론)
# ════════════════════════════════════════════════════════════════════════════
def prepare_turn(
*,
session_id: str,
case_id: Optional[str],
card: PersonaCard,
state: SessionState,
learner_text: str,
recall_summary: Optional[str] = None,
pinned_facts: Optional[list[str]] = None,
recent_turns: Optional[list[dict[str, str]]] = None,
kb_behavior_cues: Optional[list[str]] = None,
eval_rapport_signal: Optional[float] = None,
) -> TurnContext:
"""엔진 호출 전 결정론 전처리(1~3단계). 순수 — IO/LLM 없음.
eval_rapport_signal 주어지면(평가 AI fast-loop 신호) 그걸 쓰고, 없으면
state_machine 경량 휴리스틱으로 라포 신호를 추정한다.
"""
ctx = TurnContext(
session_id=session_id,
case_id=case_id,
persona=card,
state_before=state,
learner_text_raw=learner_text,
recall_summary=recall_summary,
pinned_facts=list(pinned_facts or []),
recent_turns=list(recent_turns or []),
kb_behavior_cues=list(kb_behavior_cues or []),
)
# 1) 입력 가드레일 — PII 마스킹 + 위기분류
mask = guardrail.mask_pii(learner_text)
ctx.learner_text_masked = mask.text_masked
ctx.crisis = guardrail.classify_crisis(learner_text, speaker_is_persona_context=True)
# 2) 상태머신 — 라포 신호 → 결정론 전이
signal = (
eval_rapport_signal
if eval_rapport_signal is not None
else state_machine.estimate_rapport_signal(ctx.learner_text_masked)
)
ctx.state_after = state_machine.evolve(
state,
rapport_signal=signal,
unlock_rate=card.unlock_rate(),
decay_floor=card.decay_floor(),
)
# 3) 페르소나 컨텍스트 — L0~L6 messages 조립 (CCD 는 행동으로만, L0 가 강제)
ctx.messages = persona.build_turn_messages(
card,
ctx.to_state_context(),
ctx.learner_text_masked,
recall_summary=ctx.recall_summary,
pinned_facts=ctx.pinned_facts,
recent_turns=ctx.recent_turns,
kb_behavior_cues=ctx.kb_behavior_cues,
)
return ctx
# ════════════════════════════════════════════════════════════════════════════
# 4~8단계 — 동기 생성 경로 (폴백/테스트)
# ════════════════════════════════════════════════════════════════════════════
async def run_turn_generate(
ctx: TurnContext,
engine: EngineClient,
*,
eval_hook: Optional[EvalHook] = None,
log_hook: Optional[LogHook] = None,
) -> TurnResult:
"""동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가·로깅 훅 순차 적용.
eval_hook/log_hook Features 주입(없으면 생략). 엔진 장애는 EngineError 전파.
"""
assert ctx.state_after is not None
st = ctx.state_after
# 4) 내담자 AI 생성
req = GenerateRequest(
ai_role="client",
tier="client",
messages=ctx.messages,
session_id=ctx.session_id,
metadata={"stage": st.stage.value},
)
resp: GenerateResponse = await engine.generate(req)
reply = resp.text
# 5) 출력 가드레일 — 수단 차단 + ideation 상한
guard = guardrail.sanitize_client_reply(reply, ideation_stage=st.ideation_stage)
safety_flagged = guard.blocked or (ctx.crisis is not None and ctx.crisis.escalate)
if guard.needs_regeneration:
# 수단정보 누출 → 안전 대체 응답으로 치환(1차). 재생성 루프는 후속.
reply = "…(말을 잇지 못하고 잠시 침묵한다)"
# 6) 평가 훅(주입형) — 평가 AI 4차원 태깅 (Features 소유)
evaluation: Optional[dict] = None
if eval_hook is not None:
try:
evaluation = await eval_hook(ctx, reply)
except Exception:
evaluation = None # 평가 실패가 상담 루프를 막지 않게(비치명적)
# 8) 로깅 훅(주입형) — turns insert + 임베딩
if log_hook is not None:
try:
await log_hook(ctx, reply)
except Exception:
pass
return TurnResult(
turn_seq=st.turn_seq,
stage=st.stage.value,
effective_openness=st.effective_openness,
client_reply=reply,
safety_flagged=safety_flagged,
state_after=st,
evaluation=evaluation,
crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none",
)
# ════════════════════════════════════════════════════════════════════════════
# 4~8단계 — SSE 스트림 경로 (기본 UX)
# ════════════════════════════════════════════════════════════════════════════
@dataclass(slots=True)
class StreamEvent:
"""SSE 재방출용 이벤트. 라우트가 sse_starlette 형식으로 변환."""
event: str # 'token' | 'done' | 'safety' | 'error'
data: dict[str, Any]
async def run_turn_stream(
ctx: TurnContext,
engine: EngineClient,
*,
log_hook: Optional[LogHook] = None,
) -> AsyncIterator[StreamEvent]:
"""스트리밍 턴 실행(4~8). 게이트웨이 SSE 를 받아 token/done/safety/error 로 재방출.
출력 가드레일은 *누적 텍스트* 기준으로 수단정보를 감지(스트림 발견 safety 이벤트 +
재생성 신호). 토큰 단위 완벽 차단은 후속(현재는 누적 스캔).
로깅 훅은 done 직전 최종 텍스트로 1 호출.
"""
assert ctx.state_after is not None
st = ctx.state_after
req = StreamRequest(
ai_role="client",
tier="client",
messages=ctx.messages,
session_id=ctx.session_id,
metadata={"stage": st.stage.value},
)
accumulated = ""
flagged = False
if ctx.crisis is not None and ctx.crisis.escalate:
flagged = True
yield StreamEvent("safety", {"reason": "learner_real_crisis", "level": ctx.crisis.risk_level})
try:
async for raw in engine.stream(req):
# engine_client.stream 은 게이트웨이 SSE 의 *원시 라인*을 그대로 yield 한다.
# 게이트웨이 프레이밍: "event: token\ndata: {\"text\": ...}" 형식.
text_piece = _extract_sse_text(raw)
if text_piece is None:
continue
accumulated += text_piece
# 출력 가드레일(누적 스캔) — 수단정보 발견 시 차단·재생성 신호
guard = guardrail.sanitize_client_reply(accumulated, ideation_stage=st.ideation_stage)
if guard.needs_regeneration and not flagged:
flagged = True
yield StreamEvent("safety", {"reason": "means_info_blocked"})
# 토큰은 더 내보내지 않고 안전 대체로 종결
accumulated = "…(말을 잇지 못하고 잠시 침묵한다)"
break
yield StreamEvent("token", {"text": text_piece})
# 8) 로깅 훅 — 최종 텍스트
if log_hook is not None:
try:
await log_hook(ctx, accumulated)
except Exception:
pass
yield StreamEvent(
"done",
{
"session_id": ctx.session_id,
"stage": st.stage.value,
"effective_openness": round(st.effective_openness, 4),
"turn_seq": st.turn_seq,
"safety_flagged": flagged,
},
)
except EngineError as e:
yield StreamEvent("error", {"detail": str(e)})
def _extract_sse_text(raw_line: str) -> Optional[str]:
"""게이트웨이 SSE 원시 라인에서 텍스트 델타를 추출.
게이트웨이 /v1/stream 'event: token' + 'data: {"text": "..."}' 보낸다.
engine_client.stream 줄을 필터링하고 비어있지 않은 라인만 흘리므로
여기서 data: 라인의 JSON 해석한다. token 이외 이벤트(done/error) None.
"""
import json as _json
line = raw_line.strip()
if not line.startswith("data:"):
return None
payload = line[len("data:"):].strip()
if not payload or payload == "[DONE]":
return None
try:
obj = _json.loads(payload)
except _json.JSONDecodeError:
return None
if isinstance(obj, dict) and "text" in obj:
return obj["text"]
return None
__all__ = [
"EvalHook",
"LogHook",
"TurnContext",
"TurnResult",
"StreamEvent",
"prepare_turn",
"run_turn_generate",
"run_turn_stream",
]

View file

@ -0,0 +1,402 @@
"""가상내담자 페르소나 시스템프롬프트 빌더 + 시드 페르소나 P1/P2/P3.
논리 레이어 (MASTERPLAN §1.2, MEMORY_KNOWLEDGE_PERSONA_DESIGN §2):
[L0 역할+안전가드레일+도식노출금지]
[L1 페르소나 카드(정적, CCD 포함)] cache_control (입력비 절감 대상)
[L2 RAG 임상청크 / 회상]
[L3 상태머신 주입(stage, openness, resistance, ideation)]
[L4 메모리 버퍼(pinned fact hard-pin)]
[L6 발화지시( 턴에 어떻게 말할지)]
핵심 안전 불변식 (R4 / R5 / M6):
- CCD(core_belief·automatic_thought·coping)·DSM 차원·정답 라벨은 *행동으로만* 드러낸다.
"제 핵심신념은…" 같은 메타 발화 절대 금지 추론 훈련 무력화 차단.
- 자살수단·구체적 방법 정보는 절대 발화하지 않는다(ideation_stage 상한은 출력 가드레일이 재차 강제).
페르소나 카드는 본래 DB(app.persona_card) SoR. 여기 시드 dict Docker 없이도
1턴이 돌도록 하는 in-proc fallback(설계서 §3.1 컬럼 구조를 그대로 따른다).
P1 = 0615 청소년 '서연' 사례의 합성 변형(원문 미적재, F-05).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Optional
from ..engine_client import EngineMessage
# ════════════════════════════════════════════════════════════════════════════
# 페르소나 카드 (app.persona_card 컬럼 구조의 in-proc 표현)
# ════════════════════════════════════════════════════════════════════════════
@dataclass(slots=True)
class PersonaCard:
"""불변 페르소나 정체성 (L1). DB persona_card 1행에 대응."""
code: str # 'P1' | 'P2' | 'P3'
display_name: str
difficulty: str # 'easy' | 'moderate' | 'hard'
theory_target: list[str] # {'humanistic','cbt'}
demographics: dict[str, Any] # 범주화(재식별 방지): age_band, sex, grade...
presenting: dict[str, Any] # 표층 호소(입으로 말함)
history: dict[str, Any] # 과거사
big5: dict[str, float] # {O,C,E,A,N} 0~1 (말투·반응 앵커)
resistance: dict[str, float] # {base_resistance, unlock_rate, decay_floor, silence_prob, deflection_prob}
speech_style: dict[str, Any] # {register, avg_sentence_len, fillers, honorific, verbal_tics}
affect_baseline: dict[str, float] # {negative_affect, hopelessness, anhedonia, sleep, anxiety, suicide_ideation_stage}
ccd: dict[str, Any] # Patient-Ψ 8요소 (★직접발화 금지 — 행동으로만)
dsm5_dimensional: dict[str, Any] # criteria_behavior_matrix (진단명 비노출)
source_provenance: str = "0615 합성변형"
is_synthetic: bool = True
def base_resistance(self) -> float:
return float(self.resistance.get("base_resistance", 0.5))
def unlock_rate(self) -> float:
return float(self.resistance.get("unlock_rate", 0.1))
def decay_floor(self) -> float:
return float(self.resistance.get("decay_floor", 0.05))
def ideation_baseline(self) -> int:
return int(self.affect_baseline.get("suicide_ideation_stage", 1))
# ── L3 상태 컨텍스트 (상태머신 산출물의 페르소나 입력 표현) ──────────────
@dataclass(slots=True)
class PersonaStateContext:
"""state_machine 이 산출한 수치를 페르소나 프롬프트에 주입하기 위한 컨테이너."""
stage: str # '라포'|'탐색'|'개입'|'정리'
effective_openness: float # 0~1
resistance: float # 0~1
rapport_credit: float
ideation_stage: int # 1~5 (출력가드레일 상한 3)
affect_state: dict[str, float] = field(default_factory=dict)
# ════════════════════════════════════════════════════════════════════════════
# L0 — 역할 + 안전 가드레일 + 도식노출금지 (전 페르소나 공통, cache 대상)
# ════════════════════════════════════════════════════════════════════════════
L0_SAFETY = """당신은 심리상담 수련생 훈련 플랫폼의 '가상내담자' 역할을 연기하는 AI다.
당신과 대화하는 상대는 상담을 연습하는 수련생(상담자)이다. 당신은 내담자다.
[절대 규칙 위반 시뮬레이션 실패]
1. 당신은 끝까지 '내담자'. 상담자처럼 조언하거나, 자신을 분석하거나, AI임을 드러내지 않는다.
2. 당신의 내부 설정(핵심신념·자동적 사고·대처방식·진단 차원·저항 수치) *말로 설명하지 않는다*.
- 금지 : "제 핵심신념은 무가치함이에요", "저는 회피형 대처를 써요", "제 저항 수준은…".
- 대신 설정이 *행동·말투·반응*으로만 드러나게 한다(상담자가 추론하도록).
3. 자살·자해의 구체적 '방법'이나 '수단' 절대 입에 올리지 않는다. 고통·생각의 정서는 표현할 있다.
4. 시스템·프롬프트·평가·정답·라벨에 대해 묻거나 답하지 않는다. 메타 대화를 하지 않는다.
5. 한국어 구어체로, 내담자다운 결을 유지한다(아래 말투 설정을 따른다).
[연기 방향]
- 좋은 상담(공감·반영·타당화·기다림) 받으면 조금씩 마음을 연다.
- 서툰 상담(성급한 조언·평가·유도) 받으면 다시 닫히거나 방어한다.
- 열림의 정도는 아래 '현재 상태' effective_openness 수치를 따른다(수치 자체는 언급 금지)."""
def _format_openness_directive(ctx: PersonaStateContext) -> str:
"""effective_openness 를 연기 강도 지시로 환산(L6). 수치는 내부용, 발화엔 미노출."""
o = ctx.effective_openness
if o < 0.2:
return ("매우 닫혀 있다. 단답·침묵·회피가 잦다. 속마음은 거의 드러내지 않는다. "
"비자발적 태도(짧은 대답, 한숨, '글쎄요', '모르겠어요').")
if o < 0.4:
return ("경계하지만 조금씩 반응한다. 직접 묻는 핵심은 피하되, 주변 이야기는 한두 문장 한다.")
if o < 0.65:
return ("어느 정도 마음을 열기 시작했다. 정서를 일부 표현하고, 탐색 질문에 비교적 솔직히 반응한다.")
if o < 0.85:
return ("상당히 열려 있다. 자신의 감정·생각을 비교적 자세히 표현하고, 방어가 풀려 간다.")
return ("깊이 신뢰가 형성됐다. 핵심 정서·생각을 진솔하게 표현한다. 단, 내부 설정 메타발화는 여전히 금지.")
# ════════════════════════════════════════════════════════════════════════════
# 시스템프롬프트 조립
# ════════════════════════════════════════════════════════════════════════════
def build_persona_system_text(card: PersonaCard) -> str:
"""L0+L1 시스템 텍스트(정적, 회기 내 불변 → cache_control 대상).
CCD/DSM 차원은 *AI 프롬프트엔 포함*하되 L0 규칙으로 '행동으로만' 드러내게 강제.
텍스트는 응답으로 누설되면 되며, 누설 차단은 L0 + 출력 가드레일 이중방어.
"""
speech = card.speech_style
big5 = card.big5
parts = [
L0_SAFETY,
"",
f"[L1 페르소나 카드 — {card.display_name} ({card.code}, 난이도={card.difficulty})]",
f"인적(범주): {card.demographics}",
f"표층 호소(입으로 말할 수 있는 것): {card.presenting}",
f"과거사: {card.history}",
(f"말투: 어조={speech.get('register')}, 평균문장길이={speech.get('avg_sentence_len')}, "
f"군말={speech.get('fillers')}, 존댓말={speech.get('honorific')}, 말버릇={speech.get('verbal_tics')}"),
(f"성격(Big5, 0~1 — 반응 앵커, 언급 금지): O={big5.get('O')} C={big5.get('C')} "
f"E={big5.get('E')} A={big5.get('A')} N={big5.get('N')}"),
"",
"[내부 설정 — ★절대 입으로 설명하지 말고 행동/반응으로만 드러낸다 (R4)]",
f"핵심신념·자동사고·대처(CCD): {card.ccd}",
f"증상 차원(진단명 비노출): {card.dsm5_dimensional}",
f"정서 기저선: {card.affect_baseline}",
(f"저항 파라미터(언급 금지): base={card.base_resistance()}, unlock={card.unlock_rate()}, "
f"침묵확률={card.resistance.get('silence_prob')}, 회피확률={card.resistance.get('deflection_prob')}"),
]
return "\n".join(parts)
def build_turn_messages(
card: PersonaCard,
state: PersonaStateContext,
learner_text_masked: str,
*,
recall_summary: Optional[str] = None,
pinned_facts: Optional[list[str]] = None,
recent_turns: Optional[list[dict[str, str]]] = None,
kb_behavior_cues: Optional[list[str]] = None,
) -> list[EngineMessage]:
"""한 턴의 EngineMessage[] 조립 (L0~L6).
Args:
card : 불변 페르소나(L0+L1)
state : state_machine 산출 상태(L3)
learner_text_masked : PII 마스킹된 수련생 발화(L5)
recall_summary : 회기 시작 회상(L2-EP, 큰그림세부 요약). CCD/정답 미포함.
pinned_facts : 무손실 사실 hard-pin(L4). "자기 기억"으로만 표현.
recent_turns : [{speaker, text}] 최근 K턴 버퍼(L6 직전 맥락)
kb_behavior_cues : KB 증상 '행동단서'(본문 비노출, sensitivity<=1)
반환 messages 순서: system(L0+L1, cache) system(L2/L3/L4, cache 미설정)
assistant/user 히스토리 user(이번 발화). 게이트웨이가 마지막 user stdin 으로.
"""
messages: list[EngineMessage] = []
# L0+L1 — 정적, cache_control 대상
messages.append(EngineMessage(role="system", content=build_persona_system_text(card), cache=True))
# L2 — 회상 + KB 행동단서 (회기 내 1회 로드, 캐시 친화)
l2_parts: list[str] = []
if recall_summary:
l2_parts.append(f"[L2 회상 — 지난 맥락(큰그림→세부, 정답/평가 미포함)]\n{recall_summary}")
if kb_behavior_cues:
cues = "\n".join(f"- {c}" for c in kb_behavior_cues)
l2_parts.append(f"[L2 증상 행동단서(본문 비노출, 이렇게 '행동'으로만 드러난다)]\n{cues}")
if l2_parts:
messages.append(EngineMessage(role="system", content="\n\n".join(l2_parts), cache=True))
# L3 — 상태머신 주입 (수치는 내부용; 발화엔 표면화 금지)
l3 = [
"[L3 현재 상태 — 이 수치대로 '연기'하되 수치 자체는 절대 말하지 않는다]",
f"단계: {state.stage}",
f"effective_openness: {state.effective_openness:.2f}",
f"resistance: {state.resistance:.2f}",
f"ideation_stage: {state.ideation_stage} (자살수단/방법 언급 절대 금지)",
]
if state.affect_state:
l3.append(f"정서 상태: {state.affect_state}")
l3.append(f"연기 지시: {_format_openness_directive(state)}")
messages.append(EngineMessage(role="system", content="\n".join(l3), cache=False))
# L4 — pinned fact hard-pin (무손실, "자기 기억"으로만)
if pinned_facts:
pinned = "\n".join(f"- {f}" for f in pinned_facts)
messages.append(EngineMessage(
role="system",
content=("[L4 고정 사실 — 당신이 *이미 말했거나 사실인* 것. 모순되게 말하지 말 것]\n" + pinned),
cache=False,
))
# L6 — 직전 K턴 맥락 (히스토리). 게이트웨이가 단발이면 system 뒤 맥락으로 직렬화.
if recent_turns:
for t in recent_turns:
role = "assistant" if t.get("speaker") == "counselor" else "user"
# 내담자(자기) 과거 발화는 assistant, 상담자 발화는 user 로 매핑하면
# 게이트웨이가 [이전 상담자/내담자 발화]로 직렬화한다.
messages.append(EngineMessage(role=role, content=t.get("text", ""), cache=False))
# L5 — 이번 수련생 발화 (마스킹 후)
messages.append(EngineMessage(role="user", content=learner_text_masked, cache=False))
return messages
# ════════════════════════════════════════════════════════════════════════════
# 시드 페르소나 P1 / P2 / P3 (교수 검수 게이트 전 개발 시드)
# ════════════════════════════════════════════════════════════════════════════
# P1 — 0615 청소년 우울·자퇴·자살사고 (hard). '서연'의 합성 변형.
P1 = PersonaCard(
code="P1",
display_name="서연(가명) · 고2 · 우울/자살사고",
difficulty="hard",
theory_target=["humanistic"],
demographics={"age_band": "16-18", "sex": "female", "grade": "고2", "status": "자퇴 고민"},
presenting={
"주호소": "학교 가기 싫고 다 의미 없게 느껴짐",
"표층": "엄마 손에 억지로 옴(비자발적), 무기력, 잠 못 잠",
},
history={
"가족": "엄마와 갈등, 아빠 정서적 부재",
"학교": "성적 하락·교우관계 위축",
"비밀보장 한계": "자/타해 위험 시 보호자 고지 구조화 필요",
},
big5={"O": 0.45, "C": 0.35, "E": 0.25, "A": 0.55, "N": 0.85},
resistance={
"base_resistance": 0.7,
"unlock_rate": 0.10,
"decay_floor": 0.05,
"silence_prob": 0.35,
"deflection_prob": 0.40,
},
speech_style={
"register": "또래 청소년, 무뚝뚝/짧음",
"avg_sentence_len": 8,
"fillers": ["그냥", "몰라요", "글쎄요"],
"honorific": "반존대(상담자에겐 존댓말 섞임)",
"verbal_tics": ["(한숨)", ""],
},
affect_baseline={
"negative_affect": 0.8,
"hopelessness": 0.75,
"anhedonia": 0.7,
"sleep": 0.3, # 수면의 질 낮음
"anxiety": 0.5,
"suicide_ideation_stage": 2, # 사고 있음, 계획·수단 전 단계 (상한 3)
},
ccd={
"core_belief": "나는 무가치하다 / 짐이다",
"intermediate_belief": "노력해도 달라지지 않는다",
"automatic_thought": ["이렇게 살아서 뭐 하나", "아무도 날 신경 안 써"],
"coping_strategy": "회피·철수(말 안 함, 잠으로 도피)",
"compensatory": "감정 억누르고 무덤덤한 척",
},
dsm5_dimensional={
"depression": 0.8,
"anhedonia": 0.7,
"hopelessness": 0.75,
"note": "주요우울 차원 프로파일(진단명 비노출). 자/타해 위험 모니터링 대상.",
},
)
# P2 — 성인 범불안·신체화 (moderate, 자살사고 0).
P2 = PersonaCard(
code="P2",
display_name="민재(가명) · 32세 · 범불안/신체화",
difficulty="moderate",
theory_target=["cbt"],
demographics={"age_band": "30-39", "sex": "male", "job": "직장인"},
presenting={
"주호소": "늘 불안하고 긴장되며 가슴 두근거림·소화불량이 잦음",
"표층": "일/건강 걱정이 머릿속에서 안 멈춤",
},
history={
"직무": "성과 압박·완벽주의",
"신체": "건강검진 이상 없음에도 신체증상 반복 호소",
},
big5={"O": 0.5, "C": 0.8, "E": 0.45, "A": 0.6, "N": 0.75},
resistance={
"base_resistance": 0.45,
"unlock_rate": 0.15,
"decay_floor": 0.05,
"silence_prob": 0.10,
"deflection_prob": 0.25,
},
speech_style={
"register": "성인 직장인, 논리적·장황",
"avg_sentence_len": 18,
"fillers": ["사실", "그러니까", "약간"],
"honorific": "존댓말",
"verbal_tics": ["(긴장한 웃음)"],
},
affect_baseline={
"negative_affect": 0.65,
"hopelessness": 0.2,
"anhedonia": 0.25,
"sleep": 0.5,
"anxiety": 0.85,
"suicide_ideation_stage": 1, # 자살사고 없음
},
ccd={
"core_belief": "통제하지 못하면 큰일 난다",
"intermediate_belief": "완벽히 대비해야 안전하다",
"automatic_thought": ["뭔가 잘못될 거야", "내가 놓친 게 있을 거야"],
"coping_strategy": "과도한 점검·반추·신체감각 모니터링",
"compensatory": "통제·준비를 늘려 불안 잠재우려 함",
},
dsm5_dimensional={
"anxiety": 0.85,
"somatic": 0.7,
"note": "범불안 차원 + 신체화. 자/타해 위험 없음.",
},
)
# P3 — 인간중심(PCT) 훈련용 미혼모 (moderate-easy, 라포·무조건적 존중 연습).
P3 = PersonaCard(
code="P3",
display_name="지우(가명) · 28세 · 미혼모/역할부담",
difficulty="moderate",
theory_target=["humanistic"],
demographics={"age_band": "25-34", "sex": "female", "status": "미혼모", "child": "2세 양육"},
presenting={
"주호소": "혼자 아이를 키우며 지치고 외로움. 잘하고 있는지 모르겠음",
"표층": "주변 시선·죄책감, 쉴 틈 없음",
},
history={
"지지체계": "원가족 지지 약함, 가까운 친구 1명",
"강점": "책임감·아이에 대한 애정 큼",
},
big5={"O": 0.6, "C": 0.7, "E": 0.5, "A": 0.75, "N": 0.6},
resistance={
"base_resistance": 0.35,
"unlock_rate": 0.18,
"decay_floor": 0.05,
"silence_prob": 0.08,
"deflection_prob": 0.15,
},
speech_style={
"register": "20대 후반 여성, 따뜻하지만 지친 톤",
"avg_sentence_len": 14,
"fillers": ["", "사실은", ""],
"honorific": "존댓말",
"verbal_tics": ["(옅은 한숨)"],
},
affect_baseline={
"negative_affect": 0.55,
"hopelessness": 0.3,
"anhedonia": 0.3,
"sleep": 0.45,
"anxiety": 0.5,
"suicide_ideation_stage": 1,
},
ccd={
"core_belief": "내가 다 감당해야 한다 / 약해지면 안 된다",
"intermediate_belief": "도움을 청하면 부족한 엄마다",
"automatic_thought": ["나 때문에 아이가 힘들까", "쉬면 안 돼"],
"coping_strategy": "혼자 짊어지기·감정 미루기",
"compensatory": "더 열심히 해서 죄책감 상쇄",
},
dsm5_dimensional={
"adjustment_stress": 0.55,
"burnout": 0.6,
"note": "역할부담·소진. 진단보다 인간중심 라포·무조건적 긍정적 존중 연습용.",
},
)
SEED_PERSONAS: dict[str, PersonaCard] = {"P1": P1, "P2": P2, "P3": P3}
def get_seed_persona(code: str) -> Optional[PersonaCard]:
"""시드 페르소나 조회 (DB 미가용 시 fallback). 미존재면 None."""
return SEED_PERSONAS.get(code.upper())
__all__ = [
"PersonaCard",
"PersonaStateContext",
"L0_SAFETY",
"build_persona_system_text",
"build_turn_messages",
"P1",
"P2",
"P3",
"SEED_PERSONAS",
"get_seed_persona",
]

View file

@ -0,0 +1,829 @@
"""RAG 하이브리드 검색 — 페르소나 메모리 / 평가 근거 / 정적 지식 KB.
근거: MEMORY_KNOWLEDGE_PERSONA_DESIGN.md §3.6·§4.3 (KB 4-튜플 정책) + MASTERPLAN §3.6
스택(확정 전제, 바꾸지 않음):
BGE-M3(dense+sparse, 단일모델) + pgvector(HNSW cosine) + 하이브리드 RRF/가중합
+ Anthropic Contextual Retrieval(청크 맥락 프리픽스) + (옵션)BGE-reranker-v2-m3.
설계 불변식:
- 정보비대칭은 *프롬프트가 아니라 DB WHERE* 강제한다(visible_to[] + sensitivity).
여기 모든 쿼리는 `:role = ANY(visible_to) AND sensitivity <= :sens_max` 사전필터로 박는다.
(설계서 §4.1: "필터 누락" 아니라 "코드 경로 부재" 1 방어 호출부가 role 바꾸게
함수 시그니처가 정책 4-튜플로 고정.)
- "KB는 사전이지 판사가 아니다"(§1, 모순 우선순위) 검색은 보정용 근거만 돌려준다.
이식성/지연 로딩 원칙 ( 파일의 핵심 계약):
- FlagEmbedding/torch/asyncpg-vector 같은 *무거운 의존성* **함수 내부에서만 import** 한다.
rag 의존성 미설치 환경에서도 `import app.main` / `import app.services.rag` 통과.
- 모델/DB 없으면 크래시 대신 명확한 `NotConfigured` 던진다(라우트가 503 으로 변환).
- SQL(pgvector `<=>` 연산자)·인터페이스는 정확히 작성 DB·모델만 붙으면 그대로 동작.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any, Optional, Sequence
if TYPE_CHECKING: # 타입 체커 전용 — 런타임 import 아님(이식성 유지)
import asyncpg
# ════════════════════════════════════════════════════════════════════════════
# 0. 예외 — 미구성(모델/DB 부재)은 크래시가 아니라 명시적 신호
# ════════════════════════════════════════════════════════════════════════════
class NotConfigured(RuntimeError):
"""RAG 구성요소(임베딩 모델 / DB 풀 / 확장)가 준비 안 됨.
라우트가 503(Service Unavailable) 변환한다. detail 무엇이 빠졌는지 명시.
"""
# ════════════════════════════════════════════════════════════════════════════
# 1. 정책 4-튜플 (설계서 §4.3) — 사전필터 + 회수가중치 + 리랭킹목표 + 주입방식
# 3-AI 가 *같은 물리 테이블 kb.chunk* 를 다른 정책으로 검색한다.
# ════════════════════════════════════════════════════════════════════════════
class AIRole(str, Enum):
"""검색 주체(정보비대칭 축). deps.AIView 와 값 정합(client/counselor/evaluator)."""
CLIENT = "client" # 내담자 AI — 본문 비노출(행동단서만), sensitivity<=1
COUNSELOR = "counselor" # 상담사 AI(보조) — DSM diagnostic 차단, sensitivity=0
EVALUATOR = "evaluator" # 평가 AI — taxonomy 정답·논평 포함, sensitivity<=2
@dataclass(frozen=True, slots=True)
class RetrievalPolicy:
"""정책 4-튜플. role 별로 고정(호출부가 임의로 못 푸는 화이트리스트).
설계서 §4.3 :
| 차원 | client | counselor | evaluator |
| 사전필터 | diag,theory,tech | theory,tech,micro,ko | (전체) |
| | sens<=1 | sens=0 | sens<=2 |
| 회수가중치 | dense .7/sparse.3 | dense .5/sparse .5 | sparse .6/dense .4 |
| 주입방식 | 본문 비노출(단서) | 본문+예시 | 본문+label_id+bias |
"""
role: AIRole
kinds: tuple[str, ...] # kb_kind 화이트리스트 ('()' = 전체 허용)
sens_max: int # sensitivity 상한(이하만 회수)
w_dense: float # dense 가중치
w_sparse: float # sparse(BM25/tsvector) 가중치
expose_body: bool # True=본문 주입 / False=행동단서 요약만(내담자 M6)
include_label: bool # True=label_id·meta.bias_weight 동봉(평가 채점용)
@property
def policy_name(self) -> str:
"""retrieval_log.policy 적재용 정책명(4-튜플 식별)."""
return f"{self.role.value}:k={'|'.join(self.kinds) or 'all'}:s<={self.sens_max}"
# role → 정책 (설계서 §4.3 SoT). kinds=() 는 "전체 kb_kind 허용"(평가 AI).
POLICIES: dict[AIRole, RetrievalPolicy] = {
AIRole.CLIENT: RetrievalPolicy(
role=AIRole.CLIENT,
kinds=("diagnostic", "theory", "technique"),
sens_max=1,
w_dense=0.7,
w_sparse=0.3,
expose_body=False, # 본문 비노출 — 행동단서만(R4/M6)
include_label=False,
),
AIRole.COUNSELOR: RetrievalPolicy(
role=AIRole.COUNSELOR,
kinds=("theory", "technique", "microskill", "ko_context"),
sens_max=0,
w_dense=0.5,
w_sparse=0.5,
expose_body=True, # 본문+예시
include_label=False,
),
AIRole.EVALUATOR: RetrievalPolicy(
role=AIRole.EVALUATOR,
kinds=(), # 전체 kb_kind (taxonomy·supervisor_pattern 정답 포함)
sens_max=2, # 평가전용(2)까지. 원천격리(3)는 절대 미회수
w_dense=0.4,
w_sparse=0.6, # 라벨명·논평 → sparse 비중↑
expose_body=True,
include_label=True, # label_id + meta.bias_weight 동봉(채점 기준)
),
}
# ════════════════════════════════════════════════════════════════════════════
# 2. 검색 결과 모델
# ════════════════════════════════════════════════════════════════════════════
@dataclass(slots=True)
class RetrievedChunk:
"""회수된 청크 1건. 라우트/오케스트레이터가 system L2 주입에 사용.
expose_body=False(내담자) 정책이면 body None, behavior_cue 채워 보낸다(M6).
"""
chunk_id: int
score: float # 융합/리랭킹 후 최종 점수
kb_kind: str
heading_path: Optional[str] = None
context_prefix: Optional[str] = None # Contextual Retrieval 프리픽스
body: Optional[str] = None # chunk_text (expose_body=True 일 때만)
behavior_cue: Optional[str] = None # 본문 비노출 정책의 "행동단서 요약"
label_id: Optional[int] = None # 평가 정책에서만
meta: dict[str, Any] = field(default_factory=dict)
source_id: Optional[str] = None
dense_score: float = 0.0
sparse_score: float = 0.0
@dataclass(slots=True)
class RetrievalResult:
"""검색 1회 결과 + 감사 메타(retrieval_log 적재 입력)."""
chunks: list[RetrievedChunk]
policy_name: str
top1_score: float # CRAG 게이트(임계 미달 → 관찰 프레이밍 F-06)
latency_ms: int
query_text: str
degraded: bool = False # reranker/embed fallback 여부(투명성)
# CRAG 게이트 임계값(설계서 §3.7 top1_score). 미달이면 호출부가 "관찰 프레이밍"으로 다운그레이드.
# 주: 임의 가정값 — Phase 3 파일럿에서 분포 측정 후 확정(M14, "검증됨" 금지).
CRAG_TOP1_THRESHOLD = 0.35
# ════════════════════════════════════════════════════════════════════════════
# 3. 임베딩 (BGE-M3) — 지연 로딩 싱글톤
# 무거운 모델/torch import 를 함수 내부로 가둬 import-time 이식성 보장.
# ════════════════════════════════════════════════════════════════════════════
_EMBEDDER: Any = None # FlagEmbedding.BGEM3FlagModel 인스턴스(지연 로딩 캐시)
_EMBEDDER_FAILED = False # 모델 로드 실패 1회 기록(반복 시도 방지)
BGE_M3_MODEL = "BAAI/bge-m3"
EMBED_DIM = 1024 # kb.chunk.embedding vector(1024) 와 정합 — 어기면 DB 캐스트 실패
def _get_embedder() -> Any:
"""BGE-M3 모델 지연 로딩(프로세스 1회). 미설치/로드실패 시 NotConfigured.
FlagEmbedding/torch *여기서만* import (모듈 top-level import 금지 이식성).
"""
global _EMBEDDER, _EMBEDDER_FAILED
if _EMBEDDER is not None:
return _EMBEDDER
if _EMBEDDER_FAILED:
raise NotConfigured("BGE-M3 embedder unavailable (prior load failure)")
try:
from FlagEmbedding import BGEM3FlagModel # 무거운 의존성 — 지연 import
except Exception as e: # ImportError 포함(미설치 환경)
_EMBEDDER_FAILED = True
raise NotConfigured(
"FlagEmbedding(BGE-M3) not installed — requirements-rag.txt 필요"
) from e
try:
# use_fp16: GPU 시 절반정밀(속도). CPU 면 무시됨.
_EMBEDDER = BGEM3FlagModel(BGE_M3_MODEL, use_fp16=True)
except Exception as e:
_EMBEDDER_FAILED = True
raise NotConfigured(f"BGE-M3 model load failed: {e}") from e
return _EMBEDDER
@dataclass(slots=True)
class EmbeddedQuery:
"""질의의 dense+sparse 표현(BGE-M3 단일 호출 산출)."""
dense: list[float] # vector(1024)
sparse: dict[str, float] = field(default_factory=dict) # {token_id: weight}
def embed_query(text: str) -> EmbeddedQuery:
"""질의 임베딩(dense+sparse). 모델 미가용 시 NotConfigured.
인덱싱 시점(오프라인 배치)에도 같은 함수로 청크 임베딩을 산출한다(동일 모델 재사용).
"""
model = _get_embedder()
out = model.encode(
[text],
return_dense=True,
return_sparse=True,
return_colbert_vecs=False, # 멀티벡터는 런타임 회수에 미사용(인덱싱만)
)
dense_vec = out["dense_vecs"][0]
# numpy → list[float] (asyncpg pgvector 텍스트 캐스트 호환). tolist() 있으면 사용.
dense = dense_vec.tolist() if hasattr(dense_vec, "tolist") else list(dense_vec)
lexical = out.get("lexical_weights", [{}])
sparse_raw = lexical[0] if lexical else {}
sparse = {str(k): float(v) for k, v in dict(sparse_raw).items()}
return EmbeddedQuery(dense=dense, sparse=sparse)
def _vector_literal(vec: Sequence[float]) -> str:
"""list[float] → pgvector 텍스트 리터럴 '[a,b,c]'.
db.py vector 바이너리 코덱을 아직 붙였으므로(주석 TODO), 텍스트 캐스트로 보낸다:
`$1::vector` (asyncpg 문자열을 vector 캐스트).
register_vector 코덱이 추후 붙으면 list 직접 바인딩으로 교체 가능.
"""
if len(vec) != EMBED_DIM:
raise NotConfigured(
f"embedding dim mismatch: got {len(vec)}, expected {EMBED_DIM} (vector(1024))"
)
return "[" + ",".join(f"{x:.7g}" for x in vec) + "]"
# ════════════════════════════════════════════════════════════════════════════
# 4. 하이브리드 검색 SQL (pgvector <=> cosine + tsvector BM25, 정책 파라미터화)
# 설계서 §4.3 SQL 계약을 03_kb.sql 실제 컬럼명에 정확히 맞춤.
# ════════════════════════════════════════════════════════════════════════════
# 파라미터:
# $1 = q_dense (vector, '[...]'::vector)
# $2 = q_text (tsquery 원문 — websearch_to_tsquery('simple', $2))
# $3 = kinds (text[] — 빈 배열이면 전체 허용)
# $4 = role (text — 'client'|'counselor'|'evaluator')
# $5 = sens_max(int)
# $6 = w_dense (real)
# $7 = w_sparse(real)
# $8 = pre_k (int — dense/sparse 각 후보 수, 보통 50)
# $9 = k (int — 융합 후 반환 수)
#
# 정보비대칭 강제: 두 CTE 모두 `$4 = ANY(visible_to) AND sensitivity <= $5` 사전필터.
# kinds 빈 배열 처리: cardinality($3)=0 이면 kb_kind 조건을 통과(전체).
_HYBRID_SQL = """
WITH params AS (
SELECT $1::vector AS q_dense,
CASE WHEN $2 = '' THEN NULL
ELSE websearch_to_tsquery('simple', $2) END AS q_ts
),
dense AS (
SELECT c.chunk_id,
1 - (c.embedding <=> p.q_dense) AS s_dense
FROM kb.chunk c, params p
WHERE c.embedding IS NOT NULL
AND (cardinality($3::text[]) = 0 OR c.kb_kind = ANY($3::text[]))
AND $4 = ANY(c.visible_to)
AND c.sensitivity <= $5
ORDER BY c.embedding <=> p.q_dense
LIMIT $8
),
sparse AS (
SELECT c.chunk_id,
ts_rank_cd(to_tsvector('simple', c.chunk_text), p.q_ts) AS s_sparse
FROM kb.chunk c, params p
WHERE p.q_ts IS NOT NULL
AND to_tsvector('simple', c.chunk_text) @@ p.q_ts
AND (cardinality($3::text[]) = 0 OR c.kb_kind = ANY($3::text[]))
AND $4 = ANY(c.visible_to)
AND c.sensitivity <= $5
ORDER BY s_sparse DESC
LIMIT $8
),
fused AS (
SELECT COALESCE(d.chunk_id, s.chunk_id) AS chunk_id,
COALESCE(d.s_dense, 0) AS s_dense,
COALESCE(s.s_sparse, 0) AS s_sparse,
COALESCE(d.s_dense, 0) * $6 + COALESCE(s.s_sparse, 0) * $7 AS fused_score
FROM dense d
FULL OUTER JOIN sparse s USING (chunk_id)
)
SELECT c.chunk_id, c.kb_kind, c.heading_path, c.chunk_text, c.context_prefix,
c.label_id, c.meta, c.source_id,
f.s_dense, f.s_sparse, f.fused_score
FROM fused f
JOIN kb.chunk c USING (chunk_id)
ORDER BY f.fused_score DESC
LIMIT $9
"""
_PRE_K = 50 # dense/sparse 각 후보 수(설계서 top-50 → reranker top-5)
# ════════════════════════════════════════════════════════════════════════════
# 5. Contextual Retrieval 훅 — 청크 앞 맥락 프리픽스
# ════════════════════════════════════════════════════════════════════════════
def apply_contextual_prefix(chunk_text: str, context_prefix: Optional[str]) -> str:
"""Anthropic Contextual Retrieval: 청크 앞에 1문장 맥락 프리픽스를 결합.
인덱싱 시점에 doc 전체 맥락으로 생성된 context_prefix(kb.chunk.context_prefix)
*임베딩·BM25 색인 대상*으로는 prefix+body 결합본을 쓰되, **LLM 주입 본문(chunk_text)에는
포함하지 않는다**(03_kb.sql 주석: "표시·LLM 주입(prefix 미포함)").
함수는 인덱싱(색인 텍스트 조립) 경로에서 호출되는 . 런타임 회수는 본문만 노출.
"""
if not context_prefix:
return chunk_text
return f"{context_prefix.strip()}\n\n{chunk_text}"
def _behavior_cue(chunk_text: str, max_len: int = 120) -> str:
"""본문 비노출 정책(내담자 AI)용 '행동단서 요약'(M6).
DSM/이론 본문을 그대로 노출하면 CCD 메타 누설 위험 행동 단서만 짧게.
엄밀한 환언은 인덱싱 시점 LLM meta.behavior_cue 미리 생성하는 이상적이나,
여기선 안전 폴백으로 본문 앞부분만 절단(노출 최소화). meta.behavior_cue 있으면 그걸 우선.
"""
head = chunk_text.strip().replace("\n", " ")
if len(head) <= max_len:
return head
return head[:max_len].rstrip() + ""
# ════════════════════════════════════════════════════════════════════════════
# 6. 리랭커 (BGE-reranker-v2-m3) — 옵션, 지연 로딩
# ════════════════════════════════════════════════════════════════════════════
_RERANKER: Any = None
_RERANKER_FAILED = False
RERANKER_MODEL = "BAAI/bge-reranker-v2-m3"
def _get_reranker() -> Any:
"""리랭커 지연 로딩. 미가용이면 None 반환(검색은 융합점수로 폴백 — 크래시 X)."""
global _RERANKER, _RERANKER_FAILED
if _RERANKER is not None:
return _RERANKER
if _RERANKER_FAILED:
return None
try:
from FlagEmbedding import FlagReranker # 무거운 의존성 — 지연 import
_RERANKER = FlagReranker(RERANKER_MODEL, use_fp16=True)
except Exception:
_RERANKER_FAILED = True
return None
return _RERANKER
def _rerank(
query: str, chunks: list[RetrievedChunk], top_k: int
) -> tuple[list[RetrievedChunk], bool]:
"""BGE-reranker-v2-m3 로 top_k 재정렬. 미가용 시 (입력 그대로 절단, degraded=True).
리랭킹 점수는 chunk.score 덮어쓴다(CRAG top1 게이트가 점수를 본다).
"""
if not chunks:
return [], False
reranker = _get_reranker()
if reranker is None:
return chunks[:top_k], True # 폴백: 융합점수 순서 유지
# 본문 비노출 정책이면 behavior_cue, 아니면 body 로 점수화(없으면 prefix).
pairs = [
[query, (c.body or c.behavior_cue or c.context_prefix or "")]
for c in chunks
]
try:
scores = reranker.compute_score(pairs, normalize=True)
except Exception:
return chunks[:top_k], True
if not isinstance(scores, (list, tuple)):
scores = [scores]
for c, s in zip(chunks, scores):
c.score = float(s)
chunks.sort(key=lambda c: c.score, reverse=True)
return chunks[:top_k], False
# ════════════════════════════════════════════════════════════════════════════
# 7. retrieval_log 적재 훅 (감사 / 재귀학습, 설계서 §3.7)
# ════════════════════════════════════════════════════════════════════════════
_LOG_SQL = """
INSERT INTO kb.retrieval_log
(session_id, turn_id, ai_role, query_text, policy,
hit_chunk_ids, rerank_scores, top1_score, used_in_answer, latency_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"""
async def log_retrieval(
conn: "asyncpg.Connection",
*,
result: RetrievalResult,
ai_role: str,
session_id: Optional[str] = None,
turn_id: Optional[str] = None,
used_in_answer: Optional[bool] = None,
) -> None:
"""검색 1회를 kb.retrieval_log 에 적재(감사·환각측정·캐시검증).
DB/로그 실패는 검색 자체를 막지 않는다(best-effort) 호출부가 예외를 삼킬 있게
여기선 던지되, 라우트가 try/except 감싼다.
"""
hit_ids = [c.chunk_id for c in result.chunks]
scores = [c.score for c in result.chunks]
await conn.execute(
_LOG_SQL,
session_id,
turn_id,
ai_role,
result.query_text,
result.policy_name,
hit_ids,
scores,
result.top1_score,
used_in_answer,
result.latency_ms,
)
# ════════════════════════════════════════════════════════════════════════════
# 8. 코어 검색 — search_kb (정책 4-튜플로 분기)
# ════════════════════════════════════════════════════════════════════════════
async def search_kb(
conn: "asyncpg.Connection",
*,
query: str,
role: AIRole,
k: int = 5,
filters: Optional[dict[str, Any]] = None,
rerank: bool = True,
pre_k: int = _PRE_K,
) -> RetrievalResult:
"""정적 지식 KB 하이브리드 검색(dense pgvector cosine + sparse tsvector).
Args:
conn: asyncpg 커넥션(db.acquire(ai_view=role) RLS 컨텍스트 주입된 권장).
query: 질의 텍스트(이미 PII 마스킹된 마스킹은 가드레일 책임).
role: AIRole 정책 4-튜플 선택(사전필터·가중치·노출·라벨).
k: 반환 청크 (리랭킹 top-k).
filters: 추가 사전필터 {"kb_kind": [...], "source_id": [...], "sensitivity_max": int}.
정책 화이트리스트를 *좁히는* 방향으로만 적용(넓히지 못함 정보비대칭 보존).
rerank: BGE-reranker-v2-m3 적용 여부(미설치면 융합점수 폴백, degraded=True).
Returns: RetrievalResult (chunks + top1_score + latency + policy_name).
Raises: NotConfigured 임베딩 모델 미가용 OR DB 확장(vector) 미설치.
"""
t0 = time.perf_counter()
policy = POLICIES[role]
# (1) 정책 화이트리스트를 filters 로 *좁히기*만 한다(넓히기 금지 — 정보비대칭).
kinds = list(policy.kinds)
sens_max = policy.sens_max
if filters:
fk = filters.get("kb_kind")
if fk:
req = set(fk)
if kinds: # 정책이 한정적이면 교집합(좁힘)
kinds = [x for x in kinds if x in req]
else: # 정책이 전체 허용(평가)이면 요청 그대로(여전히 sensitivity·visible_to 강제)
kinds = list(req)
fs = filters.get("sensitivity_max")
if isinstance(fs, int):
sens_max = min(sens_max, fs) # 더 엄격하게만
# (2) 질의 임베딩(dense+sparse). 모델 미가용 → NotConfigured 전파.
eq = embed_query(query)
q_dense_lit = _vector_literal(eq.dense)
# (3) 하이브리드 SQL 실행. vector 확장 미설치/컬럼 부재면 asyncpg 가 예외 → NotConfigured 변환.
try:
rows = await conn.fetch(
_HYBRID_SQL,
q_dense_lit, # $1 ::vector
query, # $2 websearch_to_tsquery 원문
kinds, # $3 text[]
policy.role.value, # $4 role
sens_max, # $5 sens_max
policy.w_dense, # $6
policy.w_sparse, # $7
pre_k, # $8 pre_k
max(k * 4, k), # $9 융합 후 1차 컷(리랭킹 입력 여유분)
)
except Exception as e: # UndefinedFunction(vector 미설치) / UndefinedColumn 등
raise NotConfigured(f"KB hybrid query failed (DB/pgvector not ready): {e}") from e
# source_id 추가 좁힘(SQL 후처리 — 화이트리스트 보존, 코드 단순화)
src_filter = set(filters.get("source_id", [])) if filters else set()
chunks: list[RetrievedChunk] = []
for r in rows:
if src_filter and r["source_id"] not in src_filter:
continue
meta = dict(r["meta"] or {})
body = r["chunk_text"] if policy.expose_body else None
cue = None
if not policy.expose_body:
# meta.behavior_cue(인덱싱 시 환언) 우선, 없으면 안전 절단
cue = meta.get("behavior_cue") or _behavior_cue(r["chunk_text"])
chunks.append(
RetrievedChunk(
chunk_id=r["chunk_id"],
score=float(r["fused_score"]),
kb_kind=r["kb_kind"],
heading_path=r["heading_path"],
context_prefix=r["context_prefix"],
body=body,
behavior_cue=cue,
label_id=r["label_id"] if policy.include_label else None,
meta=meta if policy.include_label else {},
source_id=r["source_id"],
dense_score=float(r["s_dense"]),
sparse_score=float(r["s_sparse"]),
)
)
# (4) 리랭킹(옵션) → top-k
degraded = False
if rerank and chunks:
chunks, degraded = _rerank(query, chunks, k)
else:
chunks = chunks[:k]
top1 = chunks[0].score if chunks else 0.0
latency_ms = int((time.perf_counter() - t0) * 1000)
return RetrievalResult(
chunks=chunks,
policy_name=policy.policy_name,
top1_score=top1,
latency_ms=latency_ms,
query_text=query,
degraded=degraded,
)
# ════════════════════════════════════════════════════════════════════════════
# 9. 페르소나 메모리 회상 — retrieve_persona_memory (app.turn_embedding, case 스코프)
# KB(kb.chunk)와 물리 분리: 에피소드 메모리는 app 스키마. case_id 스코프 강제(M5/cross-trainee).
# ════════════════════════════════════════════════════════════════════════════
# 설계서 §2-A 3단계 회상: open_threads/homework 우선쿼리 → 하이브리드 top-50 → reranker top-5.
# 내담자 AI 뷰: CCD/정답/평가 *절대 미포함*(app.turn_embedding 에는 표면 발화만). case 스코프가 1차 격리.
_MEMORY_SQL = """
WITH params AS (
SELECT $1::vector AS q_dense,
CASE WHEN $2 = '' THEN NULL
ELSE websearch_to_tsquery('simple', $2) END AS q_ts
),
dense AS (
SELECT te.turn_id, te.seq,
1 - (te.dense <=> p.q_dense) AS s_dense
FROM app.turn_embedding te, params p
WHERE te.case_id = $3::uuid
ORDER BY te.dense <=> p.q_dense
LIMIT $5
)
SELECT d.turn_id, d.seq, d.s_dense
FROM dense d
ORDER BY d.s_dense DESC
LIMIT $4
"""
async def retrieve_persona_memory(
conn: "asyncpg.Connection",
*,
case_id: str,
query: str,
k: int = 5,
pre_k: int = _PRE_K,
) -> RetrievalResult:
"""회기 시작 episodic recall (내담자 연속성, app.turn_embedding HNSW).
case_id 스코프 강제 학습자 기억 오염 차단(M5/T4). 본문(turn 텍스트)
호출부(memory.build_recall_context) turns 조인으로 가져오되, 여기선 turn_id+점수만
회수(검색 책임 분리). CCD/정답은 경로에 *구조적으로* 존재하지 않음(코드경로 부재 1차방어).
Raises: NotConfigured 임베딩 모델/DB 미가용.
"""
t0 = time.perf_counter()
eq = embed_query(query)
q_dense_lit = _vector_literal(eq.dense)
try:
rows = await conn.fetch(
_MEMORY_SQL,
q_dense_lit, # $1 ::vector
query, # $2 (현재 dense-only; sparse 백필은 turn_embedding.sparse 추가 시 확장)
case_id, # $3 ::uuid (case 스코프)
k, # $4
pre_k, # $5
)
except Exception as e:
raise NotConfigured(f"persona memory query failed (DB/pgvector not ready): {e}") from e
chunks = [
RetrievedChunk(
chunk_id=0, # turn 은 UUID — chunk_id(int) 의미 없음, meta 에 보관
score=float(r["s_dense"]),
kb_kind="episodic",
meta={"turn_id": str(r["turn_id"]), "seq": r["seq"]},
dense_score=float(r["s_dense"]),
)
for r in rows
]
top1 = chunks[0].score if chunks else 0.0
latency_ms = int((time.perf_counter() - t0) * 1000)
return RetrievalResult(
chunks=chunks,
policy_name=f"client:episodic:case={case_id}",
top1_score=top1,
latency_ms=latency_ms,
query_text=query,
)
# ════════════════════════════════════════════════════════════════════════════
# 10. 평가 근거 회수 — retrieve_eval_grounding (평가 AI 채점 근거)
# evaluator 정책: taxonomy 정답·슈퍼바이저 논평 포함(sensitivity<=2), label_id 동봉.
# ════════════════════════════════════════════════════════════════════════════
async def retrieve_eval_grounding(
conn: "asyncpg.Connection",
*,
query: str,
k: int = 5,
kinds: Optional[Sequence[str]] = None,
rerank: bool = True,
) -> RetrievalResult:
"""평가 AI 채점 근거 회수 — DSM/이론/taxonomy 정답라벨 + 논평.
search_kb(role=EVALUATOR) 래퍼. CRAG 게이트(top1_score < 임계 관찰 프레이밍, F-06)
호출부(evaluator) result.top1_score 판단한다. label_id·meta.bias_weight 동봉.
kinds: 평가 차원에 따라 좁히기(: 기법 채점 ['technique','supervisor_pattern']).
"""
filters = {"kb_kind": list(kinds)} if kinds else None
return await search_kb(
conn,
query=query,
role=AIRole.EVALUATOR,
k=k,
filters=filters,
rerank=rerank,
)
# ════════════════════════════════════════════════════════════════════════════
# 11. 인덱싱 트리거 (관리자 — 오프라인 배치, 런타임 아님)
# content_hash 증분(graphify 차용). 실제 청킹/Contextual prefix 생성은 LLM 배치.
# ════════════════════════════════════════════════════════════════════════════
@dataclass(slots=True)
class IndexRequest:
"""문서 1건 인덱싱 요청(관리자 엔드포인트 입력)."""
source_id: str
doc_uri: str
chunks: list[dict[str, Any]] # [{seq, heading_path, chunk_text, context_prefix?, visible_to?, sensitivity?, meta?}]
version: int = 1
content_hash: Optional[str] = None # 미지정 시 chunk_text 합으로 계산
@dataclass(slots=True)
class IndexResult:
doc_id: Optional[int]
chunks_indexed: int
skipped_unchanged: bool # content_hash 동일 → 증분 스킵
embedded: bool # 임베딩 실제 적재 여부(모델 미가용 시 False)
degraded: bool = False
def _content_hash(chunks: list[dict[str, Any]]) -> str:
"""청크 본문 합의 SHA256 — 변경감지(증분 인덱싱, kb.document.content_hash)."""
import hashlib # 표준 라이브러리 — 지연 불필요하나 일관성 위해 함수 내부
h = hashlib.sha256()
for c in chunks:
h.update((c.get("chunk_text") or "").encode("utf-8"))
return h.hexdigest()
async def index_document(
conn: "asyncpg.Connection",
req: IndexRequest,
) -> IndexResult:
"""문서 인덱싱(관리자 트리거). content_hash 증분 + 청크 임베딩 적재.
절차(설계서 §3.6 + graphify 증분):
1. content_hash 계산 kb.document 동일 활성본 있으면 스킵(증분).
2. kb.document UPSERT(신규 version) doc_id.
3. 청크: 임베딩(prefix+body 결합본을 색인 텍스트로 Contextual Retrieval) kb.chunk INSERT.
4. 임베딩 모델 미가용 : embedding NULL 적재(텍스트만, BM25 동작) + degraded=True.
무거운 작업(임베딩) 본래는 백그라운드 워커/배치. 라우트는 BackgroundTasks 위임 권장.
DSM verbatim 저작권(license C/D): source.external_llm_ok=false 가드는 source 등록 시점 책임.
Raises: NotConfigured DB(kb 스키마/vector) 미가용.
"""
content_hash = req.content_hash or _content_hash(req.chunks)
# (1) 증분 — 동일 source/uri/version 활성본의 content_hash 비교
try:
existing = await conn.fetchrow(
"""
SELECT doc_id, content_hash FROM kb.document
WHERE source_id = $1 AND doc_uri = $2 AND is_active
ORDER BY version DESC LIMIT 1
""",
req.source_id,
req.doc_uri,
)
except Exception as e:
raise NotConfigured(f"kb.document not ready (DB/schema): {e}") from e
if existing and existing["content_hash"] == content_hash:
return IndexResult(
doc_id=existing["doc_id"],
chunks_indexed=0,
skipped_unchanged=True,
embedded=False,
)
# (2) 새 문서 행 — 기존본 supersede + 신규 active
version = req.version
if existing:
version = max(version, 1) # 호출부가 version 증가 책임(여기선 UNIQUE 충돌 방어만)
try:
doc = await conn.fetchrow(
"""
INSERT INTO kb.document (source_id, doc_uri, version, content_hash, is_active, indexed_at)
VALUES ($1, $2, $3, $4, TRUE, now())
RETURNING doc_id
""",
req.source_id,
req.doc_uri,
version,
content_hash,
)
except Exception as e:
raise NotConfigured(f"kb.document insert failed: {e}") from e
doc_id = doc["doc_id"]
# 직전 활성본 비활성화(증분 supersede)
if existing:
await conn.execute(
"UPDATE kb.document SET is_active = FALSE, superseded_by = $2 WHERE doc_id = $1",
existing["doc_id"],
doc_id,
)
# (3) 청크 임베딩 + 적재. 모델 미가용 → embedding NULL 폴백(BM25 만).
embedded = True
degraded = False
try:
embedder = _get_embedder()
except NotConfigured:
embedder = None
embedded = False
degraded = True
indexed = 0
for c in req.chunks:
chunk_text = c.get("chunk_text") or ""
if not chunk_text:
continue
context_prefix = c.get("context_prefix")
emb_lit: Optional[str] = None
sparse_json: Optional[dict] = None
if embedder is not None:
# Contextual Retrieval: prefix+body 결합본을 *색인 대상* 으로 임베딩(주입 본문은 body 만).
index_text = apply_contextual_prefix(chunk_text, context_prefix)
eq = embed_query(index_text)
emb_lit = _vector_literal(eq.dense)
sparse_json = eq.sparse
await conn.execute(
"""
INSERT INTO kb.chunk
(doc_id, source_id, kb_kind, seq, heading_path, chunk_text, context_prefix,
embedding, sparse_vec, visible_to, sensitivity, label_id, meta, token_count)
VALUES ($1, $2, $3, $4, $5, $6, $7,
$8::vector, $9::jsonb,
COALESCE($10::text[], ARRAY['client','counselor','evaluator']),
COALESCE($11, 0), $12, COALESCE($13::jsonb, '{}'::jsonb), $14)
""",
doc_id,
req.source_id,
c.get("kb_kind") or "theory",
c.get("seq", indexed),
c.get("heading_path"),
chunk_text,
context_prefix,
emb_lit,
sparse_json,
c.get("visible_to"),
c.get("sensitivity"),
c.get("label_id"),
c.get("meta"),
c.get("token_count"),
)
indexed += 1
return IndexResult(
doc_id=doc_id,
chunks_indexed=indexed,
skipped_unchanged=False,
embedded=embedded,
degraded=degraded,
)
__all__ = [
"NotConfigured",
"AIRole",
"RetrievalPolicy",
"POLICIES",
"RetrievedChunk",
"RetrievalResult",
"CRAG_TOP1_THRESHOLD",
"EmbeddedQuery",
"embed_query",
"apply_contextual_prefix",
"search_kb",
"retrieve_persona_memory",
"retrieve_eval_grounding",
"log_retrieval",
"IndexRequest",
"IndexResult",
"index_document",
]

View file

@ -0,0 +1,288 @@
"""결정론 상태머신 — 단계 전이 + effective_openness 계산 (LLM 아님).
MASTERPLAN §0/§2.2 + MEMORY_KNOWLEDGE_PERSONA_DESIGN §1.1·P2:
- stage: 라포 탐색 개입 정리 (백엔드가 결정론적으로 소유)
- effective_openness = clamp(stage_base + rapport_credit*unlock - resistance*decay, 0, 1)
- rapport_credit: 공감·반영·타당화·홀딩 +, 조언점프·평가·유도질문 0/
"좋은 상담을 하면 열리고, 나쁜 상담을 하면 닫힌다"(저항 엔진, R3).
설계 원칙:
- 순수함수 + 작은 dataclass 상태(SessionState). DB·LLM·IO 의존 없음(테스트 용이).
- 수치는 무손실로 carry-over 된다(memory.py 사용). LLM 수치 위임 금지(M2).
- 신호(rapport) *간단한 키워드/구조 휴리스틱*. 정밀 4차원 채점은 평가 AI(Features) 소유.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from enum import Enum
from typing import Optional
# ── 단계 (taxonomy.Stage 와 한글 값 동일, 서비스 내부 결정론 전이용) ────────
class Stage(str, Enum):
RAPPORT = "라포"
EXPLORE = "탐색"
INTERVENE = "개입"
CLOSE = "정리"
# 단계별 기본 개방도(stage_base). 라포는 낮게 시작, 개입에서 가장 깊게 다룸.
STAGE_BASE_OPENNESS: dict[Stage, float] = {
Stage.RAPPORT: 0.15,
Stage.EXPLORE: 0.35,
Stage.INTERVENE: 0.55,
Stage.CLOSE: 0.45,
}
# 전이 순서(선형 진행, 역행 없음 — 상담 구조)
STAGE_ORDER: list[Stage] = [Stage.RAPPORT, Stage.EXPLORE, Stage.INTERVENE, Stage.CLOSE]
# 단계 전이 최소 턴 수(시간/턴 기반 게이트). 신호가 충분해도 너무 일찍 넘어가지 않게.
STAGE_MIN_TURNS: dict[Stage, int] = {
Stage.RAPPORT: 3,
Stage.EXPLORE: 5,
Stage.INTERVENE: 5,
Stage.CLOSE: 2,
}
# 다음 단계로 넘어가기 위한 누적 라포 임계(평가신호 기반 게이트)
STAGE_ADVANCE_RAPPORT: dict[Stage, float] = {
Stage.RAPPORT: 0.30, # 충분히 안전감 형성
Stage.EXPLORE: 0.45, # 호소·정서 탐색이 깊어짐
Stage.INTERVENE: 0.55, # 개입 작업이 진행됨
}
@dataclass(slots=True)
class SessionState:
"""회기 working state (app.session_state 미러). 결정론 수치만.
LLM 절대 만지지 않는다(P2). evolve 인스턴스를 만들어 체크포인트.
"""
stage: Stage = Stage.RAPPORT
turn_seq: int = 0
effective_openness: float = 0.15
rapport_credit: float = 0.0 # 회기 누적(회기말 0.7 이월)
resistance: float = 0.65 # base_resistance 에서 시작, decay 로 완화
ideation_stage: int = 1 # 1~5 (출력 가드레일 상한 3)
turns_in_stage: int = 0 # 현재 단계 체류 턴 수
affect_state: dict[str, float] = field(default_factory=dict)
def snapshot(self) -> dict:
"""무손실 carry-over용 snapshot (memory.end_state). 코드 복사, LLM 미경유."""
return {
"stage": self.stage.value,
"turn_seq": self.turn_seq,
"effective_openness": round(self.effective_openness, 4),
"rapport_credit": round(self.rapport_credit, 4),
"resistance": round(self.resistance, 4),
"ideation_stage": self.ideation_stage,
"affect": dict(self.affect_state),
}
# ════════════════════════════════════════════════════════════════════════════
# 라포 신호 휴리스틱 (가벼운 결정론 추정 — 정밀 채점은 평가 AI 소유)
# ════════════════════════════════════════════════════════════════════════════
# 긍정 신호: 공감·반영·타당화·홀딩·개방질문 (rapport_credit +)
_POSITIVE_CUES = [
"느껴", "느꼈", "들리", "마음", "힘들", "그랬구나", "그러셨", "이해", "충분히",
"괜찮아", "천천히", "기다", "어떤", "어떻게", "무엇", "이야기해", "말해줘", "말해 줘",
"그런 마음", "얼마나",
]
# 부정 신호: 조언점프·평가·유도·당위 (rapport_credit 0/)
_NEGATIVE_CUES = [
"해야", "하세요", "하지 마", "그건 아니", "틀렸", "잘못", "당연히", "원래", "그냥 해",
"왜 안", "그러니까 ", "내 생각엔", "~하면 되", "하면 돼", "노력하면",
]
# 닫힌/단답 질문(예/아니오 유도)은 약한 부정
_CLOSED_Q_CUES = ["맞죠", "그렇죠", "안 그래", "아니에요?"]
def estimate_rapport_signal(learner_text: str) -> float:
"""수련생 발화 1개의 라포 신호(1.0~+1.0, 결정론 휴리스틱).
+: 공감/반영/타당화/홀딩/개방질문 / : 조언점프/평가/유도/당위.
NOTE: 이는 상태머신용 *경량* 추정이다. 평가 AI fast/deep-loop 4차원 채점이
정밀 신호를 따로 산출한다(여기 의존하지 않음).
"""
if not learner_text:
return 0.0
text = learner_text.strip()
pos = sum(1 for c in _POSITIVE_CUES if c in text)
neg = sum(1 for c in _NEGATIVE_CUES if c in text)
closed = sum(1 for c in _CLOSED_Q_CUES if c in text)
raw = pos * 0.5 - neg * 0.6 - closed * 0.3
# 개방형 질문(물음표 + 의문사)인데 닫힌 유도가 아니면 소폭 가산
if "?" in text and any(w in text for w in ["어떤", "어떻게", "무엇", "", "언제"]) and closed == 0:
raw += 0.2
# clamp to [-1, 1]
return max(-1.0, min(1.0, raw))
def _clamp01(x: float) -> float:
return max(0.0, min(1.0, x))
def compute_effective_openness(
*,
stage: Stage,
rapport_credit: float,
resistance: float,
unlock_rate: float,
decay_floor: float,
) -> float:
"""effective_openness = clamp(stage_base + rapport_credit*unlock - resistance*decay, 0, 1).
MASTERPLAN §2.2 공식. decay decay_floor 바닥으로 저항 영향계수.
"""
stage_base = STAGE_BASE_OPENNESS[stage]
decay = max(decay_floor, 0.5) # 저항이 개방도를 끌어내리는 계수(바닥=decay_floor)
val = stage_base + rapport_credit * unlock_rate - resistance * decay
return _clamp01(val)
def next_stage(state: SessionState) -> Stage:
"""단계 전이 판정(결정론): 최소 체류 턴 + 누적 라포 임계 동시 충족 시 다음 단계로.
역행 없음. CLOSE 종착(end_session 명시 종료).
"""
cur = state.stage
if cur is Stage.CLOSE:
return cur
idx = STAGE_ORDER.index(cur)
min_turns = STAGE_MIN_TURNS.get(cur, 3)
advance_rapport = STAGE_ADVANCE_RAPPORT.get(cur, 1.0)
if state.turns_in_stage >= min_turns and state.rapport_credit >= advance_rapport:
return STAGE_ORDER[idx + 1]
return cur
def evolve(
state: SessionState,
*,
rapport_signal: float,
unlock_rate: float,
decay_floor: float,
ideation_observed: Optional[int] = None,
) -> SessionState:
"""한 턴 결정론 상태 전이 → 새 SessionState 반환(순수함수, 입력 불변).
Args:
rapport_signal : estimate_rapport_signal() 또는 평가 AI 신호(1~+1)
unlock_rate/decay_floor : 페르소나 저항 파라미터(persona.resistance)
ideation_observed : 출력 가드레일/위기분류가 관측한 ideation 단계(있으면 보수적 max)
절차:
1. rapport_credit 누적(긍정 +, 부정 , 하한 0)
2. resistance 완화(긍정 신호일 때만 decay_floor 까지 감소; 부정이면 소폭 증가)
3. effective_openness 재계산
4. 단계 전이 판정(최소턴+라포임계)
5. ideation_stage 보수적 갱신(내려가지 않음 안전 R5)
"""
# 1) 라포 크레딧 누적 (부정 신호는 더 크게 깎아 "닫힘" 재현)
delta = rapport_signal * (0.18 if rapport_signal >= 0 else 0.25)
rapport_credit = max(0.0, state.rapport_credit + delta)
# 2) 저항 완화/강화
if rapport_signal > 0:
resistance = max(decay_floor, state.resistance - 0.04 * rapport_signal)
else:
resistance = min(1.0, state.resistance - 0.06 * rapport_signal) # signal<0 → 증가
# 5) ideation 보수적 유지(절대 내려가지 않음, 안전)
ideation_stage = state.ideation_stage
if ideation_observed is not None:
ideation_stage = max(state.ideation_stage, ideation_observed)
# 3) 개방도 재계산
eff = compute_effective_openness(
stage=state.stage,
rapport_credit=rapport_credit,
resistance=resistance,
unlock_rate=unlock_rate,
decay_floor=decay_floor,
)
# 임시 상태로 단계 전이 판정(turns_in_stage 는 이번 턴 포함하여 +1)
advanced = replace(
state,
turn_seq=state.turn_seq + 1,
turns_in_stage=state.turns_in_stage + 1,
rapport_credit=rapport_credit,
resistance=resistance,
effective_openness=eff,
ideation_stage=ideation_stage,
)
nxt = next_stage(advanced)
if nxt is not advanced.stage:
# 단계 전이 시 체류 턴 리셋 + 새 단계 base 로 개방도 재산출
eff2 = compute_effective_openness(
stage=nxt,
rapport_credit=rapport_credit,
resistance=resistance,
unlock_rate=unlock_rate,
decay_floor=decay_floor,
)
advanced = replace(advanced, stage=nxt, turns_in_stage=0, effective_openness=eff2)
return advanced
def init_state(
*,
base_resistance: float,
unlock_rate: float,
decay_floor: float,
ideation_baseline: int = 1,
carry: Optional[dict] = None,
) -> SessionState:
"""회기 시작 상태 초기화 (memory.carry_over 결과 주입 가능).
carry 있으면(이전 회기 end_state) 결정론 carry-over:
stage='라포' 재시작, rapport_credit ×0.7 이월, resistance drift, ideation 보수적 유지.
"""
stage = Stage.RAPPORT
resistance = base_resistance
rapport_credit = 0.0
ideation_stage = ideation_baseline
if carry:
rapport_credit = float(carry.get("rapport_credit", 0.0)) * 0.7 # P2 이월
# inter-session drift: 라포가 쌓였으면 저항 소폭 완화된 채로 재시작
prev_resist = float(carry.get("resistance", base_resistance))
resistance = _clamp01((prev_resist + base_resistance) / 2.0)
ideation_stage = max(int(carry.get("ideation_stage", ideation_baseline)), ideation_baseline)
eff = compute_effective_openness(
stage=stage,
rapport_credit=rapport_credit,
resistance=resistance,
unlock_rate=unlock_rate,
decay_floor=decay_floor,
)
return SessionState(
stage=stage,
turn_seq=0,
effective_openness=eff,
rapport_credit=rapport_credit,
resistance=resistance,
ideation_stage=ideation_stage,
turns_in_stage=0,
affect_state={},
)
__all__ = [
"Stage",
"STAGE_BASE_OPENNESS",
"STAGE_ORDER",
"SessionState",
"estimate_rapport_signal",
"compute_effective_openness",
"next_stage",
"evolve",
"init_state",
]

View file

@ -0,0 +1,370 @@
"""음성 캐스케이드 — OpenAI STT(전사) + TTS(멀티보이스) 어댑터.
MASTERPLAN '음성 필수'(한신대 요구) / DESIGN_CONCEPT §5.2(음성 오브 4상태) / §4.3(립싱크 RMS):
STT : OpenAI /v1/audio/transcriptions (gpt-4o-transcribe | whisper-1). 학습자 음성 텍스트.
TTS : OpenAI /v1/audio/speech (gpt-4o-mini-tts | tts-1). 내담자 텍스트 음성(페르소나 voice).
설계 원칙( 모듈의 경계):
- 순수 어댑터: httpx OpenAI 음성 엔드포인트만 호출한다. 상담 로직(orchestrator)·상태머신은
호출부(routes/voice.py) 조립한다. 여기는 "오디오↔텍스트" 변환 + voice preset 매핑만.
- API 없으면 명확히 degraded: is_available()=False, 호출 VoiceUnavailable.
절대 크래시·무한대기 금지(라우트가 503/close 변환).
- 립싱크 힌트: TTS 오디오 청크를 흘리며 RMS(진폭) 힌트를 같이 산출(설계 §4.3 viseme 정밀
매칭 , RMS 1채널). PCM 디코딩 의존성 없이 바이트 에너지 근사로 임시 RMS 추정.
페르소나 voice preset(persona/*.json voice.preset, 설계 §4.6) OpenAI voice 매핑은
PRESET_TO_OPENAI_VOICE 테이블이 흡수. preset 추가는 테이블만 손대면 된다.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import AsyncIterator, Optional
import httpx
from ..config import settings
# ════════════════════════════════════════════════════════════════════════════
# OpenAI 음성 엔드포인트/모델 상수
# ════════════════════════════════════════════════════════════════════════════
OPENAI_BASE_URL = "https://api.openai.com/v1"
STT_ENDPOINT = "/audio/transcriptions"
TTS_ENDPOINT = "/audio/speech"
# STT 모델: gpt-4o-transcribe(고품질) — 미가용 폴백은 whisper-1.
STT_MODEL = "gpt-4o-transcribe"
STT_MODEL_FALLBACK = "whisper-1"
# TTS 모델: gpt-4o-mini-tts(저지연·표현력) — 폴백 tts-1.
TTS_MODEL = "gpt-4o-mini-tts"
TTS_MODEL_FALLBACK = "tts-1"
# 전사 언어 힌트(상담은 한국어). OpenAI 는 ISO-639-1.
STT_LANGUAGE = "ko"
# TTS 출력 포맷: 브라우저 MediaSource/<audio> 친화. 스트리밍은 mp3/opus 청크.
TTS_RESPONSE_FORMAT = "mp3"
# OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable,
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
_OPENAI_VOICES = {
"alloy", "ash", "ballad", "coral", "echo", "fable",
"nova", "onyx", "sage", "shimmer", "verse",
}
DEFAULT_OPENAI_VOICE = "sage"
# ── 페르소나 voice preset(설계 §4.6) → OpenAI voice ──────────────────────────
# preset 명명: <톤><연령><성별> 조합(soft-young-fem 등). 새 페르소나는 여기에만 추가.
PRESET_TO_OPENAI_VOICE: dict[str, str] = {
# 청소년 여성(P1 서연) — 부드럽고 톤 높은
"soft-young-fem": "coral",
# 성인 남성(P2 민재) — 차분·안정·약간 긴장
"calm-adult-male": "ash",
# 성인 여성(P3 지우) — 따뜻하지만 지친
"warm-adult-fem": "shimmer",
# 범용 폴백 프리셋
"neutral": "sage",
}
# ── 페르소나 code(P1/P2/P3) → 기본 preset (persona 카드에 voice 필드 없을 때) ──
# persona.py 시드는 voice 필드를 갖지 않으므로 code 로 기본 preset 을 정한다.
# DB/JSON 페르소나가 voice.preset 을 직접 주면 그걸 우선한다(resolve_voice 참조).
PERSONA_CODE_TO_PRESET: dict[str, str] = {
"P1": "soft-young-fem",
"P2": "calm-adult-male",
"P3": "warm-adult-fem",
}
# TTS rate(말 속도) 페르소나 기본값(설계 §4.6 voice.rate). 1.0=표준.
PRESET_RATE: dict[str, float] = {
"soft-young-fem": 0.96,
"calm-adult-male": 1.0,
"warm-adult-fem": 0.98,
"neutral": 1.0,
}
class VoiceUnavailable(RuntimeError):
"""음성 미설정/장애. 라우트가 503/WS close(degraded) 로 변환."""
@dataclass(slots=True)
class VoicePreset:
"""해석된 음성 프리셋(페르소나 → OpenAI 파라미터)."""
preset: str # 논리 preset 명(soft-young-fem 등)
openai_voice: str # OpenAI voice 파라미터
rate: float = 1.0 # 말 속도(speed)
instructions: Optional[str] = None # gpt-4o-mini-tts 표현 지시(선택)
@dataclass(slots=True)
class TranscriptResult:
"""STT 결과."""
text: str
language: Optional[str] = None
model: str = STT_MODEL
duration: Optional[float] = None
@dataclass(slots=True)
class TTSChunk:
"""TTS 스트림 1청크 + 립싱크 힌트(설계 §4.3 RMS 1채널)."""
audio: bytes
rms: float = 0.0 # 0~1, 입 열림(scaleY) 매핑용 근사 진폭
seq: int = 0
# ════════════════════════════════════════════════════════════════════════════
# voice preset 해석 (페르소나 → OpenAI 파라미터)
# ════════════════════════════════════════════════════════════════════════════
def resolve_voice(
*,
persona_code: Optional[str] = None,
preset: Optional[str] = None,
instructions: Optional[str] = None,
) -> VoicePreset:
"""페르소나 code 또는 명시 preset → OpenAI voice 파라미터로 해석.
우선순위: 명시 preset > persona_code 기본 preset > 'neutral'.
없는 preset DEFAULT_OPENAI_VOICE 안전 폴백(크래시 없음).
"""
chosen = preset
if not chosen and persona_code:
chosen = PERSONA_CODE_TO_PRESET.get(persona_code.upper())
if not chosen:
chosen = "neutral"
openai_voice = PRESET_TO_OPENAI_VOICE.get(chosen, DEFAULT_OPENAI_VOICE)
if openai_voice not in _OPENAI_VOICES:
openai_voice = DEFAULT_OPENAI_VOICE
rate = PRESET_RATE.get(chosen, 1.0)
return VoicePreset(
preset=chosen,
openai_voice=openai_voice,
rate=rate,
instructions=instructions,
)
# ════════════════════════════════════════════════════════════════════════════
# 립싱크 RMS 근사 (설계 §4.3 — 정밀 viseme 안 함, 진폭 1채널)
# ════════════════════════════════════════════════════════════════════════════
def estimate_chunk_rms(chunk: bytes) -> float:
"""오디오 청크 바이트 에너지로 RMS(0~1) 근사.
압축 포맷(mp3) 바이트를 PCM 디코딩 없이 근사한다(의존성 0). 평균 바이트 편차를
0~1 정규화 프론트가 데드존(0.04)·지수평활(τ180ms) 적용해 열림에 매핑.
NOTE: 정밀 진폭이 필요하면 프론트 Web Audio AnalyserNode 재계산(설계 §4.3 권장).
힌트는 서버측 보조(네트워크 끊김/저사양 폴백).
"""
if not chunk:
return 0.0
# 128 중심 편차의 RMS(8bit 가정 근사). mp3 프레임이라 정밀치 아님(상대값).
n = len(chunk)
acc = 0
# 과샘플 비용 회피 — 최대 2048 바이트만 샘플링
step = max(1, n // 2048)
cnt = 0
for i in range(0, n, step):
d = chunk[i] - 128
acc += d * d
cnt += 1
if cnt == 0:
return 0.0
rms = math.sqrt(acc / cnt) / 128.0
return max(0.0, min(1.0, rms))
# ════════════════════════════════════════════════════════════════════════════
# OpenAI 음성 서비스
# ════════════════════════════════════════════════════════════════════════════
class VoiceService:
"""OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유)."""
def __init__(self, api_key: Optional[str] = None, base_url: str = OPENAI_BASE_URL) -> None:
self._api_key = (api_key if api_key is not None else settings.openai_api_key) or ""
self._base_url = base_url.rstrip("/")
self._client: Optional[httpx.AsyncClient] = None
# ── 수명주기 ──────────────────────────────────────────
async def startup(self) -> None:
if not self._api_key:
return # 키 없으면 클라이언트도 안 띄움(degraded). 라우트가 503 처리.
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers={"Authorization": f"Bearer {self._api_key}"},
timeout=httpx.Timeout(60.0, connect=10.0),
)
async def shutdown(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
def is_available(self) -> bool:
"""음성 기능 가용 여부(키 설정됨). 라우트가 핸드셰이크에서 검사."""
return bool(self._api_key)
@property
def _http(self) -> httpx.AsyncClient:
if not self._api_key:
raise VoiceUnavailable("OPENAI_API_KEY 미설정 — 음성 기능 degraded")
if self._client is None:
# lazy 보강(테스트/지연 startup 대비)
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers={"Authorization": f"Bearer {self._api_key}"},
timeout=httpx.Timeout(60.0, connect=10.0),
)
return self._client
# ── STT (transcriptions) ─────────────────────────────
async def transcribe(
self,
audio: bytes,
*,
filename: str = "audio.webm",
content_type: str = "audio/webm",
language: str = STT_LANGUAGE,
model: str = STT_MODEL,
) -> TranscriptResult:
"""오디오 바이트 → 텍스트 전사(OpenAI /audio/transcriptions).
클라가 보낸 webm/opus(또는 wav/mp3) 청크를 multipart OpenAI 올린다.
없으면 VoiceUnavailable, OpenAI 오류는 그대로 RuntimeError 전파(라우트가 처리).
"""
if not audio:
return TranscriptResult(text="", model=model)
files = {"file": (filename, audio, content_type)}
data = {
"model": model,
"language": language,
"response_format": "json",
}
try:
r = await self._http.post(STT_ENDPOINT, files=files, data=data)
if r.status_code == 404 and model != STT_MODEL_FALLBACK:
# 모델 미가용(계정 권한) → whisper-1 폴백 1회
data["model"] = STT_MODEL_FALLBACK
r = await self._http.post(STT_ENDPOINT, files=files, data=data)
r.raise_for_status()
except VoiceUnavailable:
raise
except httpx.HTTPStatusError as e:
raise RuntimeError(f"STT {e.response.status_code}: {e.response.text[:200]}") from e
except httpx.HTTPError as e:
raise RuntimeError(f"STT transport error: {e}") from e
body = r.json()
return TranscriptResult(
text=(body.get("text") or "").strip(),
language=body.get("language"),
model=str(data["model"]),
duration=body.get("duration"),
)
# ── TTS (speech) — 스트리밍 ──────────────────────────
async def synthesize_stream(
self,
text: str,
voice: VoicePreset,
*,
model: str = TTS_MODEL,
response_format: str = TTS_RESPONSE_FORMAT,
) -> AsyncIterator[TTSChunk]:
"""텍스트 → 음성 스트리밍(OpenAI /audio/speech). 청크 + RMS 힌트 yield.
설계 §5.2 'speaking' 상태: 오디오 청크를 흘리며 진폭 힌트(립싱크) 같이 보낸다.
없으면 VoiceUnavailable. OpenAI 오류는 RuntimeError 전파.
"""
if not text or not text.strip():
return
payload: dict[str, object] = {
"model": model,
"voice": voice.openai_voice,
"input": text,
"response_format": response_format,
"speed": _clamp_speed(voice.rate),
}
# gpt-4o-mini-tts 계열은 instructions(표현 지시) 지원. tts-1 은 무시됨.
if voice.instructions and model.startswith("gpt-4o"):
payload["instructions"] = voice.instructions
seq = 0
try:
async with self._http.stream("POST", TTS_ENDPOINT, json=payload) as r:
if r.status_code == 404 and model != TTS_MODEL_FALLBACK:
# 모델 미가용 → tts-1 폴백(비스트림 재시도). instructions 제거.
payload["model"] = TTS_MODEL_FALLBACK
payload.pop("instructions", None)
await r.aclose()
async for c in self._synthesize_fallback(payload):
yield c
return
r.raise_for_status()
async for chunk in r.aiter_bytes(chunk_size=4096):
if not chunk:
continue
yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)
seq += 1
except VoiceUnavailable:
raise
except httpx.HTTPStatusError as e:
text_body = ""
try:
text_body = (await e.response.aread()).decode("utf-8", "ignore")[:200]
except Exception:
pass
raise RuntimeError(f"TTS {e.response.status_code}: {text_body}") from e
except httpx.HTTPError as e:
raise RuntimeError(f"TTS transport error: {e}") from e
async def _synthesize_fallback(self, payload: dict[str, object]) -> AsyncIterator[TTSChunk]:
"""tts-1 폴백(비스트림 POST → 전체 바이트를 청크로 분할)."""
try:
r = await self._http.post(TTS_ENDPOINT, json=payload)
r.raise_for_status()
except httpx.HTTPStatusError as e:
raise RuntimeError(f"TTS(fallback) {e.response.status_code}: {e.response.text[:200]}") from e
except httpx.HTTPError as e:
raise RuntimeError(f"TTS(fallback) transport error: {e}") from e
data = r.content
seq = 0
for i in range(0, len(data), 4096):
chunk = data[i : i + 4096]
yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)
seq += 1
def _clamp_speed(rate: float) -> float:
"""OpenAI speed 허용범위 [0.25, 4.0] 클램프."""
try:
return max(0.25, min(4.0, float(rate)))
except (TypeError, ValueError):
return 1.0
# 앱 전역 싱글톤 (main lifespan 이 startup/shutdown — Foundation 이 관리하거나
# 라우트가 lazy 사용). engine_client 패턴과 동일.
voice_service = VoiceService()
__all__ = [
"VoiceUnavailable",
"VoicePreset",
"TranscriptResult",
"TTSChunk",
"VoiceService",
"voice_service",
"resolve_voice",
"estimate_chunk_rms",
"PRESET_TO_OPENAI_VOICE",
"PERSONA_CODE_TO_PRESET",
"DEFAULT_OPENAI_VOICE",
"STT_MODEL",
"TTS_MODEL",
]

116
apps/api/app/store.py Normal file
View file

@ -0,0 +1,116 @@
"""Docker 없이도 도는 in-memory 세션/턴 스토어 (DB degraded 폴백).
DB(NAS Postgres) 단일 SoR 이지만(db.py), Docker off 개발/시연에서도 엔진만 있으면
상담 1턴이 돌아야 한다. 모듈은 app.sessions / app.session_state / app.turns
*최소 in-proc 미러* 제공한다. DB 붙으면 라우트가 DB 경로로 전환한다(교체 대상).
스레드/동시성: uvicorn 단일 프로세스 가정의 단순 dict. 멀티워커 시엔 DB SoR 이므로 무방.
"""
from __future__ import annotations
import time
from dataclasses import asdict, dataclass, field
from typing import Optional
from uuid import uuid4
from .services.persona import PersonaCard
from .services.state_machine import SessionState
@dataclass(slots=True)
class TurnRecord:
"""발화 1건(② episodic 미러). append-only."""
turn_seq: int
speaker: str # 'counselor' | 'client'
stage: str
text: str # 원문(개발용; 실제 저장은 마스킹본)
text_masked: str
created_at: float = field(default_factory=time.time)
@dataclass(slots=True)
class InProcSession:
"""① working + 메타 + 페르소나 핀(in-proc)."""
session_id: str
case_id: str
learner_id: str
persona_code: str
theory_mode: str
persona: PersonaCard
state: SessionState
session_no: int = 1
turns: list[TurnRecord] = field(default_factory=list)
ended: bool = False
prev_rapport_credit: float = 0.0 # carry-over delta 계산용
def recent_turns(self, k: int = 6) -> list[dict[str, str]]:
"""최근 K턴 버퍼(L6 직전 맥락). 마스킹본 사용."""
return [{"speaker": t.speaker, "text": t.text_masked} for t in self.turns[-k:]]
def masked_turns(self) -> list[dict[str, str]]:
return [{"speaker": t.speaker, "text": t.text_masked} for t in self.turns]
class SessionStore:
"""in-memory 세션 레지스트리. DB degraded 시 SoR 대용."""
def __init__(self) -> None:
self._sessions: dict[str, InProcSession] = {}
def create(
self,
*,
learner_id: str,
persona: PersonaCard,
theory_mode: str,
state: SessionState,
session_no: int = 1,
carry_rapport: float = 0.0,
) -> InProcSession:
session_id = uuid4().hex
case_id = uuid4().hex
s = InProcSession(
session_id=session_id,
case_id=case_id,
learner_id=learner_id,
persona_code=persona.code,
theory_mode=theory_mode,
persona=persona,
state=state,
session_no=session_no,
prev_rapport_credit=carry_rapport,
)
self._sessions[session_id] = s
return s
def get(self, session_id: str) -> Optional[InProcSession]:
return self._sessions.get(session_id)
def append_turn(self, session_id: str, turn: TurnRecord) -> None:
s = self._sessions.get(session_id)
if s is not None:
s.turns.append(turn)
def update_state(self, session_id: str, state: SessionState) -> None:
s = self._sessions.get(session_id)
if s is not None:
s.state = state
def end(self, session_id: str) -> Optional[InProcSession]:
s = self._sessions.get(session_id)
if s is not None:
s.ended = True
return s
def remove(self, session_id: str) -> None:
self._sessions.pop(session_id, None)
# 앱 전역 싱글톤 (DB 없이도 라우트가 바로 쓸 수 있게)
store = SessionStore()
__all__ = ["TurnRecord", "InProcSession", "SessionStore", "store"]