음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -15,17 +15,20 @@ from typing import Any, Iterable, Literal, cast
from .config import settings
from .db import acquire, get_pool
from .paths import repo_path
from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona
from .services.voice import resolve_voice
SEED_VERSION = 1
REPO_PERSONA_DIR = Path(__file__).resolve().parents[3] / "data" / "personas"
REPO_PERSONA_DIR = repo_path("data", "personas")
PersonaStatus = Literal["draft", "review", "approved", "archived"]
_CARD_COLUMNS = """
persona_id, code, version, status, display_name, difficulty, theory_target,
demographics, presenting, history, big5, resistance, speech_style,
affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic
affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic,
triggers
"""
_REVIEW_COLUMNS = """
@ -47,6 +50,13 @@ class CatalogPersona:
degraded: bool = False
@dataclass(frozen=True, slots=True)
class PersonaVoiceMap:
provider: str
voice_id: str
base_params: dict[str, Any]
@dataclass(frozen=True, slots=True)
class PersonaReviewItem:
persona_id: str
@ -89,6 +99,7 @@ def _card_from_payload(payload: dict[str, Any]) -> PersonaCard:
dsm5_dimensional=_json_dict(payload.get("dsm5_dimensional")),
source_provenance=str(payload.get("source_provenance") or ""),
is_synthetic=bool(payload.get("is_synthetic", True)),
triggers=_json_dict(payload.get("triggers")),
)
@ -162,6 +173,7 @@ def card_from_row(row: Any) -> PersonaCard:
dsm5_dimensional=_json_dict(row["dsm5_dimensional"]),
source_provenance=str(row["source_provenance"] or ""),
is_synthetic=bool(row["is_synthetic"]),
triggers=_json_dict(row["triggers"]),
)
@ -198,6 +210,14 @@ def persona_draft_record_from_row(row: Any) -> PersonaDraftRecord:
)
def persona_voice_map_from_row(row: Any) -> PersonaVoiceMap:
return PersonaVoiceMap(
provider=str(row["provider"]),
voice_id=str(row["voice_id"]),
base_params=_json_dict(row["base_params"]),
)
def seed_fallback_persona(code: str) -> CatalogPersona | None:
normalized = code.strip().upper()
card = get_seed_persona(normalized)
@ -228,45 +248,34 @@ def seed_fallback_personas() -> list[CatalogPersona]:
async def materialize_seed_personas() -> int:
"""Upsert built-in seed personas as approved DB catalog rows."""
"""Materialize built-in seed personas as editable DB catalog rows.
Existing rows belong to the authoring database. Startup seeding must not
overwrite faculty edits or resurrect archived personas.
"""
get_pool()
count = 0
async with acquire(role="admin") as conn:
for card in built_in_personas():
persona_id = seed_persona_id(card.code)
await conn.execute(
"""
INSERT INTO app.persona_card (
persona_id, code, version, status, display_name, difficulty,
theory_target, demographics, presenting, history, big5,
resistance, speech_style, affect_baseline, ccd,
dsm5_dimensional, source_provenance, is_synthetic,
dsm5_dimensional, source_provenance, is_synthetic, triggers,
approved_at
)
VALUES (
$1::uuid, $2, $3, 'approved', $4, $5,
$6::text[], $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb,
$11::jsonb, $12::jsonb, $13::jsonb, $14::jsonb,
$15::jsonb, $16, $17, now()
$15::jsonb, $16, $17, $18::jsonb, now()
)
ON CONFLICT (code, version) DO UPDATE SET
status = 'approved',
display_name = EXCLUDED.display_name,
difficulty = EXCLUDED.difficulty,
theory_target = EXCLUDED.theory_target,
demographics = EXCLUDED.demographics,
presenting = EXCLUDED.presenting,
history = EXCLUDED.history,
big5 = EXCLUDED.big5,
resistance = EXCLUDED.resistance,
speech_style = EXCLUDED.speech_style,
affect_baseline = EXCLUDED.affect_baseline,
ccd = EXCLUDED.ccd,
dsm5_dimensional = EXCLUDED.dsm5_dimensional,
source_provenance = EXCLUDED.source_provenance,
is_synthetic = EXCLUDED.is_synthetic,
approved_at = COALESCE(persona_card.approved_at, now())
ON CONFLICT (code, version) DO NOTHING
""",
seed_persona_id(card.code),
persona_id,
card.code,
SEED_VERSION,
card.display_name,
@ -283,6 +292,25 @@ async def materialize_seed_personas() -> int:
card.dsm5_dimensional,
card.source_provenance,
card.is_synthetic,
card.triggers,
)
voice = resolve_voice(persona_code=card.code)
await conn.execute(
"""
INSERT INTO app.persona_voice_map (
persona_id, version, voice_id, provider, base_params, prosody_map
)
VALUES ($1::uuid, $2, $3, 'openai', $4::jsonb, '{}'::jsonb)
ON CONFLICT (persona_id, version) DO NOTHING
""",
persona_id,
SEED_VERSION,
voice.openai_voice,
{
"preset": voice.preset,
"openai_voice": voice.openai_voice,
"rate": voice.rate,
},
)
count += 1
return count
@ -326,6 +354,45 @@ async def get_approved_persona(code: str) -> CatalogPersona | None:
return catalog_persona_from_row(row) if row is not None else None
async def get_persona_voice_map(
*,
persona_id: str | None,
version: int | None,
) -> PersonaVoiceMap | None:
if not persona_id or version is None:
return None
get_pool()
async with acquire(ai_context=True) as conn:
row = await conn.fetchrow(
"""
SELECT provider, voice_id, base_params
FROM app.persona_voice_map
WHERE persona_id = $1::uuid
AND version = $2
""",
persona_id,
version,
)
return persona_voice_map_from_row(row) if row is not None else None
async def get_session_voice_map(session_id: str) -> PersonaVoiceMap | None:
get_pool()
async with acquire(ai_context=True) as conn:
row = await conn.fetchrow(
"""
SELECT pvm.provider, pvm.voice_id, pvm.base_params
FROM app.sessions AS s
JOIN app.persona_voice_map AS pvm
ON pvm.persona_id = s.persona_id
AND pvm.version = s.persona_version
WHERE s.id = $1::uuid
""",
session_id,
)
return persona_voice_map_from_row(row) if row is not None else None
async def list_persona_review_queue(
*,
role: str,
@ -409,13 +476,14 @@ async def create_persona_draft(
persona_id, code, version, status, display_name, difficulty,
theory_target, demographics, presenting, history, big5,
resistance, speech_style, affect_baseline, ccd,
dsm5_dimensional, source_provenance, is_synthetic, created_by
dsm5_dimensional, source_provenance, is_synthetic, triggers,
created_by
)
VALUES (
$1::uuid, $2, $3, $4, $5, $6,
$7::text[], $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb,
$12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb,
$16::jsonb, $17, $18, $19::uuid
$16::jsonb, $17, $18, $19::jsonb, $20::uuid
)
RETURNING {_REVIEW_COLUMNS}
""",
@ -437,6 +505,7 @@ async def create_persona_draft(
card.dsm5_dimensional,
card.source_provenance,
card.is_synthetic,
card.triggers,
author_id,
)
await conn.execute(
@ -459,6 +528,113 @@ async def create_persona_draft(
return persona_review_item_from_row(row)
async def create_persona_revision_from_existing(
*,
persona_id: str,
author_id: str,
role: str,
submit_for_review: bool = False,
) -> PersonaDraftRecord | None:
"""Clone an approved persona into a new editable draft version."""
if role not in {"teacher", "admin"}:
raise ValueError("persona revision creation requires teacher or admin role")
next_status = "review" if submit_for_review else "draft"
get_pool()
async with acquire(role=role, user_id=author_id) as conn:
source = await conn.fetchrow(
f"""
SELECT {_CARD_COLUMNS}, created_at, approved_at
FROM app.persona_card
WHERE persona_id = $1::uuid
AND status = 'approved'
ORDER BY version DESC
LIMIT 1
""",
persona_id,
)
if source is None:
return None
card = card_from_row(source)
existing = await conn.fetchrow(
f"""
SELECT {_CARD_COLUMNS}, created_at, approved_at
FROM app.persona_card
WHERE upper(code) = upper($1)
AND status IN ('draft', 'review')
ORDER BY version DESC
LIMIT 1
""",
card.code,
)
if existing is not None:
return persona_draft_record_from_row(existing)
version = await conn.fetchval(
"""
SELECT COALESCE(MAX(version), 0) + 1
FROM app.persona_card
WHERE upper(code) = upper($1)
""",
card.code,
)
row = await conn.fetchrow(
f"""
INSERT INTO app.persona_card (
persona_id, code, version, status, display_name, difficulty,
theory_target, demographics, presenting, history, big5,
resistance, speech_style, affect_baseline, ccd,
dsm5_dimensional, source_provenance, is_synthetic, triggers,
created_by
)
VALUES (
$1::uuid, $2, $3, $4, $5, $6,
$7::text[], $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb,
$12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb,
$16::jsonb, $17, $18, $19::jsonb, $20::uuid
)
RETURNING {_CARD_COLUMNS}, created_at, approved_at
""",
persona_id,
card.code,
int(version or 1),
next_status,
card.display_name,
card.difficulty,
card.theory_target,
card.demographics,
card.presenting,
card.history,
card.big5,
card.resistance,
card.speech_style,
card.affect_baseline,
card.ccd,
card.dsm5_dimensional,
card.source_provenance,
card.is_synthetic,
card.triggers,
author_id,
)
await conn.execute(
"""
INSERT INTO audit.audit_log (
actor_uid, action, target_kind, target_id, detail
)
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
""",
author_id,
"persona_revision_create",
"persona_card",
persona_id,
{
"next_status": next_status,
"code": card.code,
"version": int(row["version"]),
},
)
return persona_draft_record_from_row(row)
async def update_persona_draft(
*,
persona_id: str,
@ -493,6 +669,7 @@ async def update_persona_draft(
dsm5_dimensional = $15::jsonb,
source_provenance = $16,
is_synthetic = $17,
triggers = $18::jsonb,
approved_by = NULL,
approved_at = NULL
WHERE persona_id = $1::uuid
@ -516,6 +693,7 @@ async def update_persona_draft(
card.dsm5_dimensional,
card.source_provenance,
card.is_synthetic,
card.triggers,
)
if row is None:
return None
@ -594,6 +772,73 @@ async def update_persona_review_status(
return persona_review_item_from_row(row)
async def archive_persona_family(
*,
persona_id: str,
archiver_id: str,
role: str,
) -> PersonaReviewItem | None:
"""Archive every non-archived version for the persona code.
This is the delete operation exposed to faculty. It preserves historical
session foreign keys while removing the persona from the start catalog.
"""
if role not in {"teacher", "admin"}:
raise ValueError("persona archive requires teacher or admin role")
get_pool()
async with acquire(role=role, user_id=archiver_id) as conn:
row = await conn.fetchrow(
f"""
WITH target AS (
SELECT code
FROM app.persona_card
WHERE persona_id = $1::uuid
AND status <> 'archived'
LIMIT 1
),
archived AS (
UPDATE app.persona_card AS card
SET
status = 'archived',
approved_by = NULL,
approved_at = NULL
FROM target
WHERE upper(card.code) = upper(target.code)
AND card.status <> 'archived'
RETURNING {_REVIEW_COLUMNS}
)
SELECT {_REVIEW_COLUMNS}
FROM archived
WHERE persona_id = $1::uuid
ORDER BY version DESC
LIMIT 1
""",
persona_id,
)
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)
""",
archiver_id,
"persona_archive",
"persona_card",
persona_id,
{
"next_status": "archived",
"code": str(row["code"]).upper(),
"version": int(row["version"]),
"scope": "code_family",
},
)
return persona_review_item_from_row(row)
async def list_catalog_personas() -> list[CatalogPersona]:
try:
return await list_approved_personas()
@ -619,20 +864,26 @@ __all__ = [
"PersonaReviewAction",
"PersonaStatus",
"SEED_VERSION",
"archive_persona_family",
"built_in_personas",
"card_from_row",
"catalog_persona_from_row",
"create_persona_draft",
"create_persona_revision_from_existing",
"get_approved_persona",
"get_catalog_persona",
"get_persona_draft_record",
"get_persona_voice_map",
"get_session_voice_map",
"list_approved_personas",
"list_catalog_personas",
"list_persona_review_queue",
"load_file_personas",
"materialize_seed_personas",
"persona_draft_record_from_row",
"persona_voice_map_from_row",
"persona_review_item_from_row",
"PersonaVoiceMap",
"seed_fallback_persona",
"seed_fallback_personas",
"seed_persona_id",