G0~G8 성과·동맹 측정 OS 작업 일괄 고정

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 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

File diff suppressed because it is too large Load diff

View file

@ -56,6 +56,8 @@ interface RequestOptions {
headers?: Record<string, string>;
/** AbortSignal (취소·타임아웃) */
signal?: AbortSignal;
/** 주 화면이 이미 인증된 뒤 불러오는 보조 표면은 401로 전체 앱을 로그아웃시키지 않는다. */
notifyAuthExpired?: boolean;
}
function joinUrl(path: string): string {
@ -114,7 +116,13 @@ export async function apiFetch<T = unknown>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const { method = "GET", body, headers = {}, signal } = options;
const {
method = "GET",
body,
headers = {},
signal,
notifyAuthExpired: shouldNotifyAuthExpired = true,
} = options;
const finalHeaders: Record<string, string> = {
Accept: "application/json",
@ -137,7 +145,9 @@ export async function apiFetch<T = unknown>(
if (!res.ok) {
const error = await parseError(res);
if (error.status === 401) notifyAuthExpired(path, error);
if (error.status === 401 && shouldNotifyAuthExpired) {
notifyAuthExpired(path, error);
}
throw error;
}
@ -162,6 +172,23 @@ export const api = {
apiFetch<T>(path, { ...opts, method: "DELETE" }),
};
/**
* read model용 .
* · 401 degraded ,
* .
*/
export const supplementalApi = {
get: <T = unknown>(
path: string,
opts?: Omit<RequestOptions, "method" | "body" | "notifyAuthExpired">,
) =>
apiFetch<T>(path, {
...opts,
method: "GET",
notifyAuthExpired: false,
}),
};
/* =====================================================================
(apps/api )
===================================================================== */
@ -401,7 +428,14 @@ export async function openSessionStream(
if (done) break;
buffer += decoder.decode(value, { stream: true });
processBuffer();
if (streamError) break;
if (donePayload || streamError) {
try {
await reader.cancel();
} catch {
// 프로토콜 종료 뒤 전송 계층이 이미 닫혔다면 추가 취소 실패는 무시한다.
}
break;
}
}
buffer += decoder.decode();
processBuffer(true);

View file

@ -0,0 +1,138 @@
/** Outcome & Alliance OS cross-runtime measurement contract types. */
export const MEASUREMENT_SOURCE_KINDS = [
"simulated_state",
"model_inferred",
"agent_reported",
"learner_reported",
"human_rated",
"observed_runtime",
] as const;
export const MEASUREMENT_CONSTRUCTS = [
"working_alliance",
"session_outcome",
"rupture_repair",
"counselor_skill",
"self_calibration",
"transfer",
"simulation_progress",
] as const;
export const MEASUREMENT_PERSPECTIVES = [
"client_agent_report",
"learner_self_report",
"independent_observer",
"supervisor_human",
"client_simulation",
"runtime_observation",
] as const;
export const MEASUREMENT_STATUSES = ["ready", "degraded", "error", "rejected"] as const;
export const MEASUREMENT_INSTRUMENT_KINDS = [
"validated_measure",
"training_metric",
"simulation_signal",
"runtime_metric",
] as const;
export const MEASUREMENT_AI_VIEWS = [
"client",
"counselor",
"evaluator",
"supervisor",
"research",
] as const;
export const MODEL_RUN_STATUSES = ["ready", "degraded", "error"] as const;
export const ALLIANCE_DIMENSIONS = ["goal", "task", "bond"] as const;
export const ALLIANCE_CHECKPOINTS = ["pre", "mid", "post"] as const;
export type MeasurementSourceKind = (typeof MEASUREMENT_SOURCE_KINDS)[number];
export type MeasurementConstruct = (typeof MEASUREMENT_CONSTRUCTS)[number];
export type MeasurementPerspective = (typeof MEASUREMENT_PERSPECTIVES)[number];
export type MeasurementStatus = (typeof MEASUREMENT_STATUSES)[number];
export type MeasurementInstrumentKind = (typeof MEASUREMENT_INSTRUMENT_KINDS)[number];
export type MeasurementAiView = (typeof MEASUREMENT_AI_VIEWS)[number];
export type ModelRunStatus = (typeof MODEL_RUN_STATUSES)[number];
export type AllianceDimension = (typeof ALLIANCE_DIMENSIONS)[number];
export type AllianceCheckpoint = (typeof ALLIANCE_CHECKPOINTS)[number];
export interface AllianceScores {
goal: number;
task: number;
bond: number;
}
export interface AllianceDimensionAssessment {
score: number;
confidence: number;
evidence_turn_indices: number[];
rationale: string;
}
export interface AllianceAgentAssessment {
goal: AllianceDimensionAssessment;
task: AllianceDimensionAssessment;
bond: AllianceDimensionAssessment;
}
export interface MeasurementEvent {
measurement_id: string;
session_id: string;
pulse_id: string | null;
turn_id: string | null;
supersedes_id: string | null;
construct: MeasurementConstruct;
dimension: string;
perspective: MeasurementPerspective;
source_kind: MeasurementSourceKind;
instrument_id: string;
instrument_version: string;
value: number | null;
scale_min: number;
scale_max: number;
confidence: number | null;
status: MeasurementStatus;
error_code: string | null;
evidence_turn_ids: string[];
model_run_id: string | null;
visible_to: MeasurementAiView[];
metadata: Record<string, unknown>;
created_at: string;
}
export interface MeasurementInstrument {
instrument_id: string;
instrument_version: string;
name_ko: string;
instrument_kind: MeasurementInstrumentKind;
construct: MeasurementConstruct;
language: string;
license_id: string | null;
validation_basis: string;
scoring_schema: Record<string, unknown>;
metadata: Record<string, unknown>;
created_at: string;
}
export interface MeasurementModelRun {
model_run_id: string;
session_id: string | null;
turn_id: string | null;
agent_role: "client" | "evaluator" | "coach" | "scenario" | "research";
provider: string;
model: string;
prompt_bundle_id: string;
prompt_bundle_version: string;
prompt_bundle_hash: string;
structured_schema_version: string;
input_evidence_hash: string;
status: ModelRunStatus;
error_code: string | null;
metadata: Record<string, unknown>;
created_at: string;
}

View file

@ -0,0 +1,199 @@
import type { components } from "./api.gen";
type ApiSchema<Name extends keyof components["schemas"]> =
components["schemas"][Name];
export type DeliberatePracticeMode =
ApiSchema<"PracticePrescriptionItem">["activity_mode"];
export type CalibrationPracticeMode =
ApiSchema<"MetacognitivePrescription">["practice_mode"];
export type PracticeLaunchMode =
| DeliberatePracticeMode
| CalibrationPracticeMode;
export type PracticeLaunchNovelty =
ApiSchema<"PracticePrescriptionItem">["scenario_novelty"];
interface PracticeLaunchBase {
sourceSessionId: string;
prescriptionId: string;
criterionId: string;
novelty: PracticeLaunchNovelty;
}
export interface DeliberatePracticeLaunchIntent extends PracticeLaunchBase {
kind: "deliberate";
mode: DeliberatePracticeMode;
suiteId: null;
trialId: null;
}
export interface TransferPracticeLaunchIntent extends PracticeLaunchBase {
kind: "transfer";
mode: CalibrationPracticeMode;
novelty: "unseen_transfer";
suiteId: string;
trialId: string;
}
export type PracticeLaunchIntent =
| DeliberatePracticeLaunchIntent
| TransferPracticeLaunchIntent;
export const PRACTICE_MODE_LABEL: Record<PracticeLaunchMode, string> = {
replay: "장면 다시 보기",
branch: "다른 반응 분기",
constrained_response: "제약 응답",
voice_retry: "음성 재시도",
difficulty_ladder: "난도 사다리",
counterevidence_forecast: "반대근거 예측",
evidence_recall: "근거 회상",
uncertainty_range: "불확실성 범위",
collect_more_evidence: "추가 근거 수집",
};
export const PRACTICE_NOVELTY_LABEL: Record<PracticeLaunchNovelty, string> = {
familiar: "익숙한 장면",
unseen_transfer: "처음 보는 장면",
};
const PRACTICE_CRITERION_LABEL: Record<string, string> = {
"criterion.reflect-and-check": "감정 반영 후 이해 확인",
"competency.empathic_reflection": "공감적 반영",
"competency.open_question": "개방형 질문",
"competency.rupture_repair": "관계 균열 수선",
"competency.collaborative_goal": "협력적 목표 합의",
};
const PRACTICE_COUNTEREVIDENCE_LABEL: Record<string, string> = {
unseen_transfer_not_verified: "미지 사례 전이 아직 미검증",
};
/** 학습자 화면에는 내부 criterion 식별자 대신 관찰할 행동을 표시한다. */
export function practiceCriterionLabel(criterionId: string): string {
return PRACTICE_CRITERION_LABEL[criterionId] ?? "연습 행동 기준 확인";
}
/** 학습자 화면에는 원장 enum을 노출하지 않고 제한의 의미만 표시한다. */
export function practiceCounterevidenceLabel(value: string): string {
return (
PRACTICE_COUNTEREVIDENCE_LABEL[value] ??
"추가 확인이 필요한 반대 근거"
);
}
/** 원본 UUID는 URL에 보존하되 학습자 본문에는 내부 식별자를 노출하지 않는다. */
export const PRACTICE_SOURCE_SESSION_LABEL = "원본 회기 참조";
const OPAQUE_ID_RE = /^[a-z0-9][a-z0-9._:-]{0,199}$/i;
const DELIBERATE_MODES = new Set<DeliberatePracticeMode>([
"replay",
"branch",
"constrained_response",
"voice_retry",
"difficulty_ladder",
]);
const CALIBRATION_MODES = new Set<CalibrationPracticeMode>([
"counterevidence_forecast",
"evidence_recall",
"uncertainty_range",
"collect_more_evidence",
]);
const NOVELTIES = new Set<PracticeLaunchNovelty>([
"familiar",
"unseen_transfer",
]);
function validOpaqueId(value: string): boolean {
return OPAQUE_ID_RE.test(value);
}
export function practiceLaunchSearch(intent: PracticeLaunchIntent): string {
const params = new URLSearchParams({
launch: intent.kind,
prescription: intent.prescriptionId,
source_session: intent.sourceSessionId,
criterion: intent.criterionId,
novelty: intent.novelty,
mode: intent.mode,
});
if (intent.kind === "transfer") {
params.set("suite", intent.suiteId);
params.set("trial", intent.trialId);
}
return params.toString();
}
export function practiceLaunchPath(
intent: PracticeLaunchIntent,
): string | null {
if (!validOpaqueId(intent.sourceSessionId)) return null;
if (
!validOpaqueId(intent.prescriptionId) ||
!validOpaqueId(intent.criterionId)
) {
return null;
}
if (intent.kind === "transfer") {
if (!validOpaqueId(intent.suiteId) || !validOpaqueId(intent.trialId)) {
return null;
}
}
return `/learn/practice?${practiceLaunchSearch(intent)}`;
}
export function parsePracticeLaunchIntent(
searchParams: URLSearchParams,
): PracticeLaunchIntent | null {
const kind = searchParams.get("launch");
if (kind !== "deliberate" && kind !== "transfer") return null;
const sourceSessionId = searchParams.get("source_session")?.trim() ?? "";
const prescriptionId = searchParams.get("prescription")?.trim() ?? "";
const criterionId = searchParams.get("criterion")?.trim() ?? "";
const novelty = searchParams.get("novelty")?.trim() ?? "";
const mode = searchParams.get("mode")?.trim() ?? "";
if (
!validOpaqueId(sourceSessionId) ||
!validOpaqueId(prescriptionId) ||
!validOpaqueId(criterionId) ||
!NOVELTIES.has(novelty as PracticeLaunchNovelty)
) {
return null;
}
if (kind === "deliberate") {
if (!DELIBERATE_MODES.has(mode as DeliberatePracticeMode)) return null;
if (searchParams.has("suite") || searchParams.has("trial")) return null;
return {
kind,
sourceSessionId,
prescriptionId,
criterionId,
novelty: novelty as PracticeLaunchNovelty,
mode: mode as DeliberatePracticeMode,
suiteId: null,
trialId: null,
};
}
const suiteId = searchParams.get("suite")?.trim() ?? "";
const trialId = searchParams.get("trial")?.trim() ?? "";
if (
novelty !== "unseen_transfer" ||
!CALIBRATION_MODES.has(mode as CalibrationPracticeMode) ||
!validOpaqueId(suiteId) ||
!validOpaqueId(trialId)
) {
return null;
}
return {
kind,
sourceSessionId,
prescriptionId,
criterionId,
novelty,
mode: mode as CalibrationPracticeMode,
suiteId,
trialId,
};
}

View file

@ -0,0 +1,104 @@
export type VoicePracticeSceneType =
| "silence"
| "overlap"
| "interruption"
| "prosody"
| "pace"
| "audio_quality";
export interface VoicePracticeContext {
mode: "voice";
sourceSessionId: string;
sourceSceneId: string | null;
sceneType: VoicePracticeSceneType | null;
sceneStartMs: number | null;
sceneEndMs: number | null;
}
const SOURCE_SESSION_ID_RE = /^[a-z0-9][a-z0-9_-]{0,127}$/i;
const SCENE_ID_RE = /^oas-g7-event-[a-z0-9-]+$/;
const SCENE_TYPES = new Set<VoicePracticeSceneType>([
"silence",
"overlap",
"interruption",
"prosody",
"pace",
"audio_quality",
]);
function parseMilliseconds(value: string | null): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
}
export function parseVoicePracticeContext(
searchParams: URLSearchParams,
): VoicePracticeContext | null {
if (searchParams.get("mode") !== "voice") return null;
const sourceSessionId = searchParams.get("source_session")?.trim() ?? "";
if (!SOURCE_SESSION_ID_RE.test(sourceSessionId)) return null;
const rawSceneId = searchParams.get("source_scene");
const rawSceneType = searchParams.get("scene_type");
const rawSceneStartMs = searchParams.get("scene_start_ms");
const rawSceneEndMs = searchParams.get("scene_end_ms");
const sceneFields = [
rawSceneId,
rawSceneType,
rawSceneStartMs,
rawSceneEndMs,
];
const hasSceneContext = sceneFields.some((value) => value != null);
// 특정 장면 재연습은 네 필드가 하나의 provenance 묶음이다. 일부만 있거나
// 하나라도 손상되면 전체 회기 재연습으로 조용히 축약하지 않는다.
if (hasSceneContext && sceneFields.some((value) => value == null)) return null;
const normalizedSceneId = rawSceneId?.trim() ?? "";
const normalizedSceneType = rawSceneType?.trim() ?? "";
const sceneStartMs = parseMilliseconds(rawSceneStartMs);
const sceneEndMs = parseMilliseconds(rawSceneEndMs);
if (
hasSceneContext &&
(!SCENE_ID_RE.test(normalizedSceneId) ||
!SCENE_TYPES.has(normalizedSceneType as VoicePracticeSceneType) ||
sceneStartMs == null ||
sceneEndMs == null ||
sceneEndMs <= sceneStartMs)
) {
return null;
}
return {
mode: "voice",
sourceSessionId,
sourceSceneId: hasSceneContext ? normalizedSceneId : null,
sceneType: hasSceneContext
? (normalizedSceneType as VoicePracticeSceneType)
: null,
sceneStartMs: hasSceneContext ? sceneStartMs : null,
sceneEndMs: hasSceneContext ? sceneEndMs : null,
};
}
export function voicePracticeSearch(context: VoicePracticeContext): string {
const params = new URLSearchParams({
mode: context.mode,
source_session: context.sourceSessionId,
});
if (context.sourceSceneId) params.set("source_scene", context.sourceSceneId);
if (context.sceneType) params.set("scene_type", context.sceneType);
if (context.sceneStartMs != null) params.set("scene_start_ms", String(context.sceneStartMs));
if (context.sceneEndMs != null) params.set("scene_end_ms", String(context.sceneEndMs));
return params.toString();
}
export const VOICE_SCENE_LABEL: Record<VoicePracticeSceneType, string> = {
silence: "침묵 뒤 응답",
overlap: "발화 겹침",
interruption: "끼어듦",
prosody: "운율 변화",
pace: "말 속도",
audio_quality: "음질 구간",
};