회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
This commit is contained in:
parent
a479db7a5a
commit
a0311c5957
100 changed files with 4884 additions and 11210 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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, ...]:
|
||||
|
|
|
|||
40
apps/api/app/services/outcome_repository_values.py
Normal file
40
apps/api/app/services/outcome_repository_values.py
Normal 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
|
||||
22
apps/api/app/services/practice_competency.py
Normal file
22
apps/api/app/services/practice_competency.py
Normal 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
|
||||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
358
apps/api/app/session_dashboard_projection.py
Normal file
358
apps/api/app/session_dashboard_projection.py
Normal 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
|
||||
275
apps/api/app/session_evaluation_repository.py
Normal file
275
apps/api/app/session_evaluation_repository.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
100
apps/api/app/session_persistence_values.py
Normal file
100
apps/api/app/session_persistence_values.py
Normal 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
|
||||
35
apps/api/app/session_projection.py
Normal file
35
apps/api/app/session_projection.py
Normal 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))))
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
19
apps/api/app/session_turn_memory.py
Normal file
19
apps/api/app/session_turn_memory.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
316
apps/api/app/test_calibration_validation_tdd.py
Normal file
316
apps/api/app/test_calibration_validation_tdd.py
Normal 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,
|
||||
)
|
||||
|
||||
215
apps/api/app/test_dashboard_projection_tdd.py
Normal file
215
apps/api/app/test_dashboard_projection_tdd.py
Normal 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",
|
||||
)
|
||||
|
||||
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue