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

@ -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",
]