전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
|
|
@ -13,9 +13,20 @@ from typing import Any, Iterable, Protocol
|
|||
from .db import acquire, get_pool
|
||||
from .deps import Principal
|
||||
from .config import settings
|
||||
from .persona_repository import SEED_VERSION, card_from_row, seed_fallback_persona, seed_persona_id
|
||||
from .persona_repository import (
|
||||
SEED_VERSION,
|
||||
card_from_row,
|
||||
seed_fallback_persona,
|
||||
seed_persona_id,
|
||||
)
|
||||
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
||||
from .runtime_schema import (
|
||||
REVIEW_SCHEMA_CONTRACT,
|
||||
runtime_schema_bootstrap_required,
|
||||
schema_contract_ready,
|
||||
)
|
||||
from .services import guardrail, memory, state_machine
|
||||
from .services.evaluation_contract import PERSISTED_APPROPRIATENESS_SCORE_5PT
|
||||
from .services.persona import PersonaCard
|
||||
from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
|
||||
|
||||
|
|
@ -33,16 +44,60 @@ _WORKSHEET_REVIEW_STATUS_VALUES = {
|
|||
"changes_requested",
|
||||
"rejected",
|
||||
}
|
||||
_APPROPRIATENESS_SCORE = {
|
||||
"warn": 1.0,
|
||||
"neutral": 3.0,
|
||||
"pos": 5.0,
|
||||
}
|
||||
LIVE_COACH_INITIAL_CREDITS = 3
|
||||
LIVE_COACH_MAX_CREDITS = 3
|
||||
LIVE_COACH_USE_REASON = "AI 코칭 힌트 사용"
|
||||
LIVE_COACH_RECHARGE_REASON = "좋은 발화로 내담자 변화 신호 확인"
|
||||
|
||||
SESSION_PERSONA_SELECT_COLUMNS_SQL = """
|
||||
s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
|
||||
s.session_no, s.theory_mode, s.started_at, s.ended_at,
|
||||
s.prev_rapport_credit, s.session_goals,
|
||||
COALESCE(
|
||||
NULLIF(learner.display_name, ''),
|
||||
NULLIF(learner.nickname, ''),
|
||||
NULLIF(learner.email, ''),
|
||||
s.learner_id::text
|
||||
) AS learner_label,
|
||||
pc.persona_id AS card_persona_id,
|
||||
pc.code AS card_code,
|
||||
pc.version AS card_version,
|
||||
pc.status AS card_status,
|
||||
pc.display_name AS card_display_name,
|
||||
pc.difficulty AS card_difficulty,
|
||||
pc.theory_target AS card_theory_target,
|
||||
pc.demographics AS card_demographics,
|
||||
pc.presenting AS card_presenting,
|
||||
pc.history AS card_history,
|
||||
pc.big5 AS card_big5,
|
||||
pc.resistance AS card_resistance,
|
||||
pc.speech_style AS card_speech_style,
|
||||
pc.affect_baseline AS card_affect_baseline,
|
||||
pc.ccd AS card_ccd,
|
||||
pc.dsm5_dimensional AS card_dsm5_dimensional,
|
||||
pc.triggers AS card_triggers,
|
||||
pc.source_provenance AS card_source_provenance,
|
||||
pc.is_synthetic AS card_is_synthetic
|
||||
"""
|
||||
|
||||
SESSION_PERSONA_JOINS_SQL = """
|
||||
LEFT JOIN app.persona_card pc
|
||||
ON pc.persona_id = s.persona_id
|
||||
AND pc.version = s.persona_version
|
||||
LEFT JOIN app.app_user learner ON learner.user_id = s.learner_id
|
||||
"""
|
||||
|
||||
SESSION_STATE_SELECT_COLUMNS_SQL = """
|
||||
session_id, stage, turn_seq, effective_openness, rapport_credit, resistance,
|
||||
ideation_stage, turns_in_stage, affect_state
|
||||
"""
|
||||
|
||||
SESSION_TURN_SELECT_COLUMNS_SQL = """
|
||||
session_id, id, seq, speaker, stage, text, text_masked, created_at,
|
||||
llm_provider, model, tokens_in, tokens_out, cost_usd,
|
||||
audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to
|
||||
"""
|
||||
|
||||
|
||||
class LiveCoachCreditExhausted(RuntimeError):
|
||||
"""Raised when a learner tries to use live coaching without credits."""
|
||||
|
|
@ -227,6 +282,21 @@ def _json_object_payload(value: Any) -> dict[str, Any]:
|
|||
return {}
|
||||
|
||||
|
||||
def _json_list_payload(value: Any) -> list[Any]:
|
||||
if isinstance(value, list):
|
||||
return list(value)
|
||||
if not isinstance(value, str):
|
||||
return []
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
|
||||
|
||||
def _masked_excerpt(value: str | None, *, limit: int = 220) -> str | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
|
|
@ -261,7 +331,10 @@ def _live_coach_remaining(value: int | float | None) -> int:
|
|||
|
||||
|
||||
def _live_coach_quota_payload(remaining: int | float | None) -> dict[str, int]:
|
||||
return {"remaining": _live_coach_remaining(remaining), "max": LIVE_COACH_MAX_CREDITS}
|
||||
return {
|
||||
"remaining": _live_coach_remaining(remaining),
|
||||
"max": LIVE_COACH_MAX_CREDITS,
|
||||
}
|
||||
|
||||
|
||||
def _live_coach_delta_from_record(record: dict[str, Any]) -> int:
|
||||
|
|
@ -309,9 +382,14 @@ def _live_coach_credit_event_from_row(row) -> dict[str, Any]:
|
|||
"event_type": event_type,
|
||||
"delta": int(delta),
|
||||
"balance": _live_coach_remaining(balance),
|
||||
"reason": str(_row_value(row, "reason") or (
|
||||
LIVE_COACH_RECHARGE_REASON if event_type == "recharge" else LIVE_COACH_USE_REASON
|
||||
)),
|
||||
"reason": str(
|
||||
_row_value(row, "reason")
|
||||
or (
|
||||
LIVE_COACH_RECHARGE_REASON
|
||||
if event_type == "recharge"
|
||||
else LIVE_COACH_USE_REASON
|
||||
)
|
||||
),
|
||||
"created_at": _iso_dt(row["created_at"]),
|
||||
}
|
||||
|
||||
|
|
@ -329,9 +407,14 @@ def _live_coach_credit_event_from_record(record: dict[str, Any]) -> dict[str, An
|
|||
"event_type": event_type,
|
||||
"delta": delta,
|
||||
"balance": _live_coach_remaining(record.get("credit_balance")),
|
||||
"reason": str(record.get("reason") or (
|
||||
LIVE_COACH_RECHARGE_REASON if event_type == "recharge" else LIVE_COACH_USE_REASON
|
||||
)),
|
||||
"reason": str(
|
||||
record.get("reason")
|
||||
or (
|
||||
LIVE_COACH_RECHARGE_REASON
|
||||
if event_type == "recharge"
|
||||
else LIVE_COACH_USE_REASON
|
||||
)
|
||||
),
|
||||
"created_at": str(record.get("created_at") or ""),
|
||||
}
|
||||
|
||||
|
|
@ -348,7 +431,9 @@ def _live_coach_cache_record(
|
|||
) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
balance = _live_coach_remaining(
|
||||
credit_balance if credit_balance is not None else _live_coach_cached_remaining(session_id) - 1
|
||||
credit_balance
|
||||
if credit_balance is not None
|
||||
else _live_coach_cached_remaining(session_id) - 1
|
||||
)
|
||||
return {
|
||||
"event_id": str(uuid.uuid4()),
|
||||
|
|
@ -407,8 +492,7 @@ def _card_from_joined_session_row(row) -> PersonaCard | None:
|
|||
if _row_value(row, "card_persona_id") is None:
|
||||
return None
|
||||
card_row = {
|
||||
key.removeprefix("card_"): _row_value(row, key)
|
||||
for key in _JOINED_CARD_COLUMNS
|
||||
key.removeprefix("card_"): _row_value(row, key) for key in _JOINED_CARD_COLUMNS
|
||||
}
|
||||
return card_from_row(card_row)
|
||||
|
||||
|
|
@ -471,7 +555,9 @@ def _dict_items(value: Any) -> list[dict[str, Any]]:
|
|||
return [item for item in value if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _evaluation_feedback_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
def _evaluation_feedback_rows(
|
||||
evaluation: dict[str, Any] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Normalize scalar/rationale turn-evaluation fields into feedback_scores rows."""
|
||||
if not isinstance(evaluation, dict):
|
||||
return []
|
||||
|
|
@ -499,11 +585,11 @@ def _evaluation_feedback_rows(evaluation: dict[str, Any] | None) -> list[dict[st
|
|||
)
|
||||
|
||||
appropriateness = _clean_text(evaluation.get("appropriateness")) or "neutral"
|
||||
if appropriateness not in _APPROPRIATENESS_SCORE:
|
||||
if appropriateness not in PERSISTED_APPROPRIATENESS_SCORE_5PT:
|
||||
appropriateness = "neutral"
|
||||
add(
|
||||
"appropriateness",
|
||||
score=_APPROPRIATENESS_SCORE[appropriateness],
|
||||
score=PERSISTED_APPROPRIATENESS_SCORE_5PT[appropriateness],
|
||||
rationale=_clean_masked_text(evaluation.get("appropriateness_note")),
|
||||
)
|
||||
|
||||
|
|
@ -534,7 +620,9 @@ def _evaluation_feedback_rows(evaluation: dict[str, Any] | None) -> list[dict[st
|
|||
return rows
|
||||
|
||||
|
||||
def _evaluation_technique_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]:
|
||||
def _evaluation_technique_rows(
|
||||
evaluation: dict[str, Any] | None,
|
||||
) -> list[dict[str, str]]:
|
||||
if not isinstance(evaluation, dict):
|
||||
return []
|
||||
rows: list[dict[str, str]] = []
|
||||
|
|
@ -552,7 +640,9 @@ def _evaluation_technique_rows(evaluation: dict[str, Any] | None) -> list[dict[s
|
|||
return rows
|
||||
|
||||
|
||||
def _evaluation_client_state_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]:
|
||||
def _evaluation_client_state_rows(
|
||||
evaluation: dict[str, Any] | None,
|
||||
) -> list[dict[str, str]]:
|
||||
if not isinstance(evaluation, dict):
|
||||
return []
|
||||
rows: list[dict[str, str]] = []
|
||||
|
|
@ -575,11 +665,21 @@ def _evaluation_comment_rows(evaluation: dict[str, Any] | None) -> list[dict[str
|
|||
deviation = evaluation.get("intent_deviation")
|
||||
if not isinstance(deviation, dict):
|
||||
return []
|
||||
note = _clean_masked_text(evaluation.get("appropriateness_note")) or "의도와 다른 부분"
|
||||
return [{"kind": "critique", "text": note, "intent_deviation": _mask_json_text_values(deviation)}]
|
||||
note = (
|
||||
_clean_masked_text(evaluation.get("appropriateness_note")) or "의도와 다른 부분"
|
||||
)
|
||||
return [
|
||||
{
|
||||
"kind": "critique",
|
||||
"text": note,
|
||||
"intent_deviation": _mask_json_text_values(deviation),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _evaluation_alternative_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str | None]]:
|
||||
def _evaluation_alternative_rows(
|
||||
evaluation: dict[str, Any] | None,
|
||||
) -> list[dict[str, str | None]]:
|
||||
if not isinstance(evaluation, dict):
|
||||
return []
|
||||
alternatives = evaluation.get("alternative_utterances")
|
||||
|
|
@ -747,11 +847,15 @@ async def record_llm_call_audit(payload: dict[str, Any]) -> bool:
|
|||
|
||||
The audit table intentionally stores no prompt or completion text. A DB outage
|
||||
must not block the counseling loop, so failures are reported as False.
|
||||
|
||||
dev degraded(무DB) 기동에서는 audit 인프라 자체가 없는 것이 정상 폴백이므로,
|
||||
호출부가 이를 "기록 실패"로 보고 정상 LLM 응답을 강등하지 않도록 True 를 반환한다.
|
||||
durable 환경에서 풀이 없거나 INSERT 가 실패한 경우에만 False 다.
|
||||
"""
|
||||
try:
|
||||
get_pool()
|
||||
except Exception:
|
||||
return False
|
||||
return runtime_fallback_allowed()
|
||||
|
||||
try:
|
||||
async with acquire(ai_context=True, ai_view="evaluator") as conn:
|
||||
|
|
@ -802,14 +906,18 @@ def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState:
|
|||
|
||||
def _turn_from_row(row, evaluation: dict[str, Any] | None = None) -> TurnRecord:
|
||||
created_at = _ts(row["created_at"]) or time.time()
|
||||
text_masked = _clean_text(row["text_masked"]) or _clean_masked_text(row["text"]) or ""
|
||||
text_masked = (
|
||||
_clean_text(row["text_masked"]) or _clean_masked_text(row["text"]) or ""
|
||||
)
|
||||
return TurnRecord(
|
||||
turn_seq=int(row["seq"]),
|
||||
speaker=row["speaker"],
|
||||
stage=row["stage"],
|
||||
text=text_masked,
|
||||
text_masked=text_masked,
|
||||
turn_id=str(_row_value(row, "id")) if _row_value(row, "id") is not None else None,
|
||||
turn_id=str(_row_value(row, "id"))
|
||||
if _row_value(row, "id") is not None
|
||||
else None,
|
||||
created_at=created_at,
|
||||
llm_provider=_row_value(row, "llm_provider"),
|
||||
model=_row_value(row, "model"),
|
||||
|
|
@ -826,7 +934,9 @@ def _turn_from_row(row, evaluation: dict[str, Any] | None = None) -> TurnRecord:
|
|||
)
|
||||
|
||||
|
||||
async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str, Any] | None) -> None:
|
||||
async def _persist_turn_evaluation(
|
||||
conn: Any, turn_id: str, evaluation: dict[str, Any] | None
|
||||
) -> None:
|
||||
if not isinstance(evaluation, dict):
|
||||
return
|
||||
await conn.execute("SELECT set_config('app.ai_context', '1', true)")
|
||||
|
|
@ -916,7 +1026,9 @@ async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str
|
|||
row["intent_deviation"],
|
||||
)
|
||||
|
||||
await conn.execute("DELETE FROM app.alternative_utterance WHERE turn_id = $1::uuid", turn_id)
|
||||
await conn.execute(
|
||||
"DELETE FROM app.alternative_utterance WHERE turn_id = $1::uuid", turn_id
|
||||
)
|
||||
for row in _evaluation_alternative_rows(evaluation):
|
||||
await conn.execute(
|
||||
"""
|
||||
|
|
@ -931,19 +1043,33 @@ async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str
|
|||
)
|
||||
|
||||
|
||||
async def _replace_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str, Any] | None) -> None:
|
||||
async def _replace_turn_evaluation(
|
||||
conn: Any, turn_id: str, evaluation: dict[str, Any] | None
|
||||
) -> None:
|
||||
await conn.execute("SELECT set_config('app.ai_context', '1', true)")
|
||||
await conn.execute("SELECT set_config('app.current_ai_view', 'evaluator', true)")
|
||||
await conn.execute("SELECT set_config('app.current_sens_max', '2', true)")
|
||||
await conn.execute("DELETE FROM app.feedback_scores WHERE turn_id = $1::uuid", turn_id)
|
||||
await conn.execute("DELETE FROM app.turn_technique WHERE turn_id = $1::uuid", turn_id)
|
||||
await conn.execute("DELETE FROM app.turn_client_state WHERE turn_id = $1::uuid", turn_id)
|
||||
await conn.execute("DELETE FROM app.supervisor_comment WHERE turn_id = $1::uuid", turn_id)
|
||||
await conn.execute("DELETE FROM app.alternative_utterance WHERE turn_id = $1::uuid", turn_id)
|
||||
await conn.execute(
|
||||
"DELETE FROM app.feedback_scores WHERE turn_id = $1::uuid", turn_id
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM app.turn_technique WHERE turn_id = $1::uuid", turn_id
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM app.turn_client_state WHERE turn_id = $1::uuid", turn_id
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM app.supervisor_comment WHERE turn_id = $1::uuid", turn_id
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM app.alternative_utterance WHERE turn_id = $1::uuid", turn_id
|
||||
)
|
||||
await _persist_turn_evaluation(conn, turn_id, _mask_json_text_values(evaluation))
|
||||
|
||||
|
||||
async def replace_turn_evaluation(*, turn_id: str, evaluation: dict[str, Any] | None) -> bool:
|
||||
async def replace_turn_evaluation(
|
||||
*, turn_id: str, evaluation: dict[str, Any] | None
|
||||
) -> bool:
|
||||
if not turn_id or not isinstance(evaluation, dict):
|
||||
return False
|
||||
try:
|
||||
|
|
@ -1051,10 +1177,15 @@ async def _hydrate_sessions_turn_evaluations(sessions: list[InProcSession]) -> N
|
|||
|
||||
|
||||
async def ensure_review_tables() -> None:
|
||||
"""Create runtime review/evaluation storage when the DB role allows it."""
|
||||
"""Verify the review schema, with DDL repair restricted to local development."""
|
||||
try:
|
||||
get_pool()
|
||||
async with acquire(role="admin") as conn:
|
||||
ready = await schema_contract_ready(conn, REVIEW_SCHEMA_CONTRACT)
|
||||
if not runtime_schema_bootstrap_required(
|
||||
REVIEW_SCHEMA_CONTRACT, ready=ready
|
||||
):
|
||||
return
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app.session_evaluation (
|
||||
|
|
@ -1521,7 +1652,13 @@ async def ensure_review_tables() -> None:
|
|||
)
|
||||
"""
|
||||
)
|
||||
if not await schema_contract_ready(conn, REVIEW_SCHEMA_CONTRACT):
|
||||
raise RuntimeError(
|
||||
"review/evaluation development schema bootstrap did not satisfy readiness"
|
||||
)
|
||||
except Exception:
|
||||
if settings.environment != "dev":
|
||||
raise
|
||||
return
|
||||
|
||||
|
||||
|
|
@ -1584,7 +1721,11 @@ async def load_session_evaluation(
|
|||
session_id,
|
||||
)
|
||||
if row is None:
|
||||
cached = _EVALUATION_CACHE.get(session_id) if runtime_fallback_allowed() else None
|
||||
cached = (
|
||||
_EVALUATION_CACHE.get(session_id)
|
||||
if runtime_fallback_allowed()
|
||||
else None
|
||||
)
|
||||
return cached, cached is None
|
||||
return {
|
||||
"status": row["status"],
|
||||
|
|
@ -1649,6 +1790,39 @@ async def list_session_evaluations(
|
|||
}, False
|
||||
|
||||
|
||||
async def _fetch_session_runtime_rows(
|
||||
conn: Any,
|
||||
session_ids: list[str],
|
||||
) -> tuple[dict[str, Any], dict[str, list[Any]]]:
|
||||
"""세션 상태와 턴을 두 번의 배치 조회로 적재한다."""
|
||||
if not session_ids:
|
||||
return {}, {}
|
||||
state_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {SESSION_STATE_SELECT_COLUMNS_SQL}
|
||||
FROM app.session_state
|
||||
WHERE session_id = ANY($1::uuid[])
|
||||
""",
|
||||
session_ids,
|
||||
)
|
||||
states_by_id = {
|
||||
str(state_row["session_id"]): state_row for state_row in state_rows
|
||||
}
|
||||
turn_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {SESSION_TURN_SELECT_COLUMNS_SQL}
|
||||
FROM app.turns
|
||||
WHERE session_id = ANY($1::uuid[])
|
||||
ORDER BY session_id, seq
|
||||
""",
|
||||
session_ids,
|
||||
)
|
||||
turns_by_id: dict[str, list[Any]] = {}
|
||||
for turn_row in turn_rows:
|
||||
turns_by_id.setdefault(str(turn_row["session_id"]), []).append(turn_row)
|
||||
return states_by_id, turns_by_id
|
||||
|
||||
|
||||
async def list_sessions_missing_session_evaluation(
|
||||
*,
|
||||
older_than_seconds: float,
|
||||
|
|
@ -1661,42 +1835,11 @@ async def list_sessions_missing_session_evaluation(
|
|||
stale_seconds = max(float(older_than_seconds), 0.0)
|
||||
async with acquire(ai_context=True, ai_view="evaluator") as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
|
||||
s.session_no, s.theory_mode, s.started_at, s.ended_at,
|
||||
s.prev_rapport_credit,
|
||||
COALESCE(
|
||||
NULLIF(learner.display_name, ''),
|
||||
NULLIF(learner.nickname, ''),
|
||||
NULLIF(learner.email, ''),
|
||||
s.learner_id::text
|
||||
) AS learner_label,
|
||||
pc.persona_id AS card_persona_id,
|
||||
pc.code AS card_code,
|
||||
pc.version AS card_version,
|
||||
pc.status AS card_status,
|
||||
pc.display_name AS card_display_name,
|
||||
pc.difficulty AS card_difficulty,
|
||||
pc.theory_target AS card_theory_target,
|
||||
pc.demographics AS card_demographics,
|
||||
pc.presenting AS card_presenting,
|
||||
pc.history AS card_history,
|
||||
pc.big5 AS card_big5,
|
||||
pc.resistance AS card_resistance,
|
||||
pc.speech_style AS card_speech_style,
|
||||
pc.affect_baseline AS card_affect_baseline,
|
||||
pc.ccd AS card_ccd,
|
||||
pc.dsm5_dimensional AS card_dsm5_dimensional,
|
||||
pc.triggers AS card_triggers,
|
||||
pc.source_provenance AS card_source_provenance,
|
||||
pc.is_synthetic AS card_is_synthetic
|
||||
f"""
|
||||
SELECT {SESSION_PERSONA_SELECT_COLUMNS_SQL}
|
||||
FROM app.sessions s
|
||||
LEFT JOIN app.session_evaluation se ON se.session_id = s.id
|
||||
LEFT JOIN app.persona_card pc
|
||||
ON pc.persona_id = s.persona_id
|
||||
AND pc.version = s.persona_version
|
||||
LEFT JOIN app.app_user learner ON learner.user_id = s.learner_id
|
||||
{SESSION_PERSONA_JOINS_SQL}
|
||||
WHERE s.ended_at IS NOT NULL
|
||||
AND s.ended_at <= now() - ($1::double precision * interval '1 second')
|
||||
AND se.session_id IS NULL
|
||||
|
|
@ -1712,30 +1855,18 @@ async def list_sessions_missing_session_evaluation(
|
|||
stale_seconds,
|
||||
max(1, int(limit)),
|
||||
)
|
||||
session_ids = [str(row["id"]) for row in rows]
|
||||
states_by_id, turns_by_id = await _fetch_session_runtime_rows(
|
||||
conn, session_ids
|
||||
)
|
||||
sessions: list[InProcSession] = []
|
||||
for row in rows:
|
||||
session_id = str(row["id"])
|
||||
state_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT stage, turn_seq, effective_openness, rapport_credit, resistance,
|
||||
ideation_stage, turns_in_stage, affect_state
|
||||
FROM app.session_state
|
||||
WHERE session_id = $1::uuid
|
||||
""",
|
||||
session_id,
|
||||
sess = _session_from_rows(
|
||||
row,
|
||||
states_by_id.get(session_id),
|
||||
turns_by_id.get(session_id, []),
|
||||
)
|
||||
turn_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, seq, speaker, stage, text, text_masked, created_at,
|
||||
llm_provider, model, tokens_in, tokens_out, cost_usd,
|
||||
audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to
|
||||
FROM app.turns
|
||||
WHERE session_id = $1::uuid
|
||||
ORDER BY seq
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
sess = _session_from_rows(row, state_row, turn_rows)
|
||||
if sess is not None:
|
||||
sessions.append(sess)
|
||||
return sessions, True
|
||||
|
|
@ -1793,7 +1924,9 @@ async def load_case_worksheet(
|
|||
)
|
||||
if row is None:
|
||||
return (
|
||||
_CASE_WORKSHEET_CACHE.get(session_id) if runtime_fallback_allowed() else None
|
||||
_CASE_WORKSHEET_CACHE.get(session_id)
|
||||
if runtime_fallback_allowed()
|
||||
else None
|
||||
), False
|
||||
payload = dict(row["payload"] or {})
|
||||
payload.setdefault("savedAt", _iso_dt(row["updated_at"]))
|
||||
|
|
@ -1909,7 +2042,9 @@ async def get_live_coach_quota(
|
|||
return _live_coach_quota_payload(remaining), True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("live coach quota")
|
||||
return _live_coach_quota_payload(_live_coach_cached_remaining(session_id)), False
|
||||
return _live_coach_quota_payload(
|
||||
_live_coach_cached_remaining(session_id)
|
||||
), False
|
||||
|
||||
|
||||
async def list_live_coach_credit_events(
|
||||
|
|
@ -1992,7 +2127,8 @@ async def record_live_coach_recharge(
|
|||
require_runtime_fallback_allowed("live coach recharge")
|
||||
events = _LIVE_COACH_EVENT_CACHE.setdefault(session_id, [])
|
||||
if any(
|
||||
event.get("event_type") == "recharge" and int(event.get("turn_seq") or 0) == int(turn_seq)
|
||||
event.get("event_type") == "recharge"
|
||||
and int(event.get("turn_seq") or 0) == int(turn_seq)
|
||||
for event in events
|
||||
):
|
||||
return None, False
|
||||
|
|
@ -2094,8 +2230,7 @@ async def list_session_review_statuses(
|
|||
session_ids,
|
||||
)
|
||||
return {
|
||||
str(row["session_id"]): _review_status_from_row(row)
|
||||
for row in rows
|
||||
str(row["session_id"]): _review_status_from_row(row) for row in rows
|
||||
}, True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session review status list")
|
||||
|
|
@ -2326,7 +2461,9 @@ async def list_session_archives(
|
|||
""",
|
||||
session_ids,
|
||||
)
|
||||
return {str(row["session_id"]): _archive_record_from_row(row) for row in rows}, True
|
||||
return {
|
||||
str(row["session_id"]): _archive_record_from_row(row) for row in rows
|
||||
}, True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session archive list")
|
||||
return {
|
||||
|
|
@ -2385,7 +2522,9 @@ async def set_session_archived(
|
|||
session_id,
|
||||
learner_id,
|
||||
)
|
||||
return (_archive_record_from_row(deleted) if deleted is not None else None), True
|
||||
return (
|
||||
_archive_record_from_row(deleted) if deleted is not None else None
|
||||
), True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session archive update")
|
||||
return (_SESSION_ARCHIVE_CACHE.get(session_id) if archived else None), False
|
||||
|
|
@ -2420,10 +2559,15 @@ def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | N
|
|||
ended=ended_at is not None,
|
||||
prev_rapport_credit=float(row["prev_rapport_credit"] or 0.0),
|
||||
learner_label=_clean_text(_row_value(row, "learner_label")),
|
||||
goal_stages=[
|
||||
str(v) for v in _json_list_payload(_row_value(row, "session_goals"))
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def _upsert_state(conn, session_id: str, state: state_machine.SessionState) -> None:
|
||||
async def _upsert_state(
|
||||
conn, session_id: str, state: state_machine.SessionState
|
||||
) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.session_state (
|
||||
|
|
@ -2493,6 +2637,7 @@ async def create_session(
|
|||
persona_id: str | None = None,
|
||||
persona_version: int | None = None,
|
||||
case_id: str | None = None,
|
||||
goal_stages: list[str] | None = None,
|
||||
) -> InProcSession | None:
|
||||
"""Create a DB-backed session, returning None when DB persistence is unavailable."""
|
||||
try:
|
||||
|
|
@ -2537,12 +2682,12 @@ async def create_session(
|
|||
INSERT INTO app.sessions (
|
||||
runtime_case_id, case_id, learner_id, persona_id, persona_version,
|
||||
persona_code, persona_display_name, persona_difficulty,
|
||||
session_no, theory_mode, stage_path, prev_rapport_credit
|
||||
session_no, theory_mode, stage_path, prev_rapport_credit, session_goals
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2::uuid, $3::uuid, $4::uuid, $5,
|
||||
$6, $7, $8,
|
||||
$9, $10, '[]'::jsonb, $11
|
||||
$9, $10, '[]'::jsonb, $11, $12::jsonb
|
||||
)
|
||||
RETURNING id, runtime_case_id, case_id, learner_id, persona_code,
|
||||
session_no, theory_mode, started_at, ended_at, prev_rapport_credit
|
||||
|
|
@ -2558,6 +2703,7 @@ async def create_session(
|
|||
session_no,
|
||||
theory_mode,
|
||||
carry_rapport,
|
||||
json.dumps(list(goal_stages or []), ensure_ascii=False),
|
||||
)
|
||||
await _upsert_state(conn, str(row["id"]), state)
|
||||
return InProcSession(
|
||||
|
|
@ -2574,6 +2720,7 @@ async def create_session(
|
|||
turns=[],
|
||||
ended=False,
|
||||
prev_rapport_credit=carry_rapport,
|
||||
goal_stages=list(goal_stages or []),
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session creation")
|
||||
|
|
@ -2595,41 +2742,10 @@ async def load_session(
|
|||
cohort_ids=principal.cohort_ids,
|
||||
) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
|
||||
s.session_no, s.theory_mode, s.started_at, s.ended_at,
|
||||
s.prev_rapport_credit,
|
||||
COALESCE(
|
||||
NULLIF(learner.display_name, ''),
|
||||
NULLIF(learner.nickname, ''),
|
||||
NULLIF(learner.email, ''),
|
||||
s.learner_id::text
|
||||
) AS learner_label,
|
||||
pc.persona_id AS card_persona_id,
|
||||
pc.code AS card_code,
|
||||
pc.version AS card_version,
|
||||
pc.status AS card_status,
|
||||
pc.display_name AS card_display_name,
|
||||
pc.difficulty AS card_difficulty,
|
||||
pc.theory_target AS card_theory_target,
|
||||
pc.demographics AS card_demographics,
|
||||
pc.presenting AS card_presenting,
|
||||
pc.history AS card_history,
|
||||
pc.big5 AS card_big5,
|
||||
pc.resistance AS card_resistance,
|
||||
pc.speech_style AS card_speech_style,
|
||||
pc.affect_baseline AS card_affect_baseline,
|
||||
pc.ccd AS card_ccd,
|
||||
pc.dsm5_dimensional AS card_dsm5_dimensional,
|
||||
pc.triggers AS card_triggers,
|
||||
pc.source_provenance AS card_source_provenance,
|
||||
pc.is_synthetic AS card_is_synthetic
|
||||
f"""
|
||||
SELECT {SESSION_PERSONA_SELECT_COLUMNS_SQL}
|
||||
FROM app.sessions s
|
||||
LEFT JOIN app.persona_card pc
|
||||
ON pc.persona_id = s.persona_id
|
||||
AND pc.version = s.persona_version
|
||||
LEFT JOIN app.app_user learner ON learner.user_id = s.learner_id
|
||||
{SESSION_PERSONA_JOINS_SQL}
|
||||
WHERE s.id = $1::uuid
|
||||
""",
|
||||
session_id,
|
||||
|
|
@ -2638,27 +2754,14 @@ async def load_session(
|
|||
return None
|
||||
if row["ended_at"] is not None and not allow_ended:
|
||||
return None
|
||||
state_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT stage, turn_seq, effective_openness, rapport_credit, resistance,
|
||||
ideation_stage, turns_in_stage, affect_state
|
||||
FROM app.session_state
|
||||
WHERE session_id = $1::uuid
|
||||
""",
|
||||
session_id,
|
||||
states_by_id, turns_by_id = await _fetch_session_runtime_rows(
|
||||
conn, [session_id]
|
||||
)
|
||||
turn_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, seq, speaker, stage, text, text_masked, created_at,
|
||||
llm_provider, model, tokens_in, tokens_out, cost_usd,
|
||||
audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to
|
||||
FROM app.turns
|
||||
WHERE session_id = $1::uuid
|
||||
ORDER BY seq
|
||||
""",
|
||||
session_id,
|
||||
sess = _session_from_rows(
|
||||
row,
|
||||
states_by_id.get(session_id),
|
||||
turns_by_id.get(session_id, []),
|
||||
)
|
||||
sess = _session_from_rows(row, state_row, turn_rows)
|
||||
if sess is not None:
|
||||
await _record_session_read_audit(
|
||||
conn,
|
||||
|
|
@ -2926,15 +3029,21 @@ async def _upsert_pinned_fact_candidates(conn: Any, sess: InProcSession) -> None
|
|||
)
|
||||
|
||||
|
||||
def _build_session_summary_write(sess: InProcSession, carry: memory.CarryOver) -> SessionSummaryWrite:
|
||||
def _build_session_summary_write(
|
||||
sess: InProcSession, carry: memory.CarryOver
|
||||
) -> SessionSummaryWrite:
|
||||
digest_input = memory.build_session_digest_input(
|
||||
session_id=sess.session_id,
|
||||
case_id=sess.case_id,
|
||||
session_no=sess.session_no,
|
||||
masked_turns=sess.masked_turns(visible_to="client"),
|
||||
open_threads=carry.compression_job.open_threads if carry.compression_job else [],
|
||||
open_threads=carry.compression_job.open_threads
|
||||
if carry.compression_job
|
||||
else [],
|
||||
)
|
||||
digest_result = memory.build_fallback_digest_result(
|
||||
digest_input, end_state=carry.end_state
|
||||
)
|
||||
digest_result = memory.build_fallback_digest_result(digest_input, end_state=carry.end_state)
|
||||
return SessionSummaryWrite(
|
||||
session_id=sess.session_id,
|
||||
case_id=sess.case_id,
|
||||
|
|
@ -3072,8 +3181,12 @@ async def _list_sessions(
|
|||
) -> tuple[list[InProcSession], bool]:
|
||||
try:
|
||||
get_pool()
|
||||
learner_filter = "WHERE s.learner_id = $1::uuid" if principal.role.value == "learner" else ""
|
||||
query_args: list[object] = [principal.user_id] if principal.role.value == "learner" else []
|
||||
learner_filter = (
|
||||
"WHERE s.learner_id = $1::uuid" if principal.role.value == "learner" else ""
|
||||
)
|
||||
query_args: list[object] = (
|
||||
[principal.user_id] if principal.role.value == "learner" else []
|
||||
)
|
||||
limit_clause = ""
|
||||
if session_limit is not None:
|
||||
query_args.append(max(1, int(session_limit)))
|
||||
|
|
@ -3085,40 +3198,9 @@ async def _list_sessions(
|
|||
) as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT
|
||||
s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
|
||||
s.session_no, s.theory_mode, s.started_at, s.ended_at,
|
||||
s.prev_rapport_credit,
|
||||
COALESCE(
|
||||
NULLIF(learner.display_name, ''),
|
||||
NULLIF(learner.nickname, ''),
|
||||
NULLIF(learner.email, ''),
|
||||
s.learner_id::text
|
||||
) AS learner_label,
|
||||
pc.persona_id AS card_persona_id,
|
||||
pc.code AS card_code,
|
||||
pc.version AS card_version,
|
||||
pc.status AS card_status,
|
||||
pc.display_name AS card_display_name,
|
||||
pc.difficulty AS card_difficulty,
|
||||
pc.theory_target AS card_theory_target,
|
||||
pc.demographics AS card_demographics,
|
||||
pc.presenting AS card_presenting,
|
||||
pc.history AS card_history,
|
||||
pc.big5 AS card_big5,
|
||||
pc.resistance AS card_resistance,
|
||||
pc.speech_style AS card_speech_style,
|
||||
pc.affect_baseline AS card_affect_baseline,
|
||||
pc.ccd AS card_ccd,
|
||||
pc.dsm5_dimensional AS card_dsm5_dimensional,
|
||||
pc.triggers AS card_triggers,
|
||||
pc.source_provenance AS card_source_provenance,
|
||||
pc.is_synthetic AS card_is_synthetic
|
||||
SELECT {SESSION_PERSONA_SELECT_COLUMNS_SQL}
|
||||
FROM app.sessions s
|
||||
LEFT JOIN app.persona_card pc
|
||||
ON pc.persona_id = s.persona_id
|
||||
AND pc.version = s.persona_version
|
||||
LEFT JOIN app.app_user learner ON learner.user_id = s.learner_id
|
||||
{SESSION_PERSONA_JOINS_SQL}
|
||||
{learner_filter}
|
||||
ORDER BY s.started_at DESC
|
||||
{limit_clause}
|
||||
|
|
@ -3127,32 +3209,9 @@ async def _list_sessions(
|
|||
)
|
||||
# N+1 제거: 세션별 state/turns fetch 루프(1+2N 왕복) 대신 id 집합으로 한 번씩 배치 조회.
|
||||
session_ids = [str(row["id"]) for row in rows]
|
||||
states_by_id: dict[str, Any] = {}
|
||||
turns_by_id: dict[str, list] = {}
|
||||
if session_ids:
|
||||
state_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT session_id, stage, turn_seq, effective_openness, rapport_credit, resistance,
|
||||
ideation_stage, turns_in_stage, affect_state
|
||||
FROM app.session_state
|
||||
WHERE session_id = ANY($1::uuid[])
|
||||
""",
|
||||
session_ids,
|
||||
)
|
||||
states_by_id = {str(state_row["session_id"]): state_row for state_row in state_rows}
|
||||
turn_rows_all = await conn.fetch(
|
||||
"""
|
||||
SELECT session_id, id, seq, speaker, stage, text, text_masked, created_at,
|
||||
llm_provider, model, tokens_in, tokens_out, cost_usd,
|
||||
audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to
|
||||
FROM app.turns
|
||||
WHERE session_id = ANY($1::uuid[])
|
||||
ORDER BY session_id, seq
|
||||
""",
|
||||
session_ids,
|
||||
)
|
||||
for turn_row in turn_rows_all:
|
||||
turns_by_id.setdefault(str(turn_row["session_id"]), []).append(turn_row)
|
||||
states_by_id, turns_by_id = await _fetch_session_runtime_rows(
|
||||
conn, session_ids
|
||||
)
|
||||
sessions: list[InProcSession] = []
|
||||
for row in rows:
|
||||
session_id = str(row["id"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue