개선관리 요구사항과 Google 로그인을 완료
This commit is contained in:
parent
cc0a15b7c6
commit
2a39636163
112 changed files with 10166 additions and 527 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue