G8 실제 rollback 증명 종료와 비-secure origin 회기 리뷰 크래시 수정
G8 마지막 게이트인 receipt-bound 실제 image rollback을 격리 NAS vignette-preview-20260807 에서 실행해 종료했다. Gate6 계약 정정: 감사 대상 current API 이미지가 com.docker.compose.project/service/version image label 을 갖고 있어 "helper 의 compose label 0개" 계약은 감사되지 않은 다른 이미지를 쓰지 않는 한 성립하지 않는다. 계약을 key 부재가 아니라 소속(membership) 으로 바꿔 launch-nas-preview-g8-helpers.py 에 구현했다. image 상속 label 을 baseline 으로 읽고 container 의 모든 compose label 이 baseline 과 같거나 선언된 격리 override 인지 검사하며, 최종 project 는 target 이 아니고 service 는 api/web/db/proxy 가 아니어야 한다. docker run argv 에 target label 을 주입하면 fake-runner 테스트가 먼저 깨진다 (37/37). 실행 결과: - rollback-old receipt nas-g8-723eeef22eab05e63e3fafb0 -> 79ec../c530.. - restore-current receipt nas-g8-2738846cf2cf4fbe8ce0fc26 -> 52e0../6fdb.. - release gate/approval 각 2회 멱등, audit.ci_lifecycle_event rollback/executed 2, audit.ci_human_approval_event authorize_rollback 2, silent auto-promotion 0 - HMAC journal 6-record 체인 검증, health 3/3, OpenAPI 126, auth 401, Web 200 - helper 0, listener 0, 비밀 env 파기. down/volume rm/prune 미실행, 공개 런타임 미접촉 - 계획했던 Windows SSH 터널은 NAS sshd 가 direct-tcpip 를 거부해 사용할 수 없어 sshd 설정 변경 대신 같은 격리 계약의 NAS-side probe 컨테이너로 실행했다 비-secure origin 크래시 수정: 배포된 NAS 프리뷰(평문 HTTP, 비-localhost)에 회기 스펙을 돌려 24건 실패를 확인했고 원인은 하나였다. crypto.randomUUID 는 secure context 전용인데 제품 코드 18곳이 fallback 없이 호출했고 RuptureRepairCard 는 렌더 시점 호출이라 회기 리뷰 라우트 전체가 error boundary 로 떨어졌다. 릴리스 게이트 108/108 은 localhost 후보 스택에서만 돌아 이 경로를 밟은 적이 없다. src/lib/uuid.ts 의 randomUuid() 로 통일하고 fallback 도 crypto.getRandomValues 를 우선 사용해 idempotency key 의 예측 불가능성을 유지했다. 회귀는 insecure-context-uuid.spec.ts 6/6 으로 고정했다(직접 호출 0건 검사 포함). 이 수정은 아직 NAS 에 배포하지 않았다. 검증: API 898, gateway 58, executor 28, probe 11, helper launcher 37, release agent 21, ruff clean, web api-types/typecheck/build, SSOT FAIL 0, SSOT unit 5/5, dashboard E2E 10/10, 학생 폐루프 실 DB 브라우저 4/4(일회용 클론), crypto 수정 후 기존 스펙 회귀 70/70, 복원된 NAS 실제 브라우저 SSE->DB 리뷰 PASS. 부수 발견(열린 항목): 공개 API 가 engine=false 로 degraded 인데 워치독이 이를 감지하지 못한다. engine 판정이 게이트웨이 /health 의 ok 만 보고 claude readiness probe 를 돌리지 않기 때문이다. 같은 .env 와 같은 CLI 로 새 게이트웨이를 다른 포트에 띄우면 즉시 ready 이므로 상주 프로세스의 세션만 죽은 형태다. TODO A절과 대시보드에 기록했다. 이 커밋은 파일 단위로 담겼다. 위 파일들에는 이전 세션의 미커밋 G0~G8 작업이 함께 들어 있으며, hunk 를 쪼개면 대시보드/체커/TODO 정합성이 깨져 SSOT 체커가 실패한다.
This commit is contained in:
parent
76d0b9ae9b
commit
93dd8f82d7
22 changed files with 10057 additions and 473 deletions
920
apps/web/src/pages/admin/ContinuousImprovementCockpit.tsx
Normal file
920
apps/web/src/pages/admin/ContinuousImprovementCockpit.tsx
Normal file
|
|
@ -0,0 +1,920 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Icon, Kicker } from "../../components/ui";
|
||||
import { randomUuid } from "../../lib/uuid";
|
||||
import {
|
||||
continuousImprovementApi,
|
||||
type ContentQualificationView,
|
||||
type ContinuousImprovementGateKind,
|
||||
type ContinuousImprovementViewResponse,
|
||||
type GateArtifactView,
|
||||
type HumanApprovalRequest,
|
||||
type LifecycleEventView,
|
||||
type ModelChangeGateView,
|
||||
type RegressionDagNodeView,
|
||||
type ReleaseGateView,
|
||||
} from "./continuousImprovementApi";
|
||||
|
||||
const ARTIFACT_KINDS = ["baseline", "threshold", "provenance", "rollback"] as const;
|
||||
|
||||
const ARTIFACT_LABELS: Record<(typeof ARTIFACT_KINDS)[number], string> = {
|
||||
baseline: "기준선",
|
||||
threshold: "임계값",
|
||||
provenance: "출처",
|
||||
rollback: "롤백",
|
||||
};
|
||||
|
||||
const NODE_LABELS: Record<RegressionDagNodeView["node_type"], string> = {
|
||||
reproduction_test: "재현 테스트",
|
||||
implementation: "구현",
|
||||
e2e: "E2E",
|
||||
runtime_proof: "런타임 증명",
|
||||
};
|
||||
|
||||
const EVENT_LABELS: Record<LifecycleEventView["event_status"], string> = {
|
||||
approved: "승인 원장 기록",
|
||||
requested: "롤백 실행 대기 · 실행기 미구성",
|
||||
executed: "롤백 실행",
|
||||
failed: "롤백 실행 실패",
|
||||
healthy: "모니터 정상",
|
||||
drift_detected: "드리프트 감지",
|
||||
rollback_recommended: "롤백 권고",
|
||||
rollback_verified: "롤백 검증 완료",
|
||||
};
|
||||
|
||||
const CONTENT_KIND_LABELS: Record<ContentQualificationView["content_kind"], string> = {
|
||||
case: "사례",
|
||||
rupture: "균열 수선",
|
||||
practice: "의도적 수련",
|
||||
benchmark: "벤치마크",
|
||||
};
|
||||
|
||||
type GateItem =
|
||||
| { kind: "model_change_gate"; value: ModelChangeGateView }
|
||||
| { kind: "release_gate"; value: ReleaseGateView };
|
||||
|
||||
function shortId(value: string, length = 10): string {
|
||||
return value.length > length ? `${value.slice(0, length)}…` : value;
|
||||
}
|
||||
|
||||
function dateTimeLabel(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "기록 시각 미상";
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function gateId(item: GateItem): string {
|
||||
return item.value.gate_id;
|
||||
}
|
||||
|
||||
function gateTitle(item: GateItem): string {
|
||||
if (item.kind === "model_change_gate") return "모델 변경 게이트";
|
||||
return `릴리스 ${item.value.release_id}`;
|
||||
}
|
||||
|
||||
function gateDecision(item: GateItem): HumanApprovalRequest["decision"] {
|
||||
if (item.kind === "release_gate") {
|
||||
return item.value.qualified ? "approve_promotion" : "reject";
|
||||
}
|
||||
if (item.value.gate_decision === "promote") return "approve_promotion";
|
||||
if (item.value.gate_decision === "rollback") return "authorize_rollback";
|
||||
return "keep_quarantine";
|
||||
}
|
||||
|
||||
function decisionLabel(item: GateItem): string {
|
||||
if (item.kind === "release_gate") {
|
||||
return item.value.qualified ? "승격 후보" : "게이트 미통과";
|
||||
}
|
||||
if (item.value.gate_decision === "promote") return "승격 후보";
|
||||
if (item.value.gate_decision === "rollback") return "롤백 후보";
|
||||
return "격리 유지";
|
||||
}
|
||||
|
||||
function actionLabel(item: GateItem): string {
|
||||
const decision = gateDecision(item);
|
||||
if (decision === "approve_promotion") return "사람 승인 기록";
|
||||
if (decision === "authorize_rollback") return "롤백 승인 기록";
|
||||
if (decision === "keep_quarantine") return "격리 결정 기록";
|
||||
return "거부 결정 기록";
|
||||
}
|
||||
|
||||
function artifactsFor(
|
||||
view: ContinuousImprovementViewResponse,
|
||||
kind: ContinuousImprovementGateKind,
|
||||
ownerId: string,
|
||||
): GateArtifactView[] {
|
||||
return view.gate_artifacts.filter(
|
||||
(artifact) => artifact.owner_kind === kind && artifact.owner_id === ownerId,
|
||||
);
|
||||
}
|
||||
|
||||
function hasCompleteArtifacts(artifacts: GateArtifactView[]): boolean {
|
||||
return ARTIFACT_KINDS.every((kind) =>
|
||||
artifacts.some((artifact) => artifact.artifact_kind === kind),
|
||||
);
|
||||
}
|
||||
|
||||
function isBoundarySafe(view: ContinuousImprovementViewResponse): boolean {
|
||||
return (
|
||||
view.data_classification === "synthetic_replay_red_team_coverage_drift" &&
|
||||
!view.silent_auto_promotion_allowed &&
|
||||
!view.raw_transcript_included &&
|
||||
!view.pii_included &&
|
||||
!view.clinical_claim_allowed
|
||||
);
|
||||
}
|
||||
|
||||
function BoundaryStrip({ view }: { view: ContinuousImprovementViewResponse }) {
|
||||
const boundaries = [
|
||||
["입력", "합성 replay · red-team · coverage drift"],
|
||||
["승격", "silent auto-promotion 금지"],
|
||||
["효과", "사람 승인 후 append-only"],
|
||||
["데이터", "원문 · PII · 임상 주장 미포함"],
|
||||
];
|
||||
const safe = isBoundarySafe(view);
|
||||
|
||||
return (
|
||||
<section className={`cic-boundary ${safe ? "is-safe" : "is-blocked"}`} aria-label="운영 경계">
|
||||
<div className="cic-boundary__mark" aria-hidden="true">
|
||||
<Icon name={safe ? "shield" : "alert"} size={19} />
|
||||
</div>
|
||||
{boundaries.map(([label, value]) => (
|
||||
<div className="cic-boundary__item" key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactRail({ artifacts }: { artifacts: GateArtifactView[] }) {
|
||||
return (
|
||||
<ol className="cic-artifacts" aria-label="게이트 필수 증거">
|
||||
{ARTIFACT_KINDS.map((kind, index) => {
|
||||
const matches = artifacts.filter((artifact) => artifact.artifact_kind === kind);
|
||||
const ready = matches.length > 0;
|
||||
return (
|
||||
<li className={ready ? "is-ready" : "is-missing"} key={kind}>
|
||||
<span className="cic-artifacts__index" aria-hidden="true">
|
||||
{ready ? <Icon name="check" size={14} /> : index + 1}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{ARTIFACT_LABELS[kind]}</strong>
|
||||
<span>{ready ? `${matches.length}개 고정` : "증거 없음"}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentQualificationCard({
|
||||
item,
|
||||
view,
|
||||
reason,
|
||||
onReasonChange,
|
||||
onApprove,
|
||||
approving,
|
||||
}: {
|
||||
item: ContentQualificationView;
|
||||
view: ContinuousImprovementViewResponse;
|
||||
reason: string;
|
||||
onReasonChange: (value: string) => void;
|
||||
onApprove: () => void;
|
||||
approving: boolean;
|
||||
}) {
|
||||
const payload = item.draft_payload;
|
||||
const catalog = view.catalog_entries.find(
|
||||
(entry) => entry.qualification_id === item.qualification_id,
|
||||
);
|
||||
const approval = view.approvals.find(
|
||||
(candidate) =>
|
||||
candidate.target_kind === "content_qualification" &&
|
||||
candidate.target_id === item.qualification_id,
|
||||
);
|
||||
const boundarySafe = isBoundarySafe(view);
|
||||
const evidenceReady = item.source_provenance_uris.length > 0;
|
||||
const canApprove =
|
||||
boundarySafe && Boolean(payload) && evidenceReady && !approval && !catalog && reason.trim().length > 0;
|
||||
const requirementId = `content-approval-requirement-${item.qualification_id}`;
|
||||
const titleId = `content-qualification-title-${item.qualification_id}`;
|
||||
const blockedReason = !boundarySafe
|
||||
? "데이터 경계 위반으로 승인 차단"
|
||||
: !payload
|
||||
? "검수 가능한 visible payload가 없어 승인 차단"
|
||||
: !evidenceReady
|
||||
? "repo provenance가 없어 승인 차단"
|
||||
: !reason.trim()
|
||||
? "승인 사유를 먼저 입력해야 함"
|
||||
: "visible payload와 repo provenance 검토 준비됨";
|
||||
|
||||
return (
|
||||
<article
|
||||
className="cic-content-card"
|
||||
data-qualification-id={item.qualification_id}
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="cic-content-card__kind">{CONTENT_KIND_LABELS[item.content_kind]}</span>
|
||||
<h3 id={titleId}>{payload?.title ?? item.catalog_entry_id}</h3>
|
||||
<code>{item.catalog_entry_id}</code>
|
||||
</div>
|
||||
<span className={`cic-decision ${catalog ? "is-approved" : ""}`}>
|
||||
{catalog ? "카탈로그 승인" : "사람 검토 대기"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl className="cic-content-card__facts" aria-label="후보 검증 요약">
|
||||
<div>
|
||||
<dt>난이도</dt>
|
||||
<dd>{item.difficulty_level}/5</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>독립 검토</dt>
|
||||
<dd>{item.red_team_review_count} agents</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>변형 통과</dt>
|
||||
<dd>
|
||||
{Math.round(item.benchmark_pass_rate * 100)}% · {item.benchmark_variant_count} variants
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>출처</dt>
|
||||
<dd>{item.source_count} repo source</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{payload ? (
|
||||
<details className="cic-content-review">
|
||||
<summary>검수 payload 펼쳐 보기</summary>
|
||||
<div className="cic-content-review__body">
|
||||
<section>
|
||||
<h4>합성 프로필</h4>
|
||||
<p>{payload.synthetic_profile}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>상황과 도전</h4>
|
||||
<p>{payload.scenario}</p>
|
||||
<p>{payload.rupture_or_challenge}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>학습자 과제</h4>
|
||||
<p>{payload.learner_task}</p>
|
||||
<ul>
|
||||
{payload.success_criteria.map((criterion) => (
|
||||
<li key={criterion}>{criterion}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h4>근거 추적</h4>
|
||||
<ul>
|
||||
{payload.grounded_claims.map((claim) => (
|
||||
<li key={`${claim.source_ref}:${claim.claim}`}>
|
||||
{claim.claim} <code>{claim.source_ref}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<div className="cic-content-card__blocked" role="alert">
|
||||
메타데이터 후보라 visible payload를 검수할 수 없어. 카탈로그 승인은 닫혀 있어.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{approval ? (
|
||||
<div className="cic-effect" role="status">
|
||||
<Icon name={catalog ? "check" : "alert"} size={17} />
|
||||
<div>
|
||||
<strong>{catalog ? "카탈로그 승인 원장 기록됨" : "후보 결정 원장 기록됨"}</strong>
|
||||
<span>
|
||||
{approval.decision} · {dateTimeLabel(approval.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="cic-approval-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canApprove && !approving) onApprove();
|
||||
}}
|
||||
>
|
||||
<label htmlFor={`content-reason-${item.qualification_id}`}>
|
||||
<span>콘텐츠 승인 사유</span>
|
||||
<input
|
||||
id={`content-reason-${item.qualification_id}`}
|
||||
name={`content-approval-reason-${item.qualification_id}`}
|
||||
value={reason}
|
||||
onChange={(event) => onReasonChange(event.target.value)}
|
||||
aria-describedby={requirementId}
|
||||
placeholder="예: 합성 경계와 수련 목표를 직접 검수함"
|
||||
autoComplete="off"
|
||||
maxLength={180}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!canApprove || approving}
|
||||
aria-describedby={requirementId}
|
||||
>
|
||||
{approving ? "승인 원장 기록 중…" : "카탈로그 승인"}
|
||||
</Button>
|
||||
<p
|
||||
id={requirementId}
|
||||
className={`cic-approval-requirement ${canApprove ? "is-ready" : !boundarySafe ? "is-blocked" : ""}`}
|
||||
>
|
||||
{blockedReason}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineSection({
|
||||
view,
|
||||
reasons,
|
||||
setReason,
|
||||
approve,
|
||||
approvingId,
|
||||
}: {
|
||||
view: ContinuousImprovementViewResponse;
|
||||
reasons: Record<string, string>;
|
||||
setReason: (id: string, value: string) => void;
|
||||
approve: (item: ContentQualificationView) => void;
|
||||
approvingId: string | null;
|
||||
}) {
|
||||
const latest = view.content_qualifications.slice(0, 4);
|
||||
return (
|
||||
<section className="cic-panel cic-pipeline" aria-labelledby="cic-pipeline-title">
|
||||
<header className="cic-section-head">
|
||||
<div>
|
||||
<Kicker dot={false}>CONTENT QUALIFICATION</Kicker>
|
||||
<h2 id="cic-pipeline-title">합성 콘텐츠 검증 파이프라인</h2>
|
||||
<p>생성물은 독립 red-team과 변형 benchmark를 통과해도 카탈로그에 자동 게시되지 않아.</p>
|
||||
</div>
|
||||
<span className="cic-state-chip">사람 검토 대기</span>
|
||||
</header>
|
||||
|
||||
<div className="cic-pipeline__flow" aria-label="콘텐츠 검증 단계">
|
||||
{[
|
||||
["01", "Synthetic replay", "비식별 합성 입력"],
|
||||
["02", "Independent red-team", "안전 · 누출 · 편향"],
|
||||
["03", "Coverage drift", "변형 benchmark"],
|
||||
["04", "Human catalog gate", "append-only 승인"],
|
||||
].map(([step, title, detail]) => (
|
||||
<div className="cic-pipeline__step" key={step}>
|
||||
<span>{step}</span>
|
||||
<strong>{title}</strong>
|
||||
<small>{detail}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{latest.length === 0 ? (
|
||||
<div className="cic-empty" role="status">
|
||||
<Icon name="hourglass" size={20} />
|
||||
<div>
|
||||
<strong>검토할 콘텐츠 후보가 없어</strong>
|
||||
<p>에이전트 파이프라인이 qualified candidate를 적재하면 여기에 나타나.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cic-content-grid">
|
||||
{latest.map((item) => (
|
||||
<ContentQualificationCard
|
||||
key={item.qualification_id}
|
||||
item={item}
|
||||
view={view}
|
||||
reason={reasons[item.qualification_id] ?? ""}
|
||||
onReasonChange={(value) => setReason(item.qualification_id, value)}
|
||||
onApprove={() => approve(item)}
|
||||
approving={approvingId === item.qualification_id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GateCard({
|
||||
item,
|
||||
view,
|
||||
reason,
|
||||
onReasonChange,
|
||||
onApprove,
|
||||
approving,
|
||||
boundarySafe,
|
||||
}: {
|
||||
item: GateItem;
|
||||
view: ContinuousImprovementViewResponse;
|
||||
reason: string;
|
||||
onReasonChange: (value: string) => void;
|
||||
onApprove: () => void;
|
||||
approving: boolean;
|
||||
boundarySafe: boolean;
|
||||
}) {
|
||||
const id = gateId(item);
|
||||
const artifacts = artifactsFor(view, item.kind, id);
|
||||
const complete = hasCompleteArtifacts(artifacts);
|
||||
const approval = view.approvals.find(
|
||||
(candidate) => candidate.target_kind === item.kind && candidate.target_id === id,
|
||||
);
|
||||
const effect = view.lifecycle_events.find(
|
||||
(event) => event.target_kind === item.kind && event.target_id === id,
|
||||
);
|
||||
const canSubmit = boundarySafe && complete && !approval && reason.trim().length > 0;
|
||||
const blockedReason = !boundarySafe
|
||||
? "데이터 경계 위반으로 승인 차단"
|
||||
: !complete
|
||||
? "필수 증거 4종이 모두 있어야 기록 가능"
|
||||
: !reason.trim()
|
||||
? "승인 사유를 먼저 입력해야 함"
|
||||
: undefined;
|
||||
const requirementId = `approval-requirement-${id}`;
|
||||
const requirementCopy = blockedReason ?? "증거 4종과 승인 사유가 준비됨";
|
||||
|
||||
return (
|
||||
<article className="cic-gate-card" data-gate-id={id}>
|
||||
<header>
|
||||
<div>
|
||||
<span className="cic-gate-card__kind">
|
||||
{item.kind === "model_change_gate" ? "MODEL" : "RELEASE"}
|
||||
</span>
|
||||
<h3>{gateTitle(item)}</h3>
|
||||
<code>{shortId(id, 14)}</code>
|
||||
</div>
|
||||
<span className={`cic-decision cic-decision--${gateDecision(item)}`}>
|
||||
{decisionLabel(item)}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{item.kind === "model_change_gate" && item.value.reasons.length > 0 ? (
|
||||
<ul className="cic-gate-card__reasons">
|
||||
{item.value.reasons.slice(0, 3).map((value) => (
|
||||
<li key={value}>{value}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<ArtifactRail artifacts={artifacts} />
|
||||
|
||||
{approval ? (
|
||||
<div className="cic-effect" role="status">
|
||||
<Icon name="check" size={17} />
|
||||
<div>
|
||||
<strong>append-only 승인 원장 기록됨</strong>
|
||||
<span>
|
||||
{approval.decision} · {dateTimeLabel(approval.created_at)}
|
||||
{effect ? ` · effect ${shortId(effect.lifecycle_event_id, 8)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="cic-approval-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canSubmit && !approving) onApprove();
|
||||
}}
|
||||
>
|
||||
<label htmlFor={`reason-${id}`}>
|
||||
<span>사람 승인 사유</span>
|
||||
<input
|
||||
id={`reason-${id}`}
|
||||
name={`approval-reason-${id}`}
|
||||
value={reason}
|
||||
onChange={(event) => onReasonChange(event.target.value)}
|
||||
aria-describedby={requirementId}
|
||||
placeholder="예: 기준선과 롤백 절차를 독립 검토함…"
|
||||
autoComplete="off"
|
||||
maxLength={180}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={gateDecision(item) === "authorize_rollback" ? "danger" : "primary"}
|
||||
type="submit"
|
||||
disabled={!canSubmit || approving}
|
||||
aria-describedby={requirementId}
|
||||
>
|
||||
{approving ? "원장 기록 중…" : complete ? actionLabel(item) : "증거 4종 미완료"}
|
||||
</Button>
|
||||
<p
|
||||
id={requirementId}
|
||||
className={`cic-approval-requirement ${!boundarySafe ? "is-blocked" : canSubmit ? "is-ready" : ""}`}
|
||||
>
|
||||
{requirementCopy}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function GateSection({
|
||||
view,
|
||||
reasons,
|
||||
setReason,
|
||||
approve,
|
||||
approvingId,
|
||||
}: {
|
||||
view: ContinuousImprovementViewResponse;
|
||||
reasons: Record<string, string>;
|
||||
setReason: (id: string, value: string) => void;
|
||||
approve: (item: GateItem) => void;
|
||||
approvingId: string | null;
|
||||
}) {
|
||||
const items: GateItem[] = [
|
||||
...view.model_change_gates.map((value) => ({ kind: "model_change_gate" as const, value })),
|
||||
...view.release_gates.map((value) => ({ kind: "release_gate" as const, value })),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
new Date(right.value.created_at).getTime() - new Date(left.value.created_at).getTime(),
|
||||
);
|
||||
const boundarySafe = isBoundarySafe(view);
|
||||
|
||||
return (
|
||||
<section className="cic-gates" aria-labelledby="cic-gates-title">
|
||||
<header className="cic-section-head">
|
||||
<div>
|
||||
<Kicker dot={false}>PROMOTION CONTROL</Kicker>
|
||||
<h2 id="cic-gates-title">모델 변경 · 릴리스 게이트</h2>
|
||||
<p>계산 결과는 제안일 뿐이야. 4종 증거와 명시적 사람 승인 없이는 효과가 발생하지 않아.</p>
|
||||
</div>
|
||||
</header>
|
||||
{items.length === 0 ? (
|
||||
<div className="cic-empty" role="status">
|
||||
<Icon name="shield" size={20} />
|
||||
<div>
|
||||
<strong>대기 중인 게이트가 없어</strong>
|
||||
<p>모델 변경 또는 에이전틱 릴리스 후보가 생성되면 여기서 검토해.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cic-gates__grid">
|
||||
{items.map((item) => (
|
||||
<GateCard
|
||||
key={`${item.kind}:${gateId(item)}`}
|
||||
item={item}
|
||||
view={view}
|
||||
reason={reasons[gateId(item)] ?? ""}
|
||||
onReasonChange={(value) => setReason(gateId(item), value)}
|
||||
onApprove={() => approve(item)}
|
||||
approving={approvingId === gateId(item)}
|
||||
boundarySafe={boundarySafe}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentDag({ view }: { view: ContinuousImprovementViewResponse }) {
|
||||
const incidents = view.incidents.slice(0, 3);
|
||||
return (
|
||||
<section className="cic-panel" aria-labelledby="cic-incidents-title">
|
||||
<header className="cic-section-head cic-section-head--compact">
|
||||
<div>
|
||||
<Kicker dot={false}>INCIDENT → REGRESSION</Kicker>
|
||||
<h2 id="cic-incidents-title">사건 회귀 DAG</h2>
|
||||
<p>운영 사건을 재현 테스트부터 런타임 증명까지 끊김 없이 추적해.</p>
|
||||
</div>
|
||||
</header>
|
||||
{incidents.length === 0 ? (
|
||||
<div className="cic-empty cic-empty--plain" role="status">
|
||||
<Icon name="check" size={19} />
|
||||
<div>
|
||||
<strong>등록된 운영 사건이 없어</strong>
|
||||
<p>합성 증거 기반 사건이 적재되면 회귀 DAG가 생성돼.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cic-incident-list">
|
||||
{incidents.map((incident) => {
|
||||
const nodes = view.regression_dag_nodes.filter(
|
||||
(node) => node.incident_record_id === incident.incident_record_id,
|
||||
);
|
||||
return (
|
||||
<article key={incident.incident_record_id}>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{incident.affected_contract}</strong>
|
||||
<code>{incident.incident_id}</code>
|
||||
</div>
|
||||
<span>{dateTimeLabel(incident.created_at)}</span>
|
||||
</header>
|
||||
<ol className="cic-dag" aria-label={`${incident.incident_id} 회귀 DAG`}>
|
||||
{(["reproduction_test", "implementation", "e2e", "runtime_proof"] as const).map(
|
||||
(kind) => {
|
||||
const node = nodes.find((candidate) => candidate.node_type === kind);
|
||||
return (
|
||||
<li className={`is-${node?.node_status ?? "pending"}`} key={kind}>
|
||||
<span aria-hidden="true">
|
||||
{node?.node_status === "passed" ? <Icon name="check" size={14} /> : null}
|
||||
</span>
|
||||
<strong>{NODE_LABELS[kind]}</strong>
|
||||
<small>{node?.node_status ?? "pending"}</small>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</ol>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LifecycleLedger({ events }: { events: LifecycleEventView[] }) {
|
||||
return (
|
||||
<section className="cic-panel" aria-labelledby="cic-lifecycle-title">
|
||||
<header className="cic-section-head cic-section-head--compact">
|
||||
<div>
|
||||
<Kicker dot={false}>APPEND-ONLY EFFECTS</Kicker>
|
||||
<h2 id="cic-lifecycle-title">모니터 · 롤백 원장</h2>
|
||||
<p>승인 이후 효과와 사후 검증은 별도 이벤트로 남아.</p>
|
||||
</div>
|
||||
</header>
|
||||
{events.length === 0 ? (
|
||||
<div className="cic-empty cic-empty--plain" role="status">
|
||||
<Icon name="hourglass" size={19} />
|
||||
<div>
|
||||
<strong>아직 발생한 효과가 없어</strong>
|
||||
<p>사람 승인 전에는 정상적인 빈 상태야.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ol className="cic-ledger">
|
||||
{events.slice(0, 8).map((event) => {
|
||||
const rollbackVerificationPending =
|
||||
event.event_type === "rollback" &&
|
||||
event.event_status === "executed" &&
|
||||
!events.some(
|
||||
(candidate) =>
|
||||
candidate.target_kind === event.target_kind &&
|
||||
candidate.target_id === event.target_id &&
|
||||
candidate.event_status === "rollback_verified" &&
|
||||
new Date(candidate.created_at) > new Date(event.created_at),
|
||||
);
|
||||
return (
|
||||
<li className={`is-${event.event_status}`} key={event.lifecycle_event_id}>
|
||||
<span className="cic-ledger__line" aria-hidden="true" />
|
||||
<span className="cic-ledger__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{EVENT_LABELS[event.event_status]}</strong>
|
||||
<span>
|
||||
{event.target_kind === "model_change_gate" ? "모델" : "릴리스"} · {shortId(event.target_id)}
|
||||
</span>
|
||||
{rollbackVerificationPending ? (
|
||||
<em>롤백은 실행됐지만 verification 이벤트가 아직 없어</em>
|
||||
) : null}
|
||||
</div>
|
||||
<time dateTime={event.created_at}>{dateTimeLabel(event.created_at)}</time>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContinuousImprovementCockpit() {
|
||||
const [view, setView] = useState<ContinuousImprovementViewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [approvingId, setApprovingId] = useState<string | null>(null);
|
||||
const [reasons, setReasons] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await continuousImprovementApi.read(signal);
|
||||
setView(next);
|
||||
} catch (cause) {
|
||||
if (signal?.aborted) return;
|
||||
setError(cause instanceof Error ? cause.message : "개선 원장을 불러오지 못했어.");
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
const approve = useCallback(
|
||||
async (item: GateItem) => {
|
||||
if (!view) return;
|
||||
const id = gateId(item);
|
||||
const artifacts = artifactsFor(view, item.kind, id);
|
||||
const reason = reasons[id]?.trim() ?? "";
|
||||
const alreadyApproved = view.approvals.some(
|
||||
(candidate) => candidate.target_kind === item.kind && candidate.target_id === id,
|
||||
);
|
||||
if (!isBoundarySafe(view) || !hasCompleteArtifacts(artifacts) || alreadyApproved || !reason) {
|
||||
return;
|
||||
}
|
||||
setApprovingId(id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const evidenceRefs = [...new Set(artifacts.map((artifact) => artifact.provenance_uri))];
|
||||
const result = await continuousImprovementApi.appendApproval({
|
||||
submission_id: randomUuid(),
|
||||
approval_event_id: randomUuid(),
|
||||
effect_record_id: randomUuid(),
|
||||
target_kind: item.kind,
|
||||
target_id: id,
|
||||
decision: gateDecision(item),
|
||||
reason_code: reason,
|
||||
evidence_refs: evidenceRefs,
|
||||
});
|
||||
setNotice(`append-only 승인과 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`);
|
||||
setReasons((current) => ({ ...current, [id]: "" }));
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "승인 원장을 기록하지 못했어.");
|
||||
} finally {
|
||||
setApprovingId(null);
|
||||
}
|
||||
},
|
||||
[load, reasons, view],
|
||||
);
|
||||
|
||||
const approveQualification = useCallback(
|
||||
async (item: ContentQualificationView) => {
|
||||
if (!view) return;
|
||||
const id = item.qualification_id;
|
||||
const reason = reasons[id]?.trim() ?? "";
|
||||
const alreadyApproved = view.approvals.some(
|
||||
(candidate) =>
|
||||
candidate.target_kind === "content_qualification" && candidate.target_id === id,
|
||||
);
|
||||
const alreadyCataloged = view.catalog_entries.some(
|
||||
(candidate) => candidate.qualification_id === id,
|
||||
);
|
||||
if (
|
||||
!isBoundarySafe(view) ||
|
||||
!item.draft_payload ||
|
||||
item.source_provenance_uris.length === 0 ||
|
||||
alreadyApproved ||
|
||||
alreadyCataloged ||
|
||||
!reason
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setApprovingId(id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const evidenceRefs = [
|
||||
...new Set([
|
||||
...item.source_provenance_uris,
|
||||
`audit://continuous-improvement/human-review/${id}`,
|
||||
]),
|
||||
];
|
||||
const result = await continuousImprovementApi.appendApproval({
|
||||
submission_id: randomUuid(),
|
||||
approval_event_id: randomUuid(),
|
||||
effect_record_id: randomUuid(),
|
||||
target_kind: "content_qualification",
|
||||
target_id: id,
|
||||
decision: "approve_content",
|
||||
reason_code: reason,
|
||||
evidence_refs: evidenceRefs,
|
||||
});
|
||||
setNotice(`카탈로그 승인과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`);
|
||||
setReasons((current) => ({ ...current, [id]: "" }));
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "콘텐츠 승인 원장을 기록하지 못했어.");
|
||||
} finally {
|
||||
setApprovingId(null);
|
||||
}
|
||||
},
|
||||
[load, reasons, view],
|
||||
);
|
||||
|
||||
const orderedEvents = useMemo(
|
||||
() =>
|
||||
[...(view?.lifecycle_events ?? [])].sort(
|
||||
(left, right) => new Date(right.created_at).getTime() - new Date(left.created_at).getTime(),
|
||||
),
|
||||
[view],
|
||||
);
|
||||
|
||||
if (!view && loading) {
|
||||
return (
|
||||
<div className="cic-loading" role="status" aria-live="polite">
|
||||
<span aria-hidden="true" />
|
||||
개선 원장을 확인하는 중…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!view) {
|
||||
return (
|
||||
<section className="cic-degraded" role="alert">
|
||||
<Icon name="alert" size={22} />
|
||||
<div>
|
||||
<Kicker dot={false}>DEGRADED READ MODEL</Kicker>
|
||||
<h1>개선 원장을 확인할 수 없어</h1>
|
||||
<p>{error ?? "운영 원장이 응답하지 않았어."} 승인 동작은 닫힌 상태로 유지돼.</p>
|
||||
<Button size="sm" variant="secondary" onClick={() => void load()}>
|
||||
다시 확인
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cic" data-testid="continuous-improvement-cockpit" aria-busy={loading}>
|
||||
<header className="cic-hero">
|
||||
<div>
|
||||
<Kicker>관리자 · CONTINUOUS IMPROVEMENT OS</Kicker>
|
||||
<h1>승격보다 근거를 먼저 본다</h1>
|
||||
<p>
|
||||
합성 콘텐츠, 모델 변경, 에이전틱 릴리스를 한 원장에서 검토하고 사람 승인 이후의
|
||||
효과와 롤백 검증까지 닫아.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leading={<Icon name="refresh" size={16} />}
|
||||
disabled={loading}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
원장 새로고침
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className="cic-alert" role="alert">
|
||||
<Icon name="alert" size={17} />
|
||||
<span>최신 동기화 실패 · 이전 원장을 읽기 전용으로 표시 중 · {error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{notice ? (
|
||||
<div className="cic-notice" role="status" aria-live="polite">
|
||||
<Icon name="check" size={17} />
|
||||
<span>{notice}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<BoundaryStrip view={view} />
|
||||
<PipelineSection
|
||||
view={view}
|
||||
reasons={reasons}
|
||||
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
|
||||
approve={(item) => void approveQualification(item)}
|
||||
approvingId={approvingId}
|
||||
/>
|
||||
<GateSection
|
||||
view={view}
|
||||
reasons={reasons}
|
||||
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
|
||||
approve={(item) => void approve(item)}
|
||||
approvingId={approvingId}
|
||||
/>
|
||||
|
||||
<div className="cic-lower-grid">
|
||||
<IncidentDag view={view} />
|
||||
<LifecycleLedger events={orderedEvents} />
|
||||
</div>
|
||||
|
||||
<footer className="cic-footnote">
|
||||
<Icon name="info" size={16} />
|
||||
<p>
|
||||
이 표면은 관리자 전용 운영 메타데이터만 표시해. 학습자·교수자 화면, 원문 트랜스크립트,
|
||||
PII, 임상 주장, 합산 총점은 이 계약에 포함하지 않아.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue