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

View file

@ -0,0 +1,124 @@
import {
api,
ApiError,
apiFetch,
apiUrl,
supplementalApi,
} from "../../lib/api";
import type { components, paths } from "../../lib/api.gen";
type ApiSchema<Name extends keyof components["schemas"]> =
components["schemas"][Name];
export type DeliberatePracticeReadModel =
ApiSchema<"DeliberatePracticeReadModelResponse">;
export type PracticePrescriptionItem = ApiSchema<"PracticePrescriptionItem">;
export type PracticeEpisodeItem = ApiSchema<"PracticeEpisodeItem">;
export type PracticeAttemptItem = ApiSchema<"PracticeAttemptItem">;
export type PracticeEvidenceRef = ApiSchema<"PracticeEvidenceRef">;
export type PracticeAttemptSubmissionRequest =
ApiSchema<"PracticeAttemptSubmissionRequest">;
export type PracticeAttemptSubmissionResponse =
ApiSchema<"PracticeAttemptSubmissionResponse">;
type ObserveCompletedPracticeSessionOperation = NonNullable<
paths["/practice/{prescription_id}/attempts/from-session/{practice_session_id}"]["post"]
>;
type ObserveCompletedPracticeSessionPath =
ObserveCompletedPracticeSessionOperation["parameters"]["path"];
export type ObserveCompletedPracticeSessionResponse =
ObserveCompletedPracticeSessionOperation["responses"][201]["content"]["application/json"];
export type PracticeTeacherCorrectionRequest =
ApiSchema<"PracticeTeacherCorrectionRequest">;
export type PracticeTeacherCorrectionResponse =
ApiSchema<"PracticeTeacherCorrectionResponse">;
export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">;
function learnerPracticePath(learnerId?: string): string {
return learnerId
? `/practice/learners/${encodeURIComponent(learnerId)}`
: "/practice/learners/me";
}
function dashboardSessions(dashboard: TeacherDashboardResponse) {
return [
...(dashboard.pending_reviews ?? []),
...(dashboard.recent_sessions ?? []),
];
}
async function getTeacherDashboard(signal?: AbortSignal) {
const response = await fetch(apiUrl("/teacher/dashboard"), {
method: "GET",
credentials: "include",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) {
let body: unknown;
let detail =
response.statusText || "교수자 학습자 맥락을 불러오지 못했습니다.";
try {
body = await response.json();
const candidate = (body as { detail?: unknown }).detail;
if (typeof candidate === "string") detail = candidate;
} catch {
body = undefined;
}
// 이 보조 조회의 실패는 전체 회기 리뷰의 인증 성공을 뒤집지 않는다.
// 후속 역할 보호 API는 공통 client를 사용하므로 실제 세션 만료는 그대로 전파된다.
throw new ApiError(response.status, detail, body);
}
return (await response.json()) as TeacherDashboardResponse;
}
export class PracticeLearnerContextError extends Error {
constructor() {
super("이 회기의 학습자 연습 원장을 찾지 못했습니다.");
this.name = "PracticeLearnerContextError";
}
}
export const deliberatePracticeApi = {
getForLearner: (signal?: AbortSignal) =>
supplementalApi.get<DeliberatePracticeReadModel>(learnerPracticePath(), {
signal,
}),
getForTeacherSession: async (sessionId: string, signal?: AbortSignal) => {
const dashboard = await getTeacherDashboard(signal);
const session = dashboardSessions(dashboard).find(
(item) => item.session_id === sessionId,
);
if (!session) throw new PracticeLearnerContextError();
return supplementalApi.get<DeliberatePracticeReadModel>(
learnerPracticePath(session.learner_id),
{ signal },
);
},
submitAttempt: (
prescriptionId: string,
body: PracticeAttemptSubmissionRequest,
) =>
api.post<PracticeAttemptSubmissionResponse>(
`/practice/${encodeURIComponent(prescriptionId)}/attempts`,
body,
),
observeCompletedSession: (
prescriptionId: ObserveCompletedPracticeSessionPath["prescription_id"],
practiceSessionId: ObserveCompletedPracticeSessionPath["practice_session_id"],
) =>
api.post<ObserveCompletedPracticeSessionResponse>(
`/practice/${encodeURIComponent(prescriptionId)}/attempts/from-session/${encodeURIComponent(practiceSessionId)}`,
),
appendCorrection: (
attemptRecordId: string,
body: PracticeTeacherCorrectionRequest,
) =>
apiFetch<PracticeTeacherCorrectionResponse>(
`/practice/attempts/${encodeURIComponent(attemptRecordId)}/correction`,
{ method: "PATCH", body },
),
};