fix: finalize dynamic choice labels

This commit is contained in:
Jesse_Chen
2026-07-19 04:13:12 +08:00
parent 35212af0fc
commit d9969d0829
8 changed files with 211 additions and 110 deletions
+38 -23
View File
@@ -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.
@@ -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<typeof dynamicQuestionOutputSchema>;
export type ParsedQuestionSelection = Extract<ParsedDynamicQuestionOutput, { readonly kind: "question" }>;
@@ -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");
+1 -1
View File
@@ -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;
@@ -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({
@@ -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");
+52
View File
@@ -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
+10 -39
View File
@@ -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),
@@ -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)