8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
1269 lines
45 KiB
Python
1269 lines
45 KiB
Python
"""Postgres persistence for the G7 consent-bound multimodal ledger."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from collections.abc import Mapping, Sequence
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
|
|
|
import asyncpg
|
|
|
|
from .. import db
|
|
from ..config import settings
|
|
from ..contracts.multimodal_alliance import (
|
|
AlignedVoiceTimeline,
|
|
FusionCalibration,
|
|
ModalityAxisMeasurement,
|
|
)
|
|
from ..deps import Principal, Role
|
|
from .multimodal_alliance import build_calibrated_axis_read_model
|
|
|
|
|
|
class MultimodalAllianceError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class MultimodalAllianceNotFoundError(MultimodalAllianceError):
|
|
pass
|
|
|
|
|
|
class MultimodalAllianceConflictError(MultimodalAllianceError):
|
|
pass
|
|
|
|
|
|
class MultimodalAllianceStateError(MultimodalAllianceError):
|
|
pass
|
|
|
|
|
|
class MultimodalConsentRequiredError(MultimodalAllianceStateError):
|
|
pass
|
|
|
|
|
|
class MultimodalConsentWithdrawnError(MultimodalAllianceStateError):
|
|
pass
|
|
|
|
|
|
def _value(row: Any, key: str, default: Any = None) -> Any:
|
|
if row is None:
|
|
return default
|
|
if isinstance(row, Mapping):
|
|
return row.get(key, default)
|
|
try:
|
|
return row[key]
|
|
except (KeyError, TypeError):
|
|
return getattr(row, key, default)
|
|
|
|
|
|
def _canonical_hash(payload: object) -> str:
|
|
encoded = json.dumps(
|
|
payload,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
default=str,
|
|
).encode("utf-8")
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
async def _visible_session(conn: asyncpg.Connection, session_id: UUID) -> Any:
|
|
row = await conn.fetchrow(
|
|
"SELECT id, learner_id FROM app.sessions WHERE id = $1",
|
|
session_id,
|
|
)
|
|
if row is None:
|
|
raise MultimodalAllianceNotFoundError("session not found or not visible")
|
|
return row
|
|
|
|
|
|
async def _latest_consent(conn: asyncpg.Connection, session_id: UUID) -> Any:
|
|
return await conn.fetchrow(
|
|
"""
|
|
SELECT consent_snapshot_id, learner_id, sequence_no, consent_status,
|
|
retain_audio, retain_derived_features, transcript_retained,
|
|
retention_days, policy_version, reason_code, created_at
|
|
FROM app.multimodal_consent_snapshot
|
|
WHERE session_id = $1
|
|
ORDER BY sequence_no DESC
|
|
LIMIT 1
|
|
""",
|
|
session_id,
|
|
)
|
|
|
|
|
|
async def _lock_consent_processing(
|
|
conn: asyncpg.Connection, session_id: UUID
|
|
) -> None:
|
|
"""Serialize consent mutation with all consent-bound persistence.
|
|
|
|
The database triggers still enforce the latest snapshot at INSERT time.
|
|
This shared transaction lock additionally gives concurrent withdrawal and
|
|
evaluator persistence one unambiguous order, so a writer that starts after
|
|
withdrawal commits cannot persist derived voice data from a stale grant.
|
|
"""
|
|
|
|
await conn.execute(
|
|
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
|
|
f"multimodal-consent:{session_id}",
|
|
)
|
|
|
|
|
|
async def _existing_request(
|
|
conn: asyncpg.Connection,
|
|
*,
|
|
submission_id: UUID,
|
|
request_kind: str,
|
|
content_hash: str,
|
|
session_id: UUID,
|
|
learner_id: UUID,
|
|
) -> UUID | None:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT request_kind, content_hash, session_id, learner_id, result_id
|
|
FROM app.multimodal_ingestion_request
|
|
WHERE submission_id = $1
|
|
""",
|
|
submission_id,
|
|
)
|
|
if row is None:
|
|
return None
|
|
if (
|
|
_value(row, "request_kind") != request_kind
|
|
or _value(row, "content_hash") != content_hash
|
|
or UUID(str(_value(row, "session_id"))) != session_id
|
|
or UUID(str(_value(row, "learner_id"))) != learner_id
|
|
):
|
|
raise MultimodalAllianceConflictError(
|
|
"submission_id was already used with different multimodal content"
|
|
)
|
|
return UUID(str(_value(row, "result_id")))
|
|
|
|
|
|
async def _insert_request(
|
|
conn: asyncpg.Connection,
|
|
*,
|
|
submission_id: UUID,
|
|
content_hash: str,
|
|
request_kind: str,
|
|
session_id: UUID,
|
|
learner_id: UUID,
|
|
result_id: UUID,
|
|
created_by_role: str,
|
|
) -> None:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.multimodal_ingestion_request (
|
|
submission_id, content_hash, request_kind, session_id, learner_id,
|
|
result_id, created_by_role
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
""",
|
|
submission_id,
|
|
content_hash,
|
|
request_kind,
|
|
session_id,
|
|
learner_id,
|
|
result_id,
|
|
created_by_role,
|
|
)
|
|
|
|
|
|
def _withdrawal_ids(submission_id: UUID) -> tuple[UUID, UUID]:
|
|
deletion_submission_id = uuid5(
|
|
NAMESPACE_URL, f"vignette:g7:withdrawal-submission:{submission_id}"
|
|
)
|
|
deletion_request_id = uuid5(
|
|
NAMESPACE_URL, f"vignette:g7:withdrawal-request:{submission_id}"
|
|
)
|
|
return deletion_submission_id, deletion_request_id
|
|
|
|
|
|
async def _insert_deletion_request(
|
|
conn: asyncpg.Connection,
|
|
*,
|
|
submission_id: UUID,
|
|
deletion_request_id: UUID,
|
|
session_id: UUID,
|
|
learner_id: UUID,
|
|
scopes: Sequence[str],
|
|
request_reason: str,
|
|
requested_by_uid: UUID,
|
|
created_by_role: str,
|
|
) -> tuple[UUID, bool]:
|
|
unique_scopes = tuple(sorted(set(scopes)))
|
|
if not unique_scopes or not set(unique_scopes) <= {
|
|
"audio",
|
|
"derived_features",
|
|
"transcript",
|
|
}:
|
|
raise MultimodalAllianceStateError("unsupported deletion scope")
|
|
payload = {
|
|
"session_id": str(session_id),
|
|
"learner_id": str(learner_id),
|
|
"scopes": unique_scopes,
|
|
"request_reason": request_reason,
|
|
"requested_by_uid": str(requested_by_uid),
|
|
}
|
|
content_hash = _canonical_hash(payload)
|
|
existing = await _existing_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
request_kind="deletion_request",
|
|
content_hash=content_hash,
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
)
|
|
if existing is not None:
|
|
return existing, True
|
|
await _insert_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
content_hash=content_hash,
|
|
request_kind="deletion_request",
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
result_id=deletion_request_id,
|
|
created_by_role=created_by_role,
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.multimodal_deletion_request (
|
|
deletion_request_id, submission_id, session_id, learner_id,
|
|
scopes, request_reason, requested_by_uid
|
|
) VALUES ($1,$2,$3,$4,$5::text[],$6,$7)
|
|
""",
|
|
deletion_request_id,
|
|
submission_id,
|
|
session_id,
|
|
learner_id,
|
|
list(unique_scopes),
|
|
request_reason,
|
|
requested_by_uid,
|
|
)
|
|
return deletion_request_id, False
|
|
|
|
|
|
async def append_consent_snapshot(
|
|
*,
|
|
principal: Principal,
|
|
session_id: UUID,
|
|
submission_id: UUID,
|
|
consent_status: str,
|
|
retain_audio: bool,
|
|
retain_derived_features: bool,
|
|
transcript_retained: bool,
|
|
retention_days: int | None,
|
|
policy_version: str,
|
|
reason_code: str | None,
|
|
) -> dict[str, Any]:
|
|
if principal.role != Role.LEARNER:
|
|
raise MultimodalAllianceStateError(
|
|
"only the learner may change multimodal consent"
|
|
)
|
|
if consent_status not in {"granted", "withdrawn", "not_granted"}:
|
|
raise MultimodalAllianceStateError("unsupported consent status")
|
|
if not transcript_retained:
|
|
raise MultimodalAllianceStateError(
|
|
"G7 deletes audio and derived features only; masked transcript remains retained"
|
|
)
|
|
if consent_status == "granted":
|
|
if not retain_derived_features or retention_days is None:
|
|
raise MultimodalAllianceStateError(
|
|
"granted multimodal consent requires derived retention and expiry"
|
|
)
|
|
elif retain_audio or retain_derived_features or retention_days is not None:
|
|
raise MultimodalAllianceStateError(
|
|
"ungranted consent must not retain audio or derived features"
|
|
)
|
|
learner_id = UUID(principal.user_id)
|
|
payload = {
|
|
"session_id": str(session_id),
|
|
"consent_status": consent_status,
|
|
"retain_audio": retain_audio,
|
|
"retain_derived_features": retain_derived_features,
|
|
"transcript_retained": transcript_retained,
|
|
"retention_days": retention_days,
|
|
"policy_version": policy_version.strip(),
|
|
"reason_code": reason_code.strip() if reason_code else None,
|
|
}
|
|
content_hash = _canonical_hash(payload)
|
|
consent_snapshot_id = uuid4()
|
|
deletion_request_id: UUID | None = None
|
|
async with db.acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
await _lock_consent_processing(conn, session_id)
|
|
session = await _visible_session(conn, session_id)
|
|
if UUID(str(_value(session, "learner_id"))) != learner_id:
|
|
raise MultimodalAllianceNotFoundError("session not found or not visible")
|
|
existing = await _existing_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
request_kind="consent",
|
|
content_hash=content_hash,
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
)
|
|
if existing is not None:
|
|
deletion = await conn.fetchrow(
|
|
"""
|
|
SELECT deletion_request_id FROM app.multimodal_deletion_request
|
|
WHERE session_id = $1 AND request_reason = 'consent_withdrawal'
|
|
ORDER BY requested_at DESC LIMIT 1
|
|
""",
|
|
session_id,
|
|
)
|
|
return {
|
|
"submission_id": submission_id,
|
|
"consent_snapshot_id": existing,
|
|
"consent_status": consent_status,
|
|
"deletion_request_id": (
|
|
UUID(str(_value(deletion, "deletion_request_id")))
|
|
if deletion is not None
|
|
else None
|
|
),
|
|
"idempotent_replay": True,
|
|
}
|
|
latest = await _latest_consent(conn, session_id)
|
|
if latest is not None and _value(latest, "consent_status") == "withdrawn":
|
|
raise MultimodalAllianceConflictError(
|
|
"withdrawn multimodal consent is terminal for this session"
|
|
)
|
|
sequence_no = int(_value(latest, "sequence_no", 0)) + 1
|
|
await _insert_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
content_hash=content_hash,
|
|
request_kind="consent",
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
result_id=consent_snapshot_id,
|
|
created_by_role="learner",
|
|
)
|
|
try:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.multimodal_consent_snapshot (
|
|
consent_snapshot_id, submission_id, session_id, learner_id,
|
|
sequence_no, consent_status, retain_audio,
|
|
retain_derived_features, transcript_retained, retention_days,
|
|
policy_version, reason_code, created_by_uid
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
""",
|
|
consent_snapshot_id,
|
|
submission_id,
|
|
session_id,
|
|
learner_id,
|
|
sequence_no,
|
|
consent_status,
|
|
retain_audio,
|
|
retain_derived_features,
|
|
transcript_retained,
|
|
retention_days,
|
|
payload["policy_version"],
|
|
payload["reason_code"],
|
|
learner_id,
|
|
)
|
|
if consent_status == "withdrawn":
|
|
deletion_submission_id, deletion_request_id = _withdrawal_ids(
|
|
submission_id
|
|
)
|
|
await _insert_deletion_request(
|
|
conn,
|
|
submission_id=deletion_submission_id,
|
|
deletion_request_id=deletion_request_id,
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
scopes=("audio", "derived_features"),
|
|
request_reason="consent_withdrawal",
|
|
requested_by_uid=learner_id,
|
|
created_by_role="learner",
|
|
)
|
|
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
|
|
raise MultimodalAllianceStateError(
|
|
"multimodal consent violated ownership or retention invariants"
|
|
) from exc
|
|
except asyncpg.UniqueViolationError as exc:
|
|
raise MultimodalAllianceConflictError(
|
|
"multimodal consent submission already exists"
|
|
) from exc
|
|
return {
|
|
"submission_id": submission_id,
|
|
"consent_snapshot_id": consent_snapshot_id,
|
|
"consent_status": consent_status,
|
|
"deletion_request_id": deletion_request_id,
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
|
|
async def assert_voice_processing_allowed(
|
|
*, principal: Principal, session_id: str
|
|
) -> dict[str, Any] | None:
|
|
"""Fail closed for enrolled sessions after refusal/withdrawal.
|
|
|
|
A database without the G7 migration is treated as a legacy, non-enrolled
|
|
deployment so the pre-existing voice cascade remains available. Any other
|
|
database error propagates before STT is invoked.
|
|
"""
|
|
|
|
try:
|
|
session_uuid = UUID(session_id)
|
|
except ValueError:
|
|
return None
|
|
try:
|
|
async with db.acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
await _visible_session(conn, session_uuid)
|
|
consent = await _latest_consent(conn, session_uuid)
|
|
except (
|
|
RuntimeError,
|
|
asyncpg.UndefinedTableError,
|
|
MultimodalAllianceNotFoundError,
|
|
) as exc:
|
|
if settings.environment == "dev":
|
|
return None
|
|
raise MultimodalAllianceStateError(
|
|
"multimodal consent state is unavailable; voice processing is blocked"
|
|
) from exc
|
|
if consent is None:
|
|
return None
|
|
status = str(_value(consent, "consent_status"))
|
|
if status == "withdrawn":
|
|
raise MultimodalConsentWithdrawnError(
|
|
"multimodal consent was withdrawn; new voice processing is blocked"
|
|
)
|
|
if status != "granted":
|
|
raise MultimodalConsentRequiredError(
|
|
"multimodal voice processing consent was not granted"
|
|
)
|
|
return {
|
|
"consent_snapshot_id": UUID(str(_value(consent, "consent_snapshot_id"))),
|
|
"retain_audio": bool(_value(consent, "retain_audio")),
|
|
"retain_derived_features": bool(_value(consent, "retain_derived_features")),
|
|
"retention_days": int(_value(consent, "retention_days")),
|
|
}
|
|
|
|
|
|
async def append_runtime_timeline(
|
|
*,
|
|
session_id: str,
|
|
submission_id: UUID,
|
|
timeline: AlignedVoiceTimeline,
|
|
audio_asset: Mapping[str, Any] | None,
|
|
) -> dict[str, Any] | None:
|
|
"""Append a consent-bound runtime STT timeline using evaluator RLS.
|
|
|
|
Legacy development sessions may run without the G7 migration. Staging and
|
|
production never reinterpret a missing schema/session as permission.
|
|
"""
|
|
|
|
try:
|
|
session_uuid = UUID(session_id)
|
|
except ValueError as exc:
|
|
if settings.environment == "dev":
|
|
return None
|
|
raise MultimodalAllianceStateError(
|
|
"multimodal timeline session identity is invalid"
|
|
) from exc
|
|
|
|
try:
|
|
async with db.acquire(ai_view="evaluator", ai_context=True) as conn:
|
|
await _visible_session(conn, session_uuid)
|
|
consent = await _latest_consent(conn, session_uuid)
|
|
if consent is None:
|
|
return None
|
|
consent_status = str(_value(consent, "consent_status"))
|
|
if consent_status == "withdrawn":
|
|
raise MultimodalConsentWithdrawnError(
|
|
"multimodal consent was withdrawn; timeline persistence is blocked"
|
|
)
|
|
if consent_status != "granted":
|
|
raise MultimodalConsentRequiredError(
|
|
"multimodal voice processing consent was not granted"
|
|
)
|
|
if not bool(_value(consent, "retain_derived_features")):
|
|
return {
|
|
"submission_id": submission_id,
|
|
"retention": "derived_features_disabled",
|
|
}
|
|
retained_audio_asset = (
|
|
audio_asset if bool(_value(consent, "retain_audio")) else None
|
|
)
|
|
if retained_audio_asset is None and bool(_value(consent, "retain_audio")):
|
|
raise MultimodalAllianceStateError(
|
|
"consented audio retention requires runtime audio metadata"
|
|
)
|
|
return await append_timeline(
|
|
conn=conn,
|
|
session_id=session_uuid,
|
|
submission_id=submission_id,
|
|
timeline=timeline,
|
|
audio_asset=retained_audio_asset,
|
|
)
|
|
except MultimodalAllianceNotFoundError as exc:
|
|
if settings.environment == "dev":
|
|
return None
|
|
raise MultimodalAllianceStateError(
|
|
"multimodal timeline session is unavailable"
|
|
) from exc
|
|
except MultimodalAllianceError:
|
|
raise
|
|
except (RuntimeError, asyncpg.UndefinedTableError) as exc:
|
|
if settings.environment == "dev":
|
|
return None
|
|
raise MultimodalAllianceStateError(
|
|
"multimodal timeline persistence is unavailable"
|
|
) from exc
|
|
|
|
|
|
async def append_timeline(
|
|
*,
|
|
conn: asyncpg.Connection,
|
|
session_id: UUID,
|
|
submission_id: UUID,
|
|
timeline: AlignedVoiceTimeline,
|
|
audio_asset: Mapping[str, Any] | None,
|
|
) -> dict[str, Any]:
|
|
await _lock_consent_processing(conn, session_id)
|
|
await conn.execute(
|
|
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
|
|
f"multimodal-timeline:{session_id}",
|
|
)
|
|
session = await _visible_session(conn, session_id)
|
|
learner_id = UUID(str(_value(session, "learner_id")))
|
|
consent = await _latest_consent(conn, session_id)
|
|
if consent is None or _value(consent, "consent_status") != "granted":
|
|
raise MultimodalConsentRequiredError(
|
|
"latest granted consent is required before voice processing"
|
|
)
|
|
if not bool(_value(consent, "retain_derived_features")):
|
|
raise MultimodalConsentRequiredError(
|
|
"derived feature processing was not granted"
|
|
)
|
|
retain_audio = bool(_value(consent, "retain_audio"))
|
|
if retain_audio != (audio_asset is not None):
|
|
raise MultimodalAllianceStateError(
|
|
"audio metadata must exactly follow the consent retention snapshot"
|
|
)
|
|
payload = {
|
|
"session_id": str(session_id),
|
|
"timeline": timeline.model_dump(mode="json"),
|
|
"audio_asset": dict(audio_asset) if audio_asset is not None else None,
|
|
"consent_snapshot_id": str(_value(consent, "consent_snapshot_id")),
|
|
}
|
|
content_hash = _canonical_hash(payload)
|
|
existing = await _existing_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
request_kind="timeline",
|
|
content_hash=content_hash,
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
)
|
|
if existing is not None:
|
|
return {
|
|
"submission_id": submission_id,
|
|
"timeline_id": existing,
|
|
"idempotent_replay": True,
|
|
}
|
|
timeline_id = uuid4()
|
|
audio_asset_id = uuid4() if audio_asset is not None else None
|
|
await _insert_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
content_hash=content_hash,
|
|
request_kind="timeline",
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
result_id=timeline_id,
|
|
created_by_role="evaluator",
|
|
)
|
|
try:
|
|
if audio_asset is not None:
|
|
retention_days = int(_value(consent, "retention_days"))
|
|
retained_until = datetime.now(UTC) + timedelta(days=retention_days)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.multimodal_audio_asset (
|
|
audio_asset_id, timeline_submission_id, session_id, learner_id,
|
|
consent_snapshot_id, audio_ref, audio_sha256, media_type,
|
|
byte_size, duration_ms, retained_until
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
""",
|
|
audio_asset_id,
|
|
submission_id,
|
|
session_id,
|
|
learner_id,
|
|
_value(consent, "consent_snapshot_id"),
|
|
str(audio_asset["audio_ref"]),
|
|
str(audio_asset["audio_sha256"]),
|
|
str(audio_asset["media_type"]),
|
|
int(audio_asset["byte_size"]),
|
|
timeline.audio_duration_ms,
|
|
retained_until,
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.multimodal_audio_timeline (
|
|
timeline_id, submission_id, session_id, learner_id,
|
|
consent_snapshot_id, audio_asset_id, audio_duration_ms,
|
|
word_count, event_count
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
""",
|
|
timeline_id,
|
|
submission_id,
|
|
session_id,
|
|
learner_id,
|
|
_value(consent, "consent_snapshot_id"),
|
|
audio_asset_id,
|
|
timeline.audio_duration_ms,
|
|
len(timeline.words),
|
|
len(timeline.events),
|
|
)
|
|
for word in timeline.words:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.multimodal_word_timestamp (
|
|
word_timestamp_id, timeline_id, session_id, learner_id,
|
|
word_index, start_ms, end_ms, speaker, token_hash
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
""",
|
|
uuid4(),
|
|
timeline_id,
|
|
session_id,
|
|
learner_id,
|
|
word.word_index,
|
|
word.start_ms,
|
|
word.end_ms,
|
|
word.speaker,
|
|
word.token_hash,
|
|
)
|
|
for event in timeline.events:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.multimodal_voice_event (
|
|
voice_event_record_id, timeline_id, session_id, learner_id,
|
|
event_id, event_type, start_ms, end_ms, actor,
|
|
observed_feature, uncertainty, source
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
|
""",
|
|
uuid4(),
|
|
timeline_id,
|
|
session_id,
|
|
learner_id,
|
|
event.event_id,
|
|
event.event_type,
|
|
event.start_ms,
|
|
event.end_ms,
|
|
event.actor,
|
|
event.observed_feature,
|
|
event.uncertainty,
|
|
event.source,
|
|
)
|
|
await conn.execute(
|
|
"SET CONSTRAINTS app.trg_multimodal_timeline_counts, "
|
|
"app.trg_multimodal_word_counts, app.trg_multimodal_event_counts "
|
|
"IMMEDIATE"
|
|
)
|
|
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
|
|
raise MultimodalAllianceStateError(
|
|
"multimodal timeline violated consent, clock, or observation invariants"
|
|
) from exc
|
|
except asyncpg.UniqueViolationError as exc:
|
|
raise MultimodalAllianceConflictError(
|
|
"multimodal timeline submission already exists"
|
|
) from exc
|
|
return {
|
|
"submission_id": submission_id,
|
|
"timeline_id": timeline_id,
|
|
"audio_asset_id": audio_asset_id,
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
|
|
def _measurement_values(
|
|
measurement: ModalityAxisMeasurement,
|
|
provenance: Mapping[str, str],
|
|
) -> tuple[Any, ...]:
|
|
return (
|
|
measurement.measurement_id,
|
|
measurement.axis,
|
|
measurement.modality,
|
|
measurement.status,
|
|
measurement.value,
|
|
measurement.confidence,
|
|
measurement.uncertainty,
|
|
list(measurement.evidence_refs),
|
|
measurement.model_run_id,
|
|
provenance["instrument_id"],
|
|
provenance["instrument_version"],
|
|
provenance["model_name"],
|
|
provenance["prompt_version"],
|
|
f"model_inferred_{measurement.modality}",
|
|
measurement.error_code,
|
|
)
|
|
|
|
|
|
async def append_measurement_fusion(
|
|
*,
|
|
conn: asyncpg.Connection,
|
|
session_id: UUID,
|
|
submission_id: UUID,
|
|
text: ModalityAxisMeasurement,
|
|
voice: ModalityAxisMeasurement,
|
|
calibration: FusionCalibration,
|
|
text_provenance: Mapping[str, str],
|
|
voice_provenance: Mapping[str, str],
|
|
) -> dict[str, Any]:
|
|
if text.modality != "text" or voice.modality != "voice":
|
|
raise MultimodalAllianceStateError(
|
|
"text and voice measurements must keep independent modality provenance"
|
|
)
|
|
if text.status != "ready":
|
|
raise MultimodalAllianceStateError(
|
|
"a ready text measurement is required as the fail-safe baseline"
|
|
)
|
|
await _lock_consent_processing(conn, session_id)
|
|
session = await _visible_session(conn, session_id)
|
|
learner_id = UUID(str(_value(session, "learner_id")))
|
|
consent = await _latest_consent(conn, session_id)
|
|
consent_status = _value(consent, "consent_status") if consent is not None else None
|
|
if voice.status == "ready" and consent_status != "granted":
|
|
raise MultimodalConsentRequiredError(
|
|
"ready voice measurement requires current granted consent"
|
|
)
|
|
if consent_status == "withdrawn" and voice.status != "missing":
|
|
raise MultimodalConsentWithdrawnError(
|
|
"withdrawal permits only an explicit missing voice measurement"
|
|
)
|
|
result = build_calibrated_axis_read_model(
|
|
text=text,
|
|
voice=voice,
|
|
calibration=calibration,
|
|
)
|
|
payload = {
|
|
"session_id": str(session_id),
|
|
"text": text.model_dump(mode="json"),
|
|
"voice": voice.model_dump(mode="json"),
|
|
"calibration": calibration.model_dump(mode="json"),
|
|
"text_provenance": dict(text_provenance),
|
|
"voice_provenance": dict(voice_provenance),
|
|
}
|
|
content_hash = _canonical_hash(payload)
|
|
await conn.execute(
|
|
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
|
|
f"multimodal-measurement:{submission_id}",
|
|
)
|
|
existing = await _existing_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
request_kind="measurement_fusion",
|
|
content_hash=content_hash,
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
)
|
|
if existing is not None:
|
|
return {
|
|
"submission_id": submission_id,
|
|
"fusion_record_id": existing,
|
|
"result": result.model_dump(mode="json"),
|
|
"idempotent_replay": True,
|
|
}
|
|
fusion_record_id = uuid4()
|
|
await _insert_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
content_hash=content_hash,
|
|
request_kind="measurement_fusion",
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
result_id=fusion_record_id,
|
|
created_by_role="evaluator",
|
|
)
|
|
try:
|
|
for measurement, provenance in (
|
|
(text, text_provenance),
|
|
(voice, voice_provenance),
|
|
):
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.multimodal_axis_measurement (
|
|
measurement_record_id, submission_id, session_id, learner_id,
|
|
measurement_id, axis, modality, status, value, confidence,
|
|
uncertainty, evidence_refs, model_run_id, instrument_id,
|
|
instrument_version, model_name, prompt_version, source_kind,
|
|
error_code
|
|
) VALUES (
|
|
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::text[],$13,$14,$15,
|
|
$16,$17,$18,$19
|
|
)
|
|
""",
|
|
uuid4(),
|
|
submission_id,
|
|
session_id,
|
|
learner_id,
|
|
*_measurement_values(measurement, provenance),
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.multimodal_fusion_decision (
|
|
fusion_record_id, submission_id, session_id, learner_id, axis,
|
|
status, value, uncertainty, modalities_used, measurement_ids,
|
|
fusion_applied, calibration_id, benchmark_version, text_weight,
|
|
voice_weight, text_only_accuracy, fused_accuracy,
|
|
minimum_incremental_gain, incremental_gain, counterevidence
|
|
) VALUES (
|
|
$1,$2,$3,$4,$5,$6,$7,$8,$9::text[],$10::text[],$11,$12,$13,$14,
|
|
$15,$16,$17,$18,$19,$20::text[]
|
|
)
|
|
""",
|
|
fusion_record_id,
|
|
submission_id,
|
|
session_id,
|
|
learner_id,
|
|
result.axis,
|
|
result.status,
|
|
result.value,
|
|
result.uncertainty,
|
|
list(result.modalities_used),
|
|
list(result.measurement_ids),
|
|
result.fusion_applied,
|
|
result.fusion_calibration_id,
|
|
calibration.benchmark_version,
|
|
calibration.text_weight,
|
|
calibration.voice_weight,
|
|
calibration.text_only_accuracy,
|
|
calibration.fused_accuracy,
|
|
calibration.minimum_incremental_gain,
|
|
calibration.incremental_gain,
|
|
list(result.counterevidence),
|
|
)
|
|
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
|
|
raise MultimodalAllianceStateError(
|
|
"multimodal measurement violated provenance or fusion invariants"
|
|
) from exc
|
|
except asyncpg.UniqueViolationError as exc:
|
|
raise MultimodalAllianceConflictError(
|
|
"multimodal measurement submission already exists"
|
|
) from exc
|
|
return {
|
|
"submission_id": submission_id,
|
|
"fusion_record_id": fusion_record_id,
|
|
"result": result.model_dump(mode="json"),
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
|
|
async def append_deletion_request(
|
|
*,
|
|
principal: Principal,
|
|
session_id: UUID,
|
|
submission_id: UUID,
|
|
scopes: Sequence[str],
|
|
request_reason: str = "learner_request",
|
|
) -> dict[str, Any]:
|
|
if principal.role not in {Role.LEARNER, Role.ADMIN}:
|
|
raise MultimodalAllianceStateError(
|
|
"multimodal deletion request requires learner or admin role"
|
|
)
|
|
learner_actor = UUID(principal.user_id)
|
|
deletion_request_id = uuid4()
|
|
async with db.acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
session = await _visible_session(conn, session_id)
|
|
learner_id = UUID(str(_value(session, "learner_id")))
|
|
if principal.role == Role.LEARNER and learner_id != learner_actor:
|
|
raise MultimodalAllianceNotFoundError("session not found or not visible")
|
|
await conn.execute(
|
|
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
|
|
f"multimodal-deletion:{session_id}",
|
|
)
|
|
deletion_request_id, replay = await _insert_deletion_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
deletion_request_id=deletion_request_id,
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
scopes=scopes,
|
|
request_reason=request_reason,
|
|
requested_by_uid=learner_actor,
|
|
created_by_role=principal.role.value,
|
|
)
|
|
return {
|
|
"submission_id": submission_id,
|
|
"deletion_request_id": deletion_request_id,
|
|
"idempotent_replay": replay,
|
|
}
|
|
|
|
|
|
async def request_expired_retention_deletions(
|
|
*,
|
|
conn: asyncpg.Connection,
|
|
as_of: datetime,
|
|
limit: int = 100,
|
|
) -> list[dict[str, Any]]:
|
|
"""Materialize deterministic deletion work for expired raw audio assets."""
|
|
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT a.audio_asset_id, a.session_id, a.learner_id
|
|
FROM app.multimodal_audio_asset a
|
|
WHERE a.retained_until <= $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM audit.multimodal_deletion_tombstone t
|
|
WHERE t.session_id = a.session_id AND t.scope = 'audio'
|
|
)
|
|
ORDER BY a.retained_until, a.audio_asset_id
|
|
LIMIT $2
|
|
""",
|
|
as_of,
|
|
max(1, min(int(limit), 100)),
|
|
)
|
|
results: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
audio_asset_id = UUID(str(_value(row, "audio_asset_id")))
|
|
session_id = UUID(str(_value(row, "session_id")))
|
|
learner_id = UUID(str(_value(row, "learner_id")))
|
|
submission_id = uuid5(
|
|
NAMESPACE_URL,
|
|
f"vignette:g7:retention-submission:{audio_asset_id}",
|
|
)
|
|
deletion_request_id = uuid5(
|
|
NAMESPACE_URL,
|
|
f"vignette:g7:retention-request:{audio_asset_id}",
|
|
)
|
|
request_id, replay = await _insert_deletion_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
deletion_request_id=deletion_request_id,
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
scopes=("audio",),
|
|
request_reason="retention_expired",
|
|
requested_by_uid=learner_id,
|
|
created_by_role="evaluator",
|
|
)
|
|
results.append(
|
|
{
|
|
"audio_asset_id": audio_asset_id,
|
|
"submission_id": submission_id,
|
|
"deletion_request_id": request_id,
|
|
"idempotent_replay": replay,
|
|
}
|
|
)
|
|
return results
|
|
|
|
|
|
async def complete_deletion(
|
|
*,
|
|
conn: asyncpg.Connection,
|
|
deletion_request_id: UUID,
|
|
submission_id: UUID,
|
|
tombstones: Sequence[Mapping[str, Any]],
|
|
actor_uid: UUID | None,
|
|
actor_kind: str,
|
|
) -> dict[str, Any]:
|
|
request = await conn.fetchrow(
|
|
"""
|
|
SELECT deletion_request_id, session_id, learner_id, scopes
|
|
FROM app.multimodal_deletion_request
|
|
WHERE deletion_request_id = $1
|
|
""",
|
|
deletion_request_id,
|
|
)
|
|
if request is None:
|
|
raise MultimodalAllianceNotFoundError(
|
|
"multimodal deletion request not found or not visible"
|
|
)
|
|
requested_scopes = set(_value(request, "scopes", ()))
|
|
ordered_tombstones = tuple(sorted(tombstones, key=lambda item: str(item["scope"])))
|
|
supplied_scopes = {str(item["scope"]) for item in ordered_tombstones}
|
|
if requested_scopes != supplied_scopes or len(supplied_scopes) != len(
|
|
ordered_tombstones
|
|
):
|
|
raise MultimodalAllianceStateError(
|
|
"deletion completion must prove every requested scope exactly once"
|
|
)
|
|
session_id = UUID(str(_value(request, "session_id")))
|
|
learner_id = UUID(str(_value(request, "learner_id")))
|
|
payload = {
|
|
"deletion_request_id": str(deletion_request_id),
|
|
"tombstones": [dict(item) for item in ordered_tombstones],
|
|
"actor_uid": str(actor_uid) if actor_uid else None,
|
|
"actor_kind": actor_kind,
|
|
}
|
|
content_hash = _canonical_hash(payload)
|
|
await conn.execute(
|
|
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
|
|
f"multimodal-deletion-complete:{deletion_request_id}",
|
|
)
|
|
existing = await _existing_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
request_kind="deletion_completion",
|
|
content_hash=content_hash,
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
)
|
|
if existing is not None:
|
|
existing_rows = await conn.fetch(
|
|
"""
|
|
SELECT tombstone_id
|
|
FROM audit.multimodal_deletion_tombstone
|
|
WHERE completion_submission_id = $1
|
|
ORDER BY scope
|
|
""",
|
|
submission_id,
|
|
)
|
|
return {
|
|
"submission_id": submission_id,
|
|
"deletion_request_id": deletion_request_id,
|
|
"tombstone_ids": [
|
|
UUID(str(_value(row, "tombstone_id"))) for row in existing_rows
|
|
]
|
|
or [existing],
|
|
"idempotent_replay": True,
|
|
}
|
|
tombstone_ids = [uuid4() for _ in ordered_tombstones]
|
|
await _insert_request(
|
|
conn,
|
|
submission_id=submission_id,
|
|
content_hash=content_hash,
|
|
request_kind="deletion_completion",
|
|
session_id=session_id,
|
|
learner_id=learner_id,
|
|
result_id=tombstone_ids[0],
|
|
created_by_role="evaluator" if actor_kind == "retention_worker" else "admin",
|
|
)
|
|
try:
|
|
for tombstone_id, item in zip(tombstone_ids, ordered_tombstones, strict=True):
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO audit.multimodal_deletion_tombstone (
|
|
tombstone_id, completion_submission_id, deletion_request_id,
|
|
session_id, learner_id, scope, target_ref_hash,
|
|
deletion_proof, actor_uid, actor_kind, deleted_at
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
""",
|
|
tombstone_id,
|
|
submission_id,
|
|
deletion_request_id,
|
|
session_id,
|
|
learner_id,
|
|
item["scope"],
|
|
item["target_ref_hash"],
|
|
item["deletion_proof"],
|
|
actor_uid,
|
|
actor_kind,
|
|
item["deleted_at"],
|
|
)
|
|
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
|
|
raise MultimodalAllianceStateError(
|
|
"multimodal deletion proof violated audit invariants"
|
|
) from exc
|
|
except asyncpg.UniqueViolationError as exc:
|
|
raise MultimodalAllianceConflictError(
|
|
"multimodal deletion scope was already completed"
|
|
) from exc
|
|
return {
|
|
"submission_id": submission_id,
|
|
"deletion_request_id": deletion_request_id,
|
|
"tombstone_ids": tombstone_ids,
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
|
|
async def read_session_metadata(
|
|
*, principal: Principal, session_id: UUID
|
|
) -> dict[str, Any]:
|
|
async with db.acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
session = await _visible_session(conn, session_id)
|
|
learner_id = UUID(str(_value(session, "learner_id")))
|
|
consent_rows = await conn.fetch(
|
|
"""
|
|
SELECT consent_snapshot_id, sequence_no, consent_status, retain_audio,
|
|
retain_derived_features, transcript_retained, retention_days,
|
|
policy_version, reason_code, created_at
|
|
FROM app.multimodal_consent_snapshot
|
|
WHERE session_id = $1 ORDER BY sequence_no
|
|
""",
|
|
session_id,
|
|
)
|
|
timeline_rows = await conn.fetch(
|
|
"""
|
|
SELECT timeline_id, audio_duration_ms, clock_version, word_count,
|
|
event_count, created_at, derived_features_available
|
|
FROM app.multimodal_session_metadata_v
|
|
WHERE session_id = $1 ORDER BY created_at
|
|
""",
|
|
session_id,
|
|
)
|
|
derived_available = any(
|
|
bool(_value(row, "derived_features_available")) for row in timeline_rows
|
|
)
|
|
derived_deleted = bool(timeline_rows) and not derived_available
|
|
words: Sequence[Any] = ()
|
|
events: Sequence[Any] = ()
|
|
if derived_available:
|
|
words = await conn.fetch(
|
|
"""
|
|
SELECT timeline_id, word_index, start_ms, end_ms, speaker, token_hash
|
|
FROM app.multimodal_word_timestamp
|
|
WHERE session_id = $1 ORDER BY timeline_id, word_index
|
|
""",
|
|
session_id,
|
|
)
|
|
events = await conn.fetch(
|
|
"""
|
|
SELECT timeline_id, event_id, event_type, start_ms, end_ms, actor,
|
|
observed_feature, uncertainty, source, claim_scope,
|
|
clinical_claim_allowed
|
|
FROM app.multimodal_voice_event
|
|
WHERE session_id = $1 ORDER BY timeline_id, start_ms, event_id
|
|
""",
|
|
session_id,
|
|
)
|
|
measurements = await conn.fetch(
|
|
"""
|
|
SELECT measurement_id, axis, modality, status, value, confidence,
|
|
uncertainty, evidence_refs, model_run_id, instrument_id,
|
|
instrument_version, model_name, prompt_version, source_kind,
|
|
error_code, created_at
|
|
FROM app.multimodal_axis_measurement
|
|
WHERE session_id = $1
|
|
AND ($2::boolean = FALSE OR modality = 'text')
|
|
ORDER BY created_at, axis, modality
|
|
""",
|
|
session_id,
|
|
derived_deleted,
|
|
)
|
|
fusions = await conn.fetch(
|
|
"""
|
|
SELECT fusion_record_id, axis, status, value, uncertainty,
|
|
modalities_used, measurement_ids, fusion_applied,
|
|
calibration_id, benchmark_version, incremental_gain,
|
|
counterevidence, created_at
|
|
FROM app.multimodal_fusion_decision
|
|
WHERE session_id = $1
|
|
AND ($2::boolean = FALSE OR NOT ('voice' = ANY(modalities_used)))
|
|
ORDER BY created_at, axis
|
|
""",
|
|
session_id,
|
|
derived_deleted,
|
|
)
|
|
deletions = await conn.fetch(
|
|
"""
|
|
SELECT r.deletion_request_id, r.scopes, r.request_reason, r.requested_at,
|
|
COALESCE(array_agg(t.scope ORDER BY t.scope)
|
|
FILTER (WHERE t.scope IS NOT NULL), '{}') AS completed_scopes
|
|
FROM app.multimodal_deletion_request r
|
|
LEFT JOIN audit.multimodal_deletion_tombstone t
|
|
ON t.deletion_request_id = r.deletion_request_id
|
|
WHERE r.session_id = $1
|
|
GROUP BY r.deletion_request_id, r.scopes, r.request_reason, r.requested_at
|
|
ORDER BY r.requested_at
|
|
""",
|
|
session_id,
|
|
)
|
|
return {
|
|
"session_id": session_id,
|
|
"learner_id": learner_id,
|
|
"clinical_claim_allowed": False,
|
|
"consent_snapshots": [dict(row) for row in consent_rows],
|
|
"timelines": [dict(row) for row in timeline_rows],
|
|
"word_timestamps": [dict(row) for row in words],
|
|
"voice_events": [dict(row) for row in events],
|
|
"measurements": [dict(row) for row in measurements],
|
|
"fusion_decisions": [dict(row) for row in fusions],
|
|
"deletion_requests": [dict(row) for row in deletions],
|
|
}
|
|
|
|
|
|
async def read_raw_audio_access(
|
|
*, principal: Principal, session_id: UUID
|
|
) -> list[dict[str, Any]]:
|
|
if principal.role not in {Role.LEARNER, Role.ADMIN}:
|
|
raise MultimodalAllianceStateError(
|
|
"raw audio access is limited to the learner and administrator"
|
|
)
|
|
async with db.acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
await _visible_session(conn, session_id)
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT audio_asset_id, session_id, learner_id, audio_ref, audio_sha256,
|
|
media_type, byte_size, duration_ms, retained_until, created_at
|
|
FROM app.multimodal_raw_audio_access_v
|
|
WHERE session_id = $1 ORDER BY created_at
|
|
""",
|
|
session_id,
|
|
)
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
async def read_raw_audio_asset(
|
|
*, principal: Principal, session_id: UUID, audio_asset_id: UUID
|
|
) -> dict[str, Any]:
|
|
"""Return one consent-valid raw-audio storage handle behind learner/admin RLS."""
|
|
|
|
if principal.role not in {Role.LEARNER, Role.ADMIN}:
|
|
raise MultimodalAllianceStateError(
|
|
"raw audio access is limited to the learner and administrator"
|
|
)
|
|
async with db.acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
await _visible_session(conn, session_id)
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT audio_asset_id, session_id, learner_id, audio_ref, audio_sha256,
|
|
media_type, byte_size, duration_ms, retained_until, created_at
|
|
FROM app.multimodal_raw_audio_access_v
|
|
WHERE session_id = $1 AND audio_asset_id = $2
|
|
""",
|
|
session_id,
|
|
audio_asset_id,
|
|
)
|
|
if row is None:
|
|
raise MultimodalAllianceNotFoundError(
|
|
"raw audio asset not found, expired, or deleted"
|
|
)
|
|
return dict(row)
|
|
|
|
|
|
__all__ = [
|
|
"MultimodalAllianceConflictError",
|
|
"MultimodalAllianceNotFoundError",
|
|
"MultimodalAllianceStateError",
|
|
"MultimodalConsentRequiredError",
|
|
"MultimodalConsentWithdrawnError",
|
|
"append_consent_snapshot",
|
|
"append_deletion_request",
|
|
"append_measurement_fusion",
|
|
"append_runtime_timeline",
|
|
"append_timeline",
|
|
"assert_voice_processing_allowed",
|
|
"complete_deletion",
|
|
"read_raw_audio_access",
|
|
"read_raw_audio_asset",
|
|
"read_session_metadata",
|
|
"request_expired_retention_deletions",
|
|
]
|