diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md index 862eaed8..b21be2cc 100644 --- a/.superpowers/sdd/task-4-report.md +++ b/.superpowers/sdd/task-4-report.md @@ -17,6 +17,8 @@ The Agent cannot author a question, option, label, partition ID, birth-time clai claim, or control instruction. Those fields are unrepresentable in its strict output schema. This supersedes the keyword-filter/substring-grounding design reviewed in `.omo/evidence/task-4-rereview.md` and the earlier interim `CLEAR` narrative. +The final acceptance correction has been implemented and independently re-audited by the +executor, but the main acceptance reviewer remains authoritative for completion status. ## RED evidence @@ -30,6 +32,14 @@ Tests were changed before production code: - Python: 7 tests, 1 expected failure. Two distinct same-year windows both rendered as the indistinguishable label `2012—2012 年`. +Final-fix RED artifact: `.omo/evidence/task-4-final-red.log`. + +- TypeScript: 17 tests, 2 expected failures. Exact and NFKC/whitespace-equivalent primary + labels matching either reserved choice were accepted by both binder and service instead of + failing before ID allocation. +- Python: 4/4 passed, including the new same-month/day-precision regression, confirming that + the production behavior existed but previously lacked durable coverage. + ## Implementation - `birth-time-dynamic-question-copy.ts` now contains only server-copy structural validation, @@ -37,42 +47,47 @@ Tests were changed before production code: deterministic server-copy fingerprinting. The former note blacklist and substring grounding logic were removed. - `birth-time-dynamic-question-validator.ts` accepts only strict selection objects. Binding - resolves the selected server opportunity, validates the prompt and normalized-unique - labels, validates every matching private partition, and only then allocates IDs. Malformed + resolves the selected server opportunity, validates the prompt and normalized uniqueness + across every primary and reserved visible label, validates every matching private + partition, and only then allocates IDs. Malformed server copy, private bindings, UUIDs, and persisted records raise `BirthTimeDynamicBindingError` and cannot be retried into a false low result. - Fallback sorts opportunities by information gain descending and then opportunity ID, independent of packet order. Repeated fingerprints alone are skipped as recoverable. -- `dynamic_rectification_opportunities.py` selects the least detailed year/month/day range - representation needed to distinguish visible windows. Cross-year ranges stay concise; - same-year or same-month collisions gain month or day precision. +- `dynamic_rectification_copy.py` owns localized contexts and the least detailed + year/month/day range representation needed to distinguish visible windows. Cross-year + ranges stay concise; same-year or same-month collisions gain month or day precision. + `dynamic_rectification_opportunities.py` is again below the 250-pure-LOC boundary. - The Mastra contract describes selection only and forbids prompt/options/labels/partition fields in Agent output. -The real Python-shaped fixture is checked against fresh Task 2 localized context, prompt, -labels, opportunity ID, fingerprint, and partition IDs, then parsed through the Task 3 adapter -and exercised through the Task 4 service. Task 5 persistence was not changed. +The real Python-shaped fixture retains structural CJK/no-ASCII copy, normalized label +uniqueness, partition count, opportunity ID, fingerprint, and partition-ID seam checks without +pinning exact natural-language prose. It is parsed through the Task 3 adapter and exercised +through the Task 4 service. Task 5 persistence was not changed. ## Verification | Gate | Result | Artifact | | --- | --- | --- | -| Selection-only RED | expected 9 TS + 1 Python failures | `.omo/evidence/task-4-finite-red.log` | -| Focused dynamic/guide TypeScript | 36/36 pass | `.omo/evidence/task-4-finite-focused-ts.log` | -| Focused Task 2 Python | 29/29 pass | `.omo/evidence/task-4-finite-focused-python.log` | -| Legacy Python rectification | 22/22 pass | `.omo/evidence/task-4-finite-legacy-python.log` | -| All birth-time TypeScript | 223/223 pass | `.omo/evidence/task-4-finite-birth-time.log` | -| Full frontend | 298/298 pass | `.omo/evidence/task-4-finite-frontend-full.log` | -| Changed TypeScript ESLint | pass, zero diagnostics | `.omo/evidence/task-4-finite-eslint.log` | -| Changed Python Ruff | pass | `.omo/evidence/task-4-finite-ruff.log` | -| Diff check and TypeScript pure-LOC audit | pass; all audited modules <=250 | `.omo/evidence/task-4-finite-quality.log` | -| Full TypeScript check | only known unrelated `profile-persistence.test.ts:7` TS1501 | `.omo/evidence/task-4-finite-tsc.log` | -| Fresh selection-boundary review | CLEAR / APPROVE | `.omo/evidence/task-4-selection-boundary-code-review.md` | +| Final-fix RED | expected 2 TS failures; Python 4/4 | `.omo/evidence/task-4-final-red.log` | +| Focused dynamic/guide TypeScript | 38/38 pass | `.omo/evidence/task-4-final-focused-ts.log` | +| Focused Task 2 Python | 26/26 pass | `.omo/evidence/task-4-final-focused-python.log` | +| Legacy Python rectification | 22/22 pass | `.omo/evidence/task-4-final-legacy-python.log` | +| All birth-time TypeScript | 225/225 pass | `.omo/evidence/task-4-final-birth-time.log` | +| Full frontend | 300/300 pass | `.omo/evidence/task-4-final-frontend-full.log` | +| Cumulative changed TypeScript ESLint | pass, zero diagnostics | `.omo/evidence/task-4-final-eslint.log` | +| Cumulative changed Python Ruff | pass | `.omo/evidence/task-4-final-ruff.log` | +| Diff check and all changed TS/Python LOC | pass; every audited file <=250 | `.omo/evidence/task-4-final-quality.log` | +| Full TypeScript check | only known unrelated `profile-persistence.test.ts:7` TS1501 | `.omo/evidence/task-4-final-tsc.log` | +| Fresh final-fix review | CLEAR / APPROVE; no blockers or WATCH items | `.omo/evidence/task-4-final-fix-code-review.md` | The TypeScript command remains non-zero solely because the pre-existing profile-persistence test uses a regular-expression flag newer than the configured target. No Task 4 file reports a type error. -The fresh reviewer independently probed extra model fields, note omission, normalized label -collisions, malformed private bindings, server-failure propagation, fallback ordering, -all-repeated behavior, and Python month/day collisions. Both `omo:programming` language -perspectives and `omo:remove-ai-slops` returned no blocker. +The earlier `.omo/evidence/task-4-selection-boundary-code-review.md` `CLEAR` is explicitly +superseded by `.omo/evidence/task-4-final-review.md`; it is not cited as current acceptance. +The final reviewer independently rechecked reserved-label normalization, zero allocation and +commit, the packet-only prompt API, typed Python range-copy boundary, same-month day precision, +prose-pin removal, and cumulative LOC. Both required programming language perspectives and the +remove-slops perspective returned `CLEAR / APPROVE` with no remaining WATCH item. diff --git a/frontend/src/lib/birth-time-dynamic-question-validator.ts b/frontend/src/lib/birth-time-dynamic-question-validator.ts index 1ced89e3..8cee09eb 100644 --- a/frontend/src/lib/birth-time-dynamic-question-validator.ts +++ b/frontend/src/lib/birth-time-dynamic-question-validator.ts @@ -29,6 +29,10 @@ const dynamicQuestionOutputSchema = z.discriminatedUnion("kind", [ questionSelectionSchema, noUsefulQuestionSchema, ]).readonly(); +const reservedChoices = [ + { label: "不确定 / 不记得", kind: "unknown" as const }, + { label: "都不符合", kind: "unmatched" as const }, +] as const; export type ParsedDynamicQuestionOutput = z.infer; export type ParsedQuestionSelection = Extract; @@ -66,9 +70,7 @@ function opportunityFor( export function generateDynamicQuestionPrompt( packet: CandidateDifferencePacket, - unmatchedNote: string | null, ): string { - void unmatchedNote; return modelSafeDynamicQuestionPrompt(packet); } @@ -102,7 +104,8 @@ function serverRendering(opportunity: QuestionOpportunity): { partitionId: partition.partitionId, label: partition.fallbackLabel, })); - const labels = options.map((option) => normalizeDynamicLabel(option.label)); + const labels = [...options, ...reservedChoices] + .map((option) => normalizeDynamicLabel(option.label)); if ( !dynamicServerCopyIsSafe(opportunity.fallbackPrompt, true) || options.some((option) => !dynamicServerCopyIsSafe(option.label, false)) @@ -177,8 +180,12 @@ function bindQuestion( prompt: rendering.prompt, options: [ ...primaryOptions, - { optionId: serverId(createId), label: "不确定 / 不记得", kind: "unknown", partitionId: null, candidateScores: null }, - { optionId: serverId(createId), label: "都不符合", kind: "unmatched", partitionId: null, candidateScores: null }, + ...reservedChoices.map((choice) => ({ + optionId: serverId(createId), + ...choice, + partitionId: null, + candidateScores: null, + })), ], }); if (!persisted.success) throw new BirthTimeDynamicBindingError("invalid_persisted_question"); diff --git a/frontend/src/lib/birth-time-guide-service.ts b/frontend/src/lib/birth-time-guide-service.ts index dbd64ec0..6aa7cd87 100644 --- a/frontend/src/lib/birth-time-guide-service.ts +++ b/frontend/src/lib/birth-time-guide-service.ts @@ -163,7 +163,7 @@ export function createBirthTimeGuideService(ports: GuideServicePorts) { const createId = ports.createDynamicId ?? (() => globalThis.crypto.randomUUID()); let question: PersistedDynamicChoiceQuestion | null = null; if (build.packet.opportunities.length > 0) { - const prompt = generateDynamicQuestionPrompt(build.packet, command.unmatchedNote); + const prompt = generateDynamicQuestionPrompt(build.packet); for (let attempt = 0; attempt < 2 && question === null; attempt += 1) { const text = await generatedText(ports.generator, prompt, timeoutMs); if (text === null) continue; diff --git a/frontend/tests/birth-time-dynamic-question-service.test.ts b/frontend/tests/birth-time-dynamic-question-service.test.ts index 7051bd9c..544165a1 100644 --- a/frontend/tests/birth-time-dynamic-question-service.test.ts +++ b/frontend/tests/birth-time-dynamic-question-service.test.ts @@ -184,6 +184,35 @@ test("duplicate server labels propagate without commit or ID allocation", async assert.equal(commits, 0); }); +test("reserved-label collisions propagate without commit or ID allocation", async () => { + const opportunity = differenceBuild.packet.opportunities[0]; + const privatePartitions = differenceBuild.scoringPartitions[opportunityId]; + if (!opportunity || !privatePartitions) throw new Error("missing test opportunity"); + for (const collision of ["不确定 / 不记得", "不 确定 / 不记得", "都不符合"]) { + let allocations = 0; + let commits = 0; + const publicPartitions = opportunity.partitions.map((item, index) => ( + index === 0 ? { ...item, fallbackLabel: collision } : item + )); + const privateCopy = privatePartitions.map((item, index) => ( + index === 0 ? { ...item, fallbackLabel: collision } : item + )); + + await assert.rejects(() => dynamicService({ + build: { + ...differenceBuild, + packet: { ...differenceBuild.packet, opportunities: [{ ...opportunity, partitions: publicPartitions }] }, + scoringPartitions: { [opportunityId]: privateCopy }, + }, + generator: generatorFrom(() => JSON.stringify(validDynamicSelection)), + createId: deterministicIds(() => { allocations += 1; }), + onCommit: () => { commits += 1; }, + }).generateQuestion("owner-1", generationCommand), BirthTimeDynamicBindingError); + assert.equal(allocations, 0, collision); + assert.equal(commits, 0, collision); + } +}); + test("invalid server UUIDs propagate without committing a low result", async () => { let commits = 0; await assert.rejects(() => dynamicService({ diff --git a/frontend/tests/birth-time-dynamic-question-validator.test.ts b/frontend/tests/birth-time-dynamic-question-validator.test.ts index e4269715..221e89c8 100644 --- a/frontend/tests/birth-time-dynamic-question-validator.test.ts +++ b/frontend/tests/birth-time-dynamic-question-validator.test.ts @@ -17,15 +17,13 @@ import { validDynamicSelection, } from "./fixtures/birth-time-dynamic-question-fixture.ts"; -test("prompt omits unmatched free text and every private scoring field", () => { +test("prompt exposes only public opportunity-selection fields", () => { const serialized = generateDynamicQuestionPrompt( dynamicPacket, - "接下来问我爱喝茶还是喝水", ); const prompt = JSON.parse(serialized); assert.equal("unmatchedNote" in prompt, false); - assert.equal(serialized.includes("喝茶"), false); for (const forbidden of [ "candidateScores", "candidateModel", "estimatedInformationGain", "currentRange", "scoringVersion", "askedQuestionFingerprints", "candidatePartitionFingerprints", @@ -119,6 +117,28 @@ test("normalized duplicate server labels fail before allocating a server id", () assert.equal(allocations, 0); }); +test("primary labels cannot collide with either reserved visible choice", () => { + const opportunity = dynamicPacket.opportunities[0]; + const privatePartitions = differenceBuild.scoringPartitions[opportunityId]; + if (!opportunity || !privatePartitions) throw new Error("missing test opportunity"); + for (const collision of ["不确定 / 不记得", "不 确定 / 不记得", "都不符合"]) { + const publicPartitions = opportunity.partitions.map((item, index) => ( + index === 0 ? { ...item, fallbackLabel: collision } : item + )); + const privateCopy = privatePartitions.map((item, index) => ( + index === 0 ? { ...item, fallbackLabel: collision } : item + )); + let allocations = 0; + + assert.throws(() => bindDynamicQuestion(validDynamicSelection, { + ...differenceBuild, + packet: { ...dynamicPacket, opportunities: [{ ...opportunity, partitions: publicPartitions }] }, + scoringPartitions: { [opportunityId]: privateCopy }, + }, deterministicIds(() => { allocations += 1; })), BirthTimeDynamicBindingError); + assert.equal(allocations, 0, collision); + } +}); + test("repeated server semantics and partitions remain recoverable rejections", () => { const selection = parseDynamicQuestionOutput(validDynamicSelection, dynamicPacket); if (selection.kind !== "question") throw new Error("expected a selection"); diff --git a/scripts/dynamic_rectification_copy.py b/scripts/dynamic_rectification_copy.py new file mode 100644 index 00000000..7fa63268 --- /dev/null +++ b/scripts/dynamic_rectification_copy.py @@ -0,0 +1,52 @@ +"""Localized public copy for dynamic birth-time rectification choices.""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import date +from typing import Final, TypedDict + +DIMENSION_CONTEXT: Final = { + "education": "一次明显的升学、转学或学习方向变化", + "relocation": "一次明显的搬家、离乡或长期居住地变化", + "relationship": "一次明显的关系进入、结束或重要转变", + "career": "一次明显的工作、职业方向或身份变化", + "health_pressure": "一次持续的健康压力或生活压力变化", +} +SUPPORTED_DIMENSIONS: Final = frozenset(DIMENSION_CONTEXT) + + +class DateRange(TypedDict): + window_start: str + window_end: str + + +def _range_label(item: DateRange, precision: str) -> str: + start = date.fromisoformat(item["window_start"]) + end = date.fromisoformat(item["window_end"]) + if precision == "year": + return f"{start.year} 年" if start.year == end.year else f"{start.year}—{end.year} 年" + if precision == "month": + if start.year == end.year: + return f"{start.year} 年 {start.month} 月—{end.month} 月" + return f"{start.year} 年 {start.month} 月—{end.year} 年 {end.month} 月" + if start.year == end.year and start.month == end.month: + return f"{start.year} 年 {start.month} 月 {start.day} 日—{end.day} 日" + return ( + f"{start.year} 年 {start.month} 月 {start.day} 日—" + f"{end.year} 年 {end.month} 月 {end.day} 日" + ) + + +def visible_range_labels(items: Sequence[DateRange]) -> list[str]: + """Use the least date precision that keeps every visible range distinct.""" + labels = [_range_label(item, "year") for item in items] + for precision in ("month", "day"): + duplicates = {label for label in labels if labels.count(label) > 1} + if not duplicates: + break + labels = [ + _range_label(item, precision) if label in duplicates else label + for item, label in zip(items, labels, strict=True) + ] + return labels diff --git a/scripts/dynamic_rectification_opportunities.py b/scripts/dynamic_rectification_opportunities.py index 070b7d26..b18f7586 100644 --- a/scripts/dynamic_rectification_opportunities.py +++ b/scripts/dynamic_rectification_opportunities.py @@ -17,16 +17,14 @@ from datetime import date, datetime, time, timedelta from typing import Final from uuid import NAMESPACE_URL, uuid5 +from scripts.dynamic_rectification_copy import ( + DIMENSION_CONTEXT, + SUPPORTED_DIMENSIONS, + visible_range_labels, +) + ALGORITHM_VERSION: Final = "birth-time-choice-scoring-v2" MIN_INFORMATION_GAIN: Final = 0.15 -DIMENSION_CONTEXT: Final = { - "education": "一次明显的升学、转学或学习方向变化", - "relocation": "一次明显的搬家、离乡或长期居住地变化", - "relationship": "一次明显的关系进入、结束或重要转变", - "career": "一次明显的工作、职业方向或身份变化", - "health_pressure": "一次持续的健康压力或生活压力变化", -} -SUPPORTED_DIMENSIONS: Final = frozenset(DIMENSION_CONTEXT) def canonical_hash(value: Mapping | Sequence) -> str: @@ -213,36 +211,6 @@ def opportunities(model: dict) -> list[dict]: return sorted(result, key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"])) -def _range_label(item: dict, precision: str) -> str: - start = date.fromisoformat(item["window_start"]) - end = date.fromisoformat(item["window_end"]) - if precision == "year": - return f"{start.year} 年" if start.year == end.year else f"{start.year}—{end.year} 年" - if precision == "month": - if start.year == end.year: - return f"{start.year} 年 {start.month} 月—{end.month} 月" - return f"{start.year} 年 {start.month} 月—{end.year} 年 {end.month} 月" - if start.year == end.year and start.month == end.month: - return f"{start.year} 年 {start.month} 月 {start.day} 日—{end.day} 日" - return ( - f"{start.year} 年 {start.month} 月 {start.day} 日—" - f"{end.year} 年 {end.month} 月 {end.day} 日" - ) - - -def _visible_range_labels(items: list[dict]) -> list[str]: - labels = [_range_label(item, "year") for item in items] - for precision in ("month", "day"): - duplicates = {label for label in labels if labels.count(label) > 1} - if not duplicates: - break - labels = [ - _range_label(item, precision) if label in duplicates else label - for item, label in zip(items, labels, strict=True) - ] - return labels - - def _dimension_opportunity(dimension: str, windows: list[dict], candidates: list[str]) -> dict | None: neutral_context = DIMENSION_CONTEXT[dimension] memberships: dict[int, list[str]] = defaultdict(list) @@ -269,7 +237,10 @@ def _dimension_opportunity(dimension: str, windows: list[dict], candidates: list } for window, members in populated ] - labels = _visible_range_labels(basis) + labels = visible_range_labels([ + {"window_start": item["window_start"], "window_end": item["window_end"]} + for item in basis + ]) partitions = [ { "partition_id": canonical_hash(item), diff --git a/tests/test_dynamic_rectification_opportunities.py b/tests/test_dynamic_rectification_opportunities.py index 2c3add6f..ff839a7e 100644 --- a/tests/test_dynamic_rectification_opportunities.py +++ b/tests/test_dynamic_rectification_opportunities.py @@ -4,8 +4,6 @@ import json import re from pathlib import Path -import pytest - from scripts.dynamic_rectification_opportunities import _dimension_opportunity TASK2_PACKET_FIXTURE = ( @@ -14,20 +12,7 @@ TASK2_PACKET_FIXTURE = ( ) -@pytest.mark.parametrize( - ("dimension", "domain_terms"), - [ - ("education", ("升学", "转学", "学习")), - ("relocation", ("搬家", "离乡", "居住")), - ("relationship", ("关系",)), - ("career", ("工作", "职业", "身份")), - ("health_pressure", ("健康", "压力", "生活")), - ], -) -def test_every_supported_dimension_emits_localized_validator_safe_copy( - dimension: str, - domain_terms: tuple[str, ...], -) -> None: +def test_every_supported_dimension_emits_distinct_validator_safe_copy() -> None: windows = [ { "window_start": "2018-01-01", @@ -41,26 +26,27 @@ def test_every_supported_dimension_emits_localized_validator_safe_copy( }, ] - opportunity = _dimension_opportunity(dimension, windows, ["04:00", "04:01"]) - - assert opportunity is not None - context = opportunity["neutral_context"] - prompt = opportunity["fallback_prompt"] - assert any(term in context for term in domain_terms) - assert dimension not in context - assert re.search(r"[\u3400-\u9fff]", context) - assert re.search(r"[A-Za-z]", context) is None - assert context in prompt - assert prompt.endswith("?") - assert re.search(r"[A-Za-z]", prompt) is None - assert all(label.endswith(" 年") for label in ( - item["fallback_label"] for item in opportunity["partitions"] - )) - assert [item["fallback_label"].replace(" 年", "") for item in opportunity["partitions"]] == [ - "2018—2020", - "2021—2023", + dimensions = ["education", "relocation", "relationship", "career", "health_pressure"] + opportunities = [ + _dimension_opportunity(dimension, windows, ["04:00", "04:01"]) + for dimension in dimensions ] + assert all(opportunity is not None for opportunity in opportunities) + contexts = [opportunity["neutral_context"] for opportunity in opportunities if opportunity] + assert len(contexts) == len(set(contexts)) == len(dimensions) + for dimension, opportunity in zip(dimensions, opportunities, strict=True): + assert opportunity is not None + context = opportunity["neutral_context"] + prompt = opportunity["fallback_prompt"] + labels = [item["fallback_label"] for item in opportunity["partitions"]] + assert dimension not in context + assert re.search(r"[\u3400-\u9fff]", context) + assert re.search(r"[A-Za-z]", context + prompt) is None + assert prompt.endswith("?") and prompt.count("?") == 1 + assert len(labels) == len(set(label.replace(" ", "") for label in labels)) + assert all(re.search(r"[\u3400-\u9fff]", label) for label in labels) + def test_frontend_adapter_fixture_is_real_task2_opportunity_output() -> None: packet = json.loads(TASK2_PACKET_FIXTURE.read_text(encoding="utf-8")) @@ -89,11 +75,10 @@ def test_frontend_adapter_fixture_is_real_task2_opportunity_output() -> None: ] assert fixture["dimension_code"] == "career" assert "career" not in fixture["neutral_context"] - assert fixture["neutral_context"] == opportunity["neutral_context"] - assert fixture["fallback_prompt"] == opportunity["fallback_prompt"] - assert [item["fallback_label"] for item in fixture["partitions"]] == [ - item["fallback_label"] for item in opportunity["partitions"] - ] + assert len(fixture["partitions"]) == len(opportunity["partitions"]) + assert re.search(r"[A-Za-z]", fixture["neutral_context"] + fixture["fallback_prompt"]) is None + fixture_labels = [item["fallback_label"] for item in fixture["partitions"]] + assert len(fixture_labels) == len(set(label.replace(" ", "") for label in fixture_labels)) def test_same_year_windows_receive_distinct_visible_labels() -> None: @@ -116,3 +101,25 @@ def test_same_year_windows_receive_distinct_visible_labels() -> None: labels = [item["fallback_label"] for item in opportunity["partitions"]] assert len(labels) == len(set(labels)) assert all("月" in label for label in labels) + + +def test_same_month_windows_receive_distinct_day_precision_labels() -> None: + windows = [ + { + "window_start": "2012-01-01", + "window_end": "2012-01-10", + "activations": {"04:00": 1.0, "04:01": 0.0}, + }, + { + "window_start": "2012-01-11", + "window_end": "2012-01-20", + "activations": {"04:00": 0.0, "04:01": 1.0}, + }, + ] + + opportunity = _dimension_opportunity("career", windows, ["04:00", "04:01"]) + + assert opportunity is not None + labels = [item["fallback_label"] for item in opportunity["partitions"]] + assert len(labels) == len(set(labels)) + assert all("日" in label for label in labels)