vignette/apps/api/app/services/state_machine.py
Yun Chan 778e8526d4 세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
2026-07-02 02:50:36 +09:00

294 lines
12 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.

"""결정론 상태머신 — 단계 전이 + 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 typing import Optional
from ..taxonomy import Stage # 단계 라벨 단일 정의 = taxonomy.Stage; 이 모듈은 전이 로직만 소유.
# 단계별 기본 개방도(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
@dataclass(frozen=True, slots=True)
class OpennessParams:
"""페르소나 파생 openness 곡선 파라미터 묶음(init_state 입력).
base_resistance/unlock_rate/decay_floor/ideation_baseline 4종을 한 객체로 — 호출부의
4-인자 분해(card.base_resistance() 등)를 PersonaCard.openness_params()로 일원화한다.
"""
base_resistance: float
unlock_rate: float
decay_floor: float
ideation_baseline: int = 1
def init_state(
*,
params: OpennessParams,
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 = params.base_resistance
rapport_credit = 0.0
ideation_stage = params.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", params.base_resistance))
resistance = _clamp01((prev_resist + params.base_resistance) / 2.0)
ideation_stage = max(
int(carry.get("ideation_stage", params.ideation_baseline)), params.ideation_baseline
)
eff = compute_effective_openness(
stage=stage,
rapport_credit=rapport_credit,
resistance=resistance,
unlock_rate=params.unlock_rate,
decay_floor=params.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",
"OpennessParams",
"estimate_rapport_signal",
"compute_effective_openness",
"next_stage",
"evolve",
"init_state",
]