대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Iterable, Literal, cast
|
||||
|
||||
from .config import settings
|
||||
from .db import acquire, get_pool
|
||||
|
|
@ -18,6 +18,7 @@ from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona
|
|||
|
||||
|
||||
SEED_VERSION = 1
|
||||
PersonaStatus = Literal["draft", "review", "approved", "archived"]
|
||||
|
||||
_CARD_COLUMNS = """
|
||||
persona_id, code, version, status, display_name, difficulty, theory_target,
|
||||
|
|
@ -25,6 +26,15 @@ _CARD_COLUMNS = """
|
|||
affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic
|
||||
"""
|
||||
|
||||
_REVIEW_COLUMNS = """
|
||||
persona_id, code, version, status, display_name, difficulty, theory_target,
|
||||
source_provenance, is_synthetic, created_at, approved_at
|
||||
"""
|
||||
|
||||
_PERSONA_STATUSES = {"draft", "review", "approved", "archived"}
|
||||
_REVIEW_QUEUE_STATUSES = ("draft", "review")
|
||||
PersonaReviewAction = Literal["approve", "reject"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CatalogPersona:
|
||||
|
|
@ -35,6 +45,21 @@ class CatalogPersona:
|
|||
degraded: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonaReviewItem:
|
||||
persona_id: str
|
||||
code: str
|
||||
version: int
|
||||
status: PersonaStatus
|
||||
display_name: str
|
||||
difficulty: str
|
||||
theory_target: list[str]
|
||||
source_provenance: str
|
||||
is_synthetic: bool
|
||||
created_at: str | None
|
||||
approved_at: str | None
|
||||
|
||||
|
||||
def seed_persona_id(code: str) -> str:
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:persona:{code.upper()}"))
|
||||
|
||||
|
|
@ -54,6 +79,24 @@ def _string_list(value: Iterable[Any] | None) -> list[str]:
|
|||
return [str(item) for item in value]
|
||||
|
||||
|
||||
def _optional_text(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
isoformat = getattr(value, "isoformat", None)
|
||||
if callable(isoformat):
|
||||
return str(isoformat())
|
||||
return str(value)
|
||||
|
||||
|
||||
def _normalize_statuses(statuses: Iterable[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
for status in statuses:
|
||||
value = str(status).strip().lower()
|
||||
if value in _PERSONA_STATUSES and value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
|
||||
|
||||
def card_from_row(row: Any) -> PersonaCard:
|
||||
return PersonaCard(
|
||||
code=str(row["code"]).upper(),
|
||||
|
|
@ -84,6 +127,22 @@ def catalog_persona_from_row(row: Any) -> CatalogPersona:
|
|||
)
|
||||
|
||||
|
||||
def persona_review_item_from_row(row: Any) -> PersonaReviewItem:
|
||||
return PersonaReviewItem(
|
||||
persona_id=str(row["persona_id"]),
|
||||
code=str(row["code"]).upper(),
|
||||
version=int(row["version"]),
|
||||
status=cast(PersonaStatus, str(row["status"]).lower()),
|
||||
display_name=str(row["display_name"]),
|
||||
difficulty=str(row["difficulty"]),
|
||||
theory_target=_string_list(row["theory_target"]),
|
||||
source_provenance=str(row["source_provenance"] or ""),
|
||||
is_synthetic=bool(row["is_synthetic"]),
|
||||
created_at=_optional_text(row["created_at"]),
|
||||
approved_at=_optional_text(row["approved_at"]),
|
||||
)
|
||||
|
||||
|
||||
def seed_fallback_persona(code: str) -> CatalogPersona | None:
|
||||
card = get_seed_persona(code)
|
||||
if card is None:
|
||||
|
|
@ -209,6 +268,93 @@ async def get_approved_persona(code: str) -> CatalogPersona | None:
|
|||
return catalog_persona_from_row(row) if row is not None else None
|
||||
|
||||
|
||||
async def list_persona_review_queue(
|
||||
*,
|
||||
role: str,
|
||||
statuses: Iterable[str] = _REVIEW_QUEUE_STATUSES,
|
||||
) -> list[PersonaReviewItem]:
|
||||
if role not in {"teacher", "admin"}:
|
||||
raise ValueError("persona review queue requires teacher or admin role")
|
||||
status_values = _normalize_statuses(statuses)
|
||||
if not status_values:
|
||||
return []
|
||||
|
||||
get_pool()
|
||||
async with acquire(role=role) as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_REVIEW_COLUMNS}
|
||||
FROM app.persona_card
|
||||
WHERE status = ANY($1::text[])
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'review' THEN 0
|
||||
WHEN 'draft' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
code,
|
||||
version DESC
|
||||
""",
|
||||
status_values,
|
||||
)
|
||||
return [persona_review_item_from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def update_persona_review_status(
|
||||
*,
|
||||
persona_id: str,
|
||||
action: PersonaReviewAction,
|
||||
reviewer_id: str,
|
||||
role: str,
|
||||
) -> PersonaReviewItem | None:
|
||||
if role not in {"teacher", "admin"}:
|
||||
raise ValueError("persona review update requires teacher or admin role")
|
||||
if action not in {"approve", "reject"}:
|
||||
raise ValueError("unsupported persona review action")
|
||||
|
||||
next_status = "approved" if action == "approve" else "draft"
|
||||
approved_by = reviewer_id if action == "approve" else None
|
||||
approved_at_expr = "now()" if action == "approve" else "NULL"
|
||||
|
||||
get_pool()
|
||||
async with acquire(role=role, user_id=reviewer_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
UPDATE app.persona_card
|
||||
SET
|
||||
status = $2,
|
||||
approved_by = $3::uuid,
|
||||
approved_at = {approved_at_expr}
|
||||
WHERE persona_id = $1::uuid
|
||||
AND status IN ('draft', 'review')
|
||||
RETURNING {_REVIEW_COLUMNS}
|
||||
""",
|
||||
persona_id,
|
||||
next_status,
|
||||
approved_by,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO audit.audit_log (
|
||||
actor_uid, action, target_kind, target_id, detail
|
||||
)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
|
||||
""",
|
||||
reviewer_id,
|
||||
f"persona_{action}",
|
||||
"persona_card",
|
||||
persona_id,
|
||||
{
|
||||
"next_status": next_status,
|
||||
"code": str(row["code"]).upper(),
|
||||
"version": int(row["version"]),
|
||||
},
|
||||
)
|
||||
return persona_review_item_from_row(row)
|
||||
|
||||
|
||||
async def list_catalog_personas() -> list[CatalogPersona]:
|
||||
try:
|
||||
return await list_approved_personas()
|
||||
|
|
@ -229,6 +375,9 @@ async def get_catalog_persona(code: str) -> CatalogPersona | None:
|
|||
|
||||
__all__ = [
|
||||
"CatalogPersona",
|
||||
"PersonaReviewItem",
|
||||
"PersonaReviewAction",
|
||||
"PersonaStatus",
|
||||
"SEED_VERSION",
|
||||
"card_from_row",
|
||||
"catalog_persona_from_row",
|
||||
|
|
@ -236,8 +385,11 @@ __all__ = [
|
|||
"get_catalog_persona",
|
||||
"list_approved_personas",
|
||||
"list_catalog_personas",
|
||||
"list_persona_review_queue",
|
||||
"materialize_seed_personas",
|
||||
"persona_review_item_from_row",
|
||||
"seed_fallback_persona",
|
||||
"seed_fallback_personas",
|
||||
"seed_persona_id",
|
||||
"update_persona_review_status",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue