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 산출물은 커밋에서 제외했다.
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import { api, apiUrl, supplementalApi } from "../../lib/api";
|
|
import type { components } from "../../lib/api.gen";
|
|
|
|
type ApiSchema<Name extends keyof components["schemas"]> =
|
|
components["schemas"][Name];
|
|
|
|
type WireMetadata = ApiSchema<"MultimodalSessionMetadataResponse">;
|
|
type WireRawAudio = ApiSchema<"RawAudioAccessResponse">;
|
|
export type MultimodalConsentRequest = ApiSchema<"MultimodalConsentRequest">;
|
|
export type MultimodalConsentResponse = ApiSchema<"MultimodalConsentResponse">;
|
|
export type MultimodalWithdrawalRequest =
|
|
ApiSchema<"MultimodalWithdrawalRequest">;
|
|
export type MultimodalDeletionRequest =
|
|
ApiSchema<"MultimodalDeletionRequest">;
|
|
export type MultimodalDeletionResponse =
|
|
ApiSchema<"MultimodalDeletionRequestResponse">;
|
|
|
|
export type AllianceAxis = "goal" | "task" | "bond";
|
|
export type AllianceModality = "text" | "voice";
|
|
export type MeasurementStatus = "ready" | "missing" | "error";
|
|
export type ConsentStatus = "granted" | "withdrawn" | "not_granted";
|
|
export type VoiceEventType =
|
|
| "silence"
|
|
| "overlap"
|
|
| "interruption"
|
|
| "prosody"
|
|
| "pace"
|
|
| "audio_quality";
|
|
|
|
export interface MultimodalConsentSnapshot {
|
|
consent_snapshot_id: string;
|
|
sequence_no: number;
|
|
consent_status: ConsentStatus;
|
|
retain_audio: boolean;
|
|
retain_derived_features: boolean;
|
|
transcript_retained: boolean;
|
|
retention_days: number | null;
|
|
policy_version: string;
|
|
reason_code: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface MultimodalTimeline {
|
|
timeline_id: string;
|
|
audio_duration_ms: number;
|
|
clock_version: string;
|
|
word_count: number;
|
|
event_count: number;
|
|
created_at: string;
|
|
derived_features_available: boolean;
|
|
}
|
|
|
|
export interface MultimodalWordTimestamp {
|
|
timeline_id: string;
|
|
word_index: number;
|
|
start_ms: number;
|
|
end_ms: number;
|
|
speaker: "learner" | "client";
|
|
token_hash: string;
|
|
}
|
|
|
|
export interface MultimodalVoiceEvent {
|
|
timeline_id: string;
|
|
event_id: string;
|
|
event_type: VoiceEventType;
|
|
start_ms: number;
|
|
end_ms: number;
|
|
actor: "learner" | "client" | "both" | "channel";
|
|
observed_feature: string;
|
|
uncertainty: number;
|
|
source: "observed_audio_runtime" | "stt_word_timestamps";
|
|
claim_scope: "interaction_signal";
|
|
clinical_claim_allowed: false;
|
|
}
|
|
|
|
export interface MultimodalMeasurement {
|
|
measurement_id: string;
|
|
axis: AllianceAxis;
|
|
modality: AllianceModality;
|
|
status: MeasurementStatus;
|
|
value: number | null;
|
|
confidence: number | null;
|
|
uncertainty: number;
|
|
evidence_refs: string[];
|
|
model_run_id: string | null;
|
|
instrument_id: string;
|
|
instrument_version: string;
|
|
model_name: string;
|
|
prompt_version: string;
|
|
source_kind: string;
|
|
error_code: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface MultimodalFusionDecision {
|
|
fusion_record_id: string;
|
|
axis: AllianceAxis;
|
|
status: MeasurementStatus;
|
|
value: number | null;
|
|
uncertainty: number;
|
|
modalities_used: AllianceModality[];
|
|
measurement_ids: string[];
|
|
fusion_applied: boolean;
|
|
calibration_id: string | null;
|
|
benchmark_version: string | null;
|
|
incremental_gain: number | null;
|
|
counterevidence: string[];
|
|
created_at: string;
|
|
}
|
|
|
|
export interface MultimodalDeletionRecord {
|
|
deletion_request_id: string;
|
|
scopes: Array<"audio" | "derived_features">;
|
|
request_reason: string;
|
|
requested_at: string;
|
|
completed_scopes: Array<"audio" | "derived_features">;
|
|
}
|
|
|
|
export interface RawAudioAsset {
|
|
audio_asset_id: string;
|
|
session_id: string;
|
|
media_type: string;
|
|
byte_size: number;
|
|
duration_ms: number;
|
|
retained_until: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface MultimodalAllianceReadModel {
|
|
session_id: string;
|
|
learner_id: string;
|
|
clinical_claim_allowed: false;
|
|
consent_snapshots: MultimodalConsentSnapshot[];
|
|
timelines: MultimodalTimeline[];
|
|
word_timestamps: MultimodalWordTimestamp[];
|
|
voice_events: MultimodalVoiceEvent[];
|
|
measurements: MultimodalMeasurement[];
|
|
fusion_decisions: MultimodalFusionDecision[];
|
|
deletion_requests: MultimodalDeletionRecord[];
|
|
}
|
|
|
|
type Row = Record<string, unknown>;
|
|
|
|
function objectRows(value: unknown): Row[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter(
|
|
(item): item is Row => Boolean(item) && typeof item === "object",
|
|
);
|
|
}
|
|
|
|
function text(row: Row, key: string, fallback = ""): string {
|
|
const value = row[key];
|
|
return typeof value === "string" ? value : fallback;
|
|
}
|
|
|
|
function optionalText(row: Row, key: string): string | null {
|
|
const value = row[key];
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
function numberValue(row: Row, key: string, fallback = 0): number {
|
|
const value = row[key];
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function optionalNumber(row: Row, key: string): number | null {
|
|
const value = row[key];
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function bool(row: Row, key: string): boolean {
|
|
return row[key] === true;
|
|
}
|
|
|
|
function stringArray(row: Row, key: string): string[] {
|
|
const value = row[key];
|
|
return Array.isArray(value)
|
|
? value.filter((item): item is string => typeof item === "string")
|
|
: [];
|
|
}
|
|
|
|
function isAllianceAxis(value: string): value is AllianceAxis {
|
|
return value === "goal" || value === "task" || value === "bond";
|
|
}
|
|
|
|
function isModality(value: string): value is AllianceModality {
|
|
return value === "text" || value === "voice";
|
|
}
|
|
|
|
function measurementStatus(value: string): MeasurementStatus {
|
|
return value === "ready" || value === "error" ? value : "missing";
|
|
}
|
|
|
|
function consentStatus(value: string): ConsentStatus {
|
|
if (value === "granted" || value === "withdrawn") return value;
|
|
return "not_granted";
|
|
}
|
|
|
|
function voiceEventType(value: string): VoiceEventType {
|
|
const allowed: VoiceEventType[] = [
|
|
"silence",
|
|
"overlap",
|
|
"interruption",
|
|
"prosody",
|
|
"pace",
|
|
"audio_quality",
|
|
];
|
|
return allowed.includes(value as VoiceEventType)
|
|
? (value as VoiceEventType)
|
|
: "audio_quality";
|
|
}
|
|
|
|
function normalizeMetadata(payload: WireMetadata): MultimodalAllianceReadModel {
|
|
const consentSnapshots = objectRows(payload.consent_snapshots).map((row) => ({
|
|
consent_snapshot_id: text(row, "consent_snapshot_id"),
|
|
sequence_no: numberValue(row, "sequence_no"),
|
|
consent_status: consentStatus(text(row, "consent_status")),
|
|
retain_audio: bool(row, "retain_audio"),
|
|
retain_derived_features: bool(row, "retain_derived_features"),
|
|
transcript_retained: bool(row, "transcript_retained"),
|
|
retention_days: optionalNumber(row, "retention_days"),
|
|
policy_version: text(row, "policy_version"),
|
|
reason_code: optionalText(row, "reason_code"),
|
|
created_at: text(row, "created_at"),
|
|
}));
|
|
const timelines = objectRows(payload.timelines).map((row) => ({
|
|
timeline_id: text(row, "timeline_id"),
|
|
audio_duration_ms: numberValue(row, "audio_duration_ms"),
|
|
clock_version: text(row, "clock_version"),
|
|
word_count: numberValue(row, "word_count"),
|
|
event_count: numberValue(row, "event_count"),
|
|
created_at: text(row, "created_at"),
|
|
derived_features_available: bool(row, "derived_features_available"),
|
|
}));
|
|
const words = objectRows(payload.word_timestamps)
|
|
.map((row) => ({
|
|
timeline_id: text(row, "timeline_id"),
|
|
word_index: numberValue(row, "word_index"),
|
|
start_ms: numberValue(row, "start_ms"),
|
|
end_ms: numberValue(row, "end_ms"),
|
|
speaker: text(row, "speaker") === "learner" ? "learner" as const : "client" as const,
|
|
token_hash: text(row, "token_hash"),
|
|
}))
|
|
.sort((a, b) => a.word_index - b.word_index);
|
|
const events = objectRows(payload.voice_events).map((row) => ({
|
|
timeline_id: text(row, "timeline_id"),
|
|
event_id: text(row, "event_id"),
|
|
event_type: voiceEventType(text(row, "event_type")),
|
|
start_ms: numberValue(row, "start_ms"),
|
|
end_ms: numberValue(row, "end_ms"),
|
|
actor: (["learner", "client", "both", "channel"].includes(text(row, "actor"))
|
|
? text(row, "actor")
|
|
: "channel") as MultimodalVoiceEvent["actor"],
|
|
observed_feature: text(row, "observed_feature"),
|
|
uncertainty: numberValue(row, "uncertainty", 1),
|
|
source: text(row, "source") === "stt_word_timestamps"
|
|
? "stt_word_timestamps" as const
|
|
: "observed_audio_runtime" as const,
|
|
claim_scope: "interaction_signal" as const,
|
|
clinical_claim_allowed: false as const,
|
|
}));
|
|
const measurements = objectRows(payload.measurements).flatMap((row) => {
|
|
const axis = text(row, "axis");
|
|
const modality = text(row, "modality");
|
|
if (!isAllianceAxis(axis) || !isModality(modality)) return [];
|
|
return [{
|
|
measurement_id: text(row, "measurement_id"),
|
|
axis,
|
|
modality,
|
|
status: measurementStatus(text(row, "status")),
|
|
value: optionalNumber(row, "value"),
|
|
confidence: optionalNumber(row, "confidence"),
|
|
uncertainty: numberValue(row, "uncertainty", 1),
|
|
evidence_refs: stringArray(row, "evidence_refs"),
|
|
model_run_id: optionalText(row, "model_run_id"),
|
|
instrument_id: text(row, "instrument_id"),
|
|
instrument_version: text(row, "instrument_version"),
|
|
model_name: text(row, "model_name"),
|
|
prompt_version: text(row, "prompt_version"),
|
|
source_kind: text(row, "source_kind"),
|
|
error_code: optionalText(row, "error_code"),
|
|
created_at: text(row, "created_at"),
|
|
}];
|
|
});
|
|
const fusions = objectRows(payload.fusion_decisions).flatMap((row) => {
|
|
const axis = text(row, "axis");
|
|
if (!isAllianceAxis(axis)) return [];
|
|
return [{
|
|
fusion_record_id: text(row, "fusion_record_id"),
|
|
axis,
|
|
status: measurementStatus(text(row, "status")),
|
|
value: optionalNumber(row, "value"),
|
|
uncertainty: numberValue(row, "uncertainty", 1),
|
|
modalities_used: stringArray(row, "modalities_used").filter(isModality),
|
|
measurement_ids: stringArray(row, "measurement_ids"),
|
|
fusion_applied: bool(row, "fusion_applied"),
|
|
calibration_id: optionalText(row, "calibration_id"),
|
|
benchmark_version: optionalText(row, "benchmark_version"),
|
|
incremental_gain: optionalNumber(row, "incremental_gain"),
|
|
counterevidence: stringArray(row, "counterevidence"),
|
|
created_at: text(row, "created_at"),
|
|
}];
|
|
});
|
|
const deletionRequests = objectRows(payload.deletion_requests).map((row) => ({
|
|
deletion_request_id: text(row, "deletion_request_id"),
|
|
scopes: stringArray(row, "scopes").filter(
|
|
(scope): scope is "audio" | "derived_features" =>
|
|
scope === "audio" || scope === "derived_features",
|
|
),
|
|
request_reason: text(row, "request_reason"),
|
|
requested_at: text(row, "requested_at"),
|
|
completed_scopes: stringArray(row, "completed_scopes").filter(
|
|
(scope): scope is "audio" | "derived_features" =>
|
|
scope === "audio" || scope === "derived_features",
|
|
),
|
|
}));
|
|
|
|
return {
|
|
session_id: payload.session_id,
|
|
learner_id: payload.learner_id,
|
|
clinical_claim_allowed: false,
|
|
consent_snapshots: consentSnapshots,
|
|
timelines,
|
|
word_timestamps: words,
|
|
voice_events: events,
|
|
measurements,
|
|
fusion_decisions: fusions,
|
|
deletion_requests: deletionRequests,
|
|
};
|
|
}
|
|
|
|
function normalizeRawAudio(payload: WireRawAudio): RawAudioAsset[] {
|
|
return objectRows(payload.items).map((row) => ({
|
|
audio_asset_id: text(row, "audio_asset_id"),
|
|
session_id: text(row, "session_id"),
|
|
media_type: text(row, "media_type"),
|
|
byte_size: numberValue(row, "byte_size"),
|
|
duration_ms: numberValue(row, "duration_ms"),
|
|
retained_until: text(row, "retained_until"),
|
|
created_at: text(row, "created_at"),
|
|
}));
|
|
}
|
|
|
|
function basePath(sessionId: string): string {
|
|
return `/sessions/${encodeURIComponent(sessionId)}/multimodal-alliance`;
|
|
}
|
|
|
|
export function rawAudioPlaybackUrl(
|
|
sessionId: string,
|
|
audioAssetId: string,
|
|
): string {
|
|
return apiUrl(
|
|
`${basePath(sessionId)}/raw-audio/${encodeURIComponent(audioAssetId)}`,
|
|
);
|
|
}
|
|
|
|
export const multimodalAllianceApi = {
|
|
getMetadata: (sessionId: string, signal?: AbortSignal) =>
|
|
supplementalApi
|
|
.get<WireMetadata>(basePath(sessionId), { signal })
|
|
.then(normalizeMetadata),
|
|
getRawAudio: (sessionId: string, signal?: AbortSignal) =>
|
|
supplementalApi
|
|
.get<WireRawAudio>(`${basePath(sessionId)}/raw-audio`, { signal })
|
|
.then(normalizeRawAudio),
|
|
saveConsent: (sessionId: string, body: MultimodalConsentRequest) =>
|
|
api.post<MultimodalConsentResponse>(`${basePath(sessionId)}/consent`, body),
|
|
withdraw: (sessionId: string, body: MultimodalWithdrawalRequest) =>
|
|
api.post<MultimodalConsentResponse>(`${basePath(sessionId)}/withdraw`, body),
|
|
requestDeletion: (sessionId: string, body: MultimodalDeletionRequest) =>
|
|
api.post<MultimodalDeletionResponse>(
|
|
`${basePath(sessionId)}/deletion-requests`,
|
|
body,
|
|
),
|
|
};
|