fix: align dynamic choice boundaries

This commit is contained in:
Jesse_Chen
2026-07-19 04:38:20 +08:00
parent d9969d0829
commit 4aaf1078ad
12 changed files with 182 additions and 48 deletions
+33 -17
View File
@@ -40,12 +40,21 @@ Final-fix RED artifact: `.omo/evidence/task-4-final-red.log`.
- Python: 4/4 passed, including the new same-month/day-precision regression, confirming that
the production behavior existed but previously lacked durable coverage.
Standards-axis RED artifact: `.omo/evidence/task-4-axis-red.log`.
- TypeScript: 19 tests, 2 expected failures. Server prompts of 121 and 240 characters were
accepted by binder and service instead of failing before ID allocation and commit.
- Python: 4/4 passed after replacing localized precision glyph assertions with numeric-boundary
structure and normalized uniqueness checks.
## Implementation
- `birth-time-dynamic-question-copy.ts` now contains only server-copy structural validation,
NFKC/whitespace label normalization, the note-free opportunity-selection projection, and
deterministic server-copy fingerprinting. The former note blacklist and substring
grounding logic were removed.
grounding logic were removed. Shared constants cap server questions at 120 characters and
labels at 80 across the API adapter, public schema, internal model, persisted schema, and
binding guard.
- `birth-time-dynamic-question-validator.ts` accepts only strict selection objects. Binding
resolves the selected server opportunity, validates the prompt and normalized uniqueness
across every primary and reserved visible label, validates every matching private
@@ -57,7 +66,9 @@ Final-fix RED artifact: `.omo/evidence/task-4-final-red.log`.
- `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.
Its precision discriminator is the exhaustive `Literal["year", "month", "day"]` domain;
unknown precision cannot silently fall through. `dynamic_rectification_opportunities.py`
remains below the 250-pure-LOC boundary.
- The Mastra contract describes selection only and forbids prompt/options/labels/partition
fields in Agent output.
@@ -70,24 +81,29 @@ through the Task 4 service. Task 5 persistence was not changed.
| Gate | Result | Artifact |
| --- | --- | --- |
| 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` |
| Standards-axis RED | expected 2 TS failures; Python 4/4 | `.omo/evidence/task-4-axis-red.log` |
| Focused dynamic/guide TypeScript | 40/40 pass | `.omo/evidence/task-4-axis-focused-ts.log` |
| Public dynamic-choice schema TypeScript | 7/7 pass | `.omo/evidence/task-4-axis-public-schema-ts.log` |
| Dynamic adapter boundary TypeScript | 8/8 pass | `.omo/evidence/task-4-axis-adapter-ts.log` |
| Focused Task 2 Python | 26/26 pass | `.omo/evidence/task-4-axis-focused-python.log` |
| Legacy Python rectification | 22/22 pass | `.omo/evidence/task-4-axis-legacy-python.log` |
| All birth-time TypeScript | 229/229 pass | `.omo/evidence/task-4-axis-birth-time.log` |
| Full frontend | 304/304 pass | `.omo/evidence/task-4-axis-frontend-full.log` |
| Cumulative changed TypeScript ESLint | pass, zero diagnostics | `.omo/evidence/task-4-axis-eslint.log` |
| Cumulative changed Python Ruff | pass | `.omo/evidence/task-4-axis-ruff.log` |
| Diff check and all changed TS/Python LOC | pass; every audited file <=250 | `.omo/evidence/task-4-axis-quality.log` |
| Full TypeScript check | only known unrelated `profile-persistence.test.ts:7` TS1501 | `.omo/evidence/task-4-axis-tsc.log` |
| Fresh standards-axis fix review | CLEAR / APPROVE; no blockers | `.omo/evidence/task-4-axis-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 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.
The earlier `.omo/evidence/task-4-final-fix-code-review.md` `CLEAR` is explicitly superseded by
the standards-axis review and is not cited as current acceptance. The new tests contain no
localized month/day or domain-word assertions; precision is verified through distinct normalized
labels and the number of numeric range-boundary tokens.
The fresh reviewer also verified the shared 120/80 boundary through the public schema, API
adapter, internal and persisted schemas, and binding guard. Both programming language
perspectives and the remove-slops perspective returned `CLEAR / APPROVE`.
@@ -1,5 +1,9 @@
import { z } from "zod";
import { candidateResultSchema } from "./birth-time-evidence.ts";
import {
DYNAMIC_QUESTION_LABEL_MAX_LENGTH,
DYNAMIC_QUESTION_PROMPT_MAX_LENGTH,
} from "./birth-time-dynamic-question-limits.ts";
import {
publicChoiceKindSchema,
publicDynamicChoiceQuestionSchema,
@@ -111,7 +115,7 @@ export type DynamicControlState = {
const evidencePartitionBaseSchema = z.object({
partitionId: z.string().trim().min(1),
descriptor: z.string().trim().min(1),
fallbackLabel: z.string().trim().min(1).max(80),
fallbackLabel: z.string().trim().min(1).max(DYNAMIC_QUESTION_LABEL_MAX_LENGTH),
}).strict();
export const evidencePartitionSchema = evidencePartitionBaseSchema.readonly();
@@ -126,7 +130,7 @@ export const questionOpportunitySchema = z.object({
neutralContext: z.string().trim().min(1),
estimatedInformationGain: z.number().finite().nonnegative(),
candidatePartitionFingerprint: z.string().trim().min(1),
fallbackPrompt: z.string().trim().min(1).max(240),
fallbackPrompt: z.string().trim().min(1).max(DYNAMIC_QUESTION_PROMPT_MAX_LENGTH),
partitions: z.array(evidencePartitionSchema).min(2).max(4).readonly(),
}).strict().readonly();
@@ -148,7 +152,7 @@ export const candidateDifferenceBuildSchema = z.object({
const persistedPrimaryChoiceSchema = z.object({
optionId: z.string().trim().min(1),
label: z.string().trim().min(1).max(80),
label: z.string().trim().min(1).max(DYNAMIC_QUESTION_LABEL_MAX_LENGTH),
kind: z.literal("primary"),
partitionId: z.string().trim().min(1),
candidateScores: finiteScoresSchema,
@@ -156,7 +160,7 @@ const persistedPrimaryChoiceSchema = z.object({
const persistedSpecialChoiceSchema = (kind: "unknown" | "unmatched") => z.object({
optionId: z.string().trim().min(1),
label: z.string().trim().min(1).max(80),
label: z.string().trim().min(1).max(DYNAMIC_QUESTION_LABEL_MAX_LENGTH),
kind: z.literal(kind),
partitionId: z.null(),
candidateScores: z.null(),
@@ -171,7 +175,7 @@ export const persistedDynamicChoiceQuestionSchema = z.object({
source: z.enum(["agent", "fallback"]),
questionFingerprint: z.string().trim().min(1),
candidatePartitionFingerprint: z.string().trim().min(1),
prompt: z.string().trim().min(1).max(240),
prompt: z.string().trim().min(1).max(DYNAMIC_QUESTION_PROMPT_MAX_LENGTH),
options: z.array(z.union([
persistedPrimaryChoiceSchema,
persistedSpecialChoiceSchema("unknown"),
@@ -1,4 +1,8 @@
import { z } from "zod";
import {
DYNAMIC_QUESTION_LABEL_MAX_LENGTH,
DYNAMIC_QUESTION_PROMPT_MAX_LENGTH,
} from "./birth-time-dynamic-question-limits.ts";
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
@@ -18,7 +22,7 @@ export type TimeRange = {
export const publicDynamicChoiceOptionSchema = z.object({
optionId: z.string().trim().min(1),
label: z.string().trim().min(1).max(80),
label: z.string().trim().min(1).max(DYNAMIC_QUESTION_LABEL_MAX_LENGTH),
kind: publicChoiceKindSchema,
}).strict().readonly();
@@ -55,7 +59,7 @@ function validateOptionSet(
export const publicDynamicChoiceQuestionSchema = z.object({
questionId: z.string().trim().min(1),
prompt: z.string().trim().min(1).max(240),
prompt: z.string().trim().min(1).max(DYNAMIC_QUESTION_PROMPT_MAX_LENGTH),
options: z.array(publicDynamicChoiceOptionSchema).readonly(),
}).strict().superRefine(validateOptionSet).readonly();
@@ -1,11 +1,17 @@
import { createHash } from "node:crypto";
import {
DYNAMIC_QUESTION_LABEL_MAX_LENGTH,
DYNAMIC_QUESTION_PROMPT_MAX_LENGTH,
} from "./birth-time-dynamic-question-limits.ts";
import type { CandidateDifferencePacket } from "./birth-time-dynamic-choice-internal.ts";
const clockTimePattern = /(?:^|[^\d])(?:[01]?\d|2[0-3])\s*[:]\s*[0-5]\d(?:$|[^\d])/;
export function dynamicServerCopyIsSafe(value: string, question: boolean): boolean {
const normalized = value.normalize("NFKC").trim();
if (normalized.length > (question ? 240 : 80)) return false;
if (normalized.length > (
question ? DYNAMIC_QUESTION_PROMPT_MAX_LENGTH : DYNAMIC_QUESTION_LABEL_MAX_LENGTH
)) return false;
if (!/[\u3400-\u9fff]/u.test(normalized) || /[A-Za-z]/.test(normalized)) return false;
if (clockTimePattern.test(normalized)) return false;
return !question || (normalized.match(/[?]/g) ?? []).length === 1;
@@ -0,0 +1,2 @@
export const DYNAMIC_QUESTION_PROMPT_MAX_LENGTH = 120;
export const DYNAMIC_QUESTION_LABEL_MAX_LENGTH = 80;
@@ -4,6 +4,10 @@ import {
dynamicChoiceScoringResultSchema,
} from "./birth-time-dynamic-choice-internal.ts";
import { candidateResultSchema } from "./birth-time-evidence.ts";
import {
DYNAMIC_QUESTION_LABEL_MAX_LENGTH,
DYNAMIC_QUESTION_PROMPT_MAX_LENGTH,
} from "./birth-time-dynamic-question-limits.ts";
import type {
CandidateDifferenceBuild,
DynamicChoiceScoringResult,
@@ -17,7 +21,7 @@ const apiRangeSchema = z.object({
const partitionSchema = z.object({
partition_id: z.string().trim().min(1),
descriptor: z.string().trim().min(1),
fallback_label: z.string().trim().min(1).max(80),
fallback_label: z.string().trim().min(1).max(DYNAMIC_QUESTION_LABEL_MAX_LENGTH),
candidate_scores: z.record(candidateTimeSchema, z.number().finite().nonnegative()),
}).strict();
const opportunitySchema = z.object({
@@ -26,7 +30,7 @@ const opportunitySchema = z.object({
neutral_context: z.string().trim().min(1),
estimated_information_gain: z.number().finite().nonnegative(),
candidate_partition_fingerprint: z.string().trim().min(1),
fallback_prompt: z.string().trim().min(1).max(240),
fallback_prompt: z.string().trim().min(1).max(DYNAMIC_QUESTION_PROMPT_MAX_LENGTH),
partitions: z.array(partitionSchema).min(2).max(4),
}).strict().superRefine((value, context) => {
if (new Set(value.partitions.map((item) => item.partition_id)).size !== value.partitions.length) {
@@ -3,7 +3,10 @@ import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { publicDynamicChoiceQuestionSchema } from "../src/lib/birth-time-dynamic-choice.ts";
import { persistedDynamicChoiceQuestionSchema } from "../src/lib/birth-time-dynamic-choice-internal.ts";
import {
persistedDynamicChoiceQuestionSchema,
toPublicDynamicChoiceQuestion,
} from "../src/lib/birth-time-dynamic-choice-internal.ts";
import { dynamicJourneyTurnStateSchema, journeyTurnStateSchema } from "../src/lib/birth-time-journey-turn-protocol.ts";
const internalQuestion = {
@@ -74,6 +77,20 @@ test("public questions never expose partition ids", () => {
}).success, false);
});
test("public questions enforce the shared prompt limit", () => {
const publicQuestion = toPublicDynamicChoiceQuestion(internalQuestion);
const promptWithLength = (length: number) => `${"问".repeat(length - 1)}`;
assert.equal(publicDynamicChoiceQuestionSchema.safeParse({
...publicQuestion,
prompt: promptWithLength(120),
}).success, true);
assert.equal(publicDynamicChoiceQuestionSchema.safeParse({
...publicQuestion,
prompt: promptWithLength(121),
}).success, false);
});
test("internal primary choices require a server partition", () => {
assert.equal(persistedDynamicChoiceQuestionSchema.safeParse(internalQuestion).success, true);
assert.equal(persistedDynamicChoiceQuestionSchema.safeParse({
@@ -213,6 +213,31 @@ test("reserved-label collisions propagate without commit or ID allocation", asyn
}
});
test("overlong server prompts propagate without commit or ID allocation", async () => {
const opportunity = differenceBuild.packet.opportunities[0];
if (!opportunity) throw new Error("missing test opportunity");
for (const length of [121, 240]) {
let allocations = 0;
let commits = 0;
const fallbackPrompt = `${"问".repeat(length - 1)}`;
await assert.rejects(() => dynamicService({
build: {
...differenceBuild,
packet: {
...differenceBuild.packet,
opportunities: [{ ...opportunity, fallbackPrompt }],
},
},
generator: generatorFrom(() => JSON.stringify(validDynamicSelection)),
createId: deterministicIds(() => { allocations += 1; }),
onCommit: () => { commits += 1; },
}).generateQuestion("owner-1", generationCommand), BirthTimeDynamicBindingError);
assert.equal(allocations, 0, String(length));
assert.equal(commits, 0, String(length));
}
});
test("invalid server UUIDs propagate without committing a low result", async () => {
let commits = 0;
await assert.rejects(() => dynamicService({
@@ -139,6 +139,33 @@ test("primary labels cannot collide with either reserved visible choice", () =>
}
});
test("server prompt accepts 120 characters but rejects longer copy before IDs", () => {
const opportunity = dynamicPacket.opportunities[0];
if (!opportunity) throw new Error("missing test opportunity");
const promptWithLength = (length: number) => `${"问".repeat(length - 1)}`;
const buildWithPrompt = (length: number) => ({
...differenceBuild,
packet: {
...dynamicPacket,
opportunities: [{ ...opportunity, fallbackPrompt: promptWithLength(length) }],
},
});
assert.equal(
bindDynamicQuestion(validDynamicSelection, buildWithPrompt(120), deterministicIds()).prompt.length,
120,
);
for (const length of [121, 240]) {
let allocations = 0;
assert.throws(() => bindDynamicQuestion(
validDynamicSelection,
buildWithPrompt(length),
deterministicIds(() => { allocations += 1; }),
), BirthTimeDynamicBindingError);
assert.equal(allocations, 0, String(length));
}
});
test("repeated server semantics and partitions remain recoverable rejections", () => {
const selection = parseDynamicQuestionOutput(validDynamicSelection, dynamicPacket);
if (selection.kind !== "question") throw new Error("expected a selection");
@@ -85,6 +85,19 @@ test("difference packets separate public copy from private score vectors", () =>
});
});
test("difference packets enforce the public prompt limit at the API boundary", () => {
const withPrompt = (length: number) => ({
...apiPacket,
opportunities: [{
...apiPacket.opportunities[0],
fallback_prompt: `${"问".repeat(length - 1)}`,
}],
});
assert.equal(parseCandidateDifferenceBuild(withPrompt(120)).packet.opportunities[0]?.fallbackPrompt.length, 120);
assert.throws(() => parseCandidateDifferenceBuild(withPrompt(121)));
});
test("difference packets reject wrong versions, extra fields, and invalid score keys", () => {
assert.throws(() => parseCandidateDifferenceBuild({
...apiPacket, scoring_version: "birth-time-choice-scoring-v1",
+21 -14
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Sequence
from datetime import date
from typing import Final, TypedDict
from typing import Final, Literal, TypedDict, assert_never
DIMENSION_CONTEXT: Final = {
"education": "一次明显的升学、转学或学习方向变化",
@@ -21,21 +21,28 @@ class DateRange(TypedDict):
window_end: str
def _range_label(item: DateRange, precision: str) -> str:
RangePrecision = Literal["year", "month", "day"]
def _range_label(item: DateRange, precision: RangePrecision) -> 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}"
)
match precision:
case "year":
return f"{start.year}" if start.year == end.year else f"{start.year}{end.year}"
case "month":
if start.year == end.year:
return f"{start.year}{start.month} 月—{end.month}"
return f"{start.year}{start.month} 月—{end.year}{end.month}"
case "day":
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}"
)
case unreachable:
assert_never(unreachable)
def visible_range_labels(items: Sequence[DateRange]) -> list[str]:
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import re
import unicodedata
from pathlib import Path
from scripts.dynamic_rectification_opportunities import _dimension_opportunity
@@ -44,7 +45,7 @@ def test_every_supported_dimension_emits_distinct_validator_safe_copy() -> None:
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 len(labels) == len(set(re.sub(r"\s+", "", label) for label in labels))
assert all(re.search(r"[\u3400-\u9fff]", label) for label in labels)
@@ -78,7 +79,9 @@ def test_frontend_adapter_fixture_is_real_task2_opportunity_output() -> None:
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))
assert len(fixture_labels) == len(
set(re.sub(r"\s+", "", label) for label in fixture_labels)
)
def test_same_year_windows_receive_distinct_visible_labels() -> None:
@@ -99,8 +102,11 @@ def test_same_year_windows_receive_distinct_visible_labels() -> None:
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)
normalized = [
re.sub(r"\s+", "", unicodedata.normalize("NFKC", label)) for label in labels
]
assert len(normalized) == len(set(normalized))
assert all(len(re.findall(r"\d+", label)) >= 3 for label in labels)
def test_same_month_windows_receive_distinct_day_precision_labels() -> None:
@@ -121,5 +127,8 @@ def test_same_month_windows_receive_distinct_day_precision_labels() -> None:
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)
normalized = [
re.sub(r"\s+", "", unicodedata.normalize("NFKC", label)) for label in labels
]
assert len(normalized) == len(set(normalized))
assert all(len(re.findall(r"\d+", label)) >= 4 for label in labels)