개선관리 요구사항과 Google 로그인을 완료

This commit is contained in:
Yun Chan 2026-08-28 16:07:09 +09:00
parent cc0a15b7c6
commit 2a39636163
112 changed files with 10166 additions and 527 deletions

View file

@ -33,6 +33,11 @@ from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
logger = logging.getLogger(__name__)
class SessionCreationPersistenceError(RuntimeError):
"""fail-closed 환경에서 영속 세션 생성이 실패했다."""
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
_CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_REVIEW_STATUS_CACHE: dict[str, dict[str, Any]] = {}
@ -54,8 +59,9 @@ 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.persona_id AS session_persona_id, s.persona_version AS session_persona_version,
s.session_no, s.theory_mode, s.started_at, s.ended_at,
s.prev_rapport_credit, s.session_goals,
s.prev_rapport_credit, s.session_goals, s.learner_feedback_enabled,
COALESCE(
NULLIF(learner.display_name, ''),
NULLIF(learner.nickname, ''),
@ -148,6 +154,8 @@ class SessionEvaluationWrite:
stage: str
payload: dict[str, Any]
error: str | None = None
counselor_identity: str | None = None
client_identity: str | None = None
@classmethod
def from_result(
@ -157,6 +165,8 @@ class SessionEvaluationWrite:
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,
@ -165,8 +175,20 @@ class SessionEvaluationWrite:
source=source,
scope=result.scope,
stage=result.stage,
payload=_mask_json_text_values(result.to_dict()),
error=_clean_masked_text(result.error),
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
@ -179,6 +201,8 @@ class SessionEvaluationWrite:
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,
@ -188,7 +212,14 @@ class SessionEvaluationWrite:
scope=scope,
stage=stage,
payload={},
error=_clean_masked_text(_coerce_error_message(error)),
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]:
@ -484,11 +515,11 @@ def _live_coach_payload_from_record(record: dict[str, Any]) -> dict[str, Any]:
return payload
def _row_value(row, key: str):
def _row_value(row, key: str, default: Any = None):
try:
return row[key]
except Exception:
return None
return default
def _card_from_joined_session_row(row) -> PersonaCard | None:
@ -519,15 +550,72 @@ def _clean_masked_text(value: Any) -> str | None:
return masked or None
def _mask_json_text_values(value: Any) -> Any:
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_masked_text(value) or ""
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) for key, child in value.items()}
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) for child in value]
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) for child in value]
return [
_mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for child in value
]
return value
@ -1666,8 +1754,18 @@ async def ensure_review_tables() -> None:
async def save_session_evaluation(write: SessionEvaluationWrite) -> bool:
write.payload = _mask_json_text_values(write.payload)
write.error = _clean_masked_text(write.error)
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():
_EVALUATION_CACHE[write.session_id] = record
@ -2417,10 +2515,15 @@ async def load_public_session_share(token_hash: str) -> dict[str, Any] | None:
async with acquire(ai_context=True) as conn:
row = await conn.fetchrow(
"""
SELECT session_id, payload, created_at, updated_at, revoked_at
FROM app.session_share_link
WHERE token_hash = $1
AND revoked_at IS NULL
SELECT share.session_id, share.payload, share.created_at,
share.updated_at, share.revoked_at
FROM app.session_share_link AS share
JOIN app.sessions AS session ON session.id = share.session_id
JOIN app.app_user AS learner ON learner.user_id = session.learner_id
WHERE share.token_hash = $1
AND share.revoked_at IS NULL
AND session.learner_feedback_enabled IS TRUE
AND learner.learner_feedback_enabled IS TRUE
""",
token_hash,
)
@ -2433,6 +2536,16 @@ async def load_public_session_share(token_hash: str) -> dict[str, Any] | None:
cached = _SESSION_SHARE_CACHE.get(session_id)
if not cached or cached.get("revoked_at") is not None:
return None
from . import auth_sessions
from .store import store
session = store.get(session_id)
if session is not None:
if not bool(getattr(session, "learner_feedback_enabled", True)):
return None
learner = await auth_sessions.get_managed_user(session.learner_id)
if learner is not None and not learner.learner_feedback_enabled:
return None
return {
"session_id": str(cached["session_id"]),
"payload": dict(cached.get("payload") or {}),
@ -2555,6 +2668,16 @@ def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | N
theory_mode=row["theory_mode"] or "humanistic",
persona=card,
state=_state_from_row(state_row, card),
persona_id=(
str(_row_value(row, "session_persona_id"))
if _row_value(row, "session_persona_id") is not None
else None
),
persona_version=(
int(_row_value(row, "session_persona_version"))
if _row_value(row, "session_persona_version") is not None
else None
),
session_no=int(row["session_no"] or 1),
created_at=started_at,
ended_at=ended_at,
@ -2565,6 +2688,9 @@ def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | N
goal_stages=[
str(v) for v in _json_list_payload(_row_value(row, "session_goals"))
],
learner_feedback_enabled=bool(
_row_value(row, "learner_feedback_enabled", True)
),
)
@ -2641,6 +2767,7 @@ async def create_session(
persona_version: int | None = None,
case_id: str | None = None,
goal_stages: list[str] | None = None,
learner_feedback_enabled: bool = True,
) -> InProcSession | None:
"""Create a DB-backed session, returning None when DB persistence is unavailable."""
try:
@ -2685,12 +2812,13 @@ 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_goals
session_no, theory_mode, stage_path, prev_rapport_credit, session_goals,
learner_feedback_enabled
)
VALUES (
$1::uuid, $2::uuid, $3::uuid, $4::uuid, $5,
$6, $7, $8,
$9, $10, '[]'::jsonb, $11, $12::jsonb
$9, $10, '[]'::jsonb, $11, $12::jsonb, $13
)
RETURNING id, runtime_case_id, case_id, learner_id, persona_code,
session_no, theory_mode, started_at, ended_at, prev_rapport_credit
@ -2707,6 +2835,7 @@ async def create_session(
theory_mode,
carry_rapport,
list(goal_stages or []),
learner_feedback_enabled,
)
await _upsert_state(conn, str(row["id"]), state)
return InProcSession(
@ -2717,6 +2846,8 @@ async def create_session(
theory_mode=theory_mode,
persona=card,
state=state,
persona_id=pinned_persona_id,
persona_version=pinned_persona_version,
session_no=int(row["session_no"] or session_no),
created_at=_ts(row["started_at"]) or time.time(),
ended_at=None,
@ -2724,10 +2855,21 @@ async def create_session(
ended=False,
prev_rapport_credit=carry_rapport,
goal_stages=list(goal_stages or []),
learner_feedback_enabled=learner_feedback_enabled,
)
except Exception:
require_runtime_fallback_allowed("session creation")
return None
except Exception as exc:
logger.exception(
"durable session creation failed",
extra={
"learner_id": learner_id,
"persona_code": card.code,
"persona_id": persona_id,
"persona_version": persona_version,
},
)
if runtime_fallback_allowed():
return None
raise SessionCreationPersistenceError("session_persistence_unavailable") from exc
async def load_session(