회기 연속성과 멀티 케이스 계약을 영속화
This commit is contained in:
parent
be08c0b573
commit
72353ecd82
26 changed files with 2170 additions and 127 deletions
|
|
@ -28,7 +28,15 @@ from ..deps import Principal, Role, require_role
|
|||
from ..engine_client import EngineError, engine_client
|
||||
from ..runtime_policy import runtime_fallback_allowed
|
||||
from ..session_evaluation_input import enriched_masked_turns
|
||||
from ..session_read_model import StageLabel, stage_label_or_none
|
||||
from ..session_evaluation_timeout import (
|
||||
session_evaluation_transport_timeout_seconds,
|
||||
)
|
||||
from ..session_read_model import (
|
||||
ReviewEvaluationFailure,
|
||||
StageLabel,
|
||||
stage_label_or_none,
|
||||
teacher_evaluation_failure,
|
||||
)
|
||||
from ..services import evaluator
|
||||
from ..services.evaluator import SessionEvaluation, TurnEvaluation
|
||||
from ..store import InProcSession
|
||||
|
|
@ -51,12 +59,13 @@ class TurnReevaluateRequest(BaseModel):
|
|||
|
||||
|
||||
class EvaluationSummary(BaseModel):
|
||||
"""회기 평가 조회 응답(분포 + deep 결과 합본)."""
|
||||
"""회기 평가 조회 응답(분포 + deep 결과 합본, provider 오류 원문 제외)."""
|
||||
|
||||
session_id: str
|
||||
stage: StageLabel | None = None
|
||||
status: str | None = None
|
||||
error: str | None = None
|
||||
failure: ReviewEvaluationFailure | None = None
|
||||
durable: bool = False
|
||||
deep: Optional[dict[str, Any]] = None
|
||||
distribution: dict[str, Any] = Field(default_factory=dict)
|
||||
|
|
@ -108,6 +117,15 @@ def _session_evaluation_error_status(error: str) -> int:
|
|||
return status.HTTP_502_BAD_GATEWAY
|
||||
|
||||
|
||||
def _safe_session_evaluation_retry_detail() -> str:
|
||||
"""재시도 HTTP 응답에서는 provider 예외 원문을 내보내지 않는다.
|
||||
|
||||
원인은 durable evaluation record에 서버 전용으로 보존하고, 교수자 화면은 review의
|
||||
안전 분류(evaluationFailure)로 다음 행동만 안내한다.
|
||||
"""
|
||||
return "AI 평가를 완료하지 못했습니다. 최신 평가 상태를 확인해 주세요."
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 회기 deep-loop 재평가 트리거 (교수자/관리자)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -120,7 +138,8 @@ async def reevaluate_session(
|
|||
"""회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화).
|
||||
|
||||
저장된 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다.
|
||||
엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출).
|
||||
엔진 장애는 503으로 변환한다. provider 예외 원문은 durable 기록에만 남기고 HTTP에는
|
||||
안전한 안내만 반환한다.
|
||||
"""
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
counselor_identity = getattr(sess, "learner_label", None)
|
||||
|
|
@ -144,6 +163,7 @@ async def reevaluate_session(
|
|||
theory_mode=_theory_mode_of(sess),
|
||||
scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end",
|
||||
audit_hook=session_persistence.record_llm_call_audit,
|
||||
timeout=session_evaluation_transport_timeout_seconds(),
|
||||
)
|
||||
except EngineError as e:
|
||||
detail = f"engine unavailable: {e}"
|
||||
|
|
@ -163,7 +183,10 @@ async def reevaluate_session(
|
|||
session_id,
|
||||
write.error,
|
||||
)
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=_safe_session_evaluation_retry_detail(),
|
||||
)
|
||||
|
||||
write = session_persistence.SessionEvaluationWrite.from_result(
|
||||
session_id=session_id,
|
||||
|
|
@ -178,7 +201,10 @@ async def reevaluate_session(
|
|||
logger.error("%s: session_id=%s status=%s", detail, session_id, write.status)
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
|
||||
if result.error:
|
||||
raise HTTPException(_session_evaluation_error_status(result.error), detail=result.error)
|
||||
raise HTTPException(
|
||||
_session_evaluation_error_status(result.error),
|
||||
detail=_safe_session_evaluation_retry_detail(),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -256,7 +282,10 @@ async def reevaluate_turn(
|
|||
)
|
||||
learner.evaluation = result_payload
|
||||
if result.error:
|
||||
raise HTTPException(_session_evaluation_error_status(result.error), detail=result.error)
|
||||
raise HTTPException(
|
||||
_session_evaluation_error_status(result.error),
|
||||
detail=_safe_session_evaluation_retry_detail(),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -285,13 +314,18 @@ async def get_session_evaluation(
|
|||
distribution={},
|
||||
)
|
||||
payload = record.get("payload")
|
||||
deep = payload if isinstance(payload, dict) else {}
|
||||
deep = dict(payload) if isinstance(payload, dict) else {}
|
||||
# SessionEvaluation.to_dict()에는 server-side error가 함께 저장된다. deep 객체도
|
||||
# 교수자 API 경계에서는 같은 원칙으로 제거한다.
|
||||
deep.pop("error", None)
|
||||
distribution = deep.get("distribution")
|
||||
failure = teacher_evaluation_failure(record)
|
||||
return EvaluationSummary(
|
||||
session_id=session_id,
|
||||
stage=_summary_stage(record.get("stage") or deep.get("stage")),
|
||||
status=str(record.get("status") or "") or None,
|
||||
error=str(record.get("error") or "") or None,
|
||||
error=_safe_session_evaluation_retry_detail() if failure is not None else None,
|
||||
failure=failure,
|
||||
durable=durable,
|
||||
deep=deep,
|
||||
distribution=distribution if isinstance(distribution, dict) else {},
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import secrets
|
|||
import time
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
|
@ -28,6 +29,12 @@ from ..engine_client import EngineError, engine_client
|
|||
from ..persona_repository import get_catalog_persona
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..session_evaluation_input import enriched_masked_turns
|
||||
from ..session_evaluation_timeout import (
|
||||
session_evaluation_outer_timeout_seconds,
|
||||
session_evaluation_timeout_seconds as _session_evaluation_timeout_seconds,
|
||||
session_evaluation_stale_after_seconds,
|
||||
session_evaluation_transport_timeout_seconds,
|
||||
)
|
||||
from ..services import (
|
||||
evaluator,
|
||||
feedback_policy,
|
||||
|
|
@ -44,11 +51,14 @@ from ..services import (
|
|||
state_machine,
|
||||
)
|
||||
from ..session_read_model import (
|
||||
CaseMemoryPreview,
|
||||
CaseProgressStats,
|
||||
LearnerCaseListResponse,
|
||||
LearnerCaseSummary,
|
||||
LearnerDashboardResponse,
|
||||
LearnerSessionsResponse,
|
||||
LearnerSessionSummary,
|
||||
LEARNER_VISIBLE_AI_ROLE,
|
||||
MISSING_SESSION_EVALUATION_GRACE_SECONDS,
|
||||
ReviewCaseWorksheet,
|
||||
ReviewCaseWorksheetSaveRequest,
|
||||
ReviewWorksheetItem as ReviewWorksheetItem,
|
||||
|
|
@ -96,6 +106,10 @@ EndStateValue = str | int | float | bool | None | dict[str, float]
|
|||
class SessionStartRequest(BaseModel):
|
||||
persona_code: str = Field(..., examples=["P1"])
|
||||
theory_mode: TheoryMode = "humanistic"
|
||||
# continue는 선택한 사례의 압축 기억을 이어 받고, fresh는 새 case_id/S1으로 시작한다.
|
||||
# 구클라이언트는 기존 동작을 보존하도록 continue가 기본이다.
|
||||
start_mode: Literal["continue", "fresh"] = "continue"
|
||||
case_id: UUID | None = None
|
||||
# 이번 회기 목표 단계(2026-07-13 회의 P1). 회의 권장은 2개 수준이지만
|
||||
# 소유자 지시(2026-07-15)로 1~4개까지 자유 선택을 허용한다.
|
||||
# 빈 리스트는 구계약 클라이언트 호환용 — 준비 페이지는 항상 1개 이상을 보낸다.
|
||||
|
|
@ -127,6 +141,7 @@ class SessionStartResponse(BaseModel):
|
|||
duration_limit_seconds: int = 0
|
||||
warning_before_end_seconds: int = 0
|
||||
learner_feedback_enabled: bool = True
|
||||
start_mode: Literal["continue", "fresh"] = "continue"
|
||||
|
||||
|
||||
class TurnRequest(BaseModel):
|
||||
|
|
@ -916,6 +931,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
return
|
||||
|
||||
timeout_seconds = _session_evaluation_timeout_seconds()
|
||||
transport_timeout_seconds = session_evaluation_transport_timeout_seconds()
|
||||
enriched = enriched_masked_turns(
|
||||
sess.masked_turns(),
|
||||
counselor_identity=sess.learner_label,
|
||||
|
|
@ -933,8 +949,12 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
theory_mode=sess.theory_mode,
|
||||
scope="session_end",
|
||||
audit_hook=session_persistence.record_llm_call_audit,
|
||||
timeout=transport_timeout_seconds,
|
||||
),
|
||||
timeout=timeout_seconds,
|
||||
# gateway의 생성 deadline과 HTTP deadline을 같게 두면 요청 초기화·응답 수신
|
||||
# 비용만으로 app이 먼저 취소될 수 있다. transport grace와 durable audit 기록
|
||||
# 예산 뒤에 outer grace를 두어 정상 결과를 timeout error로 바꾸지 않는다.
|
||||
timeout=session_evaluation_outer_timeout_seconds(),
|
||||
)
|
||||
write = session_persistence.SessionEvaluationWrite.from_result(
|
||||
session_id=sess.session_id,
|
||||
|
|
@ -1050,9 +1070,7 @@ async def recover_missing_session_evaluations(*, limit: int | None = None) -> in
|
|||
)
|
||||
if recovery_limit <= 0:
|
||||
return 0
|
||||
stale_after_seconds = (
|
||||
_session_evaluation_timeout_seconds() + MISSING_SESSION_EVALUATION_GRACE_SECONDS
|
||||
)
|
||||
stale_after_seconds = session_evaluation_stale_after_seconds()
|
||||
(
|
||||
candidates,
|
||||
durable,
|
||||
|
|
@ -1108,11 +1126,6 @@ def cancel_missing_session_evaluation_recovery() -> None:
|
|||
task.cancel()
|
||||
|
||||
|
||||
def _session_evaluation_timeout_seconds() -> float:
|
||||
configured = float(settings.session_evaluation_timeout or settings.engine_timeout)
|
||||
return max(configured, 1.0)
|
||||
|
||||
|
||||
async def _enqueue_session_review_ready_notification(session_id: str) -> None:
|
||||
try:
|
||||
await notifications.enqueue_session_review_ready(session_id=session_id)
|
||||
|
|
@ -1236,6 +1249,180 @@ async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsR
|
|||
)
|
||||
|
||||
|
||||
def _preview_text(value: object, *, limit: int) -> str | None:
|
||||
"""Keep the learner foldout bounded even when a legacy digest is verbose."""
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return f"{text[: max(1, limit - 1)].rstrip()}…"
|
||||
|
||||
|
||||
def _preview_items(value: object, *, limit: int, item_limit: int) -> list[str]:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
items: list[str] = []
|
||||
for raw in value:
|
||||
clipped = _preview_text(raw, limit=item_limit)
|
||||
if clipped:
|
||||
items.append(clipped)
|
||||
if len(items) >= limit:
|
||||
break
|
||||
return items
|
||||
|
||||
|
||||
def _db_datetime_iso(value: object) -> str | None:
|
||||
return value.isoformat() if isinstance(value, datetime) else None
|
||||
|
||||
|
||||
@router.get("/cases", response_model=LearnerCaseListResponse)
|
||||
async def list_learner_cases(
|
||||
persona_code: str,
|
||||
principal: CurrentPrincipal,
|
||||
) -> LearnerCaseListResponse:
|
||||
"""Return complete case-local progress for one NPC, not a capped history slice."""
|
||||
principal = _ensure_learner(principal)
|
||||
try:
|
||||
catalog_persona = await get_catalog_persona(persona_code)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="persona catalog database unavailable",
|
||||
) from exc
|
||||
if catalog_persona is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, detail=f"unknown persona {persona_code}"
|
||||
)
|
||||
try:
|
||||
rows = await session_persistence.list_case_summaries(
|
||||
learner_id=principal.user_id,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
)
|
||||
except session_persistence.CaseProgressUnavailableError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="case_progress_unavailable",
|
||||
) from exc
|
||||
|
||||
card = catalog_persona.card
|
||||
return LearnerCaseListResponse(
|
||||
cases=[
|
||||
LearnerCaseSummary(
|
||||
case_id=str(row["case_id"]),
|
||||
persona_code=card.code,
|
||||
persona_name=card.display_name,
|
||||
last_session_no=int(row["last_session_no"] or 0),
|
||||
progress=CaseProgressStats(
|
||||
total_sessions=int(row["total_sessions"] or 0),
|
||||
completed_sessions=int(row["completed_sessions"] or 0),
|
||||
total_turns=int(row["total_turns"] or 0),
|
||||
total_duration_seconds=int(row["total_duration_seconds"] or 0),
|
||||
active_session_id=(
|
||||
str(row["active_session_id"])
|
||||
if row.get("active_session_id") is not None
|
||||
else None
|
||||
),
|
||||
active_session_no=(
|
||||
int(row["active_session_no"])
|
||||
if row.get("active_session_no") is not None
|
||||
else None
|
||||
),
|
||||
active_started_at=_db_datetime_iso(row.get("active_started_at")),
|
||||
last_activity_at=_db_datetime_iso(row.get("last_activity_at")),
|
||||
),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}/memory", response_model=CaseMemoryPreview)
|
||||
async def get_learner_case_memory_preview(
|
||||
case_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> CaseMemoryPreview:
|
||||
"""Load only the learner-safe compact memory when its foldout is opened."""
|
||||
principal = _ensure_learner(principal)
|
||||
case_key = str(case_id)
|
||||
try:
|
||||
async with db.acquire(role="learner", user_id=principal.user_id) as conn:
|
||||
case_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT case_digest
|
||||
FROM app.case_profile
|
||||
WHERE case_id = $1::uuid
|
||||
AND learner_id = $2::uuid
|
||||
""",
|
||||
case_key,
|
||||
principal.user_id,
|
||||
)
|
||||
if case_row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="case_not_found")
|
||||
summary_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT ss.digest, ss.open_threads
|
||||
FROM app.session_summary AS ss
|
||||
JOIN app.sessions AS s ON s.id = ss.session_id
|
||||
WHERE ss.case_id = $1::uuid
|
||||
AND s.learner_id = $2::uuid
|
||||
ORDER BY ss.session_no DESC, ss.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
case_key,
|
||||
principal.user_id,
|
||||
)
|
||||
fact_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT LEFT(pf.value, 160) AS value
|
||||
FROM app.pinned_fact AS pf
|
||||
JOIN app.case_profile AS cp ON cp.case_id = pf.case_id
|
||||
WHERE pf.case_id = $1::uuid
|
||||
AND cp.learner_id = $2::uuid
|
||||
AND pf.status IN ('stable', 'evolving', 'locked')
|
||||
AND 'client' = ANY(pf.visible_to)
|
||||
ORDER BY pf.updated_at DESC
|
||||
LIMIT 8
|
||||
""",
|
||||
case_key,
|
||||
principal.user_id,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("case memory preview read failed", extra={"case_id": case_key})
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="case_memory_unavailable",
|
||||
) from exc
|
||||
|
||||
case_digest = _preview_text(case_row["case_digest"], limit=600)
|
||||
latest_session_digest = _preview_text(
|
||||
summary_row["digest"] if summary_row is not None else None,
|
||||
limit=600,
|
||||
)
|
||||
open_threads = _preview_items(
|
||||
summary_row["open_threads"] if summary_row is not None else [],
|
||||
limit=6,
|
||||
item_limit=160,
|
||||
)
|
||||
pinned_facts = _preview_items(
|
||||
[row["value"] for row in fact_rows],
|
||||
limit=8,
|
||||
item_limit=160,
|
||||
)
|
||||
return CaseMemoryPreview(
|
||||
case_id=case_key,
|
||||
memory_available=bool(
|
||||
case_digest or latest_session_digest or open_threads or pinned_facts
|
||||
),
|
||||
case_digest=case_digest,
|
||||
latest_session_digest=latest_session_digest,
|
||||
open_threads=open_threads,
|
||||
pinned_facts=pinned_facts,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=LearnerDashboardResponse)
|
||||
async def learner_dashboard(principal: CurrentPrincipal) -> LearnerDashboardResponse:
|
||||
"""Return the current learner's real practice dashboard aggregates."""
|
||||
|
|
@ -1391,15 +1578,16 @@ async def start_session(
|
|||
status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}"
|
||||
)
|
||||
card = catalog_persona.card
|
||||
if body.start_mode == "fresh" and body.case_id is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="fresh_start_must_not_select_case",
|
||||
)
|
||||
|
||||
case_context = await session_persistence.get_case_context(
|
||||
learner_id=principal.user_id,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
)
|
||||
recall = await _build_seed_recall(
|
||||
case_id=case_context.case_id if case_context else None
|
||||
)
|
||||
session_no = (case_context.last_session_no + 1) if case_context else 1
|
||||
# The durable transaction chooses or creates the case only after active-case
|
||||
# validation. Start with an empty recall here so a fresh request cannot see
|
||||
# any legacy case before its own empty case exists.
|
||||
recall = memory.build_recall_context()
|
||||
st = state_machine.init_state(
|
||||
params=card.openness_params(),
|
||||
carry=recall.carry,
|
||||
|
|
@ -1408,20 +1596,51 @@ async def start_session(
|
|||
carry_rapport = st.rapport_credit
|
||||
goal_stages = [str(stage) for stage in body.goal_stages]
|
||||
learner_feedback_enabled = principal.learner_feedback_enabled
|
||||
|
||||
async def build_locked_start_state(
|
||||
stable_case_id: str,
|
||||
_session_no: int,
|
||||
) -> state_machine.SessionState:
|
||||
nonlocal recall
|
||||
recall = (
|
||||
memory.build_recall_context()
|
||||
if body.start_mode == "fresh"
|
||||
else await _build_seed_recall(case_id=stable_case_id)
|
||||
)
|
||||
return state_machine.init_state(
|
||||
params=card.openness_params(),
|
||||
carry=recall.carry,
|
||||
)
|
||||
|
||||
try:
|
||||
sess = await session_persistence.create_session(
|
||||
learner_id=principal.user_id,
|
||||
card=card,
|
||||
theory_mode=body.theory_mode,
|
||||
state=st,
|
||||
session_no=session_no,
|
||||
session_no=1,
|
||||
carry_rapport=carry_rapport,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
persona_version=catalog_persona.version,
|
||||
case_id=case_context.case_id if case_context else None,
|
||||
case_id=str(body.case_id) if body.case_id is not None else None,
|
||||
start_mode=body.start_mode,
|
||||
goal_stages=goal_stages,
|
||||
learner_feedback_enabled=learner_feedback_enabled,
|
||||
locked_state_factory=build_locked_start_state,
|
||||
)
|
||||
except session_persistence.ActiveSessionExistsError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "active_session_exists",
|
||||
"session_id": exc.session_id,
|
||||
},
|
||||
) from exc
|
||||
except session_persistence.CaseNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail="case_not_found",
|
||||
) from exc
|
||||
except session_persistence.SessionCreationPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
|
|
@ -1430,6 +1649,64 @@ async def start_session(
|
|||
degraded = catalog_persona.degraded or sess is None
|
||||
if sess is None:
|
||||
require_runtime_fallback_allowed("session creation")
|
||||
# Runtime fallback cannot prove durable case memory. Keep it empty rather
|
||||
# than leaking a guessed legacy recall, while preserving a selected
|
||||
# in-process case ID when one is available.
|
||||
recall = memory.build_recall_context()
|
||||
st = state_machine.init_state(
|
||||
params=card.openness_params(),
|
||||
carry=recall.carry,
|
||||
)
|
||||
carry_rapport = st.rapport_credit
|
||||
active_session = store.find_active(
|
||||
learner_id=principal.user_id,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
persona_code=card.code,
|
||||
)
|
||||
if active_session is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "active_session_exists",
|
||||
"session_id": active_session.session_id,
|
||||
},
|
||||
)
|
||||
runtime_case_id: str | None = None
|
||||
runtime_session_no = 1
|
||||
if body.start_mode == "continue":
|
||||
related = [
|
||||
candidate
|
||||
for candidate in store.list()
|
||||
if candidate.learner_id == principal.user_id
|
||||
and (
|
||||
candidate.persona_id == catalog_persona.persona_id
|
||||
or candidate.persona_code == card.code
|
||||
)
|
||||
]
|
||||
if body.case_id is not None:
|
||||
runtime_case_id = str(body.case_id)
|
||||
related = [
|
||||
candidate
|
||||
for candidate in related
|
||||
if candidate.case_id == runtime_case_id
|
||||
]
|
||||
if not related:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail="case_not_found",
|
||||
)
|
||||
elif related:
|
||||
newest = max(related, key=lambda candidate: candidate.created_at)
|
||||
runtime_case_id = newest.case_id
|
||||
related = [
|
||||
candidate
|
||||
for candidate in related
|
||||
if candidate.case_id == runtime_case_id
|
||||
]
|
||||
if related:
|
||||
runtime_session_no = max(
|
||||
candidate.session_no for candidate in related
|
||||
) + 1
|
||||
sess = store.create(
|
||||
learner_id=principal.user_id,
|
||||
persona=card,
|
||||
|
|
@ -1437,12 +1714,14 @@ async def start_session(
|
|||
state=st,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
persona_version=catalog_persona.version,
|
||||
session_no=session_no,
|
||||
case_id=runtime_case_id,
|
||||
session_no=runtime_session_no,
|
||||
carry_rapport=carry_rapport,
|
||||
goal_stages=goal_stages,
|
||||
learner_feedback_enabled=learner_feedback_enabled,
|
||||
)
|
||||
else:
|
||||
st = sess.state
|
||||
store.put(sess)
|
||||
|
||||
# DB 재조회 전의 첫 턴과 runtime fallback에서도 인증된 학습자 표시명을
|
||||
|
|
@ -1470,6 +1749,7 @@ async def start_session(
|
|||
duration_limit_seconds=settings.session_duration_minutes * 60,
|
||||
warning_before_end_seconds=settings.session_warning_minutes * 60,
|
||||
learner_feedback_enabled=sess.learner_feedback_enabled,
|
||||
start_mode=body.start_mode,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ from ..session_read_model import (
|
|||
learner_visible_turns,
|
||||
missing_session_evaluation_record,
|
||||
stage_label,
|
||||
teacher_evaluation_failure,
|
||||
teacher_evaluation_failure_message,
|
||||
)
|
||||
from ..services import session_metrics
|
||||
from ..stage_contract import STAGE_LABEL_VALUES
|
||||
|
|
@ -298,6 +300,7 @@ def _summary(
|
|||
learner_turns = sum(1 for turn in visible_turns if turn.speaker == "counselor")
|
||||
client_turns = sum(1 for turn in visible_turns if turn.speaker == "client")
|
||||
evaluation_status = _evaluation_status_value(summary_evaluation_record)
|
||||
evaluation_failure = teacher_evaluation_failure(summary_evaluation_record)
|
||||
return TeacherSessionSummary(
|
||||
session_id=sess.session_id,
|
||||
learner_id=sess.learner_id,
|
||||
|
|
@ -322,9 +325,7 @@ def _summary(
|
|||
summary_evaluation_record,
|
||||
has_visible_turns=bool(visible_turns),
|
||||
),
|
||||
evaluation_error=(
|
||||
str(summary_evaluation_record.get("error") or "") if summary_evaluation_record else None
|
||||
),
|
||||
evaluation_error=teacher_evaluation_failure_message(evaluation_failure),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue