주기 실회기 검증과 G7 종료계약 보강
This commit is contained in:
parent
83590e9ef7
commit
7b4955c3fc
23 changed files with 2916 additions and 117 deletions
|
|
@ -32,7 +32,7 @@ class G7EvidenceProvenance(BaseModel):
|
|||
|
||||
@model_validator(mode="after")
|
||||
def require_preregistered_analysis(self) -> "G7EvidenceProvenance":
|
||||
if self.registered_at > self.held_out_labels_opened_at:
|
||||
if self.registered_at >= self.held_out_labels_opened_at:
|
||||
raise ValueError("analysis protocol must precede held-out label access")
|
||||
return self
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ class G7PowerPlan(BaseModel):
|
|||
required_held_out_participants: int = Field(ge=1)
|
||||
required_held_out_sessions: int = Field(ge=1)
|
||||
required_paired_axis_observations: int = Field(ge=3)
|
||||
alpha: float = Field(gt=0.0, le=0.05)
|
||||
alpha: Literal[0.05] = 0.05
|
||||
target_power: float = Field(ge=0.8, lt=1.0)
|
||||
minimally_detectable_gain: float = Field(gt=0.0, le=1.0)
|
||||
planned_bootstrap_samples: Literal[10000] = 10000
|
||||
|
|
@ -91,10 +91,7 @@ class G7HumanAxisLabel(BaseModel):
|
|||
|
||||
labeler_key: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$")
|
||||
score: float = Field(ge=0.0, le=1.0)
|
||||
category: str | None = Field(
|
||||
default=None,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$",
|
||||
)
|
||||
category: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$")
|
||||
|
||||
|
||||
class G7ReliabilityClaim(BaseModel):
|
||||
|
|
@ -103,11 +100,7 @@ class G7ReliabilityClaim(BaseModel):
|
|||
method: Literal["ICC(A,1)"] = "ICC(A,1)"
|
||||
labeler_keys: tuple[str, ...] = Field(min_length=2)
|
||||
reported_icc: float = Field(ge=-1.0, le=1.0)
|
||||
reported_categorical_kappa: float | None = Field(
|
||||
default=None,
|
||||
ge=-1.0,
|
||||
le=1.0,
|
||||
)
|
||||
reported_categorical_kappa: float = Field(ge=-1.0, le=1.0)
|
||||
report_sha256: Sha256 = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
|
@ -146,9 +139,6 @@ class G7PairedAxisObservation(BaseModel):
|
|||
labeler_keys = [item.labeler_key for item in self.labels]
|
||||
if len(set(labeler_keys)) != len(labeler_keys):
|
||||
raise ValueError("observation labeler keys must be unique")
|
||||
has_category = [item.category is not None for item in self.labels]
|
||||
if any(has_category) and not all(has_category):
|
||||
raise ValueError("categorical labels must be complete within an observation")
|
||||
return self
|
||||
|
||||
|
||||
|
|
@ -227,7 +217,6 @@ class G7HumanVoiceGainEvidencePack(BaseModel):
|
|||
|
||||
participant_by_session: dict[str, str] = {}
|
||||
axes_by_session: dict[str, set[AllianceAxis]] = {}
|
||||
categorical_modes: set[bool] = set()
|
||||
for observation in self.observations:
|
||||
if split_by_participant.get(observation.participant_key) != "held_out":
|
||||
raise ValueError("evaluation observations must use held-out participants")
|
||||
|
|
@ -243,16 +232,8 @@ class G7HumanVoiceGainEvidencePack(BaseModel):
|
|||
row_labelers = {item.labeler_key for item in observation.labels}
|
||||
if row_labelers != reliability_panel:
|
||||
raise ValueError("every row must use the declared reliability panel")
|
||||
categorical_modes.add(observation.labels[0].category is not None)
|
||||
|
||||
required_axes: set[AllianceAxis] = {"goal", "task", "bond"}
|
||||
if any(axes != required_axes for axes in axes_by_session.values()):
|
||||
raise ValueError("every held-out session must cover goal, task, and bond")
|
||||
if len(categorical_modes) != 1:
|
||||
raise ValueError("categorical labels must be all-present or all-absent")
|
||||
has_categories = True in categorical_modes
|
||||
if has_categories != (
|
||||
self.reliability.reported_categorical_kappa is not None
|
||||
):
|
||||
raise ValueError("categorical labels and reported kappa must appear together")
|
||||
return self
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ class VoiceGainEvidenceResult(BaseModel):
|
|||
passed: bool
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
held_out_participants: int
|
||||
total_held_out_sessions: int
|
||||
held_out_sessions: int
|
||||
total_axis_observations: int
|
||||
paired_axis_observations: int
|
||||
intention_to_evaluate_imputations: int
|
||||
text_only_one_minus_mae: float
|
||||
|
|
@ -62,7 +64,7 @@ class VoiceGainEvidenceResult(BaseModel):
|
|||
confidence_level: float
|
||||
bootstrap_samples: int
|
||||
recomputed_icc: float
|
||||
recomputed_categorical_kappa: float | None
|
||||
recomputed_categorical_kappa: float
|
||||
checks: tuple[VoiceGainEvidenceCheck, ...]
|
||||
failure_reasons: tuple[str, ...]
|
||||
|
||||
|
|
@ -221,22 +223,23 @@ def evaluate_human_voice_gain(
|
|||
labels_by_key = {item.labeler_key: item for item in observation.labels}
|
||||
ordered_labels = [labels_by_key[labeler_key] for labeler_key in panel]
|
||||
ratings.append([item.score for item in ordered_labels])
|
||||
if ordered_labels[0].category is not None:
|
||||
categories.append([str(item.category) for item in ordered_labels])
|
||||
categories.append([item.category for item in ordered_labels])
|
||||
reference = sum(item.score for item in ordered_labels) / len(ordered_labels)
|
||||
|
||||
if observation.text_only_status == "observed":
|
||||
text_observed = observation.text_only_status == "observed"
|
||||
voice_observed = observation.voice_enabled_status == "observed"
|
||||
if text_observed and voice_observed:
|
||||
assert observation.text_only_score is not None
|
||||
text_error = abs(observation.text_only_score - reference)
|
||||
else:
|
||||
text_error = 1.0
|
||||
imputation_count += 1
|
||||
if observation.voice_enabled_status == "observed":
|
||||
assert observation.voice_enabled_score is not None
|
||||
voice_error = abs(observation.voice_enabled_score - reference)
|
||||
else:
|
||||
# 한 조건만 결측이어도 두 조건을 모두 최대 오류로 대치한다. baseline-only
|
||||
# 결측이 candidate gain을 인위적으로 키우는 비대칭을 차단하면서도
|
||||
# intention-to-evaluate 행은 분석에서 유지한다.
|
||||
text_error = 1.0
|
||||
voice_error = 1.0
|
||||
imputation_count += 1
|
||||
imputation_count += int(not text_observed) + int(not voice_observed)
|
||||
errors_by_participant[observation.participant_key].append(
|
||||
(text_error, voice_error)
|
||||
)
|
||||
|
|
@ -258,11 +261,28 @@ def evaluate_human_voice_gain(
|
|||
seed=thresholds.seed,
|
||||
)
|
||||
recomputed_icc = _icc_absolute_agreement_single(ratings)
|
||||
recomputed_kappa = _fleiss_kappa(categories) if categories else None
|
||||
recomputed_kappa = _fleiss_kappa(categories)
|
||||
|
||||
held_out_participants = len(errors_by_participant)
|
||||
held_out_sessions = len({item.session_key for item in pack.observations})
|
||||
observation_count = len(pack.observations)
|
||||
all_session_keys = {item.session_key for item in pack.observations}
|
||||
complete_axes_by_session: dict[str, set[str]] = defaultdict(set)
|
||||
for observation in pack.observations:
|
||||
if (
|
||||
observation.text_only_status == "observed"
|
||||
and observation.voice_enabled_status == "observed"
|
||||
):
|
||||
complete_axes_by_session[observation.session_key].add(observation.axis)
|
||||
required_axes = {"goal", "task", "bond"}
|
||||
held_out_sessions = sum(
|
||||
axes == required_axes for axes in complete_axes_by_session.values()
|
||||
)
|
||||
total_observation_count = len(pack.observations)
|
||||
paired_observation_count = sum(
|
||||
1
|
||||
for observation in pack.observations
|
||||
if observation.text_only_status == "observed"
|
||||
and observation.voice_enabled_status == "observed"
|
||||
)
|
||||
checks: list[VoiceGainEvidenceCheck] = []
|
||||
_check(
|
||||
checks,
|
||||
|
|
@ -281,8 +301,8 @@ def evaluate_human_voice_gain(
|
|||
_check(
|
||||
checks,
|
||||
"production_observation_floor",
|
||||
observation_count >= thresholds.min_paired_axis_observations,
|
||||
observation_count,
|
||||
paired_observation_count >= thresholds.min_paired_axis_observations,
|
||||
paired_observation_count,
|
||||
thresholds.min_paired_axis_observations,
|
||||
)
|
||||
_check(
|
||||
|
|
@ -318,8 +338,8 @@ def evaluate_human_voice_gain(
|
|||
_check(
|
||||
checks,
|
||||
"power_plan_observations_achieved",
|
||||
observation_count >= pack.power_plan.required_paired_axis_observations,
|
||||
observation_count,
|
||||
paired_observation_count >= pack.power_plan.required_paired_axis_observations,
|
||||
paired_observation_count,
|
||||
pack.power_plan.required_paired_axis_observations,
|
||||
)
|
||||
_check(
|
||||
|
|
@ -355,8 +375,6 @@ def evaluate_human_voice_gain(
|
|||
recomputed_icc,
|
||||
pack.reliability.reported_icc,
|
||||
)
|
||||
if recomputed_kappa is not None:
|
||||
assert pack.reliability.reported_categorical_kappa is not None
|
||||
_check(
|
||||
checks,
|
||||
"recomputed_categorical_kappa",
|
||||
|
|
@ -390,8 +408,10 @@ def evaluate_human_voice_gain(
|
|||
passed=not failures,
|
||||
clinical_claim_allowed=False,
|
||||
held_out_participants=held_out_participants,
|
||||
total_held_out_sessions=len(all_session_keys),
|
||||
held_out_sessions=held_out_sessions,
|
||||
paired_axis_observations=observation_count,
|
||||
total_axis_observations=total_observation_count,
|
||||
paired_axis_observations=paired_observation_count,
|
||||
intention_to_evaluate_imputations=imputation_count,
|
||||
text_only_one_minus_mae=text_accuracy,
|
||||
voice_enabled_one_minus_mae=voice_accuracy,
|
||||
|
|
|
|||
|
|
@ -179,8 +179,34 @@ class G7HumanVoiceGainEvidenceTests(unittest.TestCase):
|
|||
self.assertEqual(result.intention_to_evaluate_imputations, 1)
|
||||
expected_voice_accuracy = 1.0 - ((1.0 + (0.05 * 5)) / 6)
|
||||
self.assertAlmostEqual(result.voice_enabled_one_minus_mae, expected_voice_accuracy)
|
||||
self.assertEqual(result.paired_axis_observations, 5)
|
||||
self.assertEqual(result.held_out_sessions, 1)
|
||||
self.assertIn("production_observation_floor", result.failure_reasons)
|
||||
self.assertIn("production_session_floor", result.failure_reasons)
|
||||
self.assertFalse(result.passed)
|
||||
|
||||
def test_baseline_only_missing_cannot_inflate_candidate_gain(self) -> None:
|
||||
complete = G7HumanVoiceGainEvidencePack.model_validate(_valid_payload())
|
||||
complete_result = evaluate_human_voice_gain(
|
||||
complete,
|
||||
thresholds=_test_thresholds(),
|
||||
)
|
||||
payload = _valid_payload()
|
||||
observations = payload["observations"]
|
||||
assert isinstance(observations, list)
|
||||
observations[0]["text_only_status"] = "missing"
|
||||
observations[0]["text_only_score"] = None
|
||||
missing = G7HumanVoiceGainEvidencePack.model_validate(payload)
|
||||
|
||||
missing_result = evaluate_human_voice_gain(
|
||||
missing,
|
||||
thresholds=_test_thresholds(),
|
||||
)
|
||||
|
||||
self.assertLess(missing_result.paired_gain, complete_result.paired_gain)
|
||||
self.assertEqual(missing_result.intention_to_evaluate_imputations, 1)
|
||||
self.assertEqual(missing_result.paired_axis_observations, 5)
|
||||
|
||||
def test_duplicate_or_calibration_observation_is_rejected(self) -> None:
|
||||
duplicate = _valid_payload()
|
||||
duplicate_rows = duplicate["observations"]
|
||||
|
|
@ -236,6 +262,64 @@ class G7HumanVoiceGainEvidenceTests(unittest.TestCase):
|
|||
self.assertFalse(result.passed)
|
||||
self.assertIn("reported_icc_matches_rows", result.failure_reasons)
|
||||
|
||||
def test_categorical_kappa_is_mandatory_and_recomputed(self) -> None:
|
||||
missing_report = _valid_payload()
|
||||
reliability = missing_report["reliability"]
|
||||
assert isinstance(reliability, dict)
|
||||
reliability.pop("reported_categorical_kappa")
|
||||
with self.assertRaises(ValidationError):
|
||||
G7HumanVoiceGainEvidencePack.model_validate(missing_report)
|
||||
|
||||
missing_category = _valid_payload()
|
||||
observations = missing_category["observations"]
|
||||
assert isinstance(observations, list)
|
||||
labels = observations[0]["labels"]
|
||||
assert isinstance(labels, list)
|
||||
labels[0].pop("category")
|
||||
with self.assertRaises(ValidationError):
|
||||
G7HumanVoiceGainEvidencePack.model_validate(missing_category)
|
||||
|
||||
mismatched = _valid_payload()
|
||||
reliability = mismatched["reliability"]
|
||||
assert isinstance(reliability, dict)
|
||||
reliability["reported_categorical_kappa"] = 0.8
|
||||
pack = G7HumanVoiceGainEvidencePack.model_validate(mismatched)
|
||||
result = evaluate_human_voice_gain(pack, thresholds=_test_thresholds())
|
||||
self.assertIn("reported_kappa_matches_rows", result.failure_reasons)
|
||||
|
||||
weak = _valid_payload()
|
||||
observations = weak["observations"]
|
||||
assert isinstance(observations, list)
|
||||
for observation in observations:
|
||||
labels = observation["labels"]
|
||||
assert isinstance(labels, list)
|
||||
labels[0]["category"] = "low"
|
||||
labels[1]["category"] = "high"
|
||||
reliability = weak["reliability"]
|
||||
assert isinstance(reliability, dict)
|
||||
reliability["reported_categorical_kappa"] = -1.0
|
||||
weak_pack = G7HumanVoiceGainEvidencePack.model_validate(weak)
|
||||
weak_result = evaluate_human_voice_gain(
|
||||
weak_pack,
|
||||
thresholds=_test_thresholds(),
|
||||
)
|
||||
self.assertIn("recomputed_categorical_kappa", weak_result.failure_reasons)
|
||||
|
||||
def test_analysis_registration_must_strictly_precede_held_out_access(self) -> None:
|
||||
payload = _valid_payload()
|
||||
provenance = payload["provenance"]
|
||||
assert isinstance(provenance, dict)
|
||||
provenance["registered_at"] = provenance["held_out_labels_opened_at"]
|
||||
with self.assertRaisesRegex(ValidationError, "must precede"):
|
||||
G7HumanVoiceGainEvidencePack.model_validate(payload)
|
||||
|
||||
wrong_alpha = _valid_payload()
|
||||
power_plan = wrong_alpha["power_plan"]
|
||||
assert isinstance(power_plan, dict)
|
||||
power_plan["alpha"] = 0.01
|
||||
with self.assertRaises(ValidationError):
|
||||
G7HumanVoiceGainEvidencePack.model_validate(wrong_alpha)
|
||||
|
||||
def test_custom_thresholds_require_explicit_test_factory(self) -> None:
|
||||
pack = G7HumanVoiceGainEvidencePack.model_validate(_valid_payload())
|
||||
with self.assertRaisesRegex(ValueError, "test-only"):
|
||||
|
|
|
|||
|
|
@ -333,6 +333,7 @@ test.describe("dev dashboard static command center", () => {
|
|||
for (const viewport of [
|
||||
{ width: 1440, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 320, height: 568 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`);
|
||||
|
|
|
|||
426
apps/web/e2e/periodic-learner-real-closed-loop.spec.ts
Normal file
426
apps/web/e2e/periodic-learner-real-closed-loop.spec.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIResponse,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import {
|
||||
completeAlliancePreCheckpoint,
|
||||
useRealApi,
|
||||
} from "./support";
|
||||
|
||||
interface ReturnedPracticeFixture {
|
||||
schema_version: "vignette.returned-practice-browser-fixture.v1";
|
||||
login: {
|
||||
email: string;
|
||||
role: "learner";
|
||||
display_name: string;
|
||||
cohort_ids: string[];
|
||||
};
|
||||
source_session_id: string;
|
||||
practice_session_id: string;
|
||||
deliberate: {
|
||||
prescription_id: string;
|
||||
criterion_id: string;
|
||||
novelty: "familiar" | "unseen_transfer";
|
||||
mode:
|
||||
| "replay"
|
||||
| "branch"
|
||||
| "constrained_response"
|
||||
| "voice_retry"
|
||||
| "difficulty_ladder";
|
||||
};
|
||||
transfer: {
|
||||
prescription_id: string;
|
||||
suite_id: string;
|
||||
trial_id: string;
|
||||
criterion_id: string;
|
||||
novelty: "unseen_transfer";
|
||||
mode:
|
||||
| "counterevidence_forecast"
|
||||
| "evidence_recall"
|
||||
| "uncertainty_range"
|
||||
| "collect_more_evidence";
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
session_id: string;
|
||||
persona_code: string;
|
||||
}
|
||||
|
||||
interface AuthMe {
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
interface DeliberateSubmission {
|
||||
idempotent_replay: boolean;
|
||||
}
|
||||
|
||||
interface DeliberateReadModel {
|
||||
episodes: Array<{
|
||||
session_id?: string | null;
|
||||
attempts?: unknown[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TransferSubmission {
|
||||
idempotent_replay: boolean;
|
||||
assessment: {
|
||||
execution_count: number;
|
||||
independent_execution_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TransferReadModel {
|
||||
actual_executions: Array<{
|
||||
original_transfer_trial_record_id: string;
|
||||
practice_session_id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const LIVE_GATE = process.env.E2E_PERIODIC_LEARNER_REAL_CLOSED_LOOP === "1";
|
||||
const FIXTURE_PATH = process.env.E2E_RETURNED_PRACTICE_FIXTURE ?? "";
|
||||
const RESULT_PATH = process.env.E2E_PERIODIC_LEARNER_RESULT ?? "";
|
||||
|
||||
function loadFixture(): ReturnedPracticeFixture {
|
||||
if (!FIXTURE_PATH) {
|
||||
throw new Error("E2E_RETURNED_PRACTICE_FIXTURE is required");
|
||||
}
|
||||
return JSON.parse(
|
||||
readFileSync(path.resolve(FIXTURE_PATH), "utf8"),
|
||||
) as ReturnedPracticeFixture;
|
||||
}
|
||||
|
||||
async function expectOk(response: APIResponse) {
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function signInExistingFixture(
|
||||
page: Page,
|
||||
fixture: ReturnedPracticeFixture,
|
||||
): Promise<string> {
|
||||
const login = await page.request.post("/api/auth/dev-login", {
|
||||
data: fixture.login,
|
||||
});
|
||||
await expectOk(login);
|
||||
const me = await page.request.get("/api/auth/me");
|
||||
await expectOk(me);
|
||||
return ((await me.json()) as AuthMe).user_id;
|
||||
}
|
||||
|
||||
function launchSearch(
|
||||
fixture: ReturnedPracticeFixture,
|
||||
kind: "deliberate" | "transfer",
|
||||
): string {
|
||||
const source = kind === "deliberate" ? fixture.deliberate : fixture.transfer;
|
||||
const search = new URLSearchParams({
|
||||
launch: kind,
|
||||
prescription: source.prescription_id,
|
||||
source_session: fixture.source_session_id,
|
||||
criterion: source.criterion_id,
|
||||
novelty: source.novelty,
|
||||
mode: source.mode,
|
||||
});
|
||||
if (kind === "transfer") {
|
||||
search.set("suite", fixture.transfer.suite_id);
|
||||
search.set("trial", fixture.transfer.trial_id);
|
||||
}
|
||||
return search.toString();
|
||||
}
|
||||
|
||||
function runtimeAttemptCount(
|
||||
readModel: DeliberateReadModel,
|
||||
practiceSessionId: string,
|
||||
): number {
|
||||
return readModel.episodes
|
||||
.filter((episode) => episode.session_id === practiceSessionId)
|
||||
.reduce((count, episode) => count + (episode.attempts?.length ?? 0), 0);
|
||||
}
|
||||
|
||||
function actualExecutionCount(
|
||||
readModel: TransferReadModel,
|
||||
fixture: ReturnedPracticeFixture,
|
||||
practiceSessionId: string,
|
||||
): number {
|
||||
return readModel.actual_executions.filter(
|
||||
(execution) =>
|
||||
execution.original_transfer_trial_record_id === fixture.transfer.trial_id &&
|
||||
execution.practice_session_id === practiceSessionId,
|
||||
).length;
|
||||
}
|
||||
|
||||
async function readPracticeCount(page: Page, practiceSessionId: string) {
|
||||
const response = await page.request.get("/api/practice/learners/me");
|
||||
await expectOk(response);
|
||||
return runtimeAttemptCount(
|
||||
(await response.json()) as DeliberateReadModel,
|
||||
practiceSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
async function readTransferCount(
|
||||
page: Page,
|
||||
fixture: ReturnedPracticeFixture,
|
||||
practiceSessionId: string,
|
||||
) {
|
||||
const response = await page.request.get("/api/calibration/learners/me");
|
||||
await expectOk(response);
|
||||
return actualExecutionCount(
|
||||
(await response.json()) as TransferReadModel,
|
||||
fixture,
|
||||
practiceSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
async function openEvidenceCard(
|
||||
page: Page,
|
||||
fixture: ReturnedPracticeFixture,
|
||||
practiceSessionId: string,
|
||||
kind: "deliberate" | "transfer",
|
||||
) {
|
||||
await page.goto(
|
||||
`/learn/session/${practiceSessionId}/review?${launchSearch(fixture, kind)}`,
|
||||
);
|
||||
const insightsTab = page.locator("#sr-tab-insights");
|
||||
await expect(insightsTab).toBeVisible({ timeout: 30_000 });
|
||||
if ((await insightsTab.getAttribute("aria-selected")) !== "true") {
|
||||
await insightsTab.click();
|
||||
}
|
||||
const selector =
|
||||
kind === "deliberate" ? ".dp-runtime-observation" : ".ct-actual-transfer";
|
||||
const card = page.locator("#sr-panel-insights").locator(selector);
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
return card;
|
||||
}
|
||||
|
||||
function writeResult(result: Record<string, unknown>) {
|
||||
if (!RESULT_PATH) {
|
||||
throw new Error("E2E_PERIODIC_LEARNER_RESULT is required");
|
||||
}
|
||||
const resolved = path.resolve(RESULT_PATH);
|
||||
mkdirSync(path.dirname(resolved), { recursive: true });
|
||||
writeFileSync(resolved, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
test.describe("periodic same-learner real closed loop", () => {
|
||||
test.skip(!LIVE_GATE, "Explicit disposable periodic gate only");
|
||||
|
||||
test("@single-run home recommendation, SSE session, review, G4/G5 POST and reload stay one learner", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(20 * 60_000);
|
||||
const fixture = loadFixture();
|
||||
expect(fixture.schema_version).toBe(
|
||||
"vignette.returned-practice-browser-fixture.v1",
|
||||
);
|
||||
await useRealApi(page);
|
||||
const learnerId = await signInExistingFixture(page, fixture);
|
||||
|
||||
const preparedSession = await page.request.get(
|
||||
`/api/sessions/${fixture.practice_session_id}`,
|
||||
);
|
||||
await expectOk(preparedSession);
|
||||
const practicePersona = ((await preparedSession.json()) as SessionDetail)
|
||||
.persona_code;
|
||||
expect(practicePersona).toBeTruthy();
|
||||
|
||||
await page.goto("/learn");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("다음 연습 추천", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("리뷰 확인을 우선합니다.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "리뷰 확인하기" }).click();
|
||||
await expect(page).toHaveURL(/\/learn\/history$/);
|
||||
|
||||
await page.goto(`/learn/practice?${launchSearch(fixture, "deliberate")}`);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /처방을 이어받았습니다/ }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
const escapedPersona = practicePersona.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const persona = page.getByRole("option", {
|
||||
name: new RegExp(escapedPersona),
|
||||
});
|
||||
await persona.click();
|
||||
await expect(persona).toHaveAttribute("aria-selected", "true");
|
||||
await page.getByRole("button", { name: "새 회기 시작" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${escapedPersona}\\?`));
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await completeAlliancePreCheckpoint(page);
|
||||
|
||||
const activeUrl = new URL(page.url());
|
||||
const practiceSessionId = activeUrl.pathname.split("/").filter(Boolean).at(-1);
|
||||
expect(practiceSessionId).toBeTruthy();
|
||||
expect(practiceSessionId).not.toBe(practicePersona);
|
||||
|
||||
const learnerText =
|
||||
"그 말을 꺼내기까지 많이 외롭고 조심스러웠던 것 같아요. 제가 이해한 마음이 맞을까요?";
|
||||
const streamResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
response.url().endsWith(`/api/sessions/${practiceSessionId}/stream`),
|
||||
{ timeout: 6 * 60_000 },
|
||||
);
|
||||
await page.getByLabel("학습자 발화 입력").fill(learnerText);
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
const stream = await streamResponse;
|
||||
await expectOk(stream);
|
||||
expect(stream.headers()["content-type"] ?? "").toContain("text/event-stream");
|
||||
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible();
|
||||
await expect(page.locator(".sx-utt.is-client").last()).toBeVisible({
|
||||
timeout: 6 * 60_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "회기 종료" }).click();
|
||||
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/learn/session/${practiceSessionId}/review\\?`),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const review = await page.request.get(
|
||||
`/api/sessions/${practiceSessionId}/review`,
|
||||
);
|
||||
if (!review.ok()) return false;
|
||||
return Boolean(((await review.json()) as { reviewReady?: boolean }).reviewReady);
|
||||
},
|
||||
{ timeout: 6 * 60_000, intervals: [1_000, 2_000, 5_000] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
expect(await readPracticeCount(page, practiceSessionId!)).toBe(0);
|
||||
let deliberateCard = await openEvidenceCard(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
"deliberate",
|
||||
);
|
||||
const g4PostUrl = `/api/practice/${encodeURIComponent(
|
||||
fixture.deliberate.prescription_id,
|
||||
)}/attempts/from-session/${practiceSessionId}`;
|
||||
const g4FirstPost = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" && response.url().endsWith(g4PostUrl),
|
||||
{ timeout: 5 * 60_000 },
|
||||
);
|
||||
await deliberateCard
|
||||
.getByRole("button", { name: "이번 회기를 독립 관찰로 반영" })
|
||||
.click();
|
||||
const g4First = await g4FirstPost;
|
||||
await expectOk(g4First);
|
||||
expect(((await g4First.json()) as DeliberateSubmission).idempotent_replay).toBe(
|
||||
false,
|
||||
);
|
||||
const g4FirstCount = await readPracticeCount(page, practiceSessionId!);
|
||||
expect(g4FirstCount).toBe(1);
|
||||
await page.reload();
|
||||
deliberateCard = await openEvidenceCard(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
"deliberate",
|
||||
);
|
||||
const g4ReplayPost = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" && response.url().endsWith(g4PostUrl),
|
||||
{ timeout: 5 * 60_000 },
|
||||
);
|
||||
await deliberateCard
|
||||
.getByRole("button", { name: "반영 상태 다시 확인" })
|
||||
.click();
|
||||
const g4Replay = await g4ReplayPost;
|
||||
await expectOk(g4Replay);
|
||||
expect(((await g4Replay.json()) as DeliberateSubmission).idempotent_replay).toBe(
|
||||
true,
|
||||
);
|
||||
const g4ReplayCount = await readPracticeCount(page, practiceSessionId!);
|
||||
expect(g4ReplayCount).toBe(1);
|
||||
|
||||
expect(await readTransferCount(page, fixture, practiceSessionId!)).toBe(0);
|
||||
let transferCard = await openEvidenceCard(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
"transfer",
|
||||
);
|
||||
const g5PostUrl = "/api/calibration/transfer-executions";
|
||||
const g5FirstPost = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" && response.url().endsWith(g5PostUrl),
|
||||
{ timeout: 5 * 60_000 },
|
||||
);
|
||||
await transferCard
|
||||
.getByRole("button", { name: "이 회기를 전이 근거로 확인" })
|
||||
.click();
|
||||
const g5First = await g5FirstPost;
|
||||
await expectOk(g5First);
|
||||
const g5FirstBody = (await g5First.json()) as TransferSubmission;
|
||||
expect(g5FirstBody.idempotent_replay).toBe(false);
|
||||
expect(g5FirstBody.assessment.execution_count).toBe(1);
|
||||
expect(g5FirstBody.assessment.independent_execution_count).toBe(1);
|
||||
const g5FirstCount = await readTransferCount(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
);
|
||||
expect(g5FirstCount).toBe(1);
|
||||
await page.reload();
|
||||
transferCard = await openEvidenceCard(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
"transfer",
|
||||
);
|
||||
const g5ReplayPost = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" && response.url().endsWith(g5PostUrl),
|
||||
{ timeout: 5 * 60_000 },
|
||||
);
|
||||
await transferCard
|
||||
.getByRole("button", { name: "같은 회기 기록 다시 확인" })
|
||||
.click();
|
||||
const g5Replay = await g5ReplayPost;
|
||||
await expectOk(g5Replay);
|
||||
expect(((await g5Replay.json()) as TransferSubmission).idempotent_replay).toBe(
|
||||
true,
|
||||
);
|
||||
const g5ReplayCount = await readTransferCount(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
);
|
||||
expect(g5ReplayCount).toBe(1);
|
||||
|
||||
const meAfter = await page.request.get("/api/auth/me");
|
||||
await expectOk(meAfter);
|
||||
expect(((await meAfter.json()) as AuthMe).user_id).toBe(learnerId);
|
||||
|
||||
writeResult({
|
||||
schema_version: "vignette.periodic-learner-real-closed-loop.v1",
|
||||
learner_id: learnerId,
|
||||
source_session_id: fixture.source_session_id,
|
||||
practice_session_id: practiceSessionId,
|
||||
same_learner: true,
|
||||
recommendation_verified: true,
|
||||
session_created_in_browser: true,
|
||||
sse_turn_verified: true,
|
||||
review_ready: true,
|
||||
g4: {
|
||||
initial_count: 0,
|
||||
first_count: g4FirstCount,
|
||||
replay_count: g4ReplayCount,
|
||||
replay_idempotent: true,
|
||||
},
|
||||
g5: {
|
||||
initial_count: 0,
|
||||
first_count: g5FirstCount,
|
||||
replay_count: g5ReplayCount,
|
||||
replay_idempotent: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1020,6 +1020,20 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
);
|
||||
await expectReadableButtonContrast(page, "학습 대상 선택");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expectInsideInitialViewport(
|
||||
page,
|
||||
"[data-learner-primary-action]",
|
||||
"320px 학습자 홈 핵심 행동",
|
||||
);
|
||||
await expectMinimumHitTargets(page, ".lh-tabs [role='tab']", "320px 학습 홈 탭");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await capture(page, testInfo.project.name, "home-320x568");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
}
|
||||
await capture(page, testInfo.project.name, "home");
|
||||
|
||||
await primaryAction.click();
|
||||
|
|
@ -1061,6 +1075,7 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
await expect(page.locator(".sx-utt").filter({ hasText: LEARNER_TEXT })).toBeVisible();
|
||||
await expect(page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY })).toBeVisible();
|
||||
await expect(page.locator(".sx-page")).not.toContainText("[NAME]");
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await expect(
|
||||
page.getByRole("region", { name: "현재 회기 요약" }),
|
||||
|
|
@ -1145,7 +1160,22 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
await expect(practiceCard).toContainText("미지 사례 전이 아직 미검증");
|
||||
await expect(practiceCard).not.toContainText("criterion.reflect-and-check");
|
||||
await expect(practiceCard).not.toContainText("unseen_transfer_not_verified");
|
||||
await expect(page.locator(".sr-root")).not.toContainText("[NAME]");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await practiceCard.scrollIntoViewIfNeeded();
|
||||
await expectMinimumHitTargets(
|
||||
page,
|
||||
".dp-launch-ticket__cta",
|
||||
"320px 처방 연습 시작 행동",
|
||||
);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await capture(page, testInfo.project.name, "review-prescription-320x568");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
}
|
||||
await practiceCard.scrollIntoViewIfNeeded();
|
||||
await capture(page, testInfo.project.name, "review-prescription");
|
||||
|
||||
|
|
@ -1185,6 +1215,16 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
await expect(page.locator(".lh-root")).toContainText("원본 회기 참조");
|
||||
await expect(page.locator(".lh-root")).not.toContainText("criterion.reflect-and-check");
|
||||
await expect(page.locator(".lh-root")).not.toContainText(PRIMARY_SESSION_ID.slice(0, 8));
|
||||
await expect(page.locator(".lh-root")).not.toContainText("[NAME]");
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expectMinimumHitTargets(page, ".lh-actions .vg-btn", "320px 재연습 대상 행동");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await capture(page, testInfo.project.name, "repractice-home-320x568");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
}
|
||||
await page.getByRole("button", { name: "새 회기 시작" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "처방 연습 · 장면 다시 보기" }),
|
||||
|
|
@ -1209,6 +1249,24 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
"처방 재연습 시작 행동",
|
||||
);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expectInsideInitialViewport(
|
||||
page,
|
||||
".sx-prestart__actions .vg-btn",
|
||||
"320px 처방 재연습 시작 행동",
|
||||
);
|
||||
await expectMinimumHitTargets(
|
||||
page,
|
||||
".sx-prestart__actions .vg-btn",
|
||||
"320px 처방 재연습 시작 행동",
|
||||
);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await capture(page, testInfo.project.name, "repractice-prestart-320x568");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
}
|
||||
await capture(page, testInfo.project.name, "repractice-prestart");
|
||||
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
|
|
@ -1297,6 +1355,22 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
await expect.poll(() => fixture.practiceReadCount).toBeGreaterThan(
|
||||
readsBeforeSuccess,
|
||||
);
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await observation.scrollIntoViewIfNeeded();
|
||||
await expectMinimumHitTargets(
|
||||
page,
|
||||
".dp-runtime-observation button",
|
||||
"320px 서버 관찰 행동",
|
||||
);
|
||||
await expect(observation).not.toContainText(RETRY_SESSION_ID.slice(0, 8));
|
||||
await expect(observation).not.toContainText("[NAME]");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await capture(page, testInfo.project.name, "repractice-observation-320x568");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
}
|
||||
await observation.scrollIntoViewIfNeeded();
|
||||
await capture(page, testInfo.project.name, "repractice-observation");
|
||||
|
||||
|
|
|
|||
|
|
@ -206,13 +206,13 @@ async function expectMainControlsUnclipped(page: Page) {
|
|||
const controls = [
|
||||
{ selector: ".sx-compose textarea", parent: ".sx-compose" },
|
||||
{ selector: ".sx-compose .vg-btn", parent: ".sx-compose" },
|
||||
{ selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar" },
|
||||
{ selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar", minTouchSize: 44 },
|
||||
{ selector: ".sx-controlbar .sx-segmented", parent: ".sx-controlbar" },
|
||||
{ selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar" },
|
||||
{ selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar" },
|
||||
{ selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar", minTouchSize: 44 },
|
||||
{ selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar", minTouchSize: 44 },
|
||||
];
|
||||
|
||||
return controls.map(({ selector, parent }) => {
|
||||
return controls.map(({ selector, parent, minTouchSize }) => {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
const parentEl = document.querySelector<HTMLElement>(parent);
|
||||
if (!el || !parentEl) return { selector, ok: false, reason: "missing" };
|
||||
|
|
@ -236,11 +236,21 @@ async function expectMainControlsUnclipped(page: Page) {
|
|||
rect.left >= parentRect.left - 1 &&
|
||||
rect.right <= parentRect.right + 1 &&
|
||||
rect.bottom <= parentRect.bottom + 1;
|
||||
const touchTargetOk =
|
||||
minTouchSize === undefined || (rect.width >= minTouchSize && rect.height >= minTouchSize);
|
||||
|
||||
return {
|
||||
selector,
|
||||
ok: visible && insideParent && !textClipped,
|
||||
reason: !visible ? "not-visible" : !insideParent ? "outside-parent" : textClipped ? "text-clipped" : "",
|
||||
ok: visible && insideParent && !textClipped && touchTargetOk,
|
||||
reason: !visible
|
||||
? "not-visible"
|
||||
: !insideParent
|
||||
? "outside-parent"
|
||||
: textClipped
|
||||
? "text-clipped"
|
||||
: !touchTargetOk
|
||||
? "touch-target-under-44px"
|
||||
: "",
|
||||
rect: {
|
||||
top: Math.round(rect.top),
|
||||
right: Math.round(rect.right),
|
||||
|
|
|
|||
|
|
@ -2386,7 +2386,7 @@
|
|||
flex: none;
|
||||
}
|
||||
.sx-end-button {
|
||||
min-height: 40px;
|
||||
min-height: 44px;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid color-mix(in srgb, var(--crit-solid) 34%, var(--border-subtle));
|
||||
border-radius: var(--radius);
|
||||
|
|
@ -4210,6 +4210,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* 처방 재연습은 카드 안에 과제 종류·성공 기준·출처를 다시 명시한다. 320×568처럼
|
||||
세로가 아주 짧은 화면에서 상단 단계 헤더까지 반복하면 유일한 주 행동인 "회기 시작"이
|
||||
첫 화면 아래로 밀린다. 이 구간만 중복 헤더를 접고 셸 여백을 줄여 과제 맥락과 CTA를
|
||||
함께 보이게 한다. 일반 회기 준비 화면과 390×844 이상 레이아웃은 그대로 유지한다. */
|
||||
@media (max-width: 460px) and (max-height: 620px) {
|
||||
.vg-main:has(.sx-page--prestart .sx-prestart--prescribed) {
|
||||
padding-top: var(--sp-2);
|
||||
padding-bottom: var(--sp-3);
|
||||
}
|
||||
.sx-page--prestart:has(.sx-prestart--prescribed) .sx-head {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) and (max-height: 820px) {
|
||||
.sx-sessionbar {
|
||||
min-height: 38px;
|
||||
|
|
@ -4355,19 +4369,18 @@
|
|||
.sx-page--active .sx-mic-block__ms {
|
||||
display: none;
|
||||
}
|
||||
/* 아주 좁거나 낮은 뷰포트: 세로 공간 확보를 위해 40px 로 낮춘다
|
||||
(390x720 등 여유 있는 폭에서는 44px 유지). 가로 터치 폭은 유지. */
|
||||
/* 아주 좁거나 낮은 뷰포트에서도 주요 회기 제어는 44px 터치 하한을 지킨다. */
|
||||
.sx-page--active .sx-mic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.sx-page--active .sx-pause {
|
||||
width: 44px;
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
}
|
||||
.sx-page--active .sx-end-button {
|
||||
min-width: 104px;
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -453,6 +453,10 @@ G7 내부 소스 계약은 완료됐고 `scripts/check-g7-external-proof.py`는
|
|||
`apps/api/requirements.txt`와 명시적 Python 3.11 모두 `psutil==6.1.1`로 고정됐다.
|
||||
- 현재 운영 STT 모델은 이 호스트에서 실측된 CPU int8 `small`로 고정한다. cuDNN 9가 설치되고 별도 성능·정확도
|
||||
gate를 통과하기 전까지 launcher·API runtime metadata·50분 runner/checker expected model을 모두 `small`로 유지한다.
|
||||
- human voice-gain pack은 category와 categorical κ를 필수로 포함하고, preregistration이 held-out 공개보다 앞서야 하며,
|
||||
양 조건이 모두 관측된 50회기/150축만 paired 표본으로 집계한다. 한쪽 결측이면 양쪽 모두 최대오류 ITT로 처리한다.
|
||||
`scripts/check-g7-human-voice-gain.py --input <pack.json>`을 먼저 통과하지 못하면 production runner는 마이크를
|
||||
열기 전에 exit 2로 끝난다. 스키마는 `--print-schema`로 출력한다. 관련 API+script 계약은 97/97 통과했다.
|
||||
- 무마이크 rehearsal 산출물은
|
||||
`D:\workspace\vignette-runtime-evidence\g7-rehearsal-b34623f3db05-20260809T134105Z`에 있다.
|
||||
voice `61af2e98…009c`, runtime `c6e8670e…4454`, topology `89f1cb86…a251`이며 세 leg 모두 passed,
|
||||
|
|
@ -673,9 +677,14 @@ node.exe .\node_modules\@playwright\test\cli.js test e2e/session-layout.spec.ts
|
|||
--project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1 --reporter=line
|
||||
```
|
||||
|
||||
전체 Playwright inventory는 626 tests / 44 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를
|
||||
전체 Playwright inventory는 627 tests / 45 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를
|
||||
제품 failure로 오인하지 않는다. 같은 blocker가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다.
|
||||
|
||||
주기 실회기 검증은 `scripts/run-periodic-learner-e2e.py`가 소유한다. NAS 112건 중 실 API/DB는 22건이고
|
||||
route fixture는 90건이므로 전체 숫자를 실제 회기 폐루프로 부르면 안 된다. 새 runner는 clean HEAD/tree에서만
|
||||
전용 engine/DB/API/Web을 만들고 같은 학습자의 SSE→review→G4/G5 0→1→1과 returned-practice desktop/mobile 4건을
|
||||
검증한 뒤 sentinel resource·PID·listener·temp 잔여 0을 영수증으로 남긴다. 공개 8001/55432/9099와 NAS는 금지다.
|
||||
|
||||
## 9. 핵심 변경 파일
|
||||
|
||||
- `scripts/launch-nas-preview-g8-helpers.py` · `scripts/test_launch_nas_preview_g8_helpers.py` (신규, Gate6 계약)
|
||||
|
|
|
|||
|
|
@ -110,6 +110,9 @@
|
|||
(2) **독립 blind human voice-gain pack** — held-out 30명 외 calibration split 참가자 포함 총 최소 31명 /
|
||||
held-out 50회기 / 150 paired axis / 독립 평가자 2인 /
|
||||
ICC(A,1) ≥ 0.75 · κ ≥ 0.70 · gain ≥ 0.01 · participant-cluster bootstrap 10,000회 95% CI lower > 0.
|
||||
runner는 이 pack의 category·κ·preregistration·paired completeness·결측 ITT를 3,120초 캡처 전에 검증하며,
|
||||
부적합하면 마이크를 열지 않고 exit 2로 끝낸다. 독립 검수는
|
||||
`python -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>`이고 `--print-schema`로 계약을 확인한다.
|
||||
**공개 선행 조건 완료(2026-08-09):** detached-clean `b34623f3…`·tree `32cc85c7…`에서 OpenAPI 126,
|
||||
`/admin/voice-runtime`, exact local voice provider/model과 queue 4를 제공한다. fresh launcher receipt
|
||||
`9f8d1941…a21bf`가 passed이고 두 Scheduled Task도 동일 source에 pin돼 result 0이다. Cloudflare의 Python
|
||||
|
|
@ -120,7 +123,7 @@
|
|||
`≥3000s`를 checker가 강제한다. Windows artifact는 detached-clean HEAD/tree, runner/collector/checker SHA,
|
||||
exact `psutil==6.1.1`을 매 sample 전후 pin한다. fresh launcher는 legacy API와 exact-config cloudflared를 bounded
|
||||
교체하고 새 PID/start/exe/command SHA/실제 cwd의 raw command-line 없는 receipt를 만든다. G7 runner/checker/topology
|
||||
87/87과 runtime sampler 4/4를 통과했다. `--rehearse`는 인증 WSS ready/ping/close 1000,
|
||||
human pack 계약 97/97과 기존 runtime sampler 4/4를 통과했다. `--rehearse`는 인증 WSS ready/ping/close 1000,
|
||||
runtime 7 samples, Windows topology 7 samples를 모두 통과했고 마이크 capture false·UUID/email literal 0이다.
|
||||
fail-closed 경계(동의 없음·pack 없음·3,120초 미만·host/Origin/scheme 불일치)는 CLI로 실증했다. 상세:
|
||||
`ops/outcome-os-g7-external-proof-readiness-2026-08-07.md`.
|
||||
|
|
@ -136,7 +139,9 @@
|
|||
> dump/restore를 수행하지 않으므로 매 execute 직전 fresh custom dump SHA/size/TOC와 DB identity를 별도 결속한다.
|
||||
> 자동 rollback은 image/Compose/active-state만 복구하며 적용 migration/data restore는 별도 owner 승인이 필요하다.
|
||||
> 과거 `6030a677…c611`의 localhost 108/108·평문 origin UUID 24건 실패와 후속 verified rollback은 이력으로 보존한다.
|
||||
> 매일 04:30 KST `vignette-e2e` 자동화는 ACTIVE다.
|
||||
> 매시간 `vignette-e2e` heartbeat 자동화는 ACTIVE다. 공개/NAS 화면과 health는 읽기 전용으로 보여주고,
|
||||
> 소스 material milestone에서만 전용 disposable 회기 E2E를 실행한다. runner의 clean HEAD/tree·로컬 npipe Docker·
|
||||
> loopback 동적 포트·sentinel·exact cleanup 계약과 unit 10/10은 완료됐고, 첫 실제 GREEN receipt는 clean commit 뒤 남았다.
|
||||
> 증거: [배포 증거](./ops/nas-preview-deployment-evidence-2026-08-07.md), [기계 판독 증거](./ops/evidence/nas-preview-current-deploy-2026-08-07.json), [브라우저 증거](./ops/evidence/nas-preview-live-turn-2026-08-07.png). 이는 G7 물리 마이크·독립 사람 평가 증거를 대체하지 않는다.
|
||||
- [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·모델 탐색·설정 저장 경로는 구현 완료. 남은 게이트는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 뒤 같은 프롬프트의 live 응답·계량·오류 표면화를 비교하는 것이다.
|
||||
|
||||
|
|
|
|||
|
|
@ -272,7 +272,14 @@
|
|||
.mstrip{flex-wrap:nowrap;overflow-x:auto;padding-bottom:4px;scrollbar-width:thin;}
|
||||
.mchip{flex:0 0 auto;}
|
||||
}
|
||||
@media (max-width:460px){ .kpis{grid-template-columns:1fr;} h1{font-size:22px;} .stamp{display:none;} }
|
||||
@media (max-width:460px){
|
||||
.kpis{grid-template-columns:1fr;}
|
||||
h1{font-size:22px;}
|
||||
.stamp{display:none;}
|
||||
.dg-note{overflow-wrap:anywhere;}
|
||||
.ocols{grid-template-columns:minmax(0,1fr);}
|
||||
.ocol,.ostack,.ocard{min-width:0;max-width:100%;width:auto;}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){ *{transition:none!important;} }
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -658,6 +665,7 @@
|
|||
<p class="dg-note">190차 적용(2026-08-09): exact include 52개를 clean commit <code>5221f79e…1c69</code>으로 고정하고 tree <code>e4f15001…308b</code>, 2회 동일 archive <code>1109bf86…0f4b</code>를 결속했다. fresh NAS dump <code>6f4b95a7…b529f</code>(1,015,222 bytes·TOC 1,752/TABLE DATA 129) 뒤 execute는 API 921·types·typecheck·build·insecure-context 6/6·candidate DB 회기 후반까지 통과했지만 <b>110/112</b>에서 승격 전에 fail-closed했다. 실패 2건은 Vite entry <code>/src/main.tsx</code>만 차단하던 boot 진단 테스트가 production hashed entry <code>/assets/index-*.js</code>를 차단하지 못한 환경 계약 누락이었다. 두 entry를 함께 차단하도록 고쳐 Vite source 2/2와 production preview 2/2를 통과했다. NAS mutation·rollback·DB migration은 0이며, 테스트 수정의 새 clean commit을 재결속한 한 번의 execute가 남았다.</p>
|
||||
<p class="dg-note">191차 적용(2026-08-09): 후속 clean commit <code>21461ab3…6fde</code>·tree <code>5c5f12a8…524e</code>·archive <code>20d49694…fa1a</code>는 candidate <b>112/112</b>를 통과하고 NAS 새 API/Web <code>d5021950…</code>/<code>376a3aa8…</code>를 올렸다. 실제 NAS-origin postdeploy G4 desktop/mobile 2건이 110/112에서 실패하자 active-state commit 전에 이전 <code>52e0e816…</code>/<code>6fdbb646…</code>로 자동 rollback했고 health·auth 401·OpenAPI 126·G0~G8 route·image ID를 재검증해 rollback <b>verified</b>로 종료했다. exact-image 평문 origin에서 문장 fingerprint의 <code>crypto.subtle.digest</code> 의존이 요청 전 예외를 내는 원인을 확인했다. 원문 외부 전송 없이 portable SHA-256 fallback을 추가하고 표준 digest를 payload에 exact 고정해 secure localhost 2/2와 Tailnet insecure origin 2/2를 통과했다. 새 clean commit의 전체 candidate+NAS-origin 0 failure 전까지 G8은 runtime REVALIDATION이다.</p>
|
||||
<p class="dg-note">192차 적용(2026-08-09): G8 clean-head 최종 승격을 완료했다. source HEAD <code>61a41d1f…6af</code>·tree <code>87dec55d…3b77</code>에서 2회 동일하게 만든 archive <code>4d15d055d74f…119d4d</code>를 active release로 고정했고 candidate session E2E <b>112/112</b>와 실제 <code>http://100.116.83.60:8088</code> 평문 NAS-origin browser review <b>112/112</b>(11 specs, desktop/mobile/single-run)를 모두 통과했다. 실행 이미지는 API <code>d5021950…e4b1</code>·Web <code>9796c092…4b36</code>이며 health는 <code>status=ok·db=true·engine=true</code>, 비인증 auth 401, OpenAPI 126을 재확인했다. execute 직전 fresh custom dump <code>36ec8748…24db8</code>(1,097,100 bytes, TOC 1,738 / TABLE DATA 129)를 보존했고 이전 active <code>6030a677…c611</code>과 이전 이미지는 rollback 기준으로 남겼다. 앞선 fail-closed·verified rollback 이력은 삭제하지 않는다. 이 증거로 G8을 <b>DONE</b>으로 승격하며 남은 Outcome OS gate는 G7 external proof다.</p>
|
||||
<p class="dg-note">193차 적용(2026-08-09): 학생 화면의 320×568 신뢰성 게이트를 닫았다. 처방 재연습 prestart CTA를 첫 화면에 유지하고, active 회기의 마이크·일시정지·종료를 모든 밀도 구간에서 최소 44px로 고정했으며, 대시보드의 긴 privacy token과 owner grid가 320px 문서를 384px까지 넓히던 overflow를 제거했다. typecheck, session-layout desktop/mobile <b>8/8</b>, dev-dashboard desktop/mobile <b>10/10</b>, 자기주도 focused <b>16/16</b>을 통과했다. 동시에 NAS 112건을 실 API/DB 22와 route fixture 90으로 정직하게 분리하고, clean HEAD/tree·전용 disposable stack·동일 학습자 SSE→review→G4/G5 0→1→1·exact cleanup을 강제하는 주기 runner를 추가했다. unit <b>10/10</b>과 Compose preflight는 통과했으며 첫 실제 GREEN receipt는 이 변경의 clean commit 뒤 실행한다. G7 human pack은 category·κ≥0.70·preregistration·paired completeness·결측 ITT를 캡처 전에 검증해 부적합 pack이면 마이크를 열지 않는다.</p>
|
||||
<div class="dg-principles" aria-label="디자인 생성 가드레일">
|
||||
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
|
||||
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
|
||||
|
|
@ -912,7 +920,7 @@
|
|||
</article>
|
||||
<article class="scard" data-status="doing" data-cat="Outcome OS·음성" data-owner="0">
|
||||
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">G7 GATE · INTERNAL DONE · EXTERNAL PROOF</span><span class="scard-mid"><span class="scard-title">Multimodal Alliance — 음성·비언어 동맹 신호</span><span class="scard-sum">clean 공개 runtime·인증 WSS 무마이크 rehearsal까지 닫았고, 동의 마이크·사람 평가 증거를 기다린다.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||
<div class="scard-body"><div class="kv k-good"><b>내부 구현·공개 선행조건 DONE</b><p>consent→단일 clock→독립 text/voice/fusion→철회·tombstone, streaming interim/final·word timestamp·provider event, HMAC word pseudonym, 열린 stream의 1초 동의 재검사·abort, 텍스트 보존·음성 재연결 UX를 연결했다. 운영 기본 provider는 노트북 상주 <code>local_whisper</code>/<code>melotts</code>이고 외부 Deepgram/OpenAI adapter는 fallback으로 보존한다. 격리 synthetic PCM soak는 59/59를 통과했다. Windows host topology는 detached-clean commit/tree·도구 SHA·<code>psutil</code> version과 PID/start/exe·command SHA/cwd·process/TCP high-water를 fail-closed로 수집하고, 3,120초 capture의 공통 3,000초를 canonical exit와 결속한다. 공개 API/cloudflared는 clean <code>b34623f3…</code>·tree <code>32cc85c7…</code>로 fresh 승격돼 OpenAPI 126·<code>/admin/voice-runtime</code>·<code>local_whisper/small</code>·<code>melotts/melotts-korean</code>·WS queue 4를 제공한다. receipt <code>9f8d1941…a21bf</code>가 passed이고 두 source-pinned task도 result 0이다. current six-suite 87/87·runtime sampler 4/4를 통과했다.</p></div><div class="kv k-warn"><b>외부 종료 GATE</b><p>30초 무마이크 rehearsal은 authenticated public WSS ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 <code>physical_capture=false</code>·UUID/email literal 0이다. 남은 것은 사용자가 선택한 장치와 실행 직전 명시 동의를 받은 물리 마이크 3,120초 양방향 soak, 같은 public host·공통 3,000초 시간창의 worker/Uvicorn queue와 process/TCP high-water, 최소 31명·held-out 50회기·150축·blind evaluator 2인의 독립 human voice-gain pack이다. 네 artifact가 <code>scripts/check-g7-external-proof.py</code> exit 0을 만들기 전에는 메인 상태를 DONE으로 바꾸지 않는다.</p></div></div>
|
||||
<div class="scard-body"><div class="kv k-good"><b>내부 구현·공개 선행조건 DONE</b><p>consent→단일 clock→독립 text/voice/fusion→철회·tombstone, streaming interim/final·word timestamp·provider event, HMAC word pseudonym, 열린 stream의 1초 동의 재검사·abort, 텍스트 보존·음성 재연결 UX를 연결했다. 운영 기본 provider는 노트북 상주 <code>local_whisper</code>/<code>melotts</code>이고 외부 Deepgram/OpenAI adapter는 fallback으로 보존한다. 격리 synthetic PCM soak는 59/59를 통과했다. Windows host topology는 detached-clean commit/tree·도구 SHA·<code>psutil</code> version과 PID/start/exe·command SHA/cwd·process/TCP high-water를 fail-closed로 수집하고, 3,120초 capture의 공통 3,000초를 canonical exit와 결속한다. 공개 API/cloudflared는 clean <code>b34623f3…</code>·tree <code>32cc85c7…</code>로 fresh 승격돼 OpenAPI 126·<code>/admin/voice-runtime</code>·<code>local_whisper/small</code>·<code>melotts/melotts-korean</code>·WS queue 4를 제공한다. receipt <code>9f8d1941…a21bf</code>가 passed이고 두 source-pinned task도 result 0이다. human pack은 category·κ·preregistration·paired completeness·결측 ITT를 캡처 전에 검증하며 관련 계약 97/97·runtime sampler 4/4를 통과했다.</p></div><div class="kv k-warn"><b>외부 종료 GATE</b><p>30초 무마이크 rehearsal은 authenticated public WSS ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 <code>physical_capture=false</code>·UUID/email literal 0이다. 남은 것은 사용자가 선택한 장치와 실행 직전 명시 동의를 받은 물리 마이크 3,120초 양방향 soak, 같은 public host·공통 3,000초 시간창의 worker/Uvicorn queue와 process/TCP high-water, 최소 31명·held-out 50회기·150축·blind evaluator 2인의 독립 human voice-gain pack이다. pack은 <code>check-g7-human-voice-gain.py</code>를 먼저 통과해야 하며 부적합하면 마이크를 열기 전에 exit 2다. 네 artifact가 <code>scripts/check-g7-external-proof.py</code> exit 0을 만들기 전에는 메인 상태를 DONE으로 바꾸지 않는다.</p></div></div>
|
||||
</article>
|
||||
<article class="scard" data-status="done" data-cat="Outcome OS·에이전틱" data-owner="0">
|
||||
<button class="scard-head" aria-expanded="false"><span class="chip c-done">G8 DONE · CLEAN-HEAD NAS VERIFIED</span><span class="scard-mid"><span class="scard-title">Autonomous Content & Continuous Improvement</span><span class="scard-sum">agentic worker·human gate·실제 rollback과 current clean source의 NAS-origin 112/112를 모두 닫았다.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||
|
|
@ -1068,7 +1076,7 @@
|
|||
<tbody>
|
||||
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
|
||||
<tr><td>Design SSOT / auth visual</td><td><code>npm run check:design-ssot</code> / <code>npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line</code></td><td>SSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed.</td></tr>
|
||||
<tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-08-09 현재 수집은 <b>626 tests / 44 files</b>다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다.</td></tr>
|
||||
<tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-08-09 현재 수집은 <b>627 tests / 45 files</b>다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다.</td></tr>
|
||||
<tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>ops/refactor-governance-2026-07-15.md</code>.</td></tr>
|
||||
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
|
||||
<tr><td>Outcome & Alliance OS G0</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q</code> / <code>scripts/check-measurement-ledger.sql</code> / measurement·API contract checks / web typecheck / DB-backed <code>session-persistence</code> focused E2E 3종</td><td>G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료.</td></tr>
|
||||
|
|
@ -1145,7 +1153,7 @@
|
|||
<tr><td>Docker image smoke</td><td><code>docker build -f apps/api/Dockerfile .</code> / <code>docker run ... python -c "import app.main"</code> / <code>docker build -f apps/web/Dockerfile apps/web</code></td><td>API/Web image build and API import smoke pass after packaging cleanup. Web build uses npm lockfile and ignores host <code>node_modules</code>; API image excludes local <code>.env</code> files.</td></tr>
|
||||
<tr><td>Deploy preflight</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / DB mode with local <code>DATABASE_URL</code></td><td>Passed: exact-pinned API requirements, live coaching <code>data/kb</code> source pack, env template keys, and DB readiness (<code>current_user=vignette</code>). DB mode can additionally check app-role DSN with <code>--require-app-role</code> and now verifies session read-model columns including <code>app.turns</code> voice metadata columns(<code>audio_ref</code>/<code>silence_ms</code>/<code>speech_rate</code>/<code>barge_in</code>/<code>provider_events</code>) plus worksheet review columns.</td></tr>
|
||||
<tr><td>Fresh compose smoke</td><td><code>docker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build</code> + proxy <code>/api/health</code></td><td>Passed with dummy production-safe env: API healthy, DB healthy, web/proxy up, <code>http://localhost:18080/api/health</code> 200 with <code>db:true</code>, <code>engine:true</code>, <code>engine_mode:"claude_cli"</code>. Smoke volumes/network removed after run.</td></tr>
|
||||
<tr><td>격리 NAS 프리뷰 · 회기 E2E 자동화</td><td><code>http://100.116.83.60:8088</code> / <code>vignette-e2e</code> 매일 04:30 KST / <a href="./ops/nas-preview-deployment-evidence-2026-08-07.md">배포 증거</a> / <a href="./ops/evidence/nas-preview-current-deploy-2026-08-07.json">기계 판독 증거</a></td><td>current clean source HEAD <code>61a41d1f…6af</code>·tree <code>87dec55d…3b77</code>의 archive <code>4d15d055…119d4d</code>를 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. candidate session E2E 112/112와 실제 NAS 평문 origin browser review 112/112, health ok·db/engine true, OpenAPI 126·auth 401을 통과했고 fresh dump와 이전 active/images를 보존했다. 자동화는 ACTIVE이며 material milestone+release gate·NAS preflight+배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다.</td></tr>
|
||||
<tr><td>격리 NAS 프리뷰 · 회기 E2E 자동화</td><td><code>http://100.116.83.60:8088</code> / <code>vignette-e2e</code> 매시간 heartbeat / <a href="./ops/nas-preview-deployment-evidence-2026-08-07.md">배포 증거</a> / <a href="./ops/evidence/nas-preview-current-deploy-2026-08-07.json">기계 판독 증거</a></td><td>current clean source HEAD <code>61a41d1f…6af</code>·tree <code>87dec55d…3b77</code>의 archive <code>4d15d055…119d4d</code>를 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. candidate 112/112와 실제 NAS-origin 112/112는 통과했지만 영수증은 실 API/DB 22건과 route fixture 90건을 분리한다. 자동화는 공개/NAS 화면과 health를 읽기 전용으로 매시간 보여준다. material milestone의 동일 학습자 SSE→review→G4/G5 actual 폐루프는 clean HEAD/tree·전용 engine/DB/API/Web·보호 포트 거부·sentinel·exact cleanup runner가 소유하며 unit 10/10은 통과했다. 첫 실제 GREEN receipt는 clean commit 뒤 남았다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다.</td></tr>
|
||||
<tr><td>G7 외부 증거 3-artifact 동시 시간창 오케스트레이터</td><td><code>scripts/run-g7-external-proof-window.py</code> · <a href="./ops/outcome-os-g7-external-proof-readiness-2026-08-07.md">준비 문서</a></td><td>soak·runtime·topology 세 캡처를 같은 host의 공통 3,000초 시간창으로 실행하고 checker까지 잇는다. 공개 배포·프로세스·source pin은 clean commit <code>b34623f3…</code>와 fresh receipt <code>9f8d1941…</code>로 완료했다. Cloudflare가 Python 기본 User-Agent를 403으로 거부하는 경계도 브라우저 호환 header와 회귀 4/4로 고정했다. 30초 rehearsal은 세 leg 7 samples를 모두 통과했으며 마이크를 열지 않았다. <b>남은 선행 조건:</b> 사용자의 물리 장치 선택과 명시 동의, 독립 human voice-gain pack이다.</td></tr>
|
||||
<tr><td>G7 external GATE · 최종 감사 정정</td><td><code>run-g7-external-proof-window.py</code> → <code>check-g7-external-proof.py</code></td><td><b>상태는 external GATE 유지.</b> runner 프로세스 exit는 canonical checker <code>exit 0</code>·<code>gate_closed=true</code>에 결속되고, browser Origin과 API transport host는 별도 allowlist로 검증된다. 세 artifact 교집합은 <code>≥3000s</code> + 120초 margin이고 Windows proof는 detached-clean HEAD/tree·tool SHA·<code>psutil==6.1.1</code>을 pin한다. clean-source 배포와 무마이크 rehearsal은 완료했다. 남은 것은 물리 마이크 3,120초, 동시 runtime/topology, 독립 human pack을 합친 4-artifact 실증뿐이다.</td></tr>
|
||||
<tr><td>G8 DONE · clean-head NAS 기준선</td><td><code>vignette-preview-20260807</code> · <code>run-outcome-os-release-agent.py</code></td><td><b>상태는 DONE.</b> active archive <code>4d15d055…119d4d</code>, source HEAD/tree <code>61a41d1f…6af</code>/<code>87dec55d…3b77</code>, API/Web <code>d5021950…e4b1</code>/<code>9796c092…4b36</code> exact running. candidate 112/112와 actual NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. fresh dump <code>36ec8748…24db8</code> 1,097,100 bytes, TOC 1,738 / TABLE DATA 129와 previous active <code>6030a677…c611</code>을 보존했다. release agent의 DB restore 비소유·image/Compose/active-state rollback 경계는 유지한다.</td></tr>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
|
|||
| API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||
| 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||
| 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
|
||||
| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 626 tests / 44 files · 현 작업트리 전체 GREEN 미검증 |
|
||||
| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 627 tests / 45 files · 현 작업트리 전체 GREEN 미검증 |
|
||||
|
||||
핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.**
|
||||
**E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다.
|
||||
|
|
@ -271,10 +271,17 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
|
|||
|
||||
### 3.6 실측 테스트 개수 (현재)
|
||||
|
||||
2026-08-09 `npx playwright test --list` 기준 **현재 수집 626 tests / 44 files**다.
|
||||
이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 626개 완주는 아직 증거가 없으며,
|
||||
2026-08-09 `npx playwright test --list` 기준 **현재 수집 627 tests / 45 files**다.
|
||||
이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 627개 완주는 아직 증거가 없으며,
|
||||
과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다.
|
||||
|
||||
NAS-origin 112건은 전부 실 API/DB 회기가 아니다. release receipt는 `session-layout`·`session-persistence`
|
||||
22건을 `real_api_db_specs`, 나머지 route fixture 90건을 `route_fixture_specs`로 분리한다.
|
||||
학생 동일 계정의 홈 추천→브라우저 회기 생성→실제 SSE→review ready→G4/G5 POST·reload→0→1→1은
|
||||
`scripts/run-periodic-learner-e2e.py --execute`가 전용 disposable engine/DB/API/Web에서만 실행한다.
|
||||
이 runner는 clean HEAD/tree, 로컬 npipe Docker, loopback 동적 포트, sentinel과 exact cleanup을 강제하며
|
||||
공개 8001·DB 55432·engine 9099와 NAS를 구조적으로 거부한다. 첫 GREEN receipt 전에는 스케줄 등록하지 않는다.
|
||||
|
||||
- **병렬 시나리오**: 166 tests (desktop 83 + mobile 83)
|
||||
- **`@single-run` 직렬 시나리오**: 49 tests (DB 영속화·세션 MVP·음성 성공경로·인증 시각 테마·회기말 평가 저장·교수자 명시 재평가 저장·교수자 턴 재평가 저장·교수자 UI 평가 재시도·교수자 사용자별 분석·source pack sync·이론모드 저장·동의 철회 후 voice 차단·브라우저 stream PII 마스킹·음성 transcript 저장 실패 UI 표면화 등)
|
||||
- 2026-08-06 Outcome & Alliance OS G0 검증: `test_measurement_contract.py` + runtime schema SSOT **11 passed**,
|
||||
|
|
@ -432,6 +439,10 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
|
|||
ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 `physical_capture=false`,
|
||||
UUID/email literal 0이다. current G7 six-suite는 87/87, runtime sampler는 4/4다. 이 리허설은 실제 마이크
|
||||
3,120초·공통 3,000초 high-water·독립 human pack·canonical checker exit 0을 대체하지 않는다.
|
||||
- 2026-08-09 G7 human pack preflight: production runner는 캡처 전에 category·categorical κ≥0.70,
|
||||
preregistration 선행, 50회기/150축 complete pairing, 양 조건 결측 ITT를 검증한다. 독립 검수는
|
||||
`python -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>`이며 `--print-schema`도 지원한다.
|
||||
부적합 pack은 마이크를 열기 전에 exit 2다. API evaluator 11/11 + G7 script 86/86, 합계 97/97 통과했다.
|
||||
- 2026-08-07 G7/G8 시각 QA: 데스크톱·Pixel 5에서 focused **16/16**, typecheck·build·cosmetic-filter를
|
||||
통과했다. G8 승인 차단 이유를 `title` 의존에서 상시 문구·`aria-describedby`·semantic form/Enter 제출로
|
||||
바꾸고, G7 이벤트 최소 24×24px와 시간축 키보드 포커스를 수치 회귀로 고정했다. 상세는
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@
|
|||
PID/start/exe/command SHA/cwd safe receipt를 만든다. current six-suite 87/87과 runtime sampler 4/4를 통과했다.
|
||||
무마이크 rehearsal은 완료됐지만 물리 마이크·human pack을 대체하지 않는다.
|
||||
격리 NAS 프리뷰 `http://100.116.83.60:8088`은 전용 Compose 프로젝트·포트·네트워크·볼륨에 배포했고, 실제 브라우저 회기와
|
||||
review API 저장 축어록 2턴을 확인했다. 기존 프로젝트 중단·재생성 명령은 실행하지 않았다. 매일 04:30 KST `Vignette 회기 E2E 정기 검증`
|
||||
(automation id `vignette-e2e`)은 ACTIVE이며 material milestone+release gate·NAS preflight+배포 SHA 변경 때만 프리뷰를 갱신한다. 2026-08-07 SHA `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`의 localhost 108/108과 평문 origin UUID 24건 실패는 역사 기준선으로 보존한다. 실제 receipt-bound rollback은 같은 프리뷰에서 별도 helper로 두 번 실행해 종료했다([런북](./nas-preview-g8-rollback-proof-runbook.md), [기계 판독 증거](./evidence/nas-preview-g8-actual-rollback-2026-08-07.json)). 현재는 source HEAD/tree `61a41d1f…6af`/`87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`이 실행 중이다. candidate 112/112와 실제 NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했고 fresh dump `36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129와 previous `6030a677…c611`을 보존했다. 첫 예약 실행 이력은 대기 상태다. 증거: [배포 증거](./nas-preview-deployment-evidence-2026-08-07.md),
|
||||
review API 저장 축어록 2턴을 확인했다. 기존 프로젝트 중단·재생성 명령은 실행하지 않았다. 매시간 `Vignette 앱 상태·회기 E2E 정기 검증`
|
||||
heartbeat(automation id `vignette-e2e`)는 ACTIVE이며 공개/NAS 화면·health는 읽기 전용으로 보여주고, material milestone에서만 전용 disposable 회기 E2E를 실행한다. NAS 112건은 실 API/DB 22와 route fixture 90으로 영수증에서 분리한다. 동일 학습자 SSE→review→G4/G5 actual 폐루프 runner의 clean HEAD/tree·보호 포트 거부·sentinel·exact cleanup과 unit 10/10은 완료됐고 첫 실제 GREEN receipt는 clean commit 뒤 남았다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. 2026-08-07 SHA `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`의 localhost 108/108과 평문 origin UUID 24건 실패는 역사 기준선으로 보존한다. 실제 receipt-bound rollback은 같은 프리뷰에서 별도 helper로 두 번 실행해 종료했다([런북](./nas-preview-g8-rollback-proof-runbook.md), [기계 판독 증거](./evidence/nas-preview-g8-actual-rollback-2026-08-07.json)). 현재는 source HEAD/tree `61a41d1f…6af`/`87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`이 실행 중이다. candidate 112/112와 실제 NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했고 fresh dump `36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129와 previous `6030a677…c611`을 보존했다. 증거: [배포 증거](./nas-preview-deployment-evidence-2026-08-07.md),
|
||||
[브라우저 증거](./evidence/nas-preview-live-turn-2026-08-07.png). 명시 동의 물리 마이크와 독립 human voice-gain 증거는 아직 없으므로 G7은 external GATE로 유지한다.
|
||||
- [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·Anthropic `/v1/models` 탐색·지원 추론 강도·관리자 fail-closed 저장 경로는 구현 완료. 남은 범위는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 live 응답·계량·오류 표면화 비교다. Claude CLI·Codex CLI(Terra/Medium)·Agy CLI(Gemini 3.6 Flash/High)는 로컬 live probe를 통과했다.
|
||||
- [ ] **stable-source 재부팅 후 watchdog smoke** — watchdog·로그온 boot는 detached-clean `b34623f3…` stable
|
||||
|
|
|
|||
|
|
@ -28,9 +28,11 @@ G7은 **내부 구현 DONE · external GATE**다. 이번 변경은 물리 마이
|
|||
SHA, `psutil==6.1.1`, 새 API/cloudflared PID/start/exe·command SHA/실제 cwd를 매 sample 전후 검증한다.
|
||||
checker는 voice/runtime/topology 세 artifact의 **공통 시간창이 3,000초 이상**인지 재계산한다.
|
||||
- 독립 human-labeled held-out voice-gain 계약은 raw audio·transcript·synthetic pack을 거부하고,
|
||||
사전등록·동의·participant split·model artifact·blind independent labeler provenance를 요구한다.
|
||||
production gate는 30명/50회기/150 paired axis, ICC(A,1) 0.75, 선택적 κ 0.70, gain 0.01,
|
||||
participant-cluster bootstrap 10,000회의 95% CI lower `> 0`을 재계산한다.
|
||||
held-out 공개 전에 끝난 사전등록·동의·participant split·model artifact·blind independent labeler provenance를 요구한다.
|
||||
production gate는 최소 31명(held-out 30명 외 calibration split 포함), 양 조건이 모두 관측된 완전 paired
|
||||
50회기/150축, ICC(A,1) 0.75, **필수 categorical κ 0.70**, gain 0.01,
|
||||
participant-cluster bootstrap 10,000회의 95% CI lower `> 0`을 재계산한다. 한 조건이라도 결측이면 양 조건을
|
||||
모두 최대오류로 처리해 baseline-only 결측이 candidate gain을 부풀리지 못하게 한다.
|
||||
- `scripts/check-g7-external-proof.py`는 같은 public host와 겹치는 시간창의 public soak,
|
||||
process-local runtime sampling, pinned topology sampling, independent human-gain pack 네 가지를
|
||||
모두 통과시켜야 성공한다. G7 release gate는 이 checker 증거가 있을 때만
|
||||
|
|
@ -39,8 +41,8 @@ G7은 **내부 구현 DONE · external GATE**다. 이번 변경은 물리 마이
|
|||
## 검증
|
||||
|
||||
- API 전체 `921 passed`, gateway `58 passed`, API voice 통합 `71 passed`.
|
||||
- runner/checker/topology `86 passed`, public launcher/sidecar `80 passed`, G7 통합 `166 passed`;
|
||||
Ruff·`py_compile`·PowerShell 5.1 parser·`git diff --check` 통과.
|
||||
- current G7 scripts `86/86`, API human evaluator `11/11`;
|
||||
Ruff·`py_compile`·`git diff --check` 통과.
|
||||
- Web typecheck와 API type contract 통과.
|
||||
- `voice-success.spec.ts` chromium single-run `2/2` 통과. 동의 원장 응답을 의도적으로 지연한
|
||||
동안 `getUserMedia=0`, voice WebSocket `0`을 확인했고, 성공 뒤 각각 정확히 1회였다.
|
||||
|
|
@ -70,8 +72,17 @@ DONE으로 표시하지 않는다.
|
|||
|
||||
앞의 1~3번은 **같은 public transport host의 공통 3,000초 시간창**이어야 하는데, 지금까지는 운영자가 세 명령을 따로
|
||||
띄우고 시계를 손으로 맞춰야 했다. 50분짜리 실행에서 한 번 어긋나면 처음부터 다시 해야 한다.
|
||||
`scripts/run-g7-external-proof-window.py`가 세 캡처를 **동시에** 시작하고, 전부 끝나면 human pack을
|
||||
더해 checker까지 그대로 돌린다.
|
||||
`scripts/run-g7-external-proof-window.py`는 production human pack을 **마이크를 열기 전에 먼저 완전 검증**하고,
|
||||
통과한 경우에만 세 캡처를 동시에 시작한다. 종료 뒤 canonical checker가 같은 pack과 세 runtime artifact를 다시 검증한다.
|
||||
runtime/topology interval 기본값은 child collector 상한과 같은 60초이며, 60초 초과는 시작 전에 차단한다.
|
||||
|
||||
사람 데이터 운영자는 52분 실행과 독립적으로 pack을 먼저 검증할 수 있다. `--print-schema`는 authoritative JSON Schema를
|
||||
출력하고, 오류는 JSON pointer와 유형만 반환해 participant/labeler key·라벨 값·PII를 반사하지 않는다.
|
||||
|
||||
```powershell
|
||||
& $py -X utf8 -B scripts/check-g7-human-voice-gain.py --print-schema
|
||||
& $py -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>
|
||||
```
|
||||
|
||||
```powershell
|
||||
# 실제 실행 (물리 마이크 50분 + 사람 pack 필요)
|
||||
|
|
@ -98,7 +109,9 @@ DONE으로 표시하지 않는다.
|
|||
|---|---|
|
||||
| 동의·장치 없이 운영 실행 | `physical_microphone_consent_required`, exit 2 |
|
||||
| human pack 없이 운영 실행 | `human_voice_gain_pack_required`, exit 2 |
|
||||
| malformed·underpowered·κ 미달·불완전 paired human pack | 물리 캡처 전에 human-pack 오류, exit 2 |
|
||||
| 3,120초 미만 시간창 | `production_window_too_short`, exit 2 |
|
||||
| runtime/topology interval 60초 초과 | child collector 실행 전 interval 오류, exit 2 |
|
||||
| transport host/scheme 불일치 | `hosts_must_match` 또는 scheme 오류, exit 2 |
|
||||
| 허용되지 않은 browser Origin | query/원문을 반사하지 않는 Origin 오류, exit 2 |
|
||||
|
||||
|
|
@ -106,33 +119,28 @@ DONE으로 표시하지 않는다.
|
|||
열지 않고, 인자·경로·산출 파일까지 실제로 검증한다. 보고서의 `gate_closed`는 rehearse에서 **항상
|
||||
false**다. 실제 50분 실행 전에 이걸로 먼저 실패를 뽑아내라.
|
||||
|
||||
### ⚠ 선행 조건 — 공개 런타임을 먼저 올려야 한다 (2026-08-08 발견)
|
||||
### 공개 선행 조건 — 2026-08-09 완료
|
||||
|
||||
**마이크와 사람을 다 준비해도 오늘 실행하면 50분을 버리고 artifact 2에서 실패한다.**
|
||||
공개 API/cloudflared는 detached-clean `b34623f3…`·tree `32cc85c7…`에서 fresh 승격됐다. 공개 OpenAPI 126,
|
||||
`/admin/voice-runtime`, `local_whisper/small`, `melotts/melotts-korean`, Uvicorn WS queue 4와 receipt
|
||||
`9f8d1941…a21bf` passed를 확인했다. watchdog·로그온 task도 같은 stable root에 source pin돼 명시 실행 결과 0이다.
|
||||
|
||||
공개 런타임은 **119 paths 구버전**이고 `/admin/voice-runtime`이 **배포돼 있지 않다**.
|
||||
`capture-g7-runtime-evidence.py`는 정확히 그 경로만 부르므로 artifact 2를 만들 수 없다.
|
||||
현재 소스에는 있다(`apps/api/app/routes/admin.py:1699`, NAS 프리뷰 기준 126 paths).
|
||||
|
||||
```
|
||||
공개 OpenAPI 119 paths · voice 경로 = /users/me/voice-presets, /voice/health, /voice/speech
|
||||
has /admin/voice-runtime → False
|
||||
```
|
||||
|
||||
그래서 오케스트레이터는 시작 전에 이걸 검사하고 `admin_voice_runtime_not_deployed`로 즉시 멈춘다.
|
||||
실측: 공개 런타임 대상 `--rehearse` 실행이 exit 2로 몇 초 만에 차단됐다.
|
||||
30초 `--rehearse`는 authenticated WSS ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를
|
||||
모두 통과했다. evidence는 `physical_capture=false`이며 UUID/email literal 0이다. 이 리허설은 실제 물리 마이크와
|
||||
human pack을 대체하지 않는다.
|
||||
|
||||
**판정을 미인증 HTTP 상태로 하지 않는다.** Cloudflare 앞단이 자동화 클라이언트에게 존재하지 않는
|
||||
경로까지 포함해 **모든 경로를 403(error code 1010)** 으로 돌려주기 때문에, 상태 코드로는 "배포 누락"과
|
||||
"edge 차단"을 구분할 수 없다. 처음엔 404/401 판정으로 짰다가 이 사실을 확인하고 **OpenAPI spec의
|
||||
`paths`만 authoritative하게 쓰도록** 고쳤다. 회귀에 그 이유를 테스트로 남겼다.
|
||||
|
||||
따라서 G7 실행 순서는 이렇다.
|
||||
따라서 남은 G7 실행 순서는 이렇다.
|
||||
|
||||
1. current source를 공개 런타임에 승격한다(비-secure origin `crypto.randomUUID` 수정도 여기 포함된다).
|
||||
2. `--rehearse`로 배관을 확인한다.
|
||||
3. 동의 하 물리 마이크 50분 + human pack으로 실제 실행한다.
|
||||
1. 독립 연구팀이 실제 human pack을 작성하고 standalone validator를 통과시킨다.
|
||||
2. 사용자가 정확한 마이크 장치를 고르고 3,120초 물리 캡처에 명시 동의한다.
|
||||
3. production runner로 human pack을 재검증한 뒤 mic/runtime/topology 동시 창을 실행한다.
|
||||
4. canonical checker exit 0과 `gate_closed=true`를 확인한다.
|
||||
|
||||
기대 provider 기본값은 2026-08-08 결정에 맞춰 `local_whisper/small` / `melotts/melotts-korean`이다
|
||||
(근거: `../decisions/local-voice-stack.md`). runner 회귀는 37/37, G7 통합은 166/166이다. production exit 0은
|
||||
(근거: `../decisions/local-voice-stack.md`). current G7 scripts는 86/86, API human evaluator는 11/11이다. production exit 0은
|
||||
`checker_returncode == 0 && gate_closed is true`에 결속되며 rehearse는 성공해도 gate를 닫지 않는다.
|
||||
|
|
|
|||
129
scripts/check-g7-human-voice-gain.py
Normal file
129
scripts/check-g7-human-voice-gain.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate and evaluate one independent human-held-out G7 voice-gain pack.
|
||||
|
||||
The command emits only aggregate metrics and PII-safe JSON pointers. It never
|
||||
echoes input paths, participant/labeler keys, labels, or raw validation values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
API_ROOT = REPO_ROOT / "apps/api"
|
||||
if str(API_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(API_ROOT))
|
||||
|
||||
from app.contracts.g7_external_evidence import ( # noqa: E402
|
||||
G7HumanVoiceGainEvidencePack,
|
||||
)
|
||||
from app.services.g7_voice_gain_evidence import ( # noqa: E402
|
||||
evaluate_human_voice_gain,
|
||||
)
|
||||
|
||||
|
||||
def _json_pointer(location: tuple[int | str, ...]) -> str:
|
||||
if not location:
|
||||
return "/"
|
||||
parts = []
|
||||
for item in location:
|
||||
value = str(item).replace("~", "~0").replace("/", "~1")
|
||||
parts.append(value)
|
||||
return "/" + "/".join(parts)
|
||||
|
||||
|
||||
def _base_report() -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "vignette.g7-human-voice-gain-check.v1",
|
||||
"passed": False,
|
||||
"clinical_claim_allowed": False,
|
||||
"privacy_boundary": {
|
||||
"input_path_logged": False,
|
||||
"participant_keys_logged": False,
|
||||
"labeler_keys_logged": False,
|
||||
"labels_logged": False,
|
||||
"raw_validation_values_logged": False,
|
||||
},
|
||||
"validation_errors": [],
|
||||
"result": {},
|
||||
}
|
||||
|
||||
|
||||
def validate_payload(payload: object) -> dict[str, Any]:
|
||||
report = _base_report()
|
||||
try:
|
||||
pack = G7HumanVoiceGainEvidencePack.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
report["validation_errors"] = [
|
||||
{
|
||||
"pointer": _json_pointer(tuple(item["loc"])),
|
||||
"type": item["type"],
|
||||
}
|
||||
for item in exc.errors(
|
||||
include_url=False,
|
||||
include_context=False,
|
||||
include_input=False,
|
||||
)
|
||||
]
|
||||
return report
|
||||
|
||||
try:
|
||||
result = evaluate_human_voice_gain(pack)
|
||||
except Exception as exc:
|
||||
report["validation_errors"] = [
|
||||
{"pointer": "/", "type": f"evaluation:{type(exc).__name__}"}
|
||||
]
|
||||
return report
|
||||
|
||||
report["passed"] = result.passed
|
||||
report["result"] = result.model_dump(mode="json")
|
||||
return report
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
result = argparse.ArgumentParser(description=__doc__)
|
||||
mode = result.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--input", type=Path, help="deidentified human pack JSON")
|
||||
mode.add_argument(
|
||||
"--print-schema",
|
||||
action="store_true",
|
||||
help="print the authoritative JSON Schema and exit",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
args = parser().parse_args(list(argv) if argv is not None else None)
|
||||
if args.print_schema:
|
||||
print(
|
||||
json.dumps(
|
||||
G7HumanVoiceGainEvidencePack.model_json_schema(),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
try:
|
||||
payload = json.loads(args.input.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
report = _base_report()
|
||||
report["validation_errors"] = [
|
||||
{"pointer": "/", "type": f"input:{type(exc).__name__}"}
|
||||
]
|
||||
else:
|
||||
report = validate_payload(payload)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0 if report["passed"] is True else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -42,6 +42,16 @@ from typing import Any, Callable, Iterable, Sequence
|
|||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = REPO_ROOT / "scripts"
|
||||
API_ROOT = REPO_ROOT / "apps/api"
|
||||
if str(API_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(API_ROOT))
|
||||
|
||||
from app.contracts.g7_external_evidence import ( # noqa: E402
|
||||
G7HumanVoiceGainEvidencePack,
|
||||
)
|
||||
from app.services.g7_voice_gain_evidence import ( # noqa: E402
|
||||
evaluate_human_voice_gain,
|
||||
)
|
||||
|
||||
SOAK_SCRIPT = SCRIPTS / "soak-public-voice-websocket.py"
|
||||
RUNTIME_SCRIPT = SCRIPTS / "capture-g7-runtime-evidence.py"
|
||||
|
|
@ -63,6 +73,8 @@ MIN_PRODUCTION_SECONDS = (
|
|||
MIN_REQUIRED_OVERLAP_SECONDS + CAPTURE_START_SKEW_MARGIN_SECONDS
|
||||
)
|
||||
RUNTIME_SAMPLE_MARGIN = 1
|
||||
MAX_CHILD_INTERVAL_SECONDS = 60.0
|
||||
DEFAULT_SAMPLE_INTERVAL_SECONDS = 60.0
|
||||
|
||||
|
||||
class WindowError(RuntimeError):
|
||||
|
|
@ -172,6 +184,23 @@ def sample_plan(duration_seconds: float, interval_seconds: float) -> int:
|
|||
return math.ceil(duration_seconds / interval_seconds) + RUNTIME_SAMPLE_MARGIN
|
||||
|
||||
|
||||
def validate_human_voice_gain_pack(path: Path) -> None:
|
||||
"""52분 캡처를 열기 전에 사람 pack의 production gate를 완전히 계산한다."""
|
||||
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
pack = G7HumanVoiceGainEvidencePack.model_validate(payload)
|
||||
result = evaluate_human_voice_gain(pack)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
raise WindowError("human_voice_gain_pack_unreadable") from None
|
||||
except Exception:
|
||||
# validation 원문에는 입력값이 포함될 수 있으므로 오류 code만 낸다.
|
||||
raise WindowError("human_voice_gain_pack_invalid") from None
|
||||
if not result.passed:
|
||||
reasons = ",".join(result.failure_reasons)
|
||||
raise WindowError(f"human_voice_gain_pack_failed:{reasons}")
|
||||
|
||||
|
||||
def build_soak_leg(args: argparse.Namespace, output: Path) -> Leg:
|
||||
argv = [
|
||||
sys.executable,
|
||||
|
|
@ -533,8 +562,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
default=MIN_PRODUCTION_SECONDS,
|
||||
help="실제 캡처는 최소 3120초; 유효 artifact 교집합 기준은 3000초",
|
||||
)
|
||||
parser.add_argument("--runtime-interval-seconds", type=float, default=100.0)
|
||||
parser.add_argument("--topology-interval-seconds", type=float, default=100.0)
|
||||
parser.add_argument(
|
||||
"--runtime-interval-seconds",
|
||||
type=float,
|
||||
default=DEFAULT_SAMPLE_INTERVAL_SECONDS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--topology-interval-seconds",
|
||||
type=float,
|
||||
default=DEFAULT_SAMPLE_INTERVAL_SECONDS,
|
||||
)
|
||||
parser.add_argument("--human-voice-gain", type=Path)
|
||||
parser.add_argument("--out-dir", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
|
|
@ -550,6 +587,12 @@ def validate(args: argparse.Namespace) -> None:
|
|||
raise WindowError("production_window_too_short")
|
||||
if not args.rehearse and args.human_voice_gain is None:
|
||||
raise WindowError("human_voice_gain_pack_required")
|
||||
for value, code in (
|
||||
(args.runtime_interval_seconds, "runtime_interval_out_of_bounds"),
|
||||
(args.topology_interval_seconds, "topology_interval_out_of_bounds"),
|
||||
):
|
||||
if not math.isfinite(value) or not 0.05 <= value <= MAX_CHILD_INTERVAL_SECONDS:
|
||||
raise WindowError(code)
|
||||
if args.topology_mode not in ("linux-compose", "windows-host"):
|
||||
raise WindowError("topology_mode_invalid")
|
||||
if args.topology_mode == "linux-compose":
|
||||
|
|
@ -589,6 +632,9 @@ def main(argv: Iterable[str] | None = None) -> int:
|
|||
args = build_parser().parse_args(list(argv) if argv is not None else None)
|
||||
try:
|
||||
validate(args)
|
||||
if not args.rehearse:
|
||||
assert args.human_voice_gain is not None
|
||||
validate_human_voice_gain_pack(args.human_voice_gain)
|
||||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||||
legs = plan_legs(args, args.out_dir)
|
||||
except WindowError as exc:
|
||||
|
|
|
|||
|
|
@ -88,6 +88,19 @@ POSTDEPLOY_NAS_E2E_SPECS = (
|
|||
"e2e/multimodal-alliance.spec.ts",
|
||||
"e2e/continuous-improvement-admin.spec.ts",
|
||||
)
|
||||
# Only these specs use the configured NAS API/DB instead of replacing the
|
||||
# product API with Playwright route fixtures. Keep the split explicit in the
|
||||
# receipt so the 112-test browser total cannot be mistaken for 112 live
|
||||
# session/database proofs.
|
||||
POSTDEPLOY_NAS_REAL_API_E2E_SPECS = (
|
||||
"e2e/session-layout.spec.ts",
|
||||
"e2e/session-persistence.spec.ts",
|
||||
)
|
||||
POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS = tuple(
|
||||
spec
|
||||
for spec in POSTDEPLOY_NAS_E2E_SPECS
|
||||
if spec not in POSTDEPLOY_NAS_REAL_API_E2E_SPECS
|
||||
)
|
||||
POSTDEPLOY_SOURCE_ONLY_E2E_SPECS = ("e2e/insecure-context-uuid.spec.ts",)
|
||||
POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS = (
|
||||
"e2e/returned-practice-db-closed-loop.spec.ts",
|
||||
|
|
@ -1572,8 +1585,13 @@ class ReleaseAgent:
|
|||
"base_url": self.config.target.base_url,
|
||||
"specs": list(POSTDEPLOY_NAS_E2E_SPECS),
|
||||
"projects": list(RELEASE_BROWSER_PROJECTS),
|
||||
"runtime_scope": {
|
||||
"real_api_db_specs": list(POSTDEPLOY_NAS_REAL_API_E2E_SPECS),
|
||||
"route_fixture_specs": list(POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS),
|
||||
},
|
||||
"source_only_candidate_specs": list(POSTDEPLOY_SOURCE_ONLY_E2E_SPECS),
|
||||
"separate_disposable_db_specs": list(POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS),
|
||||
"separate_disposable_db_status": "not_run_by_release_agent",
|
||||
"uuid_runtime_route_specs": [
|
||||
"e2e/session-persistence.spec.ts",
|
||||
"e2e/self-directed-learning-loop.spec.ts",
|
||||
|
|
|
|||
1405
scripts/run-periodic-learner-e2e.py
Normal file
1405
scripts/run-periodic-learner-e2e.py
Normal file
File diff suppressed because it is too large
Load diff
95
scripts/test_check_g7_human_voice_gain.py
Normal file
95
scripts/test_check_g7_human_voice_gain.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
API_ROOT = REPO_ROOT / "apps/api"
|
||||
if str(API_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(API_ROOT))
|
||||
|
||||
from app.test_g7_voice_gain_evidence import _valid_payload # noqa: E402
|
||||
from scripts.test_g7_external_proof import human_pack # noqa: E402
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).with_name("check-g7-human-voice-gain.py")
|
||||
SPEC = importlib.util.spec_from_file_location("check_g7_human_voice_gain", SCRIPT_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class G7HumanVoiceGainCliTests(unittest.TestCase):
|
||||
def test_production_pack_passes_with_aggregate_output_only(self) -> None:
|
||||
report = MODULE.validate_payload(human_pack())
|
||||
|
||||
self.assertTrue(report["passed"])
|
||||
self.assertEqual([], report["validation_errors"])
|
||||
self.assertEqual(30, report["result"]["held_out_participants"])
|
||||
serialized = json.dumps(report, ensure_ascii=False)
|
||||
self.assertNotIn("held-000", serialized)
|
||||
self.assertNotIn("labeler-001", serialized)
|
||||
self.assertFalse(report["privacy_boundary"]["participant_keys_logged"])
|
||||
|
||||
def test_underpowered_pack_fails_with_gate_names_but_no_identifiers(self) -> None:
|
||||
report = MODULE.validate_payload(_valid_payload())
|
||||
|
||||
self.assertFalse(report["passed"])
|
||||
self.assertIn(
|
||||
"production_participant_floor",
|
||||
report["result"]["failure_reasons"],
|
||||
)
|
||||
serialized = json.dumps(report, ensure_ascii=False)
|
||||
self.assertNotIn("held-out-001", serialized)
|
||||
self.assertNotIn("labeler-001", serialized)
|
||||
|
||||
def test_invalid_rows_emit_only_json_pointer_and_error_type(self) -> None:
|
||||
payload = _valid_payload()
|
||||
observations = payload["observations"]
|
||||
assert isinstance(observations, list)
|
||||
labels = observations[0]["labels"]
|
||||
assert isinstance(labels, list)
|
||||
labels[0]["category"] = "person@example.test"
|
||||
|
||||
report = MODULE.validate_payload(payload)
|
||||
|
||||
self.assertFalse(report["passed"])
|
||||
self.assertEqual({}, report["result"])
|
||||
self.assertTrue(report["validation_errors"])
|
||||
self.assertEqual(
|
||||
"/observations/0/labels/0/category",
|
||||
report["validation_errors"][0]["pointer"],
|
||||
)
|
||||
serialized = json.dumps(report, ensure_ascii=False)
|
||||
self.assertNotIn("person@example.test", serialized)
|
||||
self.assertNotIn("held-out-001", serialized)
|
||||
|
||||
def test_cli_does_not_echo_input_path_and_schema_marks_kappa_required(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
secret_name = "person-at-example.test.json"
|
||||
path = Path(directory) / secret_name
|
||||
path.write_text(json.dumps(_valid_payload()), encoding="utf-8")
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
exit_code = MODULE.main(["--input", str(path)])
|
||||
|
||||
self.assertEqual(1, exit_code)
|
||||
self.assertNotIn(secret_name, output.getvalue())
|
||||
|
||||
schema = MODULE.G7HumanVoiceGainEvidencePack.model_json_schema()
|
||||
reliability = schema["$defs"]["G7ReliabilityClaim"]
|
||||
label = schema["$defs"]["G7HumanAxisLabel"]
|
||||
self.assertIn("reported_categorical_kappa", reliability["required"])
|
||||
self.assertIn("category", label["required"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -44,6 +44,7 @@ def human_pack() -> dict[str, object]:
|
|||
)
|
||||
observations = []
|
||||
axes = ("goal", "task", "bond")
|
||||
categories = ("low", "medium", "high")
|
||||
for session_index in range(50):
|
||||
participant = f"held-{session_index % 30:03d}"
|
||||
for axis_index, axis in enumerate(axes):
|
||||
|
|
@ -60,8 +61,16 @@ def human_pack() -> dict[str, object]:
|
|||
"voice_enabled_status": "observed",
|
||||
"voice_enabled_score": target,
|
||||
"labels": [
|
||||
{"labeler_key": "labeler-001", "score": target},
|
||||
{"labeler_key": "labeler-002", "score": target},
|
||||
{
|
||||
"labeler_key": "labeler-001",
|
||||
"score": target,
|
||||
"category": categories[axis_index],
|
||||
},
|
||||
{
|
||||
"labeler_key": "labeler-002",
|
||||
"score": target,
|
||||
"category": categories[axis_index],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
|
@ -108,6 +117,7 @@ def human_pack() -> dict[str, object]:
|
|||
"reliability": {
|
||||
"labeler_keys": ["labeler-001", "labeler-002"],
|
||||
"reported_icc": 1.0,
|
||||
"reported_categorical_kappa": 1.0,
|
||||
"report_sha256": _sha(112),
|
||||
},
|
||||
"observations": observations,
|
||||
|
|
@ -432,6 +442,25 @@ class G7ExternalProofTests(unittest.TestCase):
|
|||
self.assertEqual([], errors)
|
||||
self.assertTrue(gain["passed"])
|
||||
self.assertGreaterEqual(gain["held_out_participants"], 30)
|
||||
self.assertGreaterEqual(gain["recomputed_categorical_kappa"], 0.70)
|
||||
|
||||
def test_human_pack_cannot_bypass_the_required_categorical_kappa(self) -> None:
|
||||
errors: list[str] = []
|
||||
payload = human_pack()
|
||||
reliability = payload["reliability"]
|
||||
assert isinstance(reliability, dict)
|
||||
reliability.pop("reported_categorical_kappa")
|
||||
observations = payload["observations"]
|
||||
assert isinstance(observations, list)
|
||||
for observation in observations:
|
||||
labels = observation["labels"]
|
||||
for label in labels:
|
||||
label.pop("category")
|
||||
|
||||
gain = self.checker.validate_human_gain(payload, errors)
|
||||
|
||||
self.assertEqual({}, gain)
|
||||
self.assertIn("human_gain:invalid:ValidationError", errors)
|
||||
|
||||
def test_windows_host_topology_passes_without_weakening_compose(self) -> None:
|
||||
errors: list[str] = []
|
||||
|
|
|
|||
|
|
@ -693,6 +693,15 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
|||
set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS)
|
||||
| set(self.agent_module.POSTDEPLOY_SOURCE_ONLY_E2E_SPECS),
|
||||
)
|
||||
self.assertEqual(
|
||||
set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS),
|
||||
set(self.agent_module.POSTDEPLOY_NAS_REAL_API_E2E_SPECS)
|
||||
| set(self.agent_module.POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS),
|
||||
)
|
||||
self.assertFalse(
|
||||
set(self.agent_module.POSTDEPLOY_NAS_REAL_API_E2E_SPECS)
|
||||
& set(self.agent_module.POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS)
|
||||
)
|
||||
self.assertLess(
|
||||
runner.event_log.index("runner:postdeploy_browser_review"),
|
||||
runner.event_log.index("deployment:commit_active_state"),
|
||||
|
|
@ -750,6 +759,22 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
|||
["e2e/returned-practice-db-closed-loop.spec.ts"],
|
||||
evidence["browser_review_proof"]["separate_disposable_db_specs"],
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"real_api_db_specs": [
|
||||
"e2e/session-layout.spec.ts",
|
||||
"e2e/session-persistence.spec.ts",
|
||||
],
|
||||
"route_fixture_specs": list(
|
||||
self.agent_module.POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS
|
||||
),
|
||||
},
|
||||
evidence["browser_review_proof"]["runtime_scope"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"not_run_by_release_agent",
|
||||
evidence["browser_review_proof"]["separate_disposable_db_status"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"docs/dev_dashboard.html",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
|
@ -50,8 +52,8 @@ def args(**overrides):
|
|||
"microphone_device": "",
|
||||
"confirm_physical_capture": False,
|
||||
"duration_seconds": 3_120.0,
|
||||
"runtime_interval_seconds": 100.0,
|
||||
"topology_interval_seconds": 100.0,
|
||||
"runtime_interval_seconds": 60.0,
|
||||
"topology_interval_seconds": 60.0,
|
||||
"human_voice_gain": Path("pack.json"),
|
||||
"out_dir": Path("out"),
|
||||
"rehearse": False,
|
||||
|
|
@ -154,6 +156,8 @@ class HostAlignmentTest(unittest.TestCase):
|
|||
self.assertEqual(MODULE.DEFAULT_BROWSER_ORIGIN, parsed.origin)
|
||||
self.assertEqual(MODULE.DEFAULT_ADMIN_RUNTIME_URL, parsed.admin_runtime_url)
|
||||
self.assertEqual(3_120.0, parsed.duration_seconds)
|
||||
self.assertEqual(60.0, parsed.runtime_interval_seconds)
|
||||
self.assertEqual(60.0, parsed.topology_interval_seconds)
|
||||
self.assertIsNone(parsed.allowed_browser_origins)
|
||||
help_text = parser.format_help()
|
||||
for expected in (
|
||||
|
|
@ -168,13 +172,13 @@ class HostAlignmentTest(unittest.TestCase):
|
|||
|
||||
class SamplePlanTest(unittest.TestCase):
|
||||
def test_samples_cover_the_whole_window(self) -> None:
|
||||
self.assertEqual(MODULE.sample_plan(3_120.0, 100.0), 33)
|
||||
self.assertEqual(MODULE.sample_plan(3_120.0, 60.0), 53)
|
||||
|
||||
def test_fractional_interval_always_gets_a_terminal_sample(self) -> None:
|
||||
self.assertEqual(MODULE.sample_plan(3_121.0, 100.0), 33)
|
||||
self.assertEqual(MODULE.sample_plan(3_121.0, 60.0), 54)
|
||||
|
||||
def test_invalid_plan_fails_closed(self) -> None:
|
||||
for duration, interval in ((0, 100.0), (3_000.0, 0)):
|
||||
for duration, interval in ((0, 60.0), (3_000.0, 0)):
|
||||
with self.subTest(duration=duration, interval=interval):
|
||||
with self.assertRaises(MODULE.WindowError):
|
||||
MODULE.sample_plan(duration, interval)
|
||||
|
|
@ -226,6 +230,44 @@ class ConsentGateTest(unittest.TestCase):
|
|||
def test_rehearse_relaxes_only_the_two_human_inputs(self) -> None:
|
||||
MODULE.validate(args(rehearse=True, duration_seconds=30.0, human_voice_gain=None))
|
||||
|
||||
def test_child_sampling_intervals_are_bounded_before_capture(self) -> None:
|
||||
for field in ("runtime_interval_seconds", "topology_interval_seconds"):
|
||||
with self.subTest(field=field):
|
||||
with self.assertRaises(MODULE.WindowError) as ctx:
|
||||
MODULE.validate(args(**{field: 60.001}))
|
||||
self.assertEqual(
|
||||
f"{field.removesuffix('_seconds')}_out_of_bounds",
|
||||
str(ctx.exception),
|
||||
)
|
||||
|
||||
def test_human_pack_is_fully_validated_before_capture(self) -> None:
|
||||
from app.test_g7_voice_gain_evidence import _valid_payload
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
missing = root / "missing.json"
|
||||
with self.assertRaises(MODULE.WindowError) as ctx:
|
||||
MODULE.validate_human_voice_gain_pack(missing)
|
||||
self.assertEqual("human_voice_gain_pack_unreadable", str(ctx.exception))
|
||||
|
||||
malformed = root / "malformed.json"
|
||||
malformed.write_text("{}", encoding="utf-8")
|
||||
with self.assertRaises(MODULE.WindowError) as ctx:
|
||||
MODULE.validate_human_voice_gain_pack(malformed)
|
||||
self.assertEqual("human_voice_gain_pack_invalid", str(ctx.exception))
|
||||
|
||||
underpowered = root / "underpowered.json"
|
||||
underpowered.write_text(
|
||||
json.dumps(_valid_payload()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaises(MODULE.WindowError) as ctx:
|
||||
MODULE.validate_human_voice_gain_pack(underpowered)
|
||||
self.assertTrue(
|
||||
str(ctx.exception).startswith("human_voice_gain_pack_failed:")
|
||||
)
|
||||
self.assertIn("production_participant_floor", str(ctx.exception))
|
||||
|
||||
|
||||
class LegCompositionTest(unittest.TestCase):
|
||||
def test_expected_providers_default_to_the_decided_local_stack(self) -> None:
|
||||
|
|
|
|||
360
scripts/test_run_periodic_learner_e2e.py
Normal file
360
scripts/test_run_periodic_learner_e2e.py
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).with_name("run-periodic-learner-e2e.py")
|
||||
|
||||
|
||||
def load_module() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location("run_periodic_learner_e2e", SCRIPT)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("cannot load periodic learner runner")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
runner = load_module()
|
||||
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, *, tracked_status: str = "") -> None:
|
||||
self.tracked_status = tracked_status
|
||||
self.commands: list[tuple[str, list[str]]] = []
|
||||
self.starts: list[tuple[str, list[str]]] = []
|
||||
self.stops: list[int] = []
|
||||
|
||||
def run(
|
||||
self,
|
||||
stage,
|
||||
argv,
|
||||
*,
|
||||
cwd,
|
||||
env=None,
|
||||
timeout,
|
||||
check=True,
|
||||
):
|
||||
del cwd, env, timeout, check
|
||||
self.commands.append((stage, list(argv)))
|
||||
stdout = ""
|
||||
if stage == "source_head":
|
||||
stdout = "1" * 40 + "\n"
|
||||
elif stage == "source_tree":
|
||||
stdout = "2" * 40 + "\n"
|
||||
elif stage == "source_tracked_clean":
|
||||
stdout = self.tracked_status
|
||||
elif stage == "source_untracked_inventory":
|
||||
stdout = "?? apps/api/engine.err.log.bak\n"
|
||||
elif stage == "docker_context_show":
|
||||
stdout = "desktop-linux\n"
|
||||
elif stage == "docker_context_inspect":
|
||||
stdout = json.dumps(
|
||||
[
|
||||
{
|
||||
"Endpoints": {
|
||||
"docker": {
|
||||
"Host": "npipe:////./pipe/dockerDesktopLinuxEngine"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
return runner.CommandResult(0, stdout, "", 0.01)
|
||||
|
||||
def start(
|
||||
self,
|
||||
stage,
|
||||
argv,
|
||||
*,
|
||||
cwd,
|
||||
env,
|
||||
stdout_path,
|
||||
stderr_path,
|
||||
):
|
||||
del cwd, env, stdout_path, stderr_path
|
||||
self.starts.append((stage, list(argv)))
|
||||
return runner.ProcessHandle(4242)
|
||||
|
||||
def stop_exact(self, handle, *, timeout):
|
||||
del timeout
|
||||
self.stops.append(handle.pid)
|
||||
return True
|
||||
|
||||
def http_json(self, url, *, headers=None, timeout):
|
||||
del url, headers, timeout
|
||||
return {"ok": True, "status": "ok", "db": True, "engine": True}
|
||||
|
||||
def http_text(self, url, *, timeout):
|
||||
del url, timeout
|
||||
return '<div id="root"></div>'
|
||||
|
||||
def tcp_listening(self, host, port, *, timeout=0.25):
|
||||
del host, port, timeout
|
||||
return False
|
||||
|
||||
def sleep(self, seconds):
|
||||
del seconds
|
||||
|
||||
|
||||
def config(receipt_path: Path) -> runner.RunnerConfig:
|
||||
return runner.RunnerConfig(
|
||||
receipt_path=receipt_path,
|
||||
python_exe="python.exe",
|
||||
node_exe="node.exe",
|
||||
docker_exe="docker.exe",
|
||||
execute=True,
|
||||
)
|
||||
|
||||
|
||||
class SafetyContractTests(unittest.TestCase):
|
||||
def test_rejects_every_protected_port_from_argv_and_runtime_env(self) -> None:
|
||||
for port in sorted(runner.FORBIDDEN_PORTS):
|
||||
with self.subTest(port=port):
|
||||
with self.assertRaises(runner.GateError):
|
||||
runner.assert_safe_invocation([f"http://127.0.0.1:{port}"], {})
|
||||
with self.assertRaises(runner.GateError):
|
||||
runner.assert_safe_invocation([], {"DATABASE_URL": f"postgresql://x@127.0.0.1:{port}/db"})
|
||||
|
||||
def test_rejects_public_and_nas_markers(self) -> None:
|
||||
values = [
|
||||
"https://vignette.chanpaca.net",
|
||||
"https://api-vignette.chanpaca.net/health",
|
||||
"postgresql://app@100.116.83.60:55433/vignette",
|
||||
"vignette-preview-20260807",
|
||||
"vignette-dev-db",
|
||||
"/volume1/docker/vignette",
|
||||
]
|
||||
for value in values:
|
||||
with self.subTest(value=value), self.assertRaises(runner.GateError):
|
||||
runner.assert_safe_value("contract", value)
|
||||
|
||||
def test_rejects_inherited_runtime_targets_even_when_the_port_looks_local(self) -> None:
|
||||
for key, value in (
|
||||
("DATABASE_URL", "postgresql://app@127.0.0.1:55439/vignette"),
|
||||
("ENGINE_URL", "http://127.0.0.1:9199"),
|
||||
("PLAYWRIGHT_BASE_URL", "http://127.0.0.1:5199"),
|
||||
("COMPOSE_PROJECT_NAME", "some-existing-project"),
|
||||
):
|
||||
with self.subTest(key=key), self.assertRaises(runner.GateError):
|
||||
runner.assert_no_inherited_runtime_targets({key: value})
|
||||
|
||||
def test_runtime_identity_and_generated_environment_are_disposable(self) -> None:
|
||||
source = runner.SourceIdentity("1" * 40, "2" * 40)
|
||||
with patch.object(runner, "allocate_unique_ports", return_value=(18080, 18443, 15439, 19199)):
|
||||
runtime = runner.build_runtime_identity(source)
|
||||
runner.validate_runtime_identity(runtime)
|
||||
values = runner.build_stack_environment(runtime)
|
||||
|
||||
self.assertEqual(len(set(runtime.ports)), 4)
|
||||
self.assertTrue(runner.PROJECT_RE.fullmatch(runtime.project))
|
||||
self.assertEqual(values["HTTP_PORT"], "18080")
|
||||
self.assertEqual(values["DB_HOST_PORT"], "15439")
|
||||
self.assertEqual(values["ENGINE_URL"], "http://host.docker.internal:19199")
|
||||
self.assertNotIn("vignette.chanpaca.net", json.dumps(values))
|
||||
self.assertFalse(set(runtime.ports) & runner.FORBIDDEN_PORTS)
|
||||
|
||||
def test_compose_override_labels_every_resource_and_loopback_binds_database(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
path = Path(raw) / "override.yml"
|
||||
runner.write_compose_override(path)
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(text.count("com.vignette.periodic-e2e-sentinel"), 8)
|
||||
self.assertIn('127.0.0.1:${DB_HOST_PORT}:5432', text)
|
||||
self.assertIn("pgdata:", text)
|
||||
self.assertIn("apiuploads:", text)
|
||||
self.assertIn("caddydata:", text)
|
||||
self.assertIn("vignette:", text)
|
||||
|
||||
def test_resolved_compose_contract_binds_only_loopback_and_internal_db(self) -> None:
|
||||
runtime = runner.RuntimeIdentity(
|
||||
"20260809T120000-abcdef12",
|
||||
"vignette-periodic-11111111-abcdef12",
|
||||
"periodic:" + "1" * 40 + ":" + "2" * 40 + ":run",
|
||||
18080,
|
||||
18443,
|
||||
15439,
|
||||
19199,
|
||||
)
|
||||
stack_env = {
|
||||
"POSTGRES_DB": "vignette_periodic",
|
||||
}
|
||||
payload = {
|
||||
"services": {
|
||||
name: {
|
||||
"labels": {
|
||||
"com.vignette.periodic-e2e-sentinel": runtime.sentinel
|
||||
},
|
||||
"ports": [],
|
||||
}
|
||||
for name in ("db", "api", "web", "proxy")
|
||||
},
|
||||
"volumes": {
|
||||
name: {
|
||||
"labels": {
|
||||
"com.vignette.periodic-e2e-sentinel": runtime.sentinel
|
||||
}
|
||||
}
|
||||
for name in ("pgdata", "apiuploads", "caddydata")
|
||||
},
|
||||
"networks": {
|
||||
"vignette": {
|
||||
"labels": {
|
||||
"com.vignette.periodic-e2e-sentinel": runtime.sentinel
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
payload["services"]["db"]["ports"] = [
|
||||
{"host_ip": "127.0.0.1", "target": 5432, "published": "15439"}
|
||||
]
|
||||
payload["services"]["proxy"]["ports"] = [
|
||||
{"host_ip": "127.0.0.1", "target": 80, "published": "18080"},
|
||||
{"host_ip": "127.0.0.1", "target": 443, "published": "18443"},
|
||||
]
|
||||
payload["services"]["api"]["environment"] = {
|
||||
"DATABASE_URL": "postgresql://app:secret@db:5432/vignette_periodic",
|
||||
"ENGINE_URL": "http://host.docker.internal:19199",
|
||||
"FRONTEND_BASE_URL": "http://127.0.0.1:18080",
|
||||
"OAUTH_REDIRECT_URI": "http://127.0.0.1:18080/api/auth/callback",
|
||||
}
|
||||
|
||||
class ConfigFake(FakeController):
|
||||
def run(self, stage, argv, **kwargs):
|
||||
result = super().run(stage, argv, **kwargs)
|
||||
if stage == "resolved_compose_config":
|
||||
return runner.CommandResult(0, json.dumps(payload), "", 0.01)
|
||||
return result
|
||||
|
||||
fake = ConfigFake()
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), fake)
|
||||
periodic.state.runtime = runtime
|
||||
periodic.state.docker_context = "desktop-linux"
|
||||
periodic.state.env_file = Path(raw) / "stack.env"
|
||||
periodic.state.override_file = Path(raw) / "override.yml"
|
||||
digest = periodic._validate_resolved_compose_config(stack_env)
|
||||
|
||||
self.assertEqual(len(digest), 64)
|
||||
payload["services"]["proxy"]["ports"][0]["host_ip"] = "0.0.0.0"
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), ConfigFake())
|
||||
periodic.state.runtime = runtime
|
||||
periodic.state.docker_context = "desktop-linux"
|
||||
periodic.state.env_file = Path(raw) / "stack.env"
|
||||
periodic.state.override_file = Path(raw) / "override.yml"
|
||||
with self.assertRaises(runner.GateError):
|
||||
periodic._validate_resolved_compose_config(stack_env)
|
||||
|
||||
def test_dirty_source_fails_before_process_or_compose_mutation(self) -> None:
|
||||
fake = FakeController(tracked_status=" M apps/web/src/App.tsx\n")
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
receipt = Path(raw) / "receipt.json"
|
||||
execution = runner.PeriodicRunner(config(receipt), fake).execute()
|
||||
stored = json.loads(receipt.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(execution["status"], "FAILED")
|
||||
self.assertEqual(stored["status"], "FAILED")
|
||||
self.assertIn("tracked worktree differs", execution["error"])
|
||||
self.assertEqual(fake.starts, [])
|
||||
self.assertFalse(any("compose" in command for _, command in fake.commands))
|
||||
|
||||
def test_docker_context_is_pinned_to_local_windows_named_pipe(self) -> None:
|
||||
fake = FakeController()
|
||||
periodic = runner.PeriodicRunner(config(Path("receipt.json")), fake)
|
||||
proof = periodic._pin_local_docker_context()
|
||||
self.assertEqual(proof, {"context": "desktop-linux", "transport": "npipe", "remote": False})
|
||||
self.assertEqual(periodic.state.docker_context, "desktop-linux")
|
||||
|
||||
class RemoteDockerFake(FakeController):
|
||||
def run(self, stage, argv, **kwargs):
|
||||
result = super().run(stage, argv, **kwargs)
|
||||
if stage == "docker_context_inspect":
|
||||
return runner.CommandResult(
|
||||
0,
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"Endpoints": {
|
||||
"docker": {"Host": "tcp://127.0.0.1:2375"}
|
||||
}
|
||||
}
|
||||
]
|
||||
),
|
||||
"",
|
||||
0.01,
|
||||
)
|
||||
return result
|
||||
|
||||
with self.assertRaises(runner.GateError):
|
||||
runner.PeriodicRunner(
|
||||
config(Path("receipt.json")), RemoteDockerFake()
|
||||
)._pin_local_docker_context()
|
||||
|
||||
def test_cleanup_targets_only_exact_project_and_proves_listener_zero(self) -> None:
|
||||
fake = FakeController()
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), fake)
|
||||
periodic.state.source = runner.SourceIdentity("1" * 40, "2" * 40)
|
||||
periodic.state.runtime = runner.RuntimeIdentity(
|
||||
"20260809T120000-abcdef12",
|
||||
"vignette-periodic-11111111-abcdef12",
|
||||
"periodic:" + "1" * 40 + ":" + "2" * 40 + ":run",
|
||||
18080,
|
||||
18443,
|
||||
15439,
|
||||
19199,
|
||||
)
|
||||
periodic.state.docker_context = "desktop-linux"
|
||||
periodic.state.env_file = Path(raw) / "stack.env"
|
||||
periodic.state.override_file = Path(raw) / "override.yml"
|
||||
periodic.state.stack_attempted = True
|
||||
periodic.state.engine = runner.ProcessHandle(4242)
|
||||
proof = periodic._cleanup()
|
||||
|
||||
down = next(command for stage, command in fake.commands if stage == "compose_down")
|
||||
self.assertIn("vignette-periodic-11111111-abcdef12", down)
|
||||
self.assertIn("--remove-orphans", down)
|
||||
self.assertIn("--volumes", down)
|
||||
self.assertEqual(fake.stops, [4242])
|
||||
self.assertEqual(proof["container_remainder"], 0)
|
||||
self.assertEqual(proof["volume_remainder"], 0)
|
||||
self.assertEqual(proof["network_remainder"], 0)
|
||||
self.assertEqual(set(proof["listener_counts"].values()), {0})
|
||||
self.assertTrue(periodic._cleanup_green(proof))
|
||||
|
||||
def test_untracked_runtime_source_is_rejected_but_log_backup_is_not(self) -> None:
|
||||
fake = FakeController()
|
||||
periodic = runner.PeriodicRunner(config(Path("receipt.json")), fake)
|
||||
identity = periodic.source_identity(require_clean=True)
|
||||
self.assertEqual(identity.head, "1" * 40)
|
||||
|
||||
class DangerousFake(FakeController):
|
||||
def run(self, stage, argv, **kwargs):
|
||||
result = super().run(stage, argv, **kwargs)
|
||||
if stage == "source_untracked_inventory":
|
||||
return runner.CommandResult(
|
||||
0,
|
||||
"?? apps/web/e2e/uncommitted-runtime.spec.ts\n",
|
||||
"",
|
||||
0.01,
|
||||
)
|
||||
return result
|
||||
|
||||
with self.assertRaises(runner.GateError):
|
||||
runner.PeriodicRunner(
|
||||
config(Path("receipt.json")), DangerousFake()
|
||||
).source_identity(require_clean=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue