세션 계약과 메모리 경계 보강
This commit is contained in:
parent
391639c1de
commit
2bb052f624
12 changed files with 836 additions and 116 deletions
|
|
@ -26,6 +26,7 @@ from .. import session_persistence
|
|||
from ..deps import Principal, Role, require_role
|
||||
from ..engine_client import EngineError, engine_client
|
||||
from ..runtime_policy import runtime_fallback_allowed
|
||||
from ..session_read_model import StageLabel, stage_label_or_none
|
||||
from ..services import evaluator
|
||||
from ..services.evaluator import SessionEvaluation, TurnEvaluation
|
||||
from ..store import InProcSession
|
||||
|
|
@ -50,11 +51,19 @@ class EvaluationSummary(BaseModel):
|
|||
"""회기 평가 조회 응답(분포 + deep 결과 합본)."""
|
||||
|
||||
session_id: str
|
||||
stage: str
|
||||
stage: StageLabel | None = None
|
||||
deep: Optional[dict[str, Any]] = None
|
||||
distribution: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TurnEvaluationResponse(TurnEvaluation):
|
||||
stage: StageLabel
|
||||
|
||||
|
||||
class SessionEvaluationResponse(SessionEvaluation):
|
||||
stage: StageLabel
|
||||
|
||||
|
||||
async def _load_session_or_404(session_id: str, principal: Principal) -> InProcSession:
|
||||
sess = await session_persistence.load_session(session_id, principal, allow_ended=True)
|
||||
if sess is not None:
|
||||
|
|
@ -74,6 +83,10 @@ def _theory_mode_of(sess) -> Optional[str]:
|
|||
return getattr(sess, "theory_mode", None)
|
||||
|
||||
|
||||
def _summary_stage(value: object) -> StageLabel | None:
|
||||
return stage_label_or_none(value)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def eval_health() -> dict[str, str]:
|
||||
"""평가 라우터 헬스 — Features:evaluator 로 전환됨."""
|
||||
|
|
@ -83,7 +96,7 @@ async def eval_health() -> dict[str, str]:
|
|||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 회기 deep-loop 재평가 트리거 (교수자/관리자)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
@router.post("/sessions/{session_id}/reevaluate", response_model=SessionEvaluation)
|
||||
@router.post("/sessions/{session_id}/reevaluate", response_model=SessionEvaluationResponse)
|
||||
async def reevaluate_session(
|
||||
session_id: str,
|
||||
body: ReevaluateRequest,
|
||||
|
|
@ -139,7 +152,7 @@ async def reevaluate_session(
|
|||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 단일 턴 fast-loop 재평가 트리거 (교수자/관리자)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
@router.post("/sessions/{session_id}/turn", response_model=TurnEvaluation)
|
||||
@router.post("/sessions/{session_id}/turn", response_model=TurnEvaluationResponse)
|
||||
async def reevaluate_turn(
|
||||
session_id: str,
|
||||
body: TurnReevaluateRequest,
|
||||
|
|
@ -211,13 +224,13 @@ async def get_session_evaluation(
|
|||
await _load_session_or_404(session_id, principal)
|
||||
record, _durable = await session_persistence.load_session_evaluation(session_id, principal)
|
||||
if record is None:
|
||||
return EvaluationSummary(session_id=session_id, stage="", deep=None, distribution={})
|
||||
return EvaluationSummary(session_id=session_id, stage=None, deep=None, distribution={})
|
||||
payload = record.get("payload")
|
||||
deep = payload if isinstance(payload, dict) else {}
|
||||
distribution = deep.get("distribution")
|
||||
return EvaluationSummary(
|
||||
session_id=session_id,
|
||||
stage=str(record.get("stage") or deep.get("stage") or ""),
|
||||
stage=_summary_stage(record.get("stage") or deep.get("stage")),
|
||||
deep=deep,
|
||||
distribution=distribution if isinstance(distribution, dict) else {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ _RECALL_CACHE: dict[str, memory.RecallContext] = {}
|
|||
# 세션별 KB 증상 행동단서(회기 1회 산출·캐시). 빈 list 캐시 = 회기 내 재시도 안 함(안정성).
|
||||
_KB_CUES_CACHE: dict[str, list[str]] = {}
|
||||
_RAG_WARM_SEMAPHORE = asyncio.Semaphore(1)
|
||||
_LEARNER_VISIBLE_AI_ROLE = "counselor"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# RAG 배선 헬퍼 — 내담자(CLIENT) 뷰. 임베더/KB/DB 풀 미가용 시 빈 값으로 graceful
|
||||
|
|
@ -244,12 +243,6 @@ async def _ensure_kb_cues(session_id: str, card) -> list[str]:
|
|||
return cues
|
||||
|
||||
|
||||
async def _load_prev_case_summary(case_id: str) -> Optional[dict]:
|
||||
"""직전 회기 요약(case 스코프) → build_recall_context 입력. 미존재/미가용 시 None."""
|
||||
case_memory = await _load_case_memory(case_id)
|
||||
return case_memory.get("prev_summary")
|
||||
|
||||
|
||||
async def _load_case_memory(case_id: str) -> dict:
|
||||
"""case-level 큰그림 + 직전 요약 + client-visible pinned fact를 한 번에 읽는다."""
|
||||
empty = {"case_digest": None, "prev_summary": None, "pinned_facts": []}
|
||||
|
|
@ -387,6 +380,30 @@ async def ensure_recall_context(sess: InProcSession) -> memory.RecallContext:
|
|||
return recall
|
||||
|
||||
|
||||
async def _prepare_turn_context(
|
||||
*,
|
||||
session_id: str,
|
||||
sess: InProcSession,
|
||||
learner_text: str,
|
||||
) -> orchestrator.TurnContext:
|
||||
recall = await ensure_recall_context(sess)
|
||||
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
|
||||
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(visible_to="client"),
|
||||
kb_behavior_cues=kb_cues,
|
||||
theory_mode=sess.theory_mode,
|
||||
)
|
||||
assert ctx.state_after is not None
|
||||
return ctx
|
||||
|
||||
|
||||
async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
|
||||
"""RAG 회상·KB 행동단서를 **백그라운드**로 산출해 캐시한다(요청 경로 비차단).
|
||||
|
||||
|
|
@ -1032,22 +1049,11 @@ async def submit_turn(
|
|||
"""Submit one trainee utterance and return the generated client reply."""
|
||||
principal = _ensure_learner(principal)
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
recall = await ensure_recall_context(sess)
|
||||
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
|
||||
|
||||
ctx = orchestrator.prepare_turn(
|
||||
ctx = await _prepare_turn_context(
|
||||
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(visible_to="client"),
|
||||
kb_behavior_cues=kb_cues,
|
||||
theory_mode=sess.theory_mode,
|
||||
sess=sess,
|
||||
)
|
||||
assert ctx.state_after is not None
|
||||
|
||||
try:
|
||||
result = await orchestrator.run_turn_generate(
|
||||
|
|
@ -1159,22 +1165,11 @@ async def stream_turn(
|
|||
"""Stream a generated client reply for one trainee utterance."""
|
||||
principal = _ensure_learner(principal)
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
recall = await ensure_recall_context(sess)
|
||||
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
|
||||
|
||||
ctx = orchestrator.prepare_turn(
|
||||
ctx = await _prepare_turn_context(
|
||||
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(visible_to="client"),
|
||||
kb_behavior_cues=kb_cues,
|
||||
theory_mode=sess.theory_mode,
|
||||
sess=sess,
|
||||
)
|
||||
assert ctx.state_after is not None
|
||||
|
||||
async def event_generator():
|
||||
last_beat = asyncio.get_running_loop().time()
|
||||
|
|
@ -1229,7 +1224,7 @@ async def end_session(
|
|||
session_id=session_id,
|
||||
case_id=sess.case_id,
|
||||
session_no=sess.session_no,
|
||||
masked_turns=sess.masked_turns(),
|
||||
masked_turns=sess.masked_turns(visible_to="client"),
|
||||
prev_rapport_credit=sess.prev_rapport_credit,
|
||||
open_threads=recall.open_threads,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ from typing import Any
|
|||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from .. import session_persistence
|
||||
from ..session_read_model import StageLabel, stage_label_or_none
|
||||
|
||||
router = APIRouter(tags=["share"])
|
||||
|
||||
|
|
@ -36,7 +37,7 @@ class PublicSessionShareResponse(BaseModel):
|
|||
persona: str
|
||||
date: str
|
||||
durationLabel: str
|
||||
reachedPhase: str
|
||||
reachedPhase: StageLabel | None = None
|
||||
sessionSignal: str
|
||||
reviewReady: bool = False
|
||||
goodMoments: list[str] = Field(default_factory=list)
|
||||
|
|
@ -44,6 +45,11 @@ class PublicSessionShareResponse(BaseModel):
|
|||
worksheetHighlights: list[dict[str, str]] = Field(default_factory=list)
|
||||
privacy: str = ""
|
||||
|
||||
@field_validator("reachedPhase", mode="before")
|
||||
@classmethod
|
||||
def _normalize_reached_phase(cls, value: object) -> StageLabel | None:
|
||||
return stage_label_or_none(value)
|
||||
|
||||
|
||||
def _safe_payload(payload: dict[str, Any]) -> PublicSessionShareResponse:
|
||||
return PublicSessionShareResponse(
|
||||
|
|
@ -56,7 +62,7 @@ def _safe_payload(payload: dict[str, Any]) -> PublicSessionShareResponse:
|
|||
persona=str(payload.get("persona") or ""),
|
||||
date=str(payload.get("date") or ""),
|
||||
durationLabel=str(payload.get("durationLabel") or ""),
|
||||
reachedPhase=str(payload.get("reachedPhase") or ""),
|
||||
reachedPhase=payload.get("reachedPhase"),
|
||||
sessionSignal=str(payload.get("sessionSignal") or ""),
|
||||
reviewReady=bool(payload.get("reviewReady")),
|
||||
goodMoments=[str(item) for item in payload.get("goodMoments") or []][:3],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue