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:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
180
apps/api/app/services/memory.py
Normal file
180
apps/api/app/services/memory.py
Normal 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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue