회기 연속성과 멀티 케이스 계약을 영속화
This commit is contained in:
parent
be08c0b573
commit
72353ecd82
26 changed files with 2170 additions and 127 deletions
|
|
@ -25,6 +25,7 @@ from .stage_contract import (
|
|||
stage_label_or_none as stage_label_or_none,
|
||||
)
|
||||
from .store import InProcSession, TurnRecord
|
||||
from .session_evaluation_timeout import session_evaluation_stale_after_seconds
|
||||
|
||||
WorksheetSpeaker = Literal["learner", "client"]
|
||||
WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None]
|
||||
|
|
@ -32,7 +33,6 @@ WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]]
|
|||
|
||||
LEARNER_VISIBLE_AI_ROLE = "counselor"
|
||||
KOREA_STANDARD_TIME = timezone(timedelta(hours=9), name="KST")
|
||||
MISSING_SESSION_EVALUATION_GRACE_SECONDS = 30.0
|
||||
MISSING_SESSION_EVALUATION_ERROR = (
|
||||
"회기말 평가가 제한 시간 이후에도 저장되지 않았습니다. AI 평가 재시도가 필요합니다."
|
||||
)
|
||||
|
|
@ -44,6 +44,8 @@ FIRST_SESSION_CHECKLIST_VERSION = "first-session-rapport-open-question.v1"
|
|||
|
||||
class LearnerSessionSummary(BaseModel):
|
||||
session_id: str
|
||||
# 같은 내담자도 새 사례에서 다시 S1이 될 수 있으므로, 이력 행의 연속체 경계를 함께 준다.
|
||||
case_id: str = ""
|
||||
persona_code: str
|
||||
persona_name: str
|
||||
session_no: int
|
||||
|
|
@ -65,6 +67,44 @@ class LearnerSessionsResponse(BaseModel):
|
|||
sessions: list[LearnerSessionSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CaseProgressStats(BaseModel):
|
||||
"""한 사례 안에서만 누적한 learner-visible 진행 수치."""
|
||||
|
||||
total_sessions: int = 0
|
||||
completed_sessions: int = 0
|
||||
total_turns: int = 0
|
||||
total_duration_seconds: int = 0
|
||||
active_session_id: str | None = None
|
||||
active_session_no: int | None = None
|
||||
active_started_at: str | None = None
|
||||
last_activity_at: str | None = None
|
||||
|
||||
|
||||
class LearnerCaseSummary(BaseModel):
|
||||
case_id: str
|
||||
persona_code: str
|
||||
persona_name: str
|
||||
last_session_no: int = 0
|
||||
progress: CaseProgressStats = Field(default_factory=CaseProgressStats)
|
||||
|
||||
|
||||
class LearnerCaseListResponse(BaseModel):
|
||||
# case별 누적 수치는 DB 전체 집계여야 한다. runtime cache 추정값은 반환하지 않는다.
|
||||
source: Literal["database"] = "database"
|
||||
cases: list[LearnerCaseSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CaseMemoryPreview(BaseModel):
|
||||
"""접힌 learner UI에만 쓰는 최소·마스킹된 사례 기억 투영."""
|
||||
|
||||
case_id: str
|
||||
memory_available: bool = False
|
||||
case_digest: str | None = None
|
||||
latest_session_digest: str | None = None
|
||||
open_threads: list[str] = Field(default_factory=list)
|
||||
pinned_facts: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LearnerDashboardOverview(BaseModel):
|
||||
total_sessions: int = 0
|
||||
completed_sessions: int = 0
|
||||
|
|
@ -472,6 +512,21 @@ class SessionTeacherReviewStatus(BaseModel):
|
|||
worksheetReviewedAt: str | None = None
|
||||
|
||||
|
||||
class ReviewEvaluationFailure(BaseModel):
|
||||
"""교수자용 deep-loop 실패 분류. 원문 예외·축어록은 절대 응답에 넣지 않는다."""
|
||||
|
||||
code: Literal[
|
||||
"timeout",
|
||||
"engine_unavailable",
|
||||
"legacy_argv_limit",
|
||||
"prompt_too_large",
|
||||
"invalid_structured_output",
|
||||
"missing_evaluation",
|
||||
"unknown",
|
||||
]
|
||||
retryable: bool
|
||||
|
||||
|
||||
class SessionReviewResponse(BaseModel):
|
||||
session_id: str
|
||||
sessionNo: int = Field(ge=1)
|
||||
|
|
@ -501,6 +556,7 @@ class SessionReviewResponse(BaseModel):
|
|||
pdfExportUrl: Optional[str] = None
|
||||
degraded: bool = True
|
||||
reviewReady: bool = False
|
||||
evaluationFailure: ReviewEvaluationFailure | None = None
|
||||
learnerFeedbackEnabled: bool = True
|
||||
teacherReview: SessionTeacherReviewStatus | None = None
|
||||
|
||||
|
|
@ -561,6 +617,7 @@ def learner_summary(
|
|||
client_turns = sum(1 for turn in turns if turn.speaker == "client")
|
||||
return LearnerSessionSummary(
|
||||
session_id=sess.session_id,
|
||||
case_id=sess.case_id,
|
||||
persona_code=sess.persona_code,
|
||||
persona_name=sess.persona.display_name,
|
||||
session_no=sess.session_no,
|
||||
|
|
@ -1121,6 +1178,12 @@ def _review_summary_from_evaluation(
|
|||
return fallback
|
||||
status = str(evaluation_record.get("status") or "")
|
||||
if status != "ready":
|
||||
failure = teacher_evaluation_failure(evaluation_record)
|
||||
if failure is not None and not failure.retryable:
|
||||
return (
|
||||
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. "
|
||||
"평가 입력 경로를 조정한 뒤 다시 생성해야 합니다."
|
||||
)
|
||||
return (
|
||||
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. "
|
||||
"AI 평가 재시도가 필요합니다."
|
||||
|
|
@ -1133,9 +1196,56 @@ def _review_summary_from_evaluation(
|
|||
return prefix + (details if details else "아래 코칭 항목은 저장된 축어록과 평가 AI 결과를 기준으로 합니다.")
|
||||
|
||||
|
||||
def _session_evaluation_timeout_seconds() -> float:
|
||||
configured = float(settings.session_evaluation_timeout or settings.engine_timeout)
|
||||
return max(1.0, configured)
|
||||
def teacher_evaluation_failure(
|
||||
evaluation_record: dict[str, object] | None,
|
||||
) -> ReviewEvaluationFailure | None:
|
||||
"""오류 원문을 노출하지 않고 교수자가 취할 다음 행동만 분류한다."""
|
||||
if not evaluation_record or str(evaluation_record.get("status") or "") != "error":
|
||||
return None
|
||||
raw_error = str(evaluation_record.get("error") or "").strip().lower()
|
||||
if raw_error == MISSING_SESSION_EVALUATION_ERROR.lower() or "저장되지 않았습니다" in raw_error:
|
||||
return ReviewEvaluationFailure(code="missing_evaluation", retryable=True)
|
||||
if (
|
||||
("24,000" in raw_error or "명령줄 안전 한도" in raw_error)
|
||||
and "agy" in raw_error
|
||||
):
|
||||
# 2026-08 이전 Windows Agy argv 경로의 durable 오류다. 현재 stdin 경로로는
|
||||
# 재평가할 수 있으므로, 같은 오류처럼 보이더라도 복구 재시도를 열어 둔다.
|
||||
return ReviewEvaluationFailure(code="legacy_argv_limit", retryable=True)
|
||||
if "argv" in raw_error or "24000" in raw_error or "prompt too large" in raw_error:
|
||||
return ReviewEvaluationFailure(code="prompt_too_large", retryable=False)
|
||||
if "timeout" in raw_error or "timed out" in raw_error:
|
||||
return ReviewEvaluationFailure(code="timeout", retryable=True)
|
||||
if "no_structured_output" in raw_error or "parse_error" in raw_error:
|
||||
return ReviewEvaluationFailure(code="invalid_structured_output", retryable=True)
|
||||
if (
|
||||
"engine_error" in raw_error
|
||||
or "engine unavailable" in raw_error
|
||||
or "transport error" in raw_error
|
||||
):
|
||||
return ReviewEvaluationFailure(code="engine_unavailable", retryable=True)
|
||||
return ReviewEvaluationFailure(code="unknown", retryable=True)
|
||||
|
||||
|
||||
def teacher_evaluation_failure_message(
|
||||
failure: ReviewEvaluationFailure | None,
|
||||
) -> str | None:
|
||||
"""교수자 목록 API에서 provider 원문 대신 사용할 최소 행동 안내."""
|
||||
if failure is None:
|
||||
return None
|
||||
if failure.code == "timeout":
|
||||
return "AI 평가가 제한 시간 안에 끝나지 않았습니다. 다시 시도할 수 있습니다."
|
||||
if failure.code == "engine_unavailable":
|
||||
return "평가 엔진에 일시적으로 연결하지 못했습니다. 다시 시도할 수 있습니다."
|
||||
if failure.code == "legacy_argv_limit":
|
||||
return "이전 Windows 입력 한도에 걸린 평가입니다. 현재 입력 경로로 다시 시도할 수 있습니다."
|
||||
if failure.code == "prompt_too_large":
|
||||
return "평가 입력이 허용 크기를 넘어섰습니다. 입력 경로 조정이 필요합니다."
|
||||
if failure.code == "invalid_structured_output":
|
||||
return "평가 결과 형식이 검증되지 않았습니다. 다시 시도할 수 있습니다."
|
||||
if failure.code == "missing_evaluation":
|
||||
return "회기말 평가 기록이 아직 저장되지 않았습니다. 다시 시도할 수 있습니다."
|
||||
return "AI 평가를 완료하지 못했습니다. 최신 상태를 확인해 주세요."
|
||||
|
||||
|
||||
def _missing_session_evaluation_record(
|
||||
|
|
@ -1146,7 +1256,7 @@ def _missing_session_evaluation_record(
|
|||
) -> dict[str, object] | None:
|
||||
if not sess.ended or not has_visible_turns or sess.ended_at is None:
|
||||
return None
|
||||
stale_after = _session_evaluation_timeout_seconds() + MISSING_SESSION_EVALUATION_GRACE_SECONDS
|
||||
stale_after = session_evaluation_stale_after_seconds()
|
||||
if now_ts - sess.ended_at < stale_after:
|
||||
return None
|
||||
return {
|
||||
|
|
@ -1903,6 +2013,11 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
else ""
|
||||
)
|
||||
evaluation_ready = not feedback_hidden and evaluation_status == "ready"
|
||||
evaluation_failure = (
|
||||
teacher_evaluation_failure(evaluation_record)
|
||||
if read_input.include_teacher_review and not feedback_hidden
|
||||
else None
|
||||
)
|
||||
|
||||
first_turn_ts = visible_turns[0].created_at if visible_turns else sess.created_at
|
||||
turns: list[ReviewTurn] = []
|
||||
|
|
@ -2083,6 +2198,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
pdfExportUrl=None,
|
||||
degraded=review_degraded,
|
||||
reviewReady=evaluation_ready,
|
||||
evaluationFailure=evaluation_failure,
|
||||
learnerFeedbackEnabled=read_input.learner_feedback_enabled,
|
||||
teacherReview=teacher_review,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue