vignette/apps/api/app/services/memory.py
Yun Chan 24b1b7a6e1 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턴 — 좋은/나쁜 상담에 차등 반응 실증
2026-06-25 23:37:22 +09:00

180 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""회기 라이프사이클 메모리 — 시작 회상 + 종료 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",
]