fix(rectification): keep clock-stamped events out of window intercept (BUG-631, BUG-632)
Dated life events with clock times were swallowed as birth-window replies, and compare columns still stopped at the first nine candidates after the hour-window cap. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-10 — 带钟点的经历不再被当成申报出生时段;对照列按全部候选分钟来算
|
||||
|
||||
生时校正做到一半时,如果说的是带钟点的经历(比如某日 20:00 左右分手、下午 3 点到 5 点被撞),助手不再误回「搜索范围按资料定、过程中不改」。只有在明确说出生时间,或只报一个钟点范围时,才用这句固定回复。
|
||||
|
||||
对照卡上的经历对照和未来时段按引擎里全部候选分钟来算(最多 64 个),不再只算前 9 个。一小时窗里若有 17 个候选,三列都能查到对照,不再出现「这一分钟还没对照」。Skill 版本不变。
|
||||
|
||||
|
||||
## 2026-09-09 — 性格点选题只在分不开时才问,且只作排序参考
|
||||
|
||||
生时校正的性格类点选题(相处方式、做事风格、月宿边界)不再和带年月的经历题同权。带年月的区分题还问得完时,先问那些题;只在问完仍分不开、还剩两个以上候选时才出性格题。答了只轻轻排序,不会因为性格自评把某一分钟淘汰。验证报告里这两行标成「参考」。Skill 10.0.21。
|
||||
|
||||
@@ -9763,3 +9763,35 @@
|
||||
- 相关记录:BUG-205、BUG-214、BUG-255、BUG-257、BUG-612
|
||||
- 复发自:BUG-214(同一句用户文案和合同门;本条是模型根本没成功调计算工具,不是失败计数把成功次数算超)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-631 | 申报时段拦截把带钟点的经历当成改窗口
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-09
|
||||
- 最近更新:2026-09-10
|
||||
- 影响面:`parseDeclaredBirthWindow`、`POST /api/rectification/agent` 自由文本、采集/点选题下的经历落库
|
||||
- 用户现象:用户报经历时带了钟点(例如「2024 年 8 月 8 日 20:00 左右分手」),助手立刻回复「搜索范围开始时按资料定、过程中不改」,这句话不进模型,证据也不落库。
|
||||
- 触发条件:校正做到一半,自由文本里出现 `HH:MM` / `X 点 Y 分` 加「到 / 至 / -」或「左右 / 前后」,同时这句话其实是经历而不是申报出生时段。
|
||||
- 根因:BUG-625 只按钟点样式拦截。经历里的钟点和申报出生时段共用同一套样式,没有出生语境或年月日门。
|
||||
- 修复:命中钟点后再判语境。含出生语境词才算申报;含年月日且无出生语境一律当经历;只有裸钟点范围(可带「大概 / 下午」这类虚词)仍拦截。
|
||||
- 验证:`frontend/tests/rectification-declared-window-20260909.test.ts`;既有 `rectification-window-cluster-cap-20260909.test.ts` 补了三句经历不拦截。
|
||||
- 防复发:不得只靠钟点正则拦截自由文本;带年月日的经历即使含「20:00 左右」也必须进模型。走查见 `docs/testing/rectification-window-cluster-cap-20260909.md` 第 4 节。
|
||||
- 相关记录:BUG-625
|
||||
- 复发自:BUG-625(拦截范围过宽)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-632 | 经历对照只算引擎前 9 个候选,推断前三会显示还没对照
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-09
|
||||
- 最近更新:2026-09-10
|
||||
- 影响面:`column_times_for_compare`、三列对照卡 `event_dasha_ledger_by_time` / `prospective_windows_by_time`
|
||||
- 用户现象:一小时窗有 17 个引擎候选时,对照卡某一列写「这一分钟还没对照」。
|
||||
- 触发条件:BUG-623 之后公开候选可以超过 9 个;推断层前三不在引擎候选列表的前 9 个里。
|
||||
- 根因:BUG-614 把对照从「引擎分数最高的三个」改成「引擎候选列表」,但仍保留 `limit=9`。卡片按推断前三查表,键不在这 9 个里就显示没对照。
|
||||
- 修复:默认上限改为公开候选全集(`MAX_PUBLIC_CLUSTERS`,64)。`column_times` 仍可传入更小的 active 集合;`column_compare_ms` 继续记录耗时,预算 3 秒。
|
||||
- 验证:`tests/test_rectification_v5_services.py`:17 个候选 → by_time 17 键,65 个截到 64,`column_compare_ms` 低于 3000;`column_times` 子集 3 键。前端 `columnTimesForSlowCompare` 在上一轮超过 3 秒时把 active 分钟写入引擎请求。
|
||||
- 防复发:不得把对照列截回前 9;推断层查表的分钟必须落在 by_time 键集合里。超 3 秒时只允许改传入的 `column_times`,不能再静默丢掉后段候选。
|
||||
- 相关记录:BUG-614、BUG-623
|
||||
- 复发自:BUG-614(对照集合仍被 9 截断)
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# PROGRESS · 验收补漏:申报时段误伤经历;by_time 只算 9 个(2026-09-09)
|
||||
|
||||
工作树:`.worktrees/rectification-followups-20260909`
|
||||
分支:`codex/rectification-followups-20260909`
|
||||
任务书:`docs/tasks/TASK-rectification-followups-20260909.md`
|
||||
基线:任务书写 `fef54b6e`;开工时接到 `origin/staging` @ `2de2aa0b`(任务书已在其上)。本单不依赖 BUG-626~628。
|
||||
|
||||
## 开工回执
|
||||
|
||||
- 编号 **BUG-631** / **BUG-632**(`docs/BUG_HISTORY.md` 当时最大已发布号是 630)。
|
||||
- 顺序:解析语境门 → 对照列上限 64 → 超 3 秒时把 active 分钟作为 `column_times` 传入 → 记录。
|
||||
|
||||
## 已完成
|
||||
|
||||
- **BUG-631**:`parseDeclaredBirthWindow` 命中钟点后再判语境。含出生语境词才算申报;含年月日且无出生语境一律当经历;裸钟点范围(可带「大概 / 下午」)仍拦截。`route.ts` 仍只在解析命中时固定回复。
|
||||
- **BUG-632**:`column_times_for_compare` 默认 `limit=MAX_PUBLIC_CLUSTERS`(64)。17 个候选 → by_time 17 键。`column_compare_ms` 预算 `COLUMN_COMPARE_BUDGET_MS=3000`。上一轮超过 3 秒时,TS `columnTimesForSlowCompare` 把推断层 active 分钟写入引擎请求的 `column_times`;决策层只保留仍在公开候选里的那些分钟。
|
||||
- 记录:BUG-631/632、CHANGELOG、DESIGN §11、走查第 4 节、场景清单第 7 条、本文件。Skill 版本不变(10.0.21)。
|
||||
|
||||
## 验收命令(实测)
|
||||
|
||||
工作树 `codex/rectification-followups-20260909`,相对 `origin/staging` @ `2de2aa0b`。
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `frontend` `./node_modules/.bin/tsx --test tests/rectification-declared-window-20260909.test.ts tests/rectification-window-cluster-cap-20260909.test.ts tests/rectification-v9-engine-contract.test.ts` | **23/23 pass** |
|
||||
| `frontend` `./node_modules/.bin/tsx --test tests/rectification-v9-agent.test.ts` | **23/23 pass** |
|
||||
| `.venv/bin/python -m pytest tests/test_rectification_v5_services.py -q` | **40 passed** |
|
||||
| `git diff --check` | 干净 |
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 无登录态、无 Chrome:`docs/testing/rectification-window-cluster-cap-20260909.md` 第 4 节真人走查未做。
|
||||
- 未跑完整前端套件 / `next build`。
|
||||
|
||||
## 未做
|
||||
|
||||
- 未 commit、未 push、未提升 `main`。
|
||||
- Skill 未升版。intake 自定义更小范围仍等产品答复(BUG-625 留下的)。
|
||||
@@ -93,7 +93,7 @@
|
||||
| `TASK-rectification-skipped-health-deadend-20260909.md` | `PROGRESS-rectification-skipped-health-deadend-20260909.md` | 真实用户:健康题「记不清」后职业答完即断(『没有拿到下一个问题』):holdout 的 `declined` 未归并 health/health_pressure,把跳过的健康线再问一次,撞同 id 焦点 `duplicate_focus` 静默;出口闸门把 `exhausted` 当已交付不修复;『重新加载』只重取快照 | 待执行 | `codex/rectification-skipped-health-deadend-20260909`(BUG-626~627) |
|
||||
| `TASK-rectification-domain-alias-audit-20260909.md` | `PROGRESS-rectification-domain-alias-audit-20260909.md` | 领域命名审计:健康线 `health`(账本/焦点)与 `health_pressure`(计划/引擎)在十处比较里六处未归并(holdout declined、reverse-verify、conflict probes、probeYearAlreadyCovered、引擎 oos_blind_prompts / _event_years / volunteered);职业线焦点存为 `other` 只靠 questionId 兜底。其余六领域三层同名无问题。决策:TS/Python 各一个归并函数 + 源码合同禁字面量比较 | 待执行 | `codex/rectification-domain-alias-audit-20260909`(BUG-628) |
|
||||
| `TASK-rectification-yearless-probe-downgrade-20260909.md` | `PROGRESS-rectification-yearless-probe-downgrade-20260909.md` | 产品拍板:无年月性格题(D9/D10 风格、月宿边界)降级为平局裁决——带年月题问完且候选仍分不开才问,分值减半(±1)、不计淘汰,报告标「参考」;三列卡性格描述不动;新增离线命中率测量脚本 + 导出 SQL(有出生证用户,聚合);Skill 10.0.21 | 已验收通过 | `fef54b6e`(BUG-629,Skill 10.0.21) |
|
||||
| `TASK-rectification-followups-20260909.md` | `PROGRESS-rectification-followups-20260909.md` | 验收补漏:申报时段拦截只看钟点样式,带钟点的经历(『20:00 左右分手』『3 点到 5 点被车撞』)会被吞(BUG-631);by_time 只算引擎前 9 个候选,一小时窗 17 个候选时卡片列写『还没对照』(BUG-632) | 待执行 | `codex/rectification-followups-20260909`(BUG-631~632) |
|
||||
| `TASK-rectification-followups-20260909.md` | `PROGRESS-rectification-followups-20260909.md` | 验收补漏:申报时段拦截只看钟点样式,带钟点的经历(『20:00 左右分手』『3 点到 5 点被车撞』)会被吞(BUG-631);by_time 只算引擎前 9 个候选,一小时窗 17 个候选时卡片列写『还没对照』(BUG-632) | 待验收 | `codex/rectification-followups-20260909`(BUG-631~632) |
|
||||
|
||||
### 聊天主链路与首页
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
- 口述采集没有「没有」「记不清」按钮,也没有「先这样」;输入框上方不再挂快捷回答
|
||||
- 想跳过可以打字「没有」或「记不清」
|
||||
- 每轮助手正文一句复述(可另接一句范围变化);气泡里没有「本轮对照了…」,也没有「很有帮助 / 很有价值 / 很有分量 / 特别有用」
|
||||
- 报经历时带钟点(「20:00 左右分手」「下午 3 点到 5 点被车撞」)不得被当成申报出生时段,不得回「搜索范围开始时按资料定、过程中不改」
|
||||
|
||||
## 8. 性格题只作平局参考(BUG-629)
|
||||
|
||||
|
||||
@@ -27,3 +27,17 @@
|
||||
|
||||
- [ ] 不得出现「明白了,以你说的为准」或改写搜索窗口。
|
||||
- [ ] Skill 10.0.20 写明不得口头承认。
|
||||
|
||||
## 4. 报经历带钟点不得被当成申报时段
|
||||
|
||||
采集或点选题还挂着时,用虚构经历带上钟点(不要用真实资料):
|
||||
|
||||
- 「2024 年 8 月 8 日 20:00 左右分手」
|
||||
- 「下午 3 点到 5 点被车撞」
|
||||
- 「2019 年 10 月 23 日 9 点半入职」
|
||||
|
||||
期望:
|
||||
|
||||
- [ ] 不出现「搜索范围开始时按资料定、过程中不改」那句固定回复。
|
||||
- [ ] 这句话进模型 / 证据账本,不当成申报出生时段。
|
||||
- [ ] 「我的出生时间是 14 点 45 到 14 点 50」「14:45–14:50」「我是 14:47 左右生的」仍走固定回复。
|
||||
|
||||
+1
-1
@@ -545,7 +545,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
卡顶保留范围与经历数;卡底边界句保留;「查看验证报告」默认收起。不预标「排盘用」。点「更像这个」走现有 accept RPC。采用过程中整张卡留在原处;已采用列按钮禁用。收尾句跟卡片同一列。
|
||||
|
||||
引擎 `event_dasha_ledger_by_time` / `prospective_windows_by_time` 的键是引擎 `candidate_times` 去重全集(上限 9),不是引擎分数前三。卡片仍只投影后验前三。
|
||||
引擎 `event_dasha_ledger_by_time` / `prospective_windows_by_time` 的键是引擎 `candidate_times` 去重全集(上限 64),不是引擎分数前三。卡片仍只投影后验前三。若上一轮 64 分钟对照超过 3 秒,下一轮可以把推断层仍有效的分钟作为 `column_times` 传入。
|
||||
|
||||
## 12. 校正右栏盘面
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* by_time ledgers default to the full engine candidate set (≤64).
|
||||
* If a previous compare of that set exceeded the budget, the next score
|
||||
* may pass only still-active inference minutes (BUG-632).
|
||||
*/
|
||||
|
||||
export const COLUMN_COMPARE_BUDGET_MS = 3000;
|
||||
export const MAX_COLUMN_COMPARE_TIMES = 64;
|
||||
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
export function columnTimesForSlowCompare(input: {
|
||||
previousColumnCompareMs: number | null | undefined;
|
||||
activeTimes: readonly string[] | null | undefined;
|
||||
}): string[] | undefined {
|
||||
if (
|
||||
input.previousColumnCompareMs == null
|
||||
|| !Number.isFinite(input.previousColumnCompareMs)
|
||||
|| input.previousColumnCompareMs <= COLUMN_COMPARE_BUDGET_MS
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const seen: string[] = [];
|
||||
for (const raw of input.activeTimes ?? []) {
|
||||
const clock = raw.slice(0, 5);
|
||||
if (!CLOCK.test(clock) || seen.includes(clock)) continue;
|
||||
seen.push(clock);
|
||||
if (seen.length >= MAX_COLUMN_COMPARE_TIMES) break;
|
||||
}
|
||||
return seen.length > 0 ? seen : undefined;
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
/**
|
||||
* Mid-session spoken birth-time windows are not a search-window change.
|
||||
* Detect them so the route can answer with a fixed reply and skip the model.
|
||||
*
|
||||
* Clock-shaped text is not enough (BUG-631): dated life events and other
|
||||
* non-birth sentences that happen to contain HH:MM must still reach the model.
|
||||
*/
|
||||
|
||||
const CLOCK = /(?:[01]?\d|2[0-3])\s*[::点]\s*[0-5]?\d(?:\s*分)?/;
|
||||
const CLOCK = /(?:[01]?\d|2[0-3])\s*[::点]\s*(?:半|[0-5]?\d(?:\s*分)?)?/;
|
||||
const RANGE_SEP = /\s*(?:到|至|[-–—~~])\s*/;
|
||||
const AROUND = /\s*(?:左右|前后)/;
|
||||
const RANGE_PATTERN = new RegExp(`(${CLOCK.source})${RANGE_SEP.source}(${CLOCK.source})`);
|
||||
const AROUND_PATTERN = new RegExp(`(${CLOCK.source})${AROUND.source}`);
|
||||
const BIRTH_CONTEXT = /出生时间|出生|生于|时辰|几点生|钟点是|(?<![发产陌])生的/;
|
||||
const CALENDAR_DATE = /\d{4}\s*年|\d{1,2}\s*月|\d{1,2}\s*日/;
|
||||
const BARE_FILLER = /上午|下午|傍晚|夜里|晚上|清晨|中午|我的|我|是|大概|大约|差不多|可能|就|在|的|了|吧|啊|呢|嗯|[,。,.、\s]|[-–—~~]/g;
|
||||
|
||||
export type DeclaredBirthWindow =
|
||||
| { kind: "range"; start: string; end: string }
|
||||
@@ -20,21 +26,49 @@ export function parseDeclaredBirthWindow(message: string): DeclaredBirthWindow |
|
||||
if (range) {
|
||||
const start = normalizeClock(range[1] ?? "");
|
||||
const end = normalizeClock(range[2] ?? "");
|
||||
if (start && end && start !== end) return { kind: "range", start, end };
|
||||
if (start && end && start !== end && shouldInterceptDeclaredWindow(text, range)) {
|
||||
return { kind: "range", start, end };
|
||||
}
|
||||
if (start && end && start !== end) return null;
|
||||
}
|
||||
const around = AROUND_PATTERN.exec(text);
|
||||
if (around) {
|
||||
const time = normalizeClock(around[1] ?? "");
|
||||
if (time) return { kind: "around", time };
|
||||
if (time && shouldInterceptDeclaredWindow(text, around)) {
|
||||
return { kind: "around", time };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldInterceptDeclaredWindow(text: string, match: RegExpExecArray): boolean {
|
||||
if (BIRTH_CONTEXT.test(text)) return true;
|
||||
if (CALENDAR_DATE.test(text)) return false;
|
||||
return isBareClockUtterance(text, match);
|
||||
}
|
||||
|
||||
function isBareClockUtterance(text: string, match: RegExpExecArray): boolean {
|
||||
const leftover = `${text.slice(0, match.index)}${text.slice(match.index + match[0].length)}`;
|
||||
return leftover.replace(BARE_FILLER, "") === "";
|
||||
}
|
||||
|
||||
function normalizeClock(raw: string): string | null {
|
||||
const match = /([01]?\d|2[0-3])\s*[::点]\s*([0-5]?\d)/.exec(raw);
|
||||
if (!match) return null;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (!Number.isInteger(hour) || hour > 23 || !Number.isInteger(minute) || minute > 59) return null;
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
const half = /([01]?\d|2[0-3])\s*[::点]\s*半/.exec(raw);
|
||||
if (half) {
|
||||
const hour = Number(half[1]);
|
||||
if (!Number.isInteger(hour) || hour > 23) return null;
|
||||
return `${String(hour).padStart(2, "0")}:30`;
|
||||
}
|
||||
const withMinutes = /([01]?\d|2[0-3])\s*[::点]\s*([0-5]?\d)/.exec(raw);
|
||||
if (withMinutes) {
|
||||
const hour = Number(withMinutes[1]);
|
||||
const minute = Number(withMinutes[2]);
|
||||
if (!Number.isInteger(hour) || hour > 23 || !Number.isInteger(minute) || minute > 59) return null;
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
const hourOnly = /([01]?\d|2[0-3])\s*点/.exec(raw);
|
||||
if (!hourOnly) return null;
|
||||
const hour = Number(hourOnly[1]);
|
||||
if (!Number.isInteger(hour) || hour > 23) return null;
|
||||
return `${String(hour).padStart(2, "0")}:00`;
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@ export function engineRequestBody(input: {
|
||||
candidateRange: { start_time: string; end_time: string };
|
||||
events: readonly V9EngineEvent[];
|
||||
askedProbeKeys?: readonly string[];
|
||||
columnTimes?: readonly string[];
|
||||
}): Record<string, unknown> {
|
||||
const snapshot = input.baselineBirthSnapshot;
|
||||
const birthDate = String(snapshot.birth_date ?? "");
|
||||
@@ -531,6 +532,11 @@ export function engineRequestBody(input: {
|
||||
throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine");
|
||||
}
|
||||
const askedProbeKeys = sanitizeAskedProbeKeysForEngine(input.askedProbeKeys);
|
||||
const columnTimes = [...new Set(
|
||||
(input.columnTimes ?? [])
|
||||
.map((value) => value.slice(0, 5))
|
||||
.filter((value) => timePattern.test(value)),
|
||||
)].slice(0, 64);
|
||||
return {
|
||||
birth_date: birthDate,
|
||||
start_time: input.candidateRange.start_time,
|
||||
@@ -546,6 +552,7 @@ export function engineRequestBody(input: {
|
||||
timezone_source: snapshot.timezone_source,
|
||||
local_time_status: snapshot.local_time_status,
|
||||
...(askedProbeKeys.length ? { asked_probe_keys: askedProbeKeys } : {}),
|
||||
...(columnTimes.length ? { column_times: columnTimes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -659,6 +666,7 @@ export async function runV9CandidateScore(input: {
|
||||
candidateRange: { start_time: string; end_time: string };
|
||||
events: readonly V9EngineEvent[];
|
||||
askedProbeKeys?: readonly string[];
|
||||
columnTimes?: readonly string[];
|
||||
}): Promise<V9EngineScoreResult> {
|
||||
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input));
|
||||
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
buildCaseInferenceState,
|
||||
previousInferenceFromReceipt,
|
||||
} from "./inference-adapter.ts";
|
||||
import { columnTimesForSlowCompare } from "./column-times-for-compare.ts";
|
||||
import { refinementFromDecisionReceipt } from "./refinement-packet.ts";
|
||||
import { blockScanRequestExtras } from "./search-window.ts";
|
||||
import {
|
||||
@@ -360,11 +361,23 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
};
|
||||
}
|
||||
const scoreStarted = Date.now();
|
||||
const latestReceipt = dossier.latestResult?.decisionReceipt;
|
||||
const previousCompareMs = typeof latestReceipt?.column_compare_ms === "number"
|
||||
? latestReceipt.column_compare_ms
|
||||
: null;
|
||||
const previousInference = previousInferenceFromReceipt(latestReceipt);
|
||||
const columnTimes = columnTimesForSlowCompare({
|
||||
previousColumnCompareMs: previousCompareMs,
|
||||
activeTimes: previousInference?.candidates
|
||||
.filter((candidate) => candidate.status === "active")
|
||||
.map((candidate) => candidate.time) ?? [],
|
||||
});
|
||||
const score = await runV9CandidateScore({
|
||||
baselineBirthSnapshot: compute.baselineBirthSnapshot,
|
||||
candidateRange,
|
||||
events,
|
||||
askedProbeKeys,
|
||||
columnTimes,
|
||||
});
|
||||
const engineCompareMs = Date.now() - scoreStarted;
|
||||
const vedastroStarted = Date.now();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
COLUMN_COMPARE_BUDGET_MS,
|
||||
columnTimesForSlowCompare,
|
||||
} from "../src/lib/rectification-agentic/v9/column-times-for-compare.ts";
|
||||
import { parseDeclaredBirthWindow } from "../src/lib/rectification-agentic/v9/declared-window-utterance.ts";
|
||||
|
||||
const ROUTE = readFileSync(
|
||||
new URL("../src/app/api/rectification/agent/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("dated or event-clock sentences are not treated as a declared birth window", () => {
|
||||
assert.equal(parseDeclaredBirthWindow("2024 年 8 月 8 日 20:00 左右分手"), null);
|
||||
assert.equal(parseDeclaredBirthWindow("下午 3 点到 5 点被车撞"), null);
|
||||
assert.equal(parseDeclaredBirthWindow("2019 年 10 月 23 日 9 点半入职"), null);
|
||||
assert.equal(parseDeclaredBirthWindow("20:00 左右分手"), null);
|
||||
assert.equal(parseDeclaredBirthWindow("2014年入学,大概秋天"), null);
|
||||
});
|
||||
|
||||
test("birth-context and bare clock ranges still count as a declared window", () => {
|
||||
assert.deepEqual(
|
||||
parseDeclaredBirthWindow("我的出生时间是 14 点 45 到 14 点 50"),
|
||||
{ kind: "range", start: "14:45", end: "14:50" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseDeclaredBirthWindow("我是 14:47 左右生的"),
|
||||
{ kind: "around", time: "14:47" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseDeclaredBirthWindow("14:45–14:50"),
|
||||
{ kind: "range", start: "14:45", end: "14:50" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseDeclaredBirthWindow("14:47 左右"),
|
||||
{ kind: "around", time: "14:47" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseDeclaredBirthWindow("大概 14:47 左右"),
|
||||
{ kind: "around", time: "14:47" },
|
||||
);
|
||||
});
|
||||
|
||||
test("choice-focus declared window is still answered from the route before the model", () => {
|
||||
const intercept = ROUTE.indexOf('if (action === "message" && parseDeclaredBirthWindow');
|
||||
const selectedModel = ROUTE.indexOf("const selectedModel = isStructuredChoice");
|
||||
assert.ok(intercept > 0);
|
||||
assert.ok(selectedModel > intercept);
|
||||
});
|
||||
|
||||
test("score persist passes active minutes only after a slow by_time compare", () => {
|
||||
const persist = readFileSync(
|
||||
new URL("../src/lib/rectification-agentic/v9/score-persist.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(persist, /columnTimesForSlowCompare/);
|
||||
assert.match(persist, /columnTimes,/);
|
||||
});
|
||||
|
||||
test("slow by_time compares may pass only active inference minutes", () => {
|
||||
assert.equal(
|
||||
columnTimesForSlowCompare({ previousColumnCompareMs: 1200, activeTimes: ["14:46", "14:47", "14:48"] }),
|
||||
undefined,
|
||||
);
|
||||
assert.deepEqual(
|
||||
columnTimesForSlowCompare({
|
||||
previousColumnCompareMs: COLUMN_COMPARE_BUDGET_MS + 1,
|
||||
activeTimes: ["14:46", "14:47", "14:46", "bad"],
|
||||
}),
|
||||
["14:46", "14:47"],
|
||||
);
|
||||
assert.equal(
|
||||
columnTimesForSlowCompare({ previousColumnCompareMs: 4000, activeTimes: [] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -511,3 +511,13 @@ test("engineRequestBody drops varga split hashes and keeps short semantic keys",
|
||||
assert.ok(keys.every((key) => key.length <= 120));
|
||||
assert.deepEqual(sanitizeAskedProbeKeysForEngine([VARGA_SPLIT_HASH, "varga.d9.style"]), ["varga.d9.style"]);
|
||||
});
|
||||
|
||||
test("engineRequestBody forwards a slow-compare column_times subset", () => {
|
||||
const body = engineRequestBody({
|
||||
baselineBirthSnapshot: SNAPSHOT,
|
||||
candidateRange: RANGE,
|
||||
events: toEngineEvents(EVIDENCE),
|
||||
columnTimes: ["14:46", "14:47", "14:46", "not-a-clock"],
|
||||
});
|
||||
assert.deepEqual(body.column_times, ["14:46", "14:47"]);
|
||||
});
|
||||
|
||||
@@ -35,6 +35,9 @@ test("declared birth-window utterances parse ranges and around-times", () => {
|
||||
assert.equal(parseDeclaredBirthWindow("没有"), null);
|
||||
assert.equal(parseDeclaredBirthWindow("2014年入学,大概秋天"), null);
|
||||
assert.equal(parseDeclaredBirthWindow("继续吧"), null);
|
||||
assert.equal(parseDeclaredBirthWindow("2024 年 8 月 8 日 20:00 左右分手"), null);
|
||||
assert.equal(parseDeclaredBirthWindow("下午 3 点到 5 点被车撞"), null);
|
||||
assert.equal(parseDeclaredBirthWindow("2019 年 10 月 23 日 9 点半入职"), null);
|
||||
});
|
||||
|
||||
test("choice-focus declared window is answered from the route before the model", () => {
|
||||
|
||||
@@ -218,7 +218,7 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
fingerprint = sha256({
|
||||
key: value
|
||||
for key, value in request.items()
|
||||
if key not in {"asked_probe_keys", "dropped_asked_probe_keys"}
|
||||
if key not in {"asked_probe_keys", "dropped_asked_probe_keys", "column_times"}
|
||||
})
|
||||
result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}"))
|
||||
candidate_decisions = build_candidate_decisions(
|
||||
|
||||
@@ -52,7 +52,7 @@ _EVENT_PROVENANCE_FIELDS = frozenset({
|
||||
})
|
||||
_REQUEST_FIELDS = frozenset({
|
||||
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
|
||||
"ayanamsa", "node_mode", "asked_probe_keys", "minute_step", "blocks",
|
||||
"ayanamsa", "node_mode", "asked_probe_keys", "column_times", "minute_step", "blocks",
|
||||
}) | _REQUEST_PROVENANCE_FIELDS
|
||||
ASKED_PROBE_KEY_MAX_LENGTH = 200
|
||||
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
|
||||
@@ -169,6 +169,7 @@ class RectificationRequest(TypedDict):
|
||||
local_time_status: NotRequired[str | None]
|
||||
asked_probe_keys: NotRequired[list[str]]
|
||||
dropped_asked_probe_keys: NotRequired[int]
|
||||
column_times: NotRequired[list[str]]
|
||||
minute_step: NotRequired[int]
|
||||
blocks: NotRequired[list[dict[str, Any]]]
|
||||
|
||||
@@ -354,6 +355,20 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
cleaned_request["asked_probe_keys"] = cleaned_keys
|
||||
if dropped:
|
||||
cleaned_request["dropped_asked_probe_keys"] = dropped
|
||||
if "column_times" in body:
|
||||
raw_times = body.get("column_times")
|
||||
if not isinstance(raw_times, list) or not 1 <= len(raw_times) <= 64:
|
||||
raise ValueError("column_times must contain between 1 and 64 HH:MM values")
|
||||
cleaned_times: list[str] = []
|
||||
seen_times: set[str] = set()
|
||||
for index, item in enumerate(raw_times):
|
||||
if not isinstance(item, str) or not _CLOCK.fullmatch(item):
|
||||
raise ValueError(f"column_times[{index}] must be HH:MM")
|
||||
if item in seen_times:
|
||||
continue
|
||||
seen_times.add(item)
|
||||
cleaned_times.append(item)
|
||||
cleaned_request["column_times"] = cleaned_times
|
||||
if "minute_step" in body:
|
||||
minute_step = body.get("minute_step")
|
||||
if isinstance(minute_step, bool) or not isinstance(minute_step, int) or not 1 <= minute_step <= 15:
|
||||
|
||||
@@ -51,6 +51,23 @@ def _decimal(value: Any, default: str = "0") -> Decimal:
|
||||
return Decimal(default)
|
||||
|
||||
|
||||
def _column_times_for_packet(
|
||||
request: RectificationRequest,
|
||||
candidate_decisions: Sequence[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
decision_times = [
|
||||
str(item.get("time") or "")[:5]
|
||||
for item in candidate_decisions
|
||||
if str(item.get("time") or "")[:5]
|
||||
]
|
||||
requested = request.get("column_times")
|
||||
if not isinstance(requested, list) or not requested:
|
||||
return decision_times
|
||||
wanted = {str(item)[:5] for item in requested if isinstance(item, str)}
|
||||
subset = [time for time in decision_times if time in wanted]
|
||||
return subset or decision_times
|
||||
|
||||
|
||||
_AUDIT_LABELS = {
|
||||
"d1-rashi": ("D1 本命盘", "本轮已按该分钟重算本命宫位。"),
|
||||
"d2-hora": ("D2 财帛分盘", "本轮已对照财帛主题。"),
|
||||
@@ -588,7 +605,7 @@ def build_decision_receipt(
|
||||
candidate_times=grid_times,
|
||||
cluster_width_minutes=width,
|
||||
include_discriminators=int(request.get("minute_step") or 1) <= 1,
|
||||
column_times=[item["time"] for item in candidate_decisions],
|
||||
column_times=_column_times_for_packet(request, candidate_decisions),
|
||||
)
|
||||
if packet["dasha_agreement"]["status"] == "conflict":
|
||||
if overall_confidence == "high":
|
||||
|
||||
@@ -9,8 +9,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Sequence
|
||||
|
||||
from scripts.rectification.candidate_contrast import MAX_PUBLIC_CLUSTERS
|
||||
from scripts.rectification.house_table import PLANET_ZH, SIGN_LORDS, SIGNS, SIGNS_CN
|
||||
|
||||
COLUMN_COMPARE_BUDGET_MS = 3000
|
||||
|
||||
NAKSHATRA_SPAN = 40.0 / 3.0
|
||||
NAKSHATRA_BOUNDARY_DEGREES = 2.0
|
||||
MATCH_LABELS = {
|
||||
@@ -263,12 +266,14 @@ def column_times_for_compare(
|
||||
candidate_times: Sequence[str],
|
||||
representative_time: str | None,
|
||||
*,
|
||||
limit: int = 9,
|
||||
limit: int = MAX_PUBLIC_CLUSTERS,
|
||||
) -> list[str]:
|
||||
"""Unique HH:MM keys for by_time ledgers/windows, capped at `limit`.
|
||||
|
||||
Compare cards still project at most three posterior columns; this set is
|
||||
the engine candidate list so those clocks can look up a row.
|
||||
the full public candidate list (≤64) so inference-layer clocks can look up
|
||||
a row. Callers that already know a smaller active set may pass it as
|
||||
`column_times` when a 64-minute compare would exceed COLUMN_COMPARE_BUDGET_MS.
|
||||
"""
|
||||
seen: list[str] = []
|
||||
for raw in candidate_times:
|
||||
@@ -621,14 +626,22 @@ def build_refinement_packet(
|
||||
scan = window_scan(built)
|
||||
cluster = cluster_scan(built, candidate_times, representative_time, cluster_width_minutes)
|
||||
ledger = event_dasha_ledger(request, built, representative_time)
|
||||
columns = column_times_for_compare(
|
||||
column_times if column_times is not None else candidate_times,
|
||||
representative_time,
|
||||
)
|
||||
compare_started = perf_counter()
|
||||
ledgers_by_time = event_dasha_ledgers_by_time(request, built, columns)
|
||||
windows_by_time = prospective_windows_by_time(request, built, columns)
|
||||
column_compare_ms = round((perf_counter() - compare_started) * 1000, 1)
|
||||
minute_step = request.get("minute_step")
|
||||
skip_column_compare = isinstance(minute_step, int) and not isinstance(minute_step, bool) and minute_step > 1
|
||||
if skip_column_compare:
|
||||
columns: list[str] = []
|
||||
ledgers_by_time: dict[str, Any] = {}
|
||||
windows_by_time: dict[str, Any] = {}
|
||||
column_compare_ms = 0.0
|
||||
else:
|
||||
columns = column_times_for_compare(
|
||||
column_times if column_times is not None else candidate_times,
|
||||
representative_time,
|
||||
)
|
||||
compare_started = perf_counter()
|
||||
ledgers_by_time = event_dasha_ledgers_by_time(request, built, columns)
|
||||
windows_by_time = prospective_windows_by_time(request, built, columns)
|
||||
column_compare_ms = round((perf_counter() - compare_started) * 1000, 1)
|
||||
agreement = dasha_agreement(built, candidate_times)
|
||||
stage = precision_stage(cluster, len(request.get("events") or []))
|
||||
if not include_discriminators:
|
||||
|
||||
@@ -1116,6 +1116,63 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
self.assertEqual(set(windows), set(times))
|
||||
self.assertLess(packet["column_compare_ms"], 1000)
|
||||
|
||||
def test_compare_ledgers_cover_seventeen_engine_candidates(self):
|
||||
from scripts.rectification.refinement_packet import (
|
||||
COLUMN_COMPARE_BUDGET_MS,
|
||||
build_refinement_packet,
|
||||
column_times_for_compare,
|
||||
)
|
||||
|
||||
times = [f"14:{minute:02d}" for minute in range(17)]
|
||||
self.assertEqual(column_times_for_compare(times, "14:16"), times)
|
||||
overflow = [f"{index // 60:02d}:{index % 60:02d}" for index in range(65)]
|
||||
self.assertEqual(len(column_times_for_compare(overflow, overflow[0])), MAX_PUBLIC_CLUSTERS)
|
||||
|
||||
body = request()
|
||||
body["events"] = [
|
||||
event(1, "education", "education_start"),
|
||||
event(2, "career", "promotion", precision="month"),
|
||||
]
|
||||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||||
scored_ids = [item["id"] for item in normalized["events"]]
|
||||
built = {
|
||||
"candidate_times": times,
|
||||
"matrix": {
|
||||
event_id: {
|
||||
clock: {"points": 4, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]}
|
||||
for clock in times
|
||||
}
|
||||
for event_id in scored_ids
|
||||
},
|
||||
"date_sensitivity": [],
|
||||
"missing_layers": [],
|
||||
"static_contexts": [],
|
||||
}
|
||||
packet = build_refinement_packet(
|
||||
normalized,
|
||||
built,
|
||||
representative_time="14:16",
|
||||
candidate_times=times,
|
||||
include_discriminators=False,
|
||||
)
|
||||
self.assertEqual(set(packet["event_dasha_ledger_by_time"]), set(times))
|
||||
self.assertEqual(set(packet["prospective_windows_by_time"]), set(times))
|
||||
self.assertLess(packet["column_compare_ms"], COLUMN_COMPARE_BUDGET_MS)
|
||||
subset = build_refinement_packet(
|
||||
normalized,
|
||||
built,
|
||||
representative_time="14:00",
|
||||
candidate_times=times,
|
||||
column_times=times[:3],
|
||||
include_discriminators=False,
|
||||
)
|
||||
self.assertEqual(set(subset["event_dasha_ledger_by_time"]), set(times[:3]))
|
||||
body["column_times"] = times[:3]
|
||||
self.assertEqual(
|
||||
normalize_rectification_request(body, today=date(2026, 7, 28))["column_times"],
|
||||
times[:3],
|
||||
)
|
||||
|
||||
def test_hour_window_keeps_all_seventeen_signature_clusters_including_late_tail(self):
|
||||
rows, contexts = _hour_window_signature_fixture()
|
||||
public = select_signature_representatives(rows, contexts)
|
||||
|
||||
Reference in New Issue
Block a user