주기 실회기 검증과 G7 종료계약 보강

This commit is contained in:
Yun Chan 2026-08-09 23:37:19 +09:00
parent 83590e9ef7
commit 7b4955c3fc
23 changed files with 2916 additions and 117 deletions

View file

@ -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

View file

@ -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,26 +375,24 @@ 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",
recomputed_kappa >= thresholds.min_categorical_kappa,
recomputed_kappa,
thresholds.min_categorical_kappa,
)
_check(
checks,
"reported_kappa_matches_rows",
math.isclose(
recomputed_kappa,
pack.reliability.reported_categorical_kappa,
abs_tol=0.0005,
),
_check(
checks,
"recomputed_categorical_kappa",
recomputed_kappa >= thresholds.min_categorical_kappa,
recomputed_kappa,
thresholds.min_categorical_kappa,
)
_check(
checks,
"reported_kappa_matches_rows",
math.isclose(
recomputed_kappa,
pack.reliability.reported_categorical_kappa,
)
abs_tol=0.0005,
),
recomputed_kappa,
pack.reliability.reported_categorical_kappa,
)
_check(checks, "minimum_paired_gain", gain >= thresholds.min_gain, gain, thresholds.min_gain)
_check(checks, "bootstrap_ci_excludes_zero", ci_lower > 0.0, ci_lower, "> 0")
_check(
@ -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,

View file

@ -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"):

View file

@ -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`);

View 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,
},
});
});
});

View file

@ -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");

View file

@ -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),

View file

@ -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;
}
}