전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -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"])