회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s

This commit is contained in:
Yun Chan 2026-09-08 23:28:06 +09:00
parent a479db7a5a
commit a0311c5957
100 changed files with 4884 additions and 11210 deletions

View file

@ -779,6 +779,24 @@ async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics:
return metrics
def _usage_daily_cost(
daily_buckets: dict[str, dict[str, int | float | list[UsageCostBasis]]],
) -> list[AdminUsageDailyCost]:
return [
AdminUsageDailyCost(
day=day,
turns=int(values["turns"]),
tokens_in=int(values["tokens_in"]),
tokens_out=int(values["tokens_out"]),
cost_usd=round(float(values["cost_usd"]), 6),
cost_basis=_aggregate_cost_basis(
cast(list[UsageCostBasis], values["cost_bases"])
),
)
for day, values in sorted(daily_buckets.items())
]
async def _usage_from_database(window_days: int) -> AdminUsageResponse:
async with acquire(role="admin") as conn:
total_row = await conn.fetchrow(
@ -916,19 +934,7 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
budget=_usage_budget(total_cost, total_cost_basis),
evaluator_cache=_usage_evaluator_cache(),
by_provider=all_breakdowns[:12],
daily_cost=[
AdminUsageDailyCost(
day=day,
turns=int(values["turns"]),
tokens_in=int(values["tokens_in"]),
tokens_out=int(values["tokens_out"]),
cost_usd=round(float(values["cost_usd"]), 6),
cost_basis=_aggregate_cost_basis(
cast(list[UsageCostBasis], values["cost_bases"])
),
)
for day, values in sorted(daily_buckets.items())
],
daily_cost=_usage_daily_cost(daily_buckets),
)
@ -1321,19 +1327,7 @@ def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
budget=_usage_budget(total_cost, total_cost_basis),
evaluator_cache=_usage_evaluator_cache(),
by_provider=by_provider[:12],
daily_cost=[
AdminUsageDailyCost(
day=day,
turns=int(values["turns"]),
tokens_in=int(values["tokens_in"]),
tokens_out=int(values["tokens_out"]),
cost_usd=round(float(values["cost_usd"]), 6),
cost_basis=_aggregate_cost_basis(
cast(list[UsageCostBasis], values["cost_bases"])
),
)
for day, values in sorted(daily_buckets.items())
],
daily_cost=_usage_daily_cost(daily_buckets),
)

View file

@ -23,7 +23,7 @@ from typing import Annotated, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from .. import session_persistence
from .. import session_evaluation_repository, session_persistence
from ..deps import Principal, Role, require_role
from ..engine_client import EngineError, engine_client
from ..runtime_policy import runtime_fallback_allowed
@ -167,7 +167,7 @@ async def reevaluate_session(
)
except EngineError as e:
detail = f"engine unavailable: {e}"
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id=session_id,
learner_id=sess.learner_id,
scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end",
@ -176,7 +176,7 @@ async def reevaluate_session(
counselor_identity=counselor_identity,
client_identity=client_identity,
)
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation retry error record did not reach durable store: session_id=%s error=%s",
@ -188,14 +188,14 @@ async def reevaluate_session(
detail=_safe_session_evaluation_retry_detail(),
)
write = session_persistence.SessionEvaluationWrite.from_result(
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
session_id=session_id,
learner_id=sess.learner_id,
result=result,
counselor_identity=counselor_identity,
client_identity=client_identity,
)
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
if not saved:
detail = "session evaluation retry result was generated but could not be saved"
logger.error("%s: session_id=%s status=%s", detail, session_id, write.status)
@ -302,7 +302,7 @@ async def get_session_evaluation(
아직 평가 트리거가 없었다면 deep=None + 분포.
"""
await _load_session_or_404(session_id, principal)
record, durable = await session_persistence.load_session_evaluation(session_id, principal)
record, durable = await session_evaluation_repository.load_session_evaluation(session_id, principal)
if record is None:
return EvaluationSummary(
session_id=session_id,

View file

@ -21,7 +21,7 @@ from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field, field_validator
from sse_starlette.sse import EventSourceResponse
from .. import db, session_persistence, turn_runtime
from .. import db, session_evaluation_repository, session_persistence, turn_runtime
from ..auth_sessions import user_has_consent, user_onboarding_complete
from ..config import settings
from ..deps import CurrentPrincipal, Principal, Role
@ -29,6 +29,7 @@ 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_turn_memory import build_turn_memory
from ..session_evaluation_timeout import (
session_evaluation_outer_timeout_seconds,
session_evaluation_timeout_seconds as _session_evaluation_timeout_seconds,
@ -50,15 +51,28 @@ from ..services import (
session_learning_producer,
state_machine,
)
from ..session_dashboard_projection import (
LearnerDashboardResponse,
dashboard_achievements as _dashboard_achievements,
dashboard_feedback as _dashboard_feedback,
dashboard_growth as _dashboard_growth,
dashboard_overview as _dashboard_overview,
dashboard_persona_progress as _dashboard_persona_progress,
dashboard_training_exposure as _dashboard_training_exposure,
)
from ..session_projection import (
LEARNER_VISIBLE_AI_ROLE,
iso as _iso,
learner_visible_turns as _learner_visible_turns,
stage_label as _stage_label,
)
from ..session_read_model import (
CaseMemoryPreview,
CaseProgressStats,
LearnerCaseListResponse,
LearnerCaseSummary,
LearnerDashboardResponse,
LearnerSessionsResponse,
LearnerSessionSummary,
LEARNER_VISIBLE_AI_ROLE,
ReviewCaseWorksheet,
ReviewCaseWorksheetSaveRequest,
ReviewWorksheetItem as ReviewWorksheetItem,
@ -73,18 +87,9 @@ from ..session_read_model import (
StageLabel,
build_session_progress,
build_session_review,
dashboard_achievements as _dashboard_achievements,
dashboard_feedback as _dashboard_feedback,
dashboard_growth as _dashboard_growth,
dashboard_overview as _dashboard_overview,
dashboard_persona_progress as _dashboard_persona_progress,
dashboard_training_exposure as _dashboard_training_exposure,
iso as _iso,
learner_summary as _learner_summary,
learner_visible_turns as _learner_visible_turns,
session_detail as _session_detail,
session_share_payload as _session_share_payload,
stage_label as _stage_label,
)
from ..store import InProcSession, TurnRecord, store
@ -548,12 +553,7 @@ async def _prepare_turn_context(
state=sess.state,
learner_text=learner_text,
learner_identity=sess.learner_label,
memory=orchestrator.TurnMemory(
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(visible_to="client"),
kb_behavior_cues=kb_cues,
),
memory=build_turn_memory(sess, recall, kb_cues),
theory_mode=sess.theory_mode,
scenario_context=scenario_context,
)
@ -956,14 +956,14 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
# 예산 뒤에 outer grace를 두어 정상 결과를 timeout error로 바꾸지 않는다.
timeout=session_evaluation_outer_timeout_seconds(),
)
write = session_persistence.SessionEvaluationWrite.from_result(
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
session_id=sess.session_id,
learner_id=sess.learner_id,
result=result,
counselor_identity=sess.learner_label,
client_identity=sess.persona.display_name,
)
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation save did not reach durable store: session_id=%s status=%s scope=%s",
@ -988,7 +988,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
except asyncio.TimeoutError:
message = f"session evaluation timeout after {timeout_seconds:g}s"
logger.exception("%s: session_id=%s", message, sess.session_id)
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=sess.learner_id,
scope="session_end",
@ -997,7 +997,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
counselor_identity=sess.learner_label,
client_identity=sess.persona.display_name,
)
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation error save did not reach durable store: session_id=%s error=%s",
@ -1008,7 +1008,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
await _enqueue_session_review_ready_notification(sess.session_id)
except Exception as exc:
logger.exception("session evaluation failed: session_id=%s", sess.session_id)
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=sess.learner_id,
scope="session_end",
@ -1017,7 +1017,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
counselor_identity=sess.learner_label,
client_identity=sess.persona.display_name,
)
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation failure record did not reach durable store: session_id=%s error=%s",
@ -1162,7 +1162,7 @@ async def _review_ready(sess: InProcSession, principal: Principal) -> bool:
return False
if len(turns) != len(sess.turns):
return False
evaluation_record, _ = await session_persistence.load_session_evaluation(
evaluation_record, _ = await session_evaluation_repository.load_session_evaluation(
sess.session_id,
principal,
)
@ -1774,7 +1774,7 @@ async def get_session_review(
(
evaluation_record,
evaluation_durable,
) = await session_persistence.load_session_evaluation(
) = await session_evaluation_repository.load_session_evaluation(
session_id,
review_principal,
)
@ -2245,7 +2245,8 @@ async def end_session(
await _end_persisted_session(sess, carry)
invalidate_session_context_cache(session_id)
rupture_runtime.schedule_session_scan(session_id, trigger="session_ended")
if not was_ended:
has_counselor_turns = any(t.speaker in ("counselor", "learner") for t in sess.turns)
if not was_ended and has_counselor_turns:
_schedule_session_evaluation(sess)
return SessionEndResponse(

View file

@ -8,7 +8,7 @@ from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from .. import session_persistence
from .. import session_evaluation_repository, session_persistence
from ..deps import Principal, Role, require_role
from ..runtime_policy import require_runtime_fallback_allowed
from ..session_read_model import (
@ -350,7 +350,7 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
ended_session_ids,
principal,
)
evaluation_records, evaluations_durable = await session_persistence.list_session_evaluations(
evaluation_records, evaluations_durable = await session_evaluation_repository.list_session_evaluations(
ended_session_ids,
principal,
)
@ -403,7 +403,11 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
source="database" if durable else "runtime",
total_learners=len(learners),
active_sessions=sum(1 for sess in sessions if not sess.ended),
ended_sessions=sum(1 for sess in sessions if sess.ended),
ended_sessions=sum(
1
for sess in sessions
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
safety_alerts=safety_alerts,
learner_growth=learner_growth,
pending_reviews=pending_reviews[:20],
@ -442,7 +446,7 @@ async def learner_analysis(
ended_session_ids,
principal,
)
evaluation_records, evaluations_durable = await session_persistence.list_session_evaluations(
evaluation_records, evaluations_durable = await session_evaluation_repository.list_session_evaluations(
ended_session_ids,
principal,
)
@ -462,7 +466,11 @@ async def learner_analysis(
learner_label=growth.learner_label,
total_sessions=len(ordered),
active_sessions=sum(1 for sess in ordered if not sess.ended),
ended_sessions=sum(1 for sess in ordered if sess.ended),
ended_sessions=sum(
1
for sess in ordered
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
pending_reviews=pending_reviews,
closed_reviews=closed_reviews,
summary=growth,
@ -495,7 +503,7 @@ async def update_session_review_status(
detail="active sessions cannot be closed as reviewed",
)
if sess.ended and request.status == "closed" and learner_visible_turns(sess):
evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation(
evaluation_record, evaluation_durable = await session_evaluation_repository.load_session_evaluation(
session_id,
principal,
)

View file

@ -39,6 +39,7 @@ from ..persona_repository import (
get_session_voice_map,
)
from ..runtime_policy import require_runtime_fallback_allowed
from ..session_turn_memory import build_turn_memory
from ..services import (
evaluator,
guardrail,
@ -1459,12 +1460,7 @@ async def _prepare_voice_turn_context(
state=sess.state,
learner_text=learner_text,
learner_identity=sess.learner_label,
memory=orchestrator.TurnMemory(
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(visible_to="client"),
kb_behavior_cues=kb_cues,
),
memory=build_turn_memory(sess, recall, kb_cues),
theory_mode=sess.theory_mode,
scenario_context=scenario_context,
)