회기 무발화 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

@ -1564,39 +1564,44 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
)
_MANAGED_USER_SELECT_SQL = """
SELECT
user_id,
email,
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
legal_name,
department,
grade_level,
phone,
contact_address,
nickname,
self_introduction,
avatar_url,
consent_at,
profile_completed_at,
terms_agreed_at,
privacy_agreed_at,
terms_version,
privacy_version,
created_at,
last_seen_at
FROM app.app_user
"""
async def get_managed_user(user_id: str) -> ManagedUser | None:
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
user_id,
email,
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
legal_name,
department,
grade_level,
phone,
contact_address,
nickname,
self_introduction,
avatar_url,
consent_at,
profile_completed_at,
terms_agreed_at,
privacy_agreed_at,
terms_version,
privacy_version,
created_at,
last_seen_at
FROM app.app_user
f"""
{_MANAGED_USER_SELECT_SQL}
WHERE user_id = $1::uuid AND is_active
""",
user_id,
@ -1619,34 +1624,8 @@ async def get_managed_user_by_email(email: str) -> ManagedUser | None:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
user_id,
email,
display_name,
role,
admin_access,
learner_feedback_enabled,
account_status,
cohort,
affiliation,
legal_name,
department,
grade_level,
phone,
contact_address,
nickname,
self_introduction,
avatar_url,
consent_at,
profile_completed_at,
terms_agreed_at,
privacy_agreed_at,
terms_version,
privacy_version,
created_at,
last_seen_at
FROM app.app_user
f"""
{_MANAGED_USER_SELECT_SQL}
WHERE lower(email) = $1 AND is_active
ORDER BY
CASE WHEN external_id = $2 THEN 0 ELSE 1 END,

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,
)

View file

@ -2,7 +2,6 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
@ -26,6 +25,13 @@ from .calibration_transfer import (
assess_synthetic_subgroup_drift,
assess_transfer,
)
from .outcome_repository_values import (
canonical_hash as _canonical_hash,
created_role as _created_role,
public_row as _public_row,
value as _value,
)
from .practice_competency import target_techniques
class CalibrationTransferNotFoundError(LookupError):
@ -64,36 +70,6 @@ _POSITIVE_CLIENT_STATES = frozenset(
)
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def _public_row(row: Mapping[str, Any]) -> dict[str, Any]:
"""API 응답에서 학습자 피드백 정책 판정 전용 열을 제거한다."""
payload = dict(row)
payload.pop("source_learner_feedback_enabled", None)
return payload
def _canonical_hash(payload: Mapping[str, Any]) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def _created_role(principal: Principal) -> str:
return "instructor" if principal.role == Role.TEACHER else principal.role.value
def _ensure_unique_evidence(
evidence_turn_ids: Sequence[UUID], *, required: bool = False
) -> tuple[UUID, ...]:
@ -182,6 +158,16 @@ async def append_prediction_revision(
reason = revision_reason.strip()
if not reason:
raise CalibrationTransferStateError("revision_reason must not be blank")
if not (0.0 <= predicted_success_probability <= 1.0):
raise CalibrationTransferStateError(
"predicted_success_probability must be between 0.0 and 1.0"
)
if not (0.0 <= confidence <= 1.0):
raise CalibrationTransferStateError(
"confidence must be between 0.0 and 1.0"
)
if instrument_id == "vignette.calibration-self-prediction":
instrument_id = "calibration-mirror-g5"
payload = {
"prediction_revision_id": str(prediction_revision_id),
"history_id": str(history_id),
@ -318,6 +304,11 @@ async def append_prediction_revision(
asyncpg.ForeignKeyViolationError,
asyncpg.ObjectNotInPrerequisiteStateError,
) as exc:
message = str(exc)
if "lock" in message.lower() or "reveal" in message.lower():
raise CalibrationTransferStateError(
f"self-prediction history is locked or revealed: {message}"
) from exc
raise CalibrationTransferStateError(
"prediction revision violated provenance or history invariants"
) from exc
@ -880,21 +871,9 @@ async def append_transfer_suite(
def _actual_target_techniques(competency_id: str) -> frozenset[str]:
key = competency_id.lower()
if any(token in key for token in ("empathy", "empathic", "reflection")):
return frozenset({"empathy", "reflection", "validation", "restatement"})
if any(token in key for token in ("open_question", "open-question")):
return frozenset({"facilitative_question", "exploration", "clarification"})
if any(token in key for token in ("rupture", "repair", "impact")):
return frozenset(
{"opinion_check", "validation", "reflection", "here_and_now_focus"}
)
if any(token in key for token in ("goal", "collaborative", "reagreement")):
return frozenset(
{"consent_motivation_check", "opinion_check", "restatement"}
)
if any(token in key for token in ("presence", "response-space")):
return frozenset({"holding", "reflection", "here_and_now_focus"})
targets = target_techniques(competency_id)
if targets is not None:
return targets
raise CalibrationTransferStateError(
f"unsupported actual transfer competency: {competency_id}"
)

View file

@ -2,8 +2,6 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from typing import Any, Literal, Protocol
from uuid import UUID, uuid5
@ -26,6 +24,10 @@ from .continuous_improvement import (
promote_content_to_catalog,
release_allowed,
)
from .outcome_repository_values import (
canonical_hash as _canonical_hash,
value as _value,
)
DATA_CLASSIFICATION = "synthetic_replay_red_team_coverage_drift"
@ -101,24 +103,6 @@ class RollbackExecutor(Protocol):
) -> RollbackExecutionReceipt: ...
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def _canonical_hash(payload: Any) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
async def _begin_submission(
conn: asyncpg.Connection,
*,

View file

@ -7,8 +7,6 @@ separate superseding events and never mutate the original evidence or graph.
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from typing import Any
from uuid import UUID, uuid5
@ -39,6 +37,12 @@ from .practice_runtime_observer import (
derive_runtime_episode,
observation_model_run_id,
)
from .outcome_repository_values import (
canonical_hash as _canonical_hash,
created_role as _created_role,
public_row as _public_row,
value as _value,
)
_RUNTIME_ATTEMPT_NAMESPACE = UUID("52e24f06-34be-54cb-9092-5122e384c814")
@ -60,36 +64,6 @@ class DeliberatePracticeFeedbackDisabledError(PermissionError):
"""처방/연습 원천 회기의 학습자 피드백 스냅샷이 비활성이다."""
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def _public_row(row: Mapping[str, Any]) -> dict[str, Any]:
"""API 응답에서 정책 판정 전용 내부 열을 제거한다."""
payload = dict(row)
payload.pop("source_learner_feedback_enabled", None)
return payload
def _canonical_hash(payload: Mapping[str, Any]) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def _created_role(principal: Principal) -> str:
return "instructor" if principal.role == Role.TEACHER else principal.role.value
def _ensure_unique_evidence(
evidence_turn_ids: Sequence[UUID], *, required: bool = True
) -> tuple[UUID, ...]:

View file

@ -0,0 +1,40 @@
"""결과 저장소가 공유하는 순수 값 정규화 도우미."""
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping
from typing import Any
from ..deps import Principal, Role
def value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def canonical_hash(payload: Any) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def public_row(row: Mapping[str, Any]) -> dict[str, Any]:
"""API 응답에서 학습자 피드백 정책 판정 전용 열을 제거한다."""
payload = dict(row)
payload.pop("source_learner_feedback_enabled", None)
return payload
def created_role(principal: Principal) -> str:
return "instructor" if principal.role == Role.TEACHER else principal.role.value

View file

@ -0,0 +1,22 @@
"""실제 연습과 전이 평가가 공유하는 competency 분류."""
from __future__ import annotations
def target_techniques(competency_id: str) -> frozenset[str] | None:
key = competency_id.lower()
if any(token in key for token in ("empathy", "empathic", "reflection")):
return frozenset({"empathy", "reflection", "validation", "restatement"})
if any(token in key for token in ("open_question", "open-question")):
return frozenset({"facilitative_question", "exploration", "clarification"})
if any(token in key for token in ("rupture", "repair", "impact")):
return frozenset(
{"opinion_check", "validation", "reflection", "here_and_now_focus"}
)
if any(token in key for token in ("goal", "collaborative", "reagreement")):
return frozenset(
{"consent_motivation_check", "opinion_check", "restatement"}
)
if any(token in key for token in ("presence", "response-space")):
return frozenset({"holding", "reflection", "here_and_now_focus"})
return None

View file

@ -19,6 +19,7 @@ from ..contracts.deliberate_practice import (
PracticePrescription,
ScenarioNovelty,
)
from .practice_competency import target_techniques
_OBSERVER_NAMESPACE = UUID("2d2df938-056c-56cc-86d2-99ad53bd3507")
@ -67,21 +68,9 @@ def observation_model_run_id(
def _target_techniques(competency_id: str) -> frozenset[str]:
key = competency_id.lower()
if any(token in key for token in ("empathy", "empathic", "reflection")):
return frozenset({"empathy", "reflection", "validation", "restatement"})
if any(token in key for token in ("open_question", "open-question")):
return frozenset({"facilitative_question", "exploration", "clarification"})
if any(token in key for token in ("rupture", "repair", "impact")):
return frozenset(
{"opinion_check", "validation", "reflection", "here_and_now_focus"}
)
if any(token in key for token in ("goal", "collaborative", "reagreement")):
return frozenset(
{"consent_motivation_check", "opinion_check", "restatement"}
)
if any(token in key for token in ("presence", "response-space")):
return frozenset({"holding", "reflection", "here_and_now_focus"})
targets = target_techniques(competency_id)
if targets is not None:
return targets
raise RuntimePracticeObservationError(
f"unsupported runtime practice competency: {competency_id}"
)

View file

@ -213,7 +213,12 @@ def build_learner_growth(
learner_id=learner_id,
learner_label=learner_label(learner_id),
sessions=len(ordered),
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)
),
latest_at=iso_datetime(session_activity_time(latest_session)) or "",
first_score=first_score,
latest_score=latest_score,

View file

@ -2,8 +2,6 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from typing import Any
from uuid import UUID, uuid5
@ -23,6 +21,10 @@ from .supervision_research import (
build_phase3_outcome_manifest,
compare_evaluation_versions,
)
from .outcome_repository_values import (
canonical_hash as _canonical_hash,
value as _value,
)
_POINTER_NAMESPACE = UUID("f99f95ba-365e-46ea-a613-2239275f8a2d")
@ -40,24 +42,6 @@ class SupervisionResearchNotFoundError(SupervisionResearchError):
"""Required source evidence or a visible aggregate does not exist."""
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def _canonical_hash(payload: Any) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
async def _existing_submission(
conn: asyncpg.Connection,
*,

View file

@ -193,6 +193,29 @@ class StreamingTranscriptEvent:
confidence: float | None = None
def _streaming_provider_event(
provider: str,
transcript_event: StreamingTranscriptEvent,
*,
start: float,
duration: float,
) -> dict[str, object]:
event_type = "speech_final" if transcript_event.speech_final else (
"speech_end" if transcript_event.final else "voice_activity"
)
provider_event: dict[str, object] = {
"type": event_type,
"provider": provider,
"source": "streaming_stt",
"start_ms": round(start * 1000),
"duration_ms": round(duration * 1000),
"is_final": transcript_event.final,
}
if transcript_event.confidence is not None:
provider_event["confidence"] = transcript_event.confidence
return provider_event
class DeepgramStreamingSession:
"""One Deepgram Listen WebSocket, scoped to exactly one learner utterance."""
@ -364,29 +387,21 @@ class DeepgramStreamingSession:
if not display_text and not speech_final:
return
event_type = "speech_final" if speech_final else (
"speech_end" if is_final else "voice_activity"
transcript_event = StreamingTranscriptEvent(
text=display_text,
final=is_final,
speech_final=speech_final,
confidence=confidence,
)
provider_event = _streaming_provider_event(
"deepgram",
transcript_event,
start=start,
duration=duration,
)
provider_event: dict[str, object] = {
"type": event_type,
"provider": "deepgram",
"source": "streaming_stt",
"start_ms": round(start * 1000),
"duration_ms": round(duration * 1000),
"is_final": is_final,
}
if confidence is not None:
provider_event["confidence"] = confidence
if is_final or speech_final:
self._provider_events.append(provider_event)
await self._on_event(
StreamingTranscriptEvent(
text=display_text,
final=is_final,
speech_final=speech_final,
confidence=confidence,
)
)
await self._on_event(transcript_event)
def _consume_final_words(self, value: object) -> None:
if not isinstance(value, list):
@ -620,29 +635,21 @@ class LocalWhisperStreamingSession:
if not display_text and not speech_final:
return
event_type = "speech_final" if speech_final else (
"speech_end" if is_final else "voice_activity"
transcript_event = StreamingTranscriptEvent(
text=display_text,
final=is_final,
speech_final=speech_final,
confidence=confidence,
)
provider_event = _streaming_provider_event(
"local_whisper",
transcript_event,
start=start,
duration=duration,
)
provider_event: dict[str, object] = {
"type": event_type,
"provider": "local_whisper",
"source": "streaming_stt",
"start_ms": round(start * 1000),
"duration_ms": round(duration * 1000),
"is_final": is_final,
}
if confidence is not None:
provider_event["confidence"] = confidence
if is_final or speech_final:
self._provider_events.append(provider_event)
await self._on_event(
StreamingTranscriptEvent(
text=display_text,
final=is_final,
speech_final=speech_final,
confidence=confidence,
)
)
await self._on_event(transcript_event)
def _consume_final_words(self, value: object, *, offset: float) -> None:
if not isinstance(value, list):

View file

@ -0,0 +1,358 @@
"""학습자 대시보드 DTO와 순수 projection."""
from __future__ import annotations
from collections import Counter
from typing import Literal
from pydantic import BaseModel, Field
from .services import session_metrics
from .session_projection import _rapport_percent, learner_visible_turns, stage_label
from .stage_contract import StageLabel
from .store import InProcSession
TRAINING_EXPOSURE_VERSION = "training-exposure-dominant-share.v1"
TRAINING_EXPOSURE_MIN_COMPLETED = 4
TRAINING_EXPOSURE_ATTENTION_THRESHOLD = 0.75
class LearnerDashboardOverview(BaseModel):
total_sessions: int = 0
completed_sessions: int = 0
active_sessions: int = 0
review_ready_sessions: int = 0
archived_sessions: int = 0
learner_turns: int = 0
client_turns: int = 0
last_practiced_at: str | None = None
class LearnerDashboardGrowthPoint(BaseModel):
session_id: str
session_no: int
persona_code: str
stage: StageLabel
started_at: str
ended_at: str | None = None
score: float | None = None
rapport: float | None = None
technique_count: int = 0
watch_count: int = 0
class LearnerDashboardGrowth(BaseModel):
first_score: float | None = None
latest_score: float | None = None
score_delta: float | None = None
avg_score: float | None = None
avg_rapport: float | None = None
trend: str = "insufficient"
evaluated_sessions: int = 0
top_techniques: list[str] = Field(default_factory=list)
points: list[LearnerDashboardGrowthPoint] = Field(default_factory=list)
class LearnerDashboardPersonaProgress(BaseModel):
persona_code: str
persona_name: str
sessions: int = 0
completed_sessions: int = 0
active_sessions: int = 0
review_ready_sessions: int = 0
latest_at: str | None = None
latest_stage: StageLabel | None = None
latest_score: float | None = None
trend: str = "insufficient"
rapport_percent: int = 0
class LearnerDashboardTrainingExposure(BaseModel):
version: str = TRAINING_EXPOSURE_VERSION
status: Literal["insufficient", "attention", "balanced"] = "insufficient"
label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족"
completed_sessions: int = 0
minimum_completed_sessions: int = TRAINING_EXPOSURE_MIN_COMPLETED
attention_threshold: float = TRAINING_EXPOSURE_ATTENTION_THRESHOLD
dominant_persona_code: str | None = None
dominant_persona_name: str | None = None
dominant_sessions: int = 0
dominant_share: float | None = None
definition: str = (
"종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, "
"공정성 평가나 임상진단이 아닙니다."
)
class LearnerDashboardAchievement(BaseModel):
id: str
label: str
state: Literal["done", "available", "locked"] = "locked"
detail: str
class LearnerDashboardFeedbackItem(BaseModel):
session_id: str
persona_code: str
persona_name: str
session_no: int
stage: StageLabel
turn_seq: int
created_at: str
score: float | None = None
rapport: float | None = None
note: str
techniques: list[str] = Field(default_factory=list)
class LearnerDashboardResponse(BaseModel):
source: str = "runtime"
overview: LearnerDashboardOverview
growth: LearnerDashboardGrowth
persona_progress: list[LearnerDashboardPersonaProgress] = Field(default_factory=list)
training_exposure: LearnerDashboardTrainingExposure = Field(
default_factory=LearnerDashboardTrainingExposure
)
achievements: list[LearnerDashboardAchievement] = Field(default_factory=list)
recent_feedback: list[LearnerDashboardFeedbackItem] = Field(default_factory=list)
message: str
def dashboard_overview(
sessions: list[InProcSession],
*,
visible_review_ready: dict[str, bool],
archived_sessions: int,
) -> LearnerDashboardOverview:
return LearnerDashboardOverview(
total_sessions=len(sessions),
completed_sessions=sum(
1
for sess in sessions
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
active_sessions=sum(1 for sess in sessions if not sess.ended),
review_ready_sessions=sum(1 for ready in visible_review_ready.values() if ready),
archived_sessions=archived_sessions,
learner_turns=sum(
1
for sess in sessions
for turn in learner_visible_turns(sess)
if turn.speaker == "counselor"
),
client_turns=sum(
1
for sess in sessions
for turn in learner_visible_turns(sess)
if turn.speaker == "client"
),
last_practiced_at=session_metrics.iso_datetime(
max((session_metrics.session_activity_time(sess) for sess in sessions), default=0.0)
)
if sessions
else None,
)
def _dashboard_growth_point(point: session_metrics.SessionGrowthPoint) -> LearnerDashboardGrowthPoint:
return LearnerDashboardGrowthPoint(
session_id=point.session_id,
session_no=point.session_no,
persona_code=point.persona_code,
stage=stage_label(point.stage),
started_at=point.started_at,
ended_at=point.ended_at,
score=point.score,
rapport=point.rapport,
technique_count=point.technique_count,
watch_count=point.watch_count,
)
def dashboard_growth(sessions: list[InProcSession]) -> LearnerDashboardGrowth:
metrics = session_metrics.build_learner_growth(
sessions,
learner_label=lambda _learner_id: "",
limit=1,
)
if not metrics:
return LearnerDashboardGrowth()
item = metrics[0]
points = [_dashboard_growth_point(point) for point in item.points]
return LearnerDashboardGrowth(
first_score=item.first_score,
latest_score=item.latest_score,
score_delta=item.score_delta,
avg_score=item.avg_score,
avg_rapport=item.avg_rapport,
trend=item.trend,
evaluated_sessions=sum(1 for point in points if point.score is not None),
top_techniques=item.top_techniques,
points=points,
)
def dashboard_persona_progress(
sessions: list[InProcSession],
review_ready: dict[str, bool],
learner_feedback_enabled: dict[str, bool] | None = None,
) -> list[LearnerDashboardPersonaProgress]:
grouped: dict[str, list[InProcSession]] = {}
for sess in sessions:
grouped.setdefault(sess.persona_code, []).append(sess)
rows: list[LearnerDashboardPersonaProgress] = []
for persona_code, items in grouped.items():
ordered = sorted(items, key=session_metrics.session_activity_time)
latest = ordered[-1]
metric_sessions = (
ordered
if learner_feedback_enabled is None
else [
sess
for sess in ordered
if learner_feedback_enabled.get(sess.session_id, True)
]
)
metrics = session_metrics.build_learner_growth(
metric_sessions,
learner_label=lambda _learner_id: "",
limit=1,
)
growth = metrics[0] if metrics else None
rows.append(
LearnerDashboardPersonaProgress(
persona_code=persona_code,
persona_name=latest.persona.display_name,
sessions=len(ordered),
completed_sessions=sum(
1
for sess in ordered
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
active_sessions=sum(1 for sess in ordered if not sess.ended),
review_ready_sessions=sum(
1 for sess in ordered if review_ready.get(sess.session_id, False)
),
latest_at=session_metrics.iso_datetime(
session_metrics.session_activity_time(latest)
),
latest_stage=stage_label(latest.state.stage),
latest_score=growth.latest_score if growth else None,
trend=growth.trend if growth else "insufficient",
rapport_percent=_rapport_percent(latest.state.rapport_credit),
)
)
return sorted(rows, key=lambda row: row.latest_at or "", reverse=True)
def dashboard_training_exposure(
sessions: list[InProcSession],
) -> LearnerDashboardTrainingExposure:
"""Compute a transparent practice-exposure signal from ended sessions only."""
completed = [
sess
for sess in sessions
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
]
counts = Counter(sess.persona_code for sess in completed)
if not counts:
return LearnerDashboardTrainingExposure()
dominant_code, dominant_count = sorted(
counts.items(),
key=lambda item: (-item[1], item[0]),
)[0]
dominant_session = next(
sess for sess in completed if sess.persona_code == dominant_code
)
total = len(completed)
share = dominant_count / total
if total < TRAINING_EXPOSURE_MIN_COMPLETED:
status: Literal["insufficient", "attention", "balanced"] = "insufficient"
label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족"
elif share >= TRAINING_EXPOSURE_ATTENTION_THRESHOLD:
status = "attention"
label = "훈련 집중 주의"
else:
status = "balanced"
label = "균형"
return LearnerDashboardTrainingExposure(
status=status,
label=label,
completed_sessions=total,
dominant_persona_code=dominant_code,
dominant_persona_name=dominant_session.persona.display_name,
dominant_sessions=dominant_count,
dominant_share=round(share, 4),
)
def _achievement_state(done: bool, available: bool) -> Literal["done", "available", "locked"]:
if done:
return "done"
if available:
return "available"
return "locked"
def dashboard_achievements(
sessions: list[InProcSession],
review_ready: dict[str, bool],
) -> list[LearnerDashboardAchievement]:
completed = sum(1 for sess in sessions if sess.ended)
active = sum(1 for sess in sessions if not sess.ended)
review_count = sum(1 for ready in review_ready.values() if ready)
by_persona = Counter(sess.persona_code for sess in sessions)
max_persona_sessions = max(by_persona.values(), default=0)
persona_coverage = len(by_persona)
return [
LearnerDashboardAchievement(
id="first_session_complete",
label="첫 회기 완료",
state=_achievement_state(completed >= 1, active >= 1),
detail="한 회기를 종료하면 리뷰와 워크시트 흐름이 열립니다.",
),
LearnerDashboardAchievement(
id="review_ready",
label="리뷰 확인 가능",
state=_achievement_state(review_count >= 1, completed >= 1),
detail=f"현재 리뷰 가능한 회기 {review_count}건입니다.",
),
LearnerDashboardAchievement(
id="persona_repeat",
label="같은 내담자 반복 연습",
state=_achievement_state(max_persona_sessions >= 3, max_persona_sessions >= 1),
detail="같은 페르소나를 반복하면 변화 추이를 더 안정적으로 볼 수 있습니다.",
),
LearnerDashboardAchievement(
id="persona_coverage",
label="여러 페르소나 경험",
state=_achievement_state(persona_coverage >= 3, persona_coverage >= 2),
detail=f"현재 {persona_coverage}개 페르소나에서 연습 기록이 있습니다.",
),
]
def dashboard_feedback(sessions: list[InProcSession]) -> list[LearnerDashboardFeedbackItem]:
items: list[LearnerDashboardFeedbackItem] = []
for item in session_metrics.recent_feedback_notes(sessions, limit=5):
score = item.get("score")
rapport = item.get("rapport")
items.append(
LearnerDashboardFeedbackItem(
session_id=str(item["session_id"]),
persona_code=str(item["persona_code"]),
persona_name=str(item["persona_name"]),
session_no=int(item["session_no"]),
stage=stage_label(item["stage"]),
turn_seq=int(item["turn_seq"]),
created_at=str(item["created_at"]),
score=float(score) if isinstance(score, (int, float)) else None,
rapport=float(rapport) if isinstance(rapport, (int, float)) else None,
note=str(item["note"]),
techniques=[str(label) for label in item.get("techniques", [])],
)
)
return items

View file

@ -0,0 +1,275 @@
"""DB 기반 세션 평가 저장소와 in-process 폴백."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from .db import acquire, get_pool
from .deps import Principal
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from .session_persistence_values import (
_clean_role_masked_text,
_mask_json_text_values,
_ts,
)
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
def _coerce_error_message(error: BaseException | str) -> str:
if isinstance(error, BaseException):
message = str(error).strip()
return message or error.__class__.__name__
return str(error).strip() or "unknown session evaluation error"
class _SessionEvaluationResult(Protocol):
scope: str
stage: str
error: str | None
def to_dict(self) -> dict[str, Any]: ...
@dataclass(slots=True)
class SessionEvaluationWrite:
session_id: str
learner_id: str
status: str
source: str
scope: str
stage: str
payload: dict[str, Any]
error: str | None = None
counselor_identity: str | None = None
client_identity: str | None = None
@classmethod
def from_result(
cls,
*,
session_id: str,
learner_id: str,
result: _SessionEvaluationResult,
source: str = "engine",
counselor_identity: str | None = None,
client_identity: str | None = None,
) -> "SessionEvaluationWrite":
return cls(
session_id=session_id,
learner_id=learner_id,
status="error" if result.error else "ready",
source=source,
scope=result.scope,
stage=result.stage,
payload=_mask_json_text_values(
result.to_dict(),
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
error=_clean_role_masked_text(
result.error,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
counselor_identity=counselor_identity,
client_identity=client_identity,
)
@classmethod
def from_error(
cls,
*,
session_id: str,
learner_id: str,
scope: str,
stage: str,
error: BaseException | str,
source: str = "engine",
counselor_identity: str | None = None,
client_identity: str | None = None,
) -> "SessionEvaluationWrite":
return cls(
session_id=session_id,
learner_id=learner_id,
status="error",
source=source,
scope=scope,
stage=stage,
payload={},
error=_clean_role_masked_text(
_coerce_error_message(error),
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
counselor_identity=counselor_identity,
client_identity=client_identity,
)
def cache_record(self) -> dict[str, Any]:
return {
"status": self.status,
"source": self.source,
"scope": self.scope,
"stage": self.stage,
"payload": self.payload,
"error": self.error,
}
async def save_session_evaluation(write: SessionEvaluationWrite) -> bool:
write.payload = _mask_json_text_values(
write.payload,
counselor_identity=write.counselor_identity,
client_identity=write.client_identity,
synthetic_generated=True,
)
write.error = _clean_role_masked_text(
write.error,
counselor_identity=write.counselor_identity,
client_identity=write.client_identity,
synthetic_generated=True,
)
record = write.cache_record()
if runtime_fallback_allowed():
existing = _EVALUATION_CACHE.get(write.session_id)
if _should_replace_evaluation_record(existing, record):
_EVALUATION_CACHE[write.session_id] = record
try:
get_pool()
async with acquire(role="learner", user_id=write.learner_id) as conn:
await conn.execute(
"""
INSERT INTO app.session_evaluation (
session_id, status, source, scope, stage, payload, error,
created_at, updated_at
)
VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, $7, now(), now())
ON CONFLICT (session_id) DO UPDATE SET
status = EXCLUDED.status,
source = EXCLUDED.source,
scope = EXCLUDED.scope,
stage = EXCLUDED.stage,
payload = EXCLUDED.payload,
error = EXCLUDED.error,
updated_at = now()
WHERE app.session_evaluation.status <> 'ready'
OR EXCLUDED.status = 'ready'
""",
write.session_id,
write.status,
write.source,
write.scope,
write.stage,
write.payload,
write.error,
)
return True
except Exception:
require_runtime_fallback_allowed("session evaluation")
return False
async def load_session_evaluation(
session_id: str,
principal: Principal,
) -> tuple[dict[str, Any] | None, bool]:
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
row = await conn.fetchrow(
"""
SELECT status, source, scope, stage, payload, error, updated_at
FROM app.session_evaluation
WHERE session_id = $1::uuid
""",
session_id,
)
if row is None:
cached = (
_EVALUATION_CACHE.get(session_id)
if runtime_fallback_allowed()
else None
)
return cached, cached is None
return {
"status": row["status"],
"source": row["source"],
"scope": row["scope"],
"stage": row["stage"],
"payload": dict(row["payload"] or {}),
"error": row["error"],
"updated_at": _ts(row["updated_at"]),
}, True
except Exception:
require_runtime_fallback_allowed("session evaluation")
return _EVALUATION_CACHE.get(session_id), False
async def list_session_evaluations(
session_ids: list[str],
principal: Principal,
) -> tuple[dict[str, dict[str, Any]], bool]:
if not session_ids:
return {}, True
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
rows = await conn.fetch(
"""
SELECT session_id::text AS session_id, status, source, scope, stage, payload, error, updated_at
FROM app.session_evaluation
WHERE session_id = ANY($1::uuid[])
""",
session_ids,
)
records = {
str(row["session_id"]): {
"status": row["status"],
"source": row["source"],
"scope": row["scope"],
"stage": row["stage"],
"payload": dict(row["payload"] or {}),
"error": row["error"],
"updated_at": _ts(row["updated_at"]),
}
for row in rows
}
cached_used = False
if runtime_fallback_allowed():
for session_id in session_ids:
if session_id not in records and session_id in _EVALUATION_CACHE:
records[session_id] = _EVALUATION_CACHE[session_id]
cached_used = True
return records, not cached_used
except Exception:
require_runtime_fallback_allowed("session evaluation")
return {
session_id: _EVALUATION_CACHE[session_id]
for session_id in session_ids
if session_id in _EVALUATION_CACHE
}, False
def _should_replace_evaluation_record(
existing: dict[str, Any] | None,
replacement: dict[str, Any],
) -> bool:
"""늦게 도착한 실패가 이미 확정된 ready 평가를 덮지 못하게 한다."""
return not (
str((existing or {}).get("status") or "") == "ready"
and str(replacement.get("status") or "") != "ready"
)

View file

@ -9,7 +9,7 @@ import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Iterable, Literal, Protocol
from typing import Any, Awaitable, Callable, Iterable, Literal
from .db import acquire, get_pool
from .deps import Principal
@ -21,6 +21,12 @@ from .persona_repository import (
seed_persona_id,
)
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from .session_persistence_values import (
_clean_masked_text,
_clean_text,
_mask_json_text_values,
_ts,
)
from .runtime_schema import (
REVIEW_SCHEMA_CONTRACT,
runtime_schema_bootstrap_required,
@ -54,7 +60,6 @@ class CaseProgressUnavailableError(RuntimeError):
"""DB 전체 집계가 필요한 사례 진행 수치를 안전하게 읽지 못했다."""
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
_CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_REVIEW_STATUS_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_SHARE_CACHE: dict[str, dict[str, Any]] = {}
@ -128,13 +133,6 @@ class LiveCoachCreditExhausted(RuntimeError):
"""Raised when a learner tries to use live coaching without credits."""
def _coerce_error_message(error: BaseException | str) -> str:
if isinstance(error, BaseException):
message = str(error).strip()
return message or error.__class__.__name__
return str(error).strip() or "unknown session evaluation error"
@dataclass(slots=True)
class CaseContext:
case_id: str
@ -152,103 +150,6 @@ class SessionSummaryWrite:
open_threads: list[str]
class _SessionEvaluationResult(Protocol):
scope: str
stage: str
error: str | None
def to_dict(self) -> dict[str, Any]: ...
@dataclass(slots=True)
class SessionEvaluationWrite:
session_id: str
learner_id: str
status: str
source: str
scope: str
stage: str
payload: dict[str, Any]
error: str | None = None
counselor_identity: str | None = None
client_identity: str | None = None
@classmethod
def from_result(
cls,
*,
session_id: str,
learner_id: str,
result: _SessionEvaluationResult,
source: str = "engine",
counselor_identity: str | None = None,
client_identity: str | None = None,
) -> "SessionEvaluationWrite":
return cls(
session_id=session_id,
learner_id=learner_id,
status="error" if result.error else "ready",
source=source,
scope=result.scope,
stage=result.stage,
payload=_mask_json_text_values(
result.to_dict(),
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
error=_clean_role_masked_text(
result.error,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
counselor_identity=counselor_identity,
client_identity=client_identity,
)
@classmethod
def from_error(
cls,
*,
session_id: str,
learner_id: str,
scope: str,
stage: str,
error: BaseException | str,
source: str = "engine",
counselor_identity: str | None = None,
client_identity: str | None = None,
) -> "SessionEvaluationWrite":
return cls(
session_id=session_id,
learner_id=learner_id,
status="error",
source=source,
scope=scope,
stage=stage,
payload={},
error=_clean_role_masked_text(
_coerce_error_message(error),
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=True,
),
counselor_identity=counselor_identity,
client_identity=client_identity,
)
def cache_record(self) -> dict[str, Any]:
return {
"status": self.status,
"source": self.source,
"scope": self.scope,
"stage": self.stage,
"payload": self.payload,
"error": self.error,
}
_JOINED_CARD_COLUMNS = (
"card_persona_id",
"card_code",
@ -272,14 +173,6 @@ _JOINED_CARD_COLUMNS = (
)
def _ts(value: datetime | None) -> float | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
def share_token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@ -551,90 +444,6 @@ def _stage(stage: object) -> str:
return getattr(stage, "value", str(stage))
def _clean_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _clean_masked_text(value: Any) -> str | None:
text = _clean_text(value)
if text is None:
return None
masked = guardrail.mask_pii(text).text_masked.strip()
return masked or None
def _clean_role_masked_text(
value: Any,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
synthetic_generated: bool = False,
) -> str | None:
text = _clean_text(value)
if text is None:
return None
masked = guardrail.mask_role_identities(
text,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
).text_masked.strip()
return masked or None
def _mask_json_text_values(
value: Any,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
synthetic_generated: bool = False,
) -> Any:
if isinstance(value, str):
return (
_clean_role_masked_text(
value,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
or ""
)
if isinstance(value, dict):
return {
key: _mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for key, child in value.items()
}
if isinstance(value, list):
return [
_mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for child in value
]
if isinstance(value, tuple):
return [
_mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for child in value
]
return value
def _safe_float(value: Any) -> float | None:
if isinstance(value, (int, float)):
return float(value)
@ -1769,148 +1578,6 @@ async def ensure_review_tables() -> None:
return
async def save_session_evaluation(write: SessionEvaluationWrite) -> bool:
write.payload = _mask_json_text_values(
write.payload,
counselor_identity=write.counselor_identity,
client_identity=write.client_identity,
synthetic_generated=True,
)
write.error = _clean_role_masked_text(
write.error,
counselor_identity=write.counselor_identity,
client_identity=write.client_identity,
synthetic_generated=True,
)
record = write.cache_record()
if runtime_fallback_allowed():
existing = _EVALUATION_CACHE.get(write.session_id)
if _should_replace_evaluation_record(existing, record):
_EVALUATION_CACHE[write.session_id] = record
try:
get_pool()
async with acquire(role="learner", user_id=write.learner_id) as conn:
await conn.execute(
"""
INSERT INTO app.session_evaluation (
session_id, status, source, scope, stage, payload, error,
created_at, updated_at
)
VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, $7, now(), now())
ON CONFLICT (session_id) DO UPDATE SET
status = EXCLUDED.status,
source = EXCLUDED.source,
scope = EXCLUDED.scope,
stage = EXCLUDED.stage,
payload = EXCLUDED.payload,
error = EXCLUDED.error,
updated_at = now()
WHERE app.session_evaluation.status <> 'ready'
OR EXCLUDED.status = 'ready'
""",
write.session_id,
write.status,
write.source,
write.scope,
write.stage,
write.payload,
write.error,
)
return True
except Exception:
require_runtime_fallback_allowed("session evaluation")
return False
async def load_session_evaluation(
session_id: str,
principal: Principal,
) -> tuple[dict[str, Any] | None, bool]:
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
row = await conn.fetchrow(
"""
SELECT status, source, scope, stage, payload, error, updated_at
FROM app.session_evaluation
WHERE session_id = $1::uuid
""",
session_id,
)
if row is None:
cached = (
_EVALUATION_CACHE.get(session_id)
if runtime_fallback_allowed()
else None
)
return cached, cached is None
return {
"status": row["status"],
"source": row["source"],
"scope": row["scope"],
"stage": row["stage"],
"payload": dict(row["payload"] or {}),
"error": row["error"],
"updated_at": _ts(row["updated_at"]),
}, True
except Exception:
require_runtime_fallback_allowed("session evaluation")
return _EVALUATION_CACHE.get(session_id), False
async def list_session_evaluations(
session_ids: list[str],
principal: Principal,
) -> tuple[dict[str, dict[str, Any]], bool]:
if not session_ids:
return {}, True
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
rows = await conn.fetch(
"""
SELECT session_id::text AS session_id, status, source, scope, stage, payload, error, updated_at
FROM app.session_evaluation
WHERE session_id = ANY($1::uuid[])
""",
session_ids,
)
records = {
str(row["session_id"]): {
"status": row["status"],
"source": row["source"],
"scope": row["scope"],
"stage": row["stage"],
"payload": dict(row["payload"] or {}),
"error": row["error"],
"updated_at": _ts(row["updated_at"]),
}
for row in rows
}
cached_used = False
if runtime_fallback_allowed():
for session_id in session_ids:
if session_id not in records and session_id in _EVALUATION_CACHE:
records[session_id] = _EVALUATION_CACHE[session_id]
cached_used = True
return records, not cached_used
except Exception:
require_runtime_fallback_allowed("session evaluation")
return {
session_id: _EVALUATION_CACHE[session_id]
for session_id in session_ids
if session_id in _EVALUATION_CACHE
}, False
async def _fetch_session_runtime_rows(
conn: Any,
session_ids: list[str],
@ -2825,7 +2492,14 @@ async def list_case_summaries(
SELECT
s.case_id,
count(*)::int AS total_sessions,
count(*) FILTER (WHERE s.ended_at IS NOT NULL)::int AS completed_sessions,
count(*) FILTER (
WHERE s.ended_at IS NOT NULL
AND EXISTS (
SELECT 1 FROM app.turns AS ct
WHERE ct.session_id = s.id
AND ct.speaker = 'counselor'
)
)::int AS completed_sessions,
COALESCE(
sum(
GREATEST(
@ -3702,12 +3376,3 @@ def _iso_dt(value: datetime | None) -> str:
if value is None:
return ""
return value.astimezone(timezone.utc).isoformat()
def _should_replace_evaluation_record(
existing: dict[str, Any] | None,
replacement: dict[str, Any],
) -> bool:
"""늦게 도착한 실패가 이미 확정된 ready 평가를 덮지 못하게 한다."""
return not (
str((existing or {}).get("status") or "") == "ready"
and str(replacement.get("status") or "") != "ready"
)

View file

@ -0,0 +1,100 @@
"""세션 영속성에서 공유하는 정규화·마스킹 도우미."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from .services import guardrail
def _ts(value: datetime | None) -> float | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
def _clean_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _clean_masked_text(value: Any) -> str | None:
text = _clean_text(value)
if text is None:
return None
masked = guardrail.mask_pii(text).text_masked.strip()
return masked or None
def _clean_role_masked_text(
value: Any,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
synthetic_generated: bool = False,
) -> str | None:
text = _clean_text(value)
if text is None:
return None
masked = guardrail.mask_role_identities(
text,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
).text_masked.strip()
return masked or None
def _mask_json_text_values(
value: Any,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
synthetic_generated: bool = False,
) -> Any:
if isinstance(value, str):
return (
_clean_role_masked_text(
value,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
or ""
)
if isinstance(value, dict):
return {
key: _mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for key, child in value.items()
}
if isinstance(value, list):
return [
_mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for child in value
]
if isinstance(value, tuple):
return [
_mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for child in value
]
return value

View file

@ -0,0 +1,35 @@
"""브라우저용 세션 projection이 공유하는 순수 도우미."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import cast
from .services import state_machine
from .stage_contract import StageLabel, stage_label as _normalize_stage_label
from .store import InProcSession, TurnRecord
LEARNER_VISIBLE_AI_ROLE = "counselor"
# 전 주기(라포→정리) 기준 라포 만점: 마지막 전이 임계(개입→정리)를 100으로 본다.
_FULL_CYCLE_RAPPORT = max(state_machine.STAGE_ADVANCE_RAPPORT.values())
def stage_label(stage: object) -> StageLabel:
return cast(StageLabel, _normalize_stage_label(stage))
def iso(ts: float | None) -> str | None:
if ts is None:
return None
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds")
def learner_visible_turns(sess: InProcSession) -> list[TurnRecord]:
return sess.turns_visible_to(LEARNER_VISIBLE_AI_ROLE)
def _rapport_percent(credit: float) -> int:
if _FULL_CYCLE_RAPPORT <= 0:
return 0
return max(0, min(100, int(round(float(credit or 0.0) / _FULL_CYCLE_RAPPORT * 100))))

View file

@ -628,7 +628,7 @@ def learner_summary(
client_turn_count=client_turns,
started_at=iso(sess.created_at) or "",
ended_at=iso(sess.ended_at),
review_ready=review_ready,
review_ready=review_ready if learner_turns > 0 else False,
learner_feedback_enabled=(
sess.learner_feedback_enabled
if learner_feedback_enabled is None
@ -647,7 +647,9 @@ def dashboard_overview(
) -> LearnerDashboardOverview:
return LearnerDashboardOverview(
total_sessions=len(sessions),
completed_sessions=sum(1 for sess in sessions if sess.ended),
completed_sessions=sum(
1 for sess in sessions if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
active_sessions=sum(1 for sess in sessions if not sess.ended),
review_ready_sessions=sum(1 for ready in visible_review_ready.values() if ready),
archived_sessions=archived_sessions,
@ -742,7 +744,11 @@ def dashboard_persona_progress(
persona_code=persona_code,
persona_name=latest.persona.display_name,
sessions=len(ordered),
completed_sessions=sum(1 for sess in ordered if sess.ended),
completed_sessions=sum(
1
for sess in ordered
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
),
active_sessions=sum(1 for sess in ordered if not sess.ended),
review_ready_sessions=sum(
1 for sess in ordered if review_ready.get(sess.session_id, False)
@ -768,7 +774,11 @@ def dashboard_training_exposure(
) -> LearnerDashboardTrainingExposure:
"""Compute a transparent practice-exposure signal from ended sessions only."""
completed = [sess for sess in sessions if sess.ended]
completed = [
sess
for sess in sessions
if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns)
]
counts = Counter(sess.persona_code for sess in completed)
if not counts:
return LearnerDashboardTrainingExposure()

View file

@ -0,0 +1,19 @@
"""세션 경로가 공유하는 내담자 턴 메모리 projection."""
from __future__ import annotations
from .services import memory, orchestrator
from .store import InProcSession
def build_turn_memory(
sess: InProcSession,
recall: memory.RecallContext,
kb_cues: list[str],
) -> orchestrator.TurnMemory:
return 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,
)

View file

@ -627,6 +627,50 @@ class CalibrationTransferStoreTests(unittest.IsolatedAsyncioTestCase):
evidence_turn_ids=(),
)
async def test_append_prediction_revision_normalizes_legacy_instrument_id(self) -> None:
principal = _principal(Role.LEARNER)
learner_id = UUID(principal.user_id)
session_id = uuid4()
history_id = uuid4()
revision_id = uuid4()
conn = AsyncMock()
conn.fetchrow.side_effect = [
{"id": session_id, "learner_id": learner_id, "case_id": uuid4()},
None,
None,
{"prediction_revision_id": str(revision_id), "revision_no": 1},
]
@asynccontextmanager
async def fake_acquire(**_kwargs):
yield conn
with patch.object(calibration_transfer_store.db, "acquire", fake_acquire):
res = await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=uuid4(),
prediction_revision_id=revision_id,
history_id=history_id,
session_id=session_id,
competency_id="competency.empathic-check",
practice_block_id="oas-g5-block-one",
scenario_variant_id="variant-1",
phrase_family_id="phrase-1",
revision_no=1,
supersedes_prediction_revision_id=None,
predicted_success_probability=0.7,
confidence=0.8,
recorded_sequence=1,
revision_reason="호환성 검증",
instrument_id="vignette.calibration-self-prediction",
instrument_version="1.0.0",
evidence_turn_ids=(),
)
self.assertEqual(res["revision_no"], 1)
# Verify normalized instrument_id "calibration-mirror-g5" was passed to DB
call_args = conn.fetchrow.call_args_list[-1][0]
self.assertIn("calibration-mirror-g5", call_args)
async def test_evidence_refs_are_uuid_only(self) -> None:
self.assertEqual(
calibration_transfer_store._uuid_evidence(

View file

@ -0,0 +1,316 @@
from __future__ import annotations
import unittest
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
import asyncpg
from fastapi import FastAPI
from fastapi.testclient import TestClient
from .deps import Principal, Role
from .routes import calibration_transfer
from .services import calibration_transfer_store
from .services.calibration_transfer_store import (
CalibrationTransferConflictError,
CalibrationTransferStateError,
)
def _principal(role: Role = Role.LEARNER, user_id: str | None = None) -> Principal:
return Principal(
user_id=user_id or str(uuid4()),
role=role,
cohort_ids=["g5-cohort"],
)
class CalibrationValidationTDDTests(unittest.IsolatedAsyncioTestCase):
"""Multi-angle TDD tests for OAS prediction revisions and lock boundaries."""
async def test_append_prediction_revision_rejects_out_of_bounds_probability(self) -> None:
"""predicted_success_probability must be strictly bounded within [0.0, 1.0]."""
principal = _principal()
submission_id = uuid4()
history_id = uuid4()
session_id = uuid4()
# > 1.0 should fail
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=submission_id,
prediction_revision_id=uuid4(),
history_id=history_id,
session_id=session_id,
competency_id="competency.empathic_reflection",
practice_block_id="oas-g5-block-1",
scenario_variant_id="variant-1",
phrase_family_id="family-1",
revision_no=1,
supersedes_prediction_revision_id=None,
predicted_success_probability=1.25,
confidence=0.8,
recorded_sequence=1,
revision_reason="Valid reason",
instrument_id="calibration-mirror-g5",
instrument_version="1.0.0",
)
self.assertIn("predicted_success_probability", str(ctx.exception))
# < 0.0 should fail
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=submission_id,
prediction_revision_id=uuid4(),
history_id=history_id,
session_id=session_id,
competency_id="competency.empathic_reflection",
practice_block_id="oas-g5-block-1",
scenario_variant_id="variant-1",
phrase_family_id="family-1",
revision_no=1,
supersedes_prediction_revision_id=None,
predicted_success_probability=-0.05,
confidence=0.8,
recorded_sequence=1,
revision_reason="Valid reason",
instrument_id="calibration-mirror-g5",
instrument_version="1.0.0",
)
self.assertIn("predicted_success_probability", str(ctx.exception))
async def test_append_prediction_revision_rejects_out_of_bounds_confidence(self) -> None:
"""confidence must be strictly bounded within [0.0, 1.0]."""
principal = _principal()
submission_id = uuid4()
history_id = uuid4()
session_id = uuid4()
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=submission_id,
prediction_revision_id=uuid4(),
history_id=history_id,
session_id=session_id,
competency_id="competency.empathic_reflection",
practice_block_id="oas-g5-block-1",
scenario_variant_id="variant-1",
phrase_family_id="family-1",
revision_no=1,
supersedes_prediction_revision_id=None,
predicted_success_probability=0.7,
confidence=1.5,
recorded_sequence=1,
revision_reason="Valid reason",
instrument_id="calibration-mirror-g5",
instrument_version="1.0.0",
)
self.assertIn("confidence", str(ctx.exception))
async def test_append_prediction_revision_rejects_empty_or_whitespace_reason(self) -> None:
"""Blank or whitespace-only revision_reason must be rejected."""
principal = _principal()
for blank in ("", " ", "\t\n"):
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=uuid4(),
prediction_revision_id=uuid4(),
history_id=uuid4(),
session_id=uuid4(),
competency_id="competency.empathic_reflection",
practice_block_id="oas-g5-block-1",
scenario_variant_id="variant-1",
phrase_family_id="family-1",
revision_no=1,
supersedes_prediction_revision_id=None,
predicted_success_probability=0.7,
confidence=0.8,
recorded_sequence=1,
revision_reason=blank,
instrument_id="calibration-mirror-g5",
instrument_version="1.0.0",
)
self.assertIn("revision_reason", str(ctx.exception))
async def test_append_prediction_revision_blocks_locked_history(self) -> None:
"""A locked prediction history must reject subsequent revision appends."""
principal = _principal()
history_id = uuid4()
session_id = uuid4()
# Mock db connection to simulate trigger-level lock rejection on history
mock_conn = AsyncMock()
mock_conn.fetchrow.side_effect = [
# 1. _visible_session
{"id": session_id, "session_id": session_id, "learner_id": principal.user_id},
# 2. _existing_by_submission
None,
# 3. SELECT * FROM app.calibration_prediction_history WHERE history_id = $1
{
"history_id": history_id,
"session_id": session_id,
"learner_id": principal.user_id,
"competency_id": "competency.empathic_reflection",
"practice_block_id": "oas-g5-block-1",
"scenario_variant_id": "variant-1",
"phrase_family_id": "family-1",
},
# 4. INSERT INTO app.calibration_prediction_revision (trigger raises lock error)
asyncpg.ObjectNotInPrerequisiteStateError(
"self-prediction cannot be revised after lock or external reveal"
),
]
with patch("app.services.calibration_transfer_store.db.acquire") as mock_acquire:
mock_acquire.return_value.__aenter__.return_value = mock_conn
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_revision(
principal=principal,
submission_id=uuid4(),
prediction_revision_id=uuid4(),
history_id=history_id,
session_id=session_id,
competency_id="competency.empathic_reflection",
practice_block_id="oas-g5-block-1",
scenario_variant_id="variant-1",
phrase_family_id="family-1",
revision_no=2,
supersedes_prediction_revision_id=uuid4(),
predicted_success_probability=0.7,
confidence=0.8,
recorded_sequence=2,
revision_reason="Trying to revise locked prediction",
instrument_id="calibration-mirror-g5",
instrument_version="1.0.0",
)
self.assertIn("locked", str(ctx.exception).lower())
async def test_append_prediction_lock_requires_learner_role(self) -> None:
"""Only learners can append prediction locks."""
principal = _principal(role=Role.TEACHER)
with self.assertRaises(CalibrationTransferStateError) as ctx:
await calibration_transfer_store.append_prediction_lock(
principal=principal,
submission_id=uuid4(),
lock_id=uuid4(),
history_id=uuid4(),
prediction_revision_id=uuid4(),
locked_sequence=1,
)
self.assertIn("learner role", str(ctx.exception).lower())
async def test_append_prediction_lock_idempotent_replay(self) -> None:
"""Replaying identical lock submission returns idempotent_replay=True."""
principal = _principal(role=Role.LEARNER)
submission_id = uuid4()
history_id = uuid4()
lock_id = uuid4()
revision_id = uuid4()
payload = {
"lock_id": str(lock_id),
"history_id": str(history_id),
"prediction_revision_id": str(revision_id),
"locked_sequence": 1,
"learner_id": principal.user_id,
}
content_hash = calibration_transfer_store._canonical_hash(payload)
mock_conn = AsyncMock()
mock_conn.fetchrow.side_effect = [
# 1. history lookup
{
"history_id": history_id,
"session_id": uuid4(),
"learner_id": principal.user_id,
},
# 2. _existing_by_submission finds existing lock with identical content_hash
{"lock_id": lock_id, "content_hash": content_hash},
]
with patch("app.services.calibration_transfer_store.db.acquire") as mock_acquire:
mock_acquire.return_value.__aenter__.return_value = mock_conn
result = await calibration_transfer_store.append_prediction_lock(
principal=principal,
submission_id=submission_id,
lock_id=lock_id,
history_id=history_id,
prediction_revision_id=revision_id,
locked_sequence=1,
)
self.assertTrue(result.get("idempotent_replay"))
self.assertEqual(result.get("lock_id"), lock_id)
async def test_append_prediction_lock_replayed_with_different_content_raises_conflict(self) -> None:
"""Replaying lock submission with changed payload raises conflict error."""
principal = _principal(role=Role.LEARNER)
submission_id = uuid4()
history_id = uuid4()
lock_id = uuid4()
revision_id = uuid4()
mock_conn = AsyncMock()
mock_conn.fetchrow.side_effect = [
# 1. history lookup
{
"history_id": history_id,
"session_id": uuid4(),
"learner_id": principal.user_id,
},
# 2. _existing_by_submission finds existing lock but different content_hash
{"lock_id": lock_id, "content_hash": "different-hash-value"},
]
with patch("app.services.calibration_transfer_store.db.acquire") as mock_acquire:
mock_acquire.return_value.__aenter__.return_value = mock_conn
with self.assertRaises(CalibrationTransferConflictError) as ctx:
await calibration_transfer_store.append_prediction_lock(
principal=principal,
submission_id=submission_id,
lock_id=lock_id,
history_id=history_id,
prediction_revision_id=revision_id,
locked_sequence=1,
)
self.assertIn("different content", str(ctx.exception))
async def test_append_prediction_lock_conflict_raises_conflict_error(self) -> None:
"""A conflicting concurrent lock raises CalibrationTransferConflictError."""
principal = _principal(role=Role.LEARNER)
submission_id = uuid4()
history_id = uuid4()
lock_id = uuid4()
revision_id = uuid4()
mock_conn = AsyncMock()
mock_conn.fetchrow.side_effect = [
# 1. history lookup
{
"history_id": history_id,
"session_id": uuid4(),
"learner_id": principal.user_id,
},
# 2. _existing_by_submission returns None (new submission)
None,
# 3. INSERT raises UniqueViolationError
asyncpg.UniqueViolationError("duplicate key value violates unique constraint"),
]
with patch("app.services.calibration_transfer_store.db.acquire") as mock_acquire:
mock_acquire.return_value.__aenter__.return_value = mock_conn
with self.assertRaises(CalibrationTransferConflictError):
await calibration_transfer_store.append_prediction_lock(
principal=principal,
submission_id=submission_id,
lock_id=lock_id,
history_id=history_id,
prediction_revision_id=revision_id,
locked_sequence=1,
)

View file

@ -0,0 +1,215 @@
from __future__ import annotations
import unittest
from unittest.mock import AsyncMock, patch
from uuid import uuid4
from app.deps import Principal, Role
from app.services import session_metrics, state_machine
from app.services.persona import P1, P2
from app.session_dashboard_projection import (
dashboard_overview,
dashboard_persona_progress,
dashboard_training_exposure,
)
from app.store import InProcSession, TurnRecord
def _make_session(
session_id: str,
session_no: int,
learner_id: str,
persona_code: str = "P1",
ended: bool = True,
turns: list[TurnRecord] | None = None,
) -> InProcSession:
persona = {P1.code: P1, P2.code: P2}.get(persona_code, P1)
state = state_machine.init_state(params=persona.openness_params())
state.stage = state_machine.Stage.EXPLORE
sess = InProcSession(
session_id=session_id,
case_id=session_id,
learner_id=learner_id,
persona_code=persona.code,
theory_mode="humanistic",
persona=persona,
state=state,
session_no=session_no,
created_at=1000.0 + session_no * 10.0,
ended_at=1000.0 + session_no * 10.0 + 600 if ended else None,
ended=ended,
turns=[],
)
if turns is not None:
sess.turns = turns
else:
sess.turns = [
TurnRecord(
turn_seq=1,
speaker="counselor",
stage=state.stage.value,
text="어떤 점이 힘드신가요?",
text_masked="어떤 점이 힘드신가요?",
evaluation={
"appropriateness": "pos",
"appropriateness_note": "공감적 반응",
"rapport_signal": 0.6,
"techniques": [{"label": "reflection"}],
},
created_at=1000.0 + session_no * 10.0 + 5.0,
)
]
return sess
class DashboardProjectionTDDTests(unittest.TestCase):
"""Multi-angle TDD tests for metrics projection invariants (REQ-010)."""
def test_learner_growth_metrics_excludes_zero_turn_from_ended_sessions(self) -> None:
"""LearnerGrowthMetrics.ended_sessions must exclude zero-turn sessions."""
learner_id = "00000000-0000-0000-0000-000000000111"
# Session 1: completed with turns
sess1 = _make_session("sess-1", 1, learner_id, ended=True)
# Session 2: zero-turn ended session
sess2 = _make_session("sess-2", 2, learner_id, ended=True, turns=[])
# Session 3: active session
sess3 = _make_session("sess-3", 3, learner_id, ended=False)
growth_list = session_metrics.build_learner_growth(
[sess1, sess2, sess3],
learner_label=lambda _uid: "홍길동",
)
self.assertEqual(len(growth_list), 1)
growth = growth_list[0]
self.assertEqual(growth.sessions, 3)
# Invariant: ended_sessions must equal completed sessions with dialogue (1), not 2
self.assertEqual(
growth.ended_sessions,
1,
"ended_sessions in LearnerGrowthMetrics must not count 0-turn sessions",
)
def test_training_exposure_multi_persona_isolation(self) -> None:
"""Zero-turn sessions must not inflate exposure count across multiple personas."""
learner_id = "00000000-0000-0000-0000-000000000111"
# P1: 1 completed session, 1 zero-turn ended session
p1_completed = _make_session("sess-p1-1", 1, learner_id, persona_code="P1", ended=True)
p1_zero = _make_session("sess-p1-2", 2, learner_id, persona_code="P1", ended=True, turns=[])
# P2: 1 completed session
p2_completed = _make_session("sess-p2-1", 3, learner_id, persona_code="P2", ended=True)
exposure = dashboard_training_exposure([p1_completed, p1_zero, p2_completed])
self.assertEqual(exposure.completed_sessions, 2)
# Persona progress table check
progress_rows = dashboard_persona_progress(
[p1_completed, p1_zero, p2_completed],
review_ready={},
)
p1_row = next(r for r in progress_rows if r.persona_code == "P1")
p2_row = next(r for r in progress_rows if r.persona_code == "P2")
self.assertEqual(p1_row.sessions, 2)
self.assertEqual(p1_row.completed_sessions, 1)
self.assertEqual(p2_row.sessions, 1)
self.assertEqual(p2_row.completed_sessions, 1)
class TeacherDashboardProjectionTDDTests(unittest.IsolatedAsyncioTestCase):
"""Teacher dashboard projection alignment for zero-turn sessions."""
async def test_teacher_dashboard_excludes_zero_turn_from_ended_sessions(self) -> None:
"""Teacher dashboard top-level ended_sessions must exclude zero-turn ended sessions."""
from app.routes import teacher
principal = Principal(
user_id="teacher-1",
role=Role.TEACHER,
cohort_ids=["cohort-1"],
)
learner_id = "learner-1"
sess_normal = _make_session("sess-1", 1, learner_id, ended=True)
sess_zero = _make_session("sess-2", 2, learner_id, ended=True, turns=[])
with (
patch.object(
teacher.session_persistence,
"list_all_sessions",
AsyncMock(return_value=([sess_normal, sess_zero], True)),
),
patch.object(
teacher.session_persistence,
"list_safety_alerts",
AsyncMock(return_value=([], True)),
),
patch.object(
teacher.session_persistence,
"list_session_review_statuses",
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
):
response = await teacher.teacher_dashboard(principal)
self.assertEqual(
response.ended_sessions,
1,
"Teacher dashboard ended_sessions must exclude 0-turn ended sessions",
)
self.assertEqual(
response.learner_growth[0].ended_sessions,
1,
"Learner growth in teacher dashboard must exclude 0-turn ended sessions",
)
async def test_teacher_learner_analysis_excludes_zero_turn_from_ended_sessions(self) -> None:
"""Teacher learner analysis ended_sessions must exclude zero-turn ended sessions."""
from app.routes import teacher
principal = Principal(
user_id="teacher-1",
role=Role.TEACHER,
cohort_ids=["cohort-1"],
)
learner_id = "learner-1"
sess_normal = _make_session("sess-1", 1, learner_id, ended=True)
sess_zero = _make_session("sess-2", 2, learner_id, ended=True, turns=[])
with (
patch.object(
teacher.session_persistence,
"list_all_sessions",
AsyncMock(return_value=([sess_normal, sess_zero], True)),
),
patch.object(
teacher.session_persistence,
"list_session_review_statuses",
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
):
response = await teacher.learner_analysis(learner_id, principal)
self.assertEqual(
response.ended_sessions,
1,
"Teacher learner analysis ended_sessions must exclude 0-turn ended sessions",
)
self.assertEqual(
response.summary.ended_sessions,
1,
"Teacher learner analysis summary.ended_sessions must exclude 0-turn ended sessions",
)

View file

@ -32,7 +32,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
patch.object(eval_routes, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(eval_routes.evaluator, "evaluate_session", AsyncMock(return_value=result)),
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
) as save_evaluation,
@ -68,7 +68,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
patch.object(eval_routes, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(eval_routes.evaluator, "evaluate_session", AsyncMock(return_value=result)),
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=False),
),
@ -95,7 +95,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(side_effect=eval_routes.EngineError("provider body: [NAME]")),
),
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
) as save_evaluation,
@ -135,7 +135,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=result),
) as evaluate_session,
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
),
@ -167,7 +167,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=result),
) as evaluate_session,
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
),
@ -343,7 +343,7 @@ class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
with (
patch.object(eval_routes, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(record, True)),
),

View file

@ -8,7 +8,7 @@ import unittest
from unittest.mock import patch
from .deps import Principal, Role
from . import session_persistence
from . import session_evaluation_repository, session_persistence
from .routes import sessions
from .services import evaluator, guardrail, session_metrics
from .services import persona as persona_service
@ -256,7 +256,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
turns_evaluated=2,
)
write = session_persistence.SessionEvaluationWrite.from_result(
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
session_id="session-1",
learner_id="learner-1",
result=result,
@ -306,7 +306,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
error="보라별 평가 경고",
)
write = session_persistence.SessionEvaluationWrite.from_result(
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
session_id="session-privacy",
learner_id="learner-privacy",
result=result,
@ -330,7 +330,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
supervisor_rationale="윤찬과 서연의 상호작용을 확인했다.",
)
write = session_persistence.SessionEvaluationWrite.from_result(
write = session_evaluation_repository.SessionEvaluationWrite.from_result(
session_id="session-role-privacy",
learner_id="learner-role-privacy",
result=result,
@ -345,7 +345,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("[CLIENT]", blob)
def test_session_evaluation_write_from_error_preserves_fallback_shape(self) -> None:
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id="session-1",
learner_id="learner-1",
scope="session_end",
@ -361,7 +361,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(write.error, "engine timeout")
def test_session_evaluation_write_from_error_names_empty_exception(self) -> None:
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id="session-1",
learner_id="learner-1",
scope="session_end",
@ -381,7 +381,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
write = session_persistence.SessionEvaluationWrite.from_error(
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id="session-1",
learner_id="learner-1",
scope="session_end",
@ -405,7 +405,7 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
conn = FakeEvaluationConn()
write = session_persistence.SessionEvaluationWrite(
write = session_evaluation_repository.SessionEvaluationWrite(
session_id="11111111-1111-1111-1111-111111111111",
learner_id="22222222-2222-2222-2222-222222222222",
status="ready",
@ -417,10 +417,10 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
)
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(session_persistence, "acquire", return_value=FakeAcquire(conn)),
patch.object(session_evaluation_repository, "get_pool", return_value=object()),
patch.object(session_evaluation_repository, "acquire", return_value=FakeAcquire(conn)),
):
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
self.assertTrue(saved)
_, args = conn.executed[0]
@ -439,20 +439,20 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
error = {"status": "error", "error": "late timeout"}
self.assertFalse(
session_persistence._should_replace_evaluation_record(ready, error)
session_evaluation_repository._should_replace_evaluation_record(ready, error)
)
self.assertTrue(
session_persistence._should_replace_evaluation_record(error, ready)
session_evaluation_repository._should_replace_evaluation_record(error, ready)
)
self.assertTrue(
session_persistence._should_replace_evaluation_record(ready, ready)
session_evaluation_repository._should_replace_evaluation_record(ready, ready)
)
async def test_fallback_cache_keeps_ready_result_when_late_error_arrives(self) -> None:
session_id = "11111111-1111-1111-1111-111111111111"
session_persistence._EVALUATION_CACHE[session_id] = {"status": "ready"}
self.addCleanup(session_persistence._EVALUATION_CACHE.pop, session_id, None)
write = session_persistence.SessionEvaluationWrite.from_error(
session_evaluation_repository._EVALUATION_CACHE[session_id] = {"status": "ready"}
self.addCleanup(session_evaluation_repository._EVALUATION_CACHE.pop, session_id, None)
write = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id=session_id,
learner_id="22222222-2222-2222-2222-222222222222",
scope="session_end",
@ -461,14 +461,14 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
)
with (
patch.object(session_persistence, "runtime_fallback_allowed", return_value=True),
patch.object(session_persistence, "get_pool", side_effect=RuntimeError("offline")),
patch.object(session_persistence, "require_runtime_fallback_allowed"),
patch.object(session_evaluation_repository, "runtime_fallback_allowed", return_value=True),
patch.object(session_evaluation_repository, "get_pool", side_effect=RuntimeError("offline")),
patch.object(session_evaluation_repository, "require_runtime_fallback_allowed"),
):
saved = await session_persistence.save_session_evaluation(write)
saved = await session_evaluation_repository.save_session_evaluation(write)
self.assertFalse(saved)
self.assertEqual(session_persistence._EVALUATION_CACHE[session_id]["status"], "ready")
self.assertEqual(session_evaluation_repository._EVALUATION_CACHE[session_id]["status"], "ready")
def test_rebuild_turn_evaluation_restores_review_shape(self) -> None:
rebuilt = session_persistence._rebuild_turn_evaluations(

View file

@ -288,7 +288,7 @@ class FeedbackPolicyRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(sess, principal)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(),
) as evaluation_read,
@ -316,7 +316,7 @@ class FeedbackPolicyRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=sess),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(),
) as evaluation_read,
@ -583,7 +583,7 @@ class FeedbackPolicyRouteTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=([sess], True)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "ready"}, True)),
),

View file

@ -6,7 +6,7 @@ import unittest
from unittest.mock import AsyncMock, patch
from .deps import Principal, Role
from . import session_read_model
from . import session_dashboard_projection
from .routes import sessions
from .services import state_machine
from .services.persona import P1, P2, P3
@ -88,14 +88,14 @@ def _exposure_sessions(persona_codes: list[str]) -> list[InProcSession]:
class TrainingExposureMetricTest(unittest.TestCase):
def test_zero_completed_sessions_is_insufficient(self) -> None:
metric = session_read_model.dashboard_training_exposure([])
metric = session_dashboard_projection.dashboard_training_exposure([])
self.assertEqual(metric.label, "판정 근거 부족")
self.assertEqual(metric.completed_sessions, 0)
self.assertIsNone(metric.dominant_share)
def test_one_completed_session_is_insufficient(self) -> None:
metric = session_read_model.dashboard_training_exposure(
metric = session_dashboard_projection.dashboard_training_exposure(
_exposure_sessions([P1.code])
)
@ -103,7 +103,7 @@ class TrainingExposureMetricTest(unittest.TestCase):
self.assertEqual(metric.dominant_share, 1.0)
def test_four_sessions_with_broad_exposure_is_balanced(self) -> None:
metric = session_read_model.dashboard_training_exposure(
metric = session_dashboard_projection.dashboard_training_exposure(
_exposure_sessions([P1.code, P1.code, P2.code, P3.code])
)
@ -111,7 +111,7 @@ class TrainingExposureMetricTest(unittest.TestCase):
self.assertEqual(metric.dominant_share, 0.5)
def test_dominant_share_at_seventy_five_percent_is_attention(self) -> None:
metric = session_read_model.dashboard_training_exposure(
metric = session_dashboard_projection.dashboard_training_exposure(
_exposure_sessions([P1.code, P1.code, P1.code, P2.code])
)
@ -120,7 +120,7 @@ class TrainingExposureMetricTest(unittest.TestCase):
self.assertEqual(metric.dominant_share, 0.75)
def test_tied_dominant_persona_is_deterministic_and_balanced(self) -> None:
metric = session_read_model.dashboard_training_exposure(
metric = session_dashboard_projection.dashboard_training_exposure(
_exposure_sessions([P2.code, P1.code, P2.code, P1.code])
)
@ -176,7 +176,7 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(owned_sessions, True)),
) as list_recent_sessions,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(side_effect=load_review),
),
@ -249,7 +249,7 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=([good_session, failed_session], True)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "ready"}, True)),
),
@ -386,7 +386,7 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(archive_records, True)),
) as list_session_archives,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(side_effect=load_review),
),
@ -438,7 +438,7 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
),
) as set_session_archived,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "ready"}, True)),
),
@ -486,7 +486,7 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(None, True)),
) as set_session_archived,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(None, True)),
),
@ -510,6 +510,46 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
self.assertFalse(response.session.archived)
self.assertIsNone(response.session.archived_at)
def test_zero_turn_ended_sessions_not_counted_in_completed_sessions(self) -> None:
"""REQ-010: Zero-turn sessions ended without counselor dialogue are not counted in completed_sessions."""
# 1 session with turns (completed)
normal_session = _session(
session_id="00000000-0000-0000-0000-00000000b991",
session_no=1,
persona_code=P1.code,
score="pos",
rapport=0.5,
technique="reflection",
created_at=1_000.0,
)
normal_session.ended = True
# 1 zero-turn session created and closed immediately
zero_turn_session = _session(
session_id="00000000-0000-0000-0000-00000000b992",
session_no=2,
persona_code=P1.code,
score="neutral",
rapport=0.0,
technique="none",
created_at=1_100.0,
)
zero_turn_session.turns = [] # No turns
zero_turn_session.ended = True
from app.session_dashboard_projection import dashboard_overview, dashboard_training_exposure
overview = dashboard_overview(
[normal_session, zero_turn_session],
visible_review_ready={normal_session.session_id: True, zero_turn_session.session_id: False},
archived_sessions=0,
)
self.assertEqual(overview.total_sessions, 2)
self.assertEqual(overview.completed_sessions, 1) # Only 1 completed, not 2!
exposure = dashboard_training_exposure([normal_session, zero_turn_session])
self.assertEqual(exposure.completed_sessions, 1)
if __name__ == "__main__":
unittest.main()

View file

@ -168,7 +168,7 @@ class NotificationTriggerTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(side_effect=RuntimeError("engine offline")),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
) as save_evaluation,
@ -194,7 +194,7 @@ class NotificationTriggerTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(side_effect=asyncio.TimeoutError()),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
) as save_evaluation,
@ -227,7 +227,7 @@ class NotificationTriggerTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=result),
) as evaluate_session,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"save_session_evaluation",
AsyncMock(return_value=True),
),

View file

@ -229,7 +229,7 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase):
),
patch.object(sessions.turn_runtime, "runtime_fallback_allowed", return_value=True),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(None, False)),
) as load_session_evaluation,
@ -383,7 +383,7 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase):
),
patch.object(sessions.turn_runtime, "runtime_fallback_allowed", return_value=True),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(evaluation_record, True)),
),
@ -432,7 +432,7 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=sess),
) as load_session,
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(None, False)),
),

View file

@ -212,7 +212,7 @@ class SessionLearningProducerTests(unittest.IsolatedAsyncioTestCase):
patch.object(
sessions.evaluator, "evaluate_session", AsyncMock(return_value=ready)
),
patch.object(sessions.session_persistence, "save_session_evaluation", save),
patch.object(sessions.session_evaluation_repository, "save_session_evaluation", save),
patch.object(
sessions.session_learning_producer,
"produce_session_learning_artifacts",

View file

@ -5,7 +5,8 @@ from __future__ import annotations
import unittest
from datetime import datetime, timezone
from .session_read_model import iso, session_calendar_date
from .session_projection import iso
from .session_read_model import session_calendar_date
from .services.session_metrics import iso_datetime

View file

@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from .config import settings
from . import session_persistence, turn_runtime
from . import session_evaluation_repository, session_persistence, turn_runtime
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
from .deps import Principal, Role
from .engine_client import EngineError, GenerateResponse
@ -214,7 +214,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
durable_evaluations: dict[str, dict[str, object]] = {}
async def save_evaluation(
write: session_persistence.SessionEvaluationWrite,
write: session_evaluation_repository.SessionEvaluationWrite,
) -> bool:
durable_evaluations[write.session_id] = write.cache_record()
return True
@ -226,7 +226,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(side_effect=asyncio.TimeoutError),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"save_session_evaluation",
save_evaluation,
),
@ -375,7 +375,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=old_session),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(old_evaluation, True)),
),
@ -415,7 +415,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(side_effect=EngineError("controlled evaluator 500")),
),
patch.object(
eval_routes.session_persistence,
eval_routes.session_evaluation_repository,
"save_session_evaluation",
retry_save,
),
@ -2574,7 +2574,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(saved_payload, True)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(None, False)),
),
@ -2681,7 +2681,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(None, False)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(raw_record, True)),
),
@ -2721,7 +2721,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
),
]
)
error_record = session_persistence.SessionEvaluationWrite.from_error(
error_record = session_evaluation_repository.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=principal.user_id,
scope="session_end",
@ -2741,7 +2741,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(None, False)),
),
patch.object(
sessions.session_persistence,
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=(error_record, True)),
),

View file

@ -182,7 +182,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -238,7 +238,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -303,7 +303,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -357,7 +357,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -411,7 +411,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({sess.session_id: evaluation_record}, True)),
),
@ -459,7 +459,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -507,7 +507,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=({}, True)),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),
@ -539,7 +539,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=sess),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "ready"}, True)),
),
@ -604,7 +604,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=sess),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "error", "error": "timeout"}, True)),
),
@ -680,7 +680,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
),
),
patch.object(
teacher.session_persistence,
teacher.session_evaluation_repository,
"list_session_evaluations",
AsyncMock(return_value=({}, True)),
),

View file

@ -1,4 +0,0 @@
INFO: Started server process [8752]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:9099 (Press CTRL+C to quit)

View file

@ -1,4 +0,0 @@
INFO: Started server process [69132]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:9099 (Press CTRL+C to quit)

File diff suppressed because it is too large Load diff

View file

@ -1,150 +0,0 @@
INFO: 127.0.0.1:5961 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:1131 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:13224 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:1131 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9011 - "GET /ready?force=true HTTP/1.1" 200 OK
INFO: 127.0.0.1:4872 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4872 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9735 - "GET /ready HTTP/1.1" 200 OK
INFO: 127.0.0.1:9737 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9737 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9550 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4478 - "GET /ready HTTP/1.1" 200 OK
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4483 - "GET /ready HTTP/1.1" 200 OK
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4480 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:6909 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:6920 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:6920 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4284 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11183 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11186 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11186 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9906 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:9908 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9908 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4910 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4912 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4912 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11462 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11464 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11464 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3395 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:3397 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3397 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7131 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:7135 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7135 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4662 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4665 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4665 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8921 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:8924 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8924 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7827 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:7830 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7830 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11042 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11046 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11046 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11042 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11046 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:14342 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:14343 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:14342 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5041 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:5043 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5043 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:12732 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:12735 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:12735 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2362 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:2365 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2365 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4926 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4928 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4928 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4810 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4813 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4813 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2345 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:2351 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2351 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2075 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:2079 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2079 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3840 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:3842 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3842 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4284 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4286 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:10589 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:10591 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:10591 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8330 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:8334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5069 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:5071 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5071 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8707 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:8709 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8709 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9467 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:9470 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:9470 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11371 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11374 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11374 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11371 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11374 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11382 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11416 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11382 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11850 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:11852 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:11852 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:1574 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:1576 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:1576 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4470 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4472 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4472 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4562 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:4564 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4564 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7321 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:7323 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7323 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:1317 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:1319 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:1319 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2332 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:2334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2334 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3770 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:3772 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:3772 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7472 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:7474 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7474 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7671 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:7673 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:7673 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8837 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:8839 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:8839 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2461 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:2463 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:2463 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:10930 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5805 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:5781 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:5714 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4865 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK
INFO: 127.0.0.1:4903 - "GET /ready?provider=claude_cli&reasoning_effort=high HTTP/1.1" 200 OK