음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue