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:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
138
apps/web/src/lib/measurementContract.ts
Normal file
138
apps/web/src/lib/measurementContract.ts
Normal 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;
|
||||
}
|
||||
199
apps/web/src/lib/practiceLaunchIntent.ts
Normal file
199
apps/web/src/lib/practiceLaunchIntent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
104
apps/web/src/lib/voicePracticeContext.ts
Normal file
104
apps/web/src/lib/voicePracticeContext.ts
Normal 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: "음질 구간",
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue