Files
Jyotisha/docs/tasks/TASK-rectification-unknown-time-20260906.md
T

9.3 KiB
Raw Blame History

TASK · 生时校正"完全不知道出生时间"路线:先比时段,再进分钟(2026-09-06)

  • 基线:origin/staging @ b938c76a(文档头 211f9bb2
  • 分支:codex/rectification-unknown-time-20260906
  • 执行方:coding agent;验收:Claude
  • 串行:排在 TASK-rectification-range-reading-20260906.md 之后(都改 case-service.ts / 采用旁白)
  • 涉及文件:scripts/rectification/contracts.pyscripts/rectification/scoring_service.pycalculation_spec)、scripts/active_rectification_event_engine.py::_candidate_datetimesscripts/rectification/api_service.pyfrontend/src/lib/rectification-agentic/v9/case-service.tsengine-client.tsmethod-followup.tschoice-card.tsfrontend/src/app/api/rectification/cases/open/route.tsfrontend/src/components/birth-time-intake.tsxfrontend/src/lib/declared-birth-window.ts
  • 不改:scripts/jyotish_api_server.py 主体、page.tsx、采用/确认门
  • BUG 编号:能力补齐,不预留

0. 为什么做这件事

上游 yinduzhanxing 便携 skill 的 unknown 路线是:先用高信号领域比 morning / afternoon / evening / night 四个时段,赢的时段下一轮再切三段。我们的 intake 有 birth_time_source=unknown,但之后要么劝退("生时校正以后需要时再做"),要么按代码路径开一个 00:0023:59 的 1440 分钟 Case——分钟级流程在 24 小时上既慢又没意义。另一方面,昨天的校准说明引擎在分钟级近乎随机,但上升星座每 2 小时换一次、Dasha 宫位命中在时段级的差异是真实存在的:时段比较恰恰是这套引擎最有把握的用法。

1. 现状实证

# 事实 位置
1 intake 选"完全不清楚"后,文案是"已跳过具体出生时间…生时校正以后需要时再做",并提供"我可以选一段时间范围"回退到 period_only birth-time-intake.tsx L222232
2 declaredClockRange(source="unknown") 返回 00:0023:59deriveCandidateRange 据此给 Case 一个 1440 分钟窗口;引擎 _candidate_datetimes 允许 ≤1440 declared-birth-window.ts L31、L146case-service.ts L123event_engine.py L96
3 旧 journey 链 scanInput() 对 unknown 返回 null(那条链已不是 v9 主链) birth-time-journey-assessment.ts L13
4 intake 已收集 birth_time_clue(家人线索文本)并落 profile,但校正没有用它 birth-time-intake-model.ts L379birth-time-journey-dynamic-case.ts L58
5 引擎耗时实测(虚构资料,3 件事):61 分钟窗口 0.9 s,241 分钟 3.2 s;线性外推 1440 分钟 ≈ 20 s,7 件事约 ×2 本单附录脚本
6 时段模型:DECLARED_PERIOD_RANGES 五段(0408 / 0812 / 1218 / 1823 / 2304 declared-birth-window.ts L19

2. 决策记录

  1. 两段式。 Stage 0"时段比较"Case 以 stage="block_scan" 打开,候选窗口 00:00–23:59,引擎以 10 分钟步长(144 个候选)只做事件计分,不生成探针、不出候选卡;输出五个时段各自的相对支持(按时段内候选分求和归一)与"哪些事件在拉开差距"。Stage 1:用户在时段卡上选定一段(或系统领先段被用户认可)→ Case 的 candidate_range 改为该时段(≤6 小时),stage="minute",进入现有分钟流程。
  2. 时段卡是四选项:A/B/C 取相对支持最高的三段(标出支持度),D "说不好 / 都不像"。选 A/B/C = 进入该段;选 D = 继续采集经历再比一次(同分钟流程的采集轮)。不写账本、不进推断层(时段选择是窗口决策,不是证据)。
  3. 家人线索先用。 开场先把 birth_time_clue 读给模型:"家人记得天快亮 / 吃晚饭时"之类,模型只能把它映射成一个建议时段放在旁白里,不能改窗口;窗口只由用户点选或事件计分改。
  4. 门槛。 时段比较前至少 3 件带年月经历、2 个领域(复用 MIN_ACCEPTANCE_*);不够就先采集。block_scan 阶段 acceptance_allowed / selection_allowed 恒 false,不得采用任何分钟。
  5. 引擎契约:请求新增可选 minute_step115,默认 1);calculation_spec.minuteStep 随之;minute_step>1discriminating_event_probes 为空、precision_stageblock_scan;指纹包含 minute_step(步长不同结果不同)。
  6. 时段划分沿用 DECLARED_PERIOD_RANGES 五段,不引入上游的 dawn/noon 七段。

3. 硬红线

  • block_scan 阶段不得出现候选卡、采用按钮、代表分钟;旁白不得说"更像 X 点"。
  • 分钟流程(stage=minute)的全部既有用例原样通过;minute_step=1 时引擎输出与改前逐字节一致(test_rectification_v5_services.py 已有指纹用例加一条)。
  • 24 小时 × 1 分钟不再允许开 Case(deriveCandidateRange 对 unknown 只走 block_scan)。
  • 引擎耗时:block_scan 单次 ≤ 15 s(7 件事),超出要在进度记录里写实测并给出步长/事件上限方案。
  • 真实用户资料不进测试。

4. 任务分解

C1 引擎步长

  • contracts.py 接收 minute_step_candidate_datetimes 用它;calculation_specminuteStepscoring_service.build_event_contribution_matrix 的网格一致性检查沿用;api_service.score_candidatesminute_step>1 时跳过 build_refinement_packet 的探针部分,precision_stage={"current":"block_scan"}。新增 api_service.block_scan(request):返回 blocks: [{period, start_time, end_time, relative_support, top_events[]}](按五段聚合)。jyotish_api_server.py 只加一行注册。
  • 验收:tests/test_rectification_v5_services.py——minute_step=1 指纹/输出不变;minute_step=10 候选数 = 144block_scan 五段支持度和为 100;耗时断言(≤15 s,7 件事虚构资料)。

C2 Case 阶段

  • agentic_rectification_casesstage text default 'minute'(迁移,需 test:db);case-service.ts::deriveCandidateRangeunknown 返回 00:0023:59 且 stage=block_scanopen 路由透传。
  • 验收:rectification-v9-database.test.tsDocker)或 BLOCKED.mdcase-service 单测:unknown → block_scan。

C3 采集与时段卡

  • method-followup.tsstage=block_scan 时不走 rankRenderableDiscriminators,只走采集链;训练门开后由 decideRectification 新分支 ask_block_choice(不复用 discriminate);choice-card.tschoice_kind: "block_choice"(TS 内部,不进 Python 探针契约);答题写 case.candidate_range + stage=minute,并触发一次正常重算。
  • engine-client.ts 新增 runV9BlockScanrectification-v9-tools.ts 的 compare 在 block_scan 阶段调它。
  • 开场:openingBrief 加入 birth_time_clue(决策 3);intake 的 unknown 文案改为"可以直接开始生时校正:先从你记得的经历比出大致时段",删掉劝退句。
  • 验收:rectification-block-scan-20260906.test.ts——3 件事 2 域后 next_action 为 ask_block_choice;选 B 后 candidate_range = 该段、stage=minute、snapshotCurrent=false;选 D 后回到采集;block_scan 阶段 can_adopt=false、无候选卡。

C4 记录

  • CHANGELOG.mdPROGRESS-rectification-unknown-time-20260906.mddocs/testing/rectification-unknown-time-20260906.md(真实环境:intake 选"完全不清楚"→ 开校正 → 说 3 件事 → 出时段卡 → 选一段 → 进入分钟流程);frontend/DESIGN.mdfrontend/docs/VOICE.mdSKILL.md 若需要新增 block_scan 状态一行则 bump 到 10.0.15 并在 CHANGELOG 写明。

5. 让步顺序

C1 + C2 + C3 不可拆(缺一个就没有产品价值);C4 不可省。若耗时红线过不了,先把步长提到 15 分钟并写明。

6. 开工前置命令

git fetch origin --prune
git worktree add -b codex/rectification-unknown-time-20260906 .worktrees/rectification-unknown-time-20260906 origin/staging
cd .worktrees/rectification-unknown-time-20260906
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
ln -s /workspace/Jyotisha/.venv .venv
.venv/bin/python -m pytest tests/test_rectification_v5_services.py tests/test_active_rectification_api.py -q
cd frontend && ls tests/rectification-*.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"

附录 · 耗时复现(虚构资料)

# .venv/bin/python,在仓库根目录
import time, uuid, sys
sys.path.insert(0, "."); sys.path.insert(0, "scripts")
from scripts.rectification.contracts import normalize_rectification_request
from scripts.rectification.api_service import score_candidates
EV = [("education","education_start","2016-09-01","2016-09-30","month"),
      ("relationship","relationship_end","2024-08-08","2024-08-08","day"),
      ("relocation","relocation","2023-07-01","2023-07-31","month")]
for start, end in (("04:30","05:30"), ("13:00","17:00")):
    req = normalize_rectification_request({"birth_date":"1998-03-15","start_time":start,"end_time":end,
      "lat":39.9042,"lon":116.4074,"tz":8.0,"events":[{"id":str(uuid.uuid4()),"domain":d,"event_kind":k,
      "date_start":s,"date_end":e,"precision":p,"summary":k} for d,k,s,e,p in EV]})
    t = time.time(); out = score_candidates(req)
    print(start, end, len(out["candidate_scores"]), "minutes", round(time.time()-t, 1), "s")