Compare commits

...
Author SHA1 Message Date
Jesse_ChenandClaude Opus 5.5 435c8e19f9 Answer step defaults to low reasoning effort (issue #134)
Independent Staging Quality Gate / validate (push) Successful in 24m29s
Independent Staging Quality Gate / publish (push) Successful in 4m17s
Product chose A after the A/B: low cut answer-step reasoning tokens ~45%
and answer time 34s -> 22s with no quality loss. Unset now means "low";
CONSULT_ANSWER_REASONING_EFFORT=off restores the pre-change request body,
and the old "unset keeps the body" guarantee moves to "off".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:00:22 +08:00
jesseandClaude Opus 5.5 537beb2934 test: each test gets its own foreground VedAstro pool, drained at teardown (BUG-1498)
Independent Staging Quality Gate / validate (push) Successful in 16m7s
Independent Staging Quality Gate / publish (push) Successful in 4m22s
A task left running wrote its outcome into the next test's cache directory,
so the next test saw a remembered failure and never called the gateway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:39:28 +08:00
jesseandClaude Opus 5.5 2dea3c2994 docs(bug-history): BUG-1498 investigating — gate flake in daily starlanguage snapshot test
Independent Staging Quality Gate / validate (push) Failing after 9m6s
Independent Staging Quality Gate / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:18:43 +08:00
jesseandClaude Opus 5.5 532a18ccac feat: person switcher says when and where, edits and deletes in place, and sits on the chat-side chart (issue #150)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:18:19 +08:00
jesseandClaude Opus 5.5 fd34df9b65 fix: rectification recap no longer repeats a date the event already carries (BUG-1496, issue #161)
Independent Staging Quality Gate / validate (push) Failing after 9m22s
Independent Staging Quality Gate / publish (push) Skipped
Staging showed 「记下了:2016-09 2016 年 9 月上大学。」. evidenceRecapLine only
recognised an unspaced 「2016年」 as the phrase's own date, so a spaced year or
an ISO / slash date got the label in front again. It now recognises
`\d{4}\s*年` and `\d{4}[-/.]\d{1,2}`; phrases without a date still get the
label. Regression test fails before the fix and passes after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:06:31 +08:00
jesse 88a03808a9 docs(tasks): D60 two-way comparison brief for local Grok (issue #149)
Nadi/D150 seconds were falsified in BUG-1240; this brief only tests D60 as a
discriminator: production V9 vs V9 + D60 segment boundaries on the 77-case v5
holdout, both directions (help and harm), preregistered gates G1-G5, plus a
20-case model-in-the-loop conversation comparison. Offline, no production or
frozen-file changes. Reserves BUG-1497.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 7f7321b77725c8798292f184fc038fcfaf8c11ff)
2026-10-28 18:55:56 +08:00
jesseandClaude Opus 5.5 7721aa9a2c feat: rectification opening names the time source; unchanged range is said (issue #148)
Independent Staging Quality Gate / validate (push) Successful in 17m18s
Independent Staging Quality Gate / publish (push) Failing after 15m2s
Product decisions 2026-10-28 (issuecomment-36121):
- The opening's window sentence says where the reader's time came from
  (出生记录时间 / 大概时间 / 你填的时间 / 时间段), still one sentence. When the
  source is known the server writes the opening; unknown and legacy get none.
- When a turn recorded events, the rescore completed and it is not the
  delivery turn, an exactly unchanged range gets
  「这件事在现在的范围里每个时刻都对得上,分不出前后。」. It never invites
  more events (BUG-1084).
- Not done, by decision: per-layer Lagna/nakshatra/dasha narration
  (BUG-606/1253), precision-gated layers (scoring), voluntary/involuntary
  career exits, "what would narrow it further" (BUG-1084).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:28:50 +08:00
jesseandClaude Opus 5.5 8e932959b5 feat: rectification screen — timeline-only minute, board on demand, result card (issues #146, #147)
- The working minute is written once, on the timeline; the board's title row
  no longer repeats it.
- 「看盘面」 sits in the header at every width; the board is closed by default
  and opens as a right column on desktop (bottom sheet on phones).

- A distinct result (D4) leads with 最可能的出生时间 + minute + Lagna; the range
  drops to the next line. Indistinct results keep the range headline (D3).
- The most likely column adopts with 「采用 HH:MM 并打开星盘」 and goes to /chart
  after the server saved it; other columns stay adoptable as 「改用这个」 under
  还不能排除. No confidence label. Record-conflict wording unchanged.
- 「我说错了,改一条」: confirmed events (from the case read's existing evidence
  list) prefill the composer; nothing is sent by the pick. No 「再补一件事」:
  the card must not invite more events (BUG-1084, issue #148 decision).
- The segment card keeps "which chart" (BUG-1151): no minute lead, same
  adopt label, same actions row.

Docs: DESIGN §10/§11/§12, VOICE, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:23:04 +08:00
Jesse_ChenandClaude f80f3b75f8 Merge origin/staging into upstream sync 9 batch B (issue #159)
Independent Staging Quality Gate / validate (push) Successful in 18m34s
Independent Staging Quality Gate / publish (push) Successful in 5m14s
Co-Authored-By: Claude (agent loop) <noreply@anthropic.com>
2026-10-28 17:23:59 +08:00
jesse-uxandGrok (agent loop) cbe665107f test(freeze-guard): keep the sync8 freeze paths in the forbidden list (issue #159)
The sync9b edit replaced the two sync8 entries in
IDENTITY_FORBIDDEN_PREFIXES instead of appending after them, so the guard
covered two records instead of four while still passing. Restore the
sync8 pair under its own comment and keep the sync9b pair after it.

The freshness test passes again (9) and the frontend confirmation-gate
test for the moved sealed report passes (8).

Co-Authored-By: Grok (agent loop)
2026-10-28 17:01:31 +08:00
Jesse_ChenandClaude 8015e8efb7 docs(tasks): upstream sync 9 batch B first rejection (agent loop, issue #159)
The freshness test's IDENTITY_FORBIDDEN_PREFIXES list is append-only;
the branch replaced the two sync8 entries instead of adding the sync9b
ones after them, which weakens the identity guard. Restore them.

Co-Authored-By: Claude (agent loop) <noreply@anthropic.com>
2026-10-28 16:47:37 +08:00
jesse-uxandGrok (agent loop) 5c5f333d4d fix(varga): port the five sarpa ranges and the 22nd drekkana house (BUG-1494, BUG-1495, issue #159)
Upstream 50847e57, ported semantically:

- Sarpa Drekkana now accepts the five classical ranges (Cancer 2/3,
  Scorpio 1/2, Pisces 3) and partitions on the raw longitude instead of
  the rounded display degrees.
- The 22nd Drekkana is the eighth house of D3 (+7), not +21 zodiacal
  ten-degree divisions.

scripts/varga.py is part of the extended frozen identity, so the sealed
holdout and the reported-offset sweep were re-frozen under ERR-110:
20/20 sealed trials and 900/900 sweep trials match the sync8 records on
every compared field, and the metrics, summary and widening geometry are
unchanged.

Tests: the upstream varga/ sarpa files are added under
tests/test_upstream_sync9b_*.py; the stale +21 assertion and the sarpa
definition string are updated with the original/new/reason columns in
PROGRESS.

Co-Authored-By: Grok (agent loop)
2026-10-28 16:25:45 +08:00
Jesse_ChenandClaude 2243220841 docs(tasks): upstream sync 9 batch B brief (agent loop, issue #159)
Product chose A on #159: change the frozen varga.py for the five Sarpa
drekkanas and the 22nd drekkana, and refreeze only on identical 20/900
rectification scores. Narayana AD strength (B2) is deferred: our code
has no strength-profile family for it to propagate.

Co-Authored-By: Claude (agent loop) <noreply@anthropic.com>
2026-10-28 15:46:21 +08:00
66 changed files with 46908 additions and 297 deletions
+38
View File
@@ -1,5 +1,43 @@
# 印度占星 Skill 更新日志
## 2026-10-29 — 咨询回答快约三分之一
- 咨询里写回答那一步,模型的思考量默认改为「低」:对比测试里思考量少约一半,一轮回答从约 34 秒降到约 22 秒,回答质量逐份并排读过没有变差(issue #134,产品选 A)。
- 只影响写回答那一步;排盘、查资料那几步不变。
- 需要退回时,在服务器设 `CONSULT_ANSWER_REASONING_EFFORT=off` 即恢复原来的请求。
## 2026-10-28 — 切换人物时看得出是谁的盘
- 「当前星盘」按钮上写名字、出生日期时间和城市;名单每行写关系和出生时间、城市。
- 每一行右边「⋯」可以直接编辑这个人,或删除别人(确认框和星盘档案一样);底部「新增一个人」直接打开新增表单。
- 对话右边的盘面顶部也能切换,规则和对话顶栏一样:已经说过话再换人会新开一个对话。
- Skill 版本不变(issue #150)。
## 2026-10-28 — 生时校正复述不再把日期说两遍
- 说「2016 年 9 月上大学」这类自带日期的事,回复以前会写成「2016-09 2016 年 9 月上大学」,现在只说一遍。
- Skill 版本不变(BUG-1496,issue #161)。
## 2026-10-28 — 生时校正对话说得更清楚
- 开场那句会说明是按什么时间开始找的(出生记录、你填的大概时间,或你给的时间段)。
- 你说的一件事如果在现在的范围里哪个时刻都对得上、分不出前后,会直接说一句,不再什么都不提。
- 打分和出结果的规则不变。Skill 版本不变(issue #148)。
## 2026-10-28 — 生时校正的结果卡和盘面
- 结果分得出先后时,卡片最上面直接写最可能的出生时间和上升,按钮「采用 这个时间 并打开星盘」,采用后直接去星盘页;还不能排除的时间留在下面,仍然可以改用。
- 卡上可以点「我说错了,改一条」:选一条记下的事,输入框里先写好开头,补完发出就会改掉那一条。
- 代表时刻只在对话上方的时间轴写一次;当前盘面改成点「看盘面」再打开。
- Skill 版本不变(issue #146、#147)。
## 2026-10-28 — 两处分盘敏感点按来源修正
- Sarpa Drekkana 从三段补成书上的五段:巨蟹第 3 段、天蝎第 2 段以前漏掉,现在会标出来;正好落在 10 度边界上的盘不再划到上一段。
- 第 22 号 Drekkana 改为 D3 的第 8 宫(以前多移了 14 个星座)。这个点目前还没接进完整解读的敏感点表,所以报告上看不到差别。
- 生时校正 20 条密封案例与 900 个报时窗口,名次与分钟误差与上一份回放逐条相同。
- Skill 版本不变(BUG-1494、BUG-1495,issue #159)。
## 2026-10-28 — 生时校正有了自己的入口
- 侧栏「星盘」下面新增「生时校正」,打开一页就能看到现在的状态:没做过就开始,没做完就继续,做完了看结果或再校正一次。开始前先核对一遍出生资料。
+61
View File
@@ -20416,3 +20416,64 @@
- 关联:ERR-111(特征指纹随哈希种子变)、BUG-1482(#132 让模块能按名字被子进程找到)。
- 复发自:无
- 修复版本:分支 `codex/heavy-compute-processes-20261028`。
## BUG-1494 | Sarpa Drekkana 少两段,边界还按四舍五入后的度数判段
- 状态:resolved(代码 + 回归测试;待部署)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #159,上游 `50847e57`)
- 影响面:完整解读里的 Sarpa Drekkana 标记。`scripts/varga.py`。
- 现象:只认 Cancer-2、Scorpio-1、Pisces-3 三段。Raman《Hindu Predictive Astrology》Drekkana 章是五段,Cancer-3 与 Scorpio-2 漏掉。判段还先把星座内度数四舍五入到 4 位再整除,边界附近会划到上一段。
- 触发条件:行星或上升落在被漏掉的两段,或经度正好在 10 度边界附近(如 100.0、220.0、360.0)。
- 根因:`SARPA_DREKKANA_SIGNS` 一个星座只记一个段号;`int(round(deg,4)/10)+1` 让 99.99999 与 100.0 之类的边界口径不稳。
- 修复:每星座改成段号元组(Cancer 2/3、Scorpio 1/2、Pisces 3),用原始经度 `int((lon % 30) / 10) + 1` 判段,`definition` 改成五段文字。生时校正不读这两个函数。
- 验证:`tests/test_upstream_sync9b_sarpa_precision.py`(6 条边界参数化)、`tests/test_upstream_sync9b_varga_bphs.py`(36 段全枚举与五段区间断言)、`tests/test_varga_bphs.py` 通过。密封校正 20 条与报时窗口 900 条与上一份记录逐条相同。
- 防复发:36 段全枚举 + 区间边界测试;判段只用原始经度,不再用显示用的四舍五入度数。
- 关联:BUG-1495。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync9b-20261028`。
## BUG-1495 | 第 22 号 Drekkana 按 21 个十度分段取,多移了 14 个星座
- 状态:resolved(代码 + 回归测试;待部署)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #159,上游 `50847e57`)
- 影响面:`scripts/varga.py::calc_22nd_drekkana`。全仓只有单元测试直接调用它;完整解读的敏感点表目前没有生产者写 `drekkana_22nd`(`_calc_sensitive_points` 只产 bhrigu_bindu 与 sarpa_drekkana),所以这一项目前不会出现在报告里。这是既有接线缺口,本单不接线,也不把「注册了」写成「已计算」。
- 现象:结果取 D3 星座 +21。《Crux of Vedic Astrology》p.329:第 22 号 Drekkana 是 D3 的第 8 宫,即 +7。
- 触发条件:调用 `calc_22nd_drekkana`。
- 根因:把「在黄道上数 21 个十度分段」当成「在 D3 里移 21 个星座」。
- 修复:改 `+7`;`offset_from_lagna_drekkana` 仍是 22,输出字段名不变。
- 验证:`tests/test_upstream_sync9b_varga_bphs.py` 的固定反例(射手 D3 起,第 8 宫为巨蟹)与 36 段独立枚举(不调用生产偏移)通过;`tests/test_varga_bphs.py` 同步改成固定反例。密封 20 条 + 报时 900 条与上一份记录逐条相同。
- 防复发:独立枚举测试用「数满 22 个十度分段再映射 D3」的旁路口径,不复用生产公式。
- 关联:BUG-1494。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync9b-20261028`。
## BUG-1496 | 生时校正复述把日期说了两遍(「2016-09 2016 年 9 月上大学」)
- 状态:resolved(代码 + 回归测试;待部署后 staging 复测)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #161;issue #148 验收时在 staging 实测发现)
- 影响面:`frontend/src/lib/rectification-agentic/v9/host-fallback.ts::evidenceRecapLine`。所有证据轮复述都经过它:本轮入账复述(`acceptedRecapLines`)、每条回复的入账清单(`recordedEvidenceByTurn`)、收束卡「我说错了,改一条」的事件列表(`fixableEvidenceLines`)。
- 现象:用户说「2016 年 9 月上大学。」,回复「记下了:2016-09 2016 年 9 月上大学。」
- 触发条件:事件说法里自带日期,且年份和「年」之间有空格,或写成 ISO / 斜杠日期。
- 根因:拼复述时,判断「事件说法已带日期」的规则只认不带空格的 `\d{4}年`(BUG-1136 加的拼接规则),漏掉「2016 年」「2016-09」「2016/9」,于是又在前面加了一次日期标签。
- 修复:改为识别 `\d{4}\s*年` 或 `\d{4}[-/.]\d{1,2}`;事件说法里有这样的日期就只用事件说法,没带日期的照旧加标签。
- 验证:`tests/rectification-host-fallback.test.ts` 新增「the recap does not repeat a date the event phrase already carries」,修复前红、修复后绿;覆盖带空格年份、不带空格、ISO、斜杠四种写法,以及不带日期、中文标签、非年份数字(「600 分」)三种不变的情况。`rectification-message-cleanup-20261001`、`rectification-grounding-20260927`、`rectification-event-date-bounds-20261002` 同时通过。
- 防复发:上面那条测试固定了「带空格的年份」这一例。拼复述只在 `evidenceRecapLine` 一处做。
- 关联:BUG-1136。
- 复发自:无。
- 修复版本:分支 `codex/rectification-recap-date-dup-20261028`。
## BUG-1498 | staging 门禁 Python 快速门失败:`test_daily_starlanguage_does_not_eat_yesterday_cache` 网关调用 0 次
- 状态:resolved(测试隔离 + 回归测试;以修复后第一轮 staging 门禁通过为准)
- 首次发现 / 最近更新:2026-10-28(BUG-1481 验证段首次提到)/ 2026-10-28(staging 门禁 run 2017、2018 连续两次失败)
- 影响面:只影响 CI 门禁,不影响线上。失败的是 `tests/test_vedastro_snapshot_cache.py::test_daily_starlanguage_does_not_eat_yesterday_cache`;同样的机制可能打到任何用前台 VedAstro 线程池的测试。
- 现象:`assert calls["gateway"] == 1` 得到 `0 == 1`;同一轮其余 1094 项通过。run 2016 这一项通过,run 2017、2018 失败,三次 Python 代码相同;本机单独跑或整套跑都通过。
- 触发条件:整套测试一起跑,前面某个测试留在前台线程池里的任务,在下一个测试开始之后才跑完。
- 根因:`scripts/vedastro_foreground.py::_FOREGROUND_VEDASTRO_EXECUTOR` 是模块级线程池,整个 pytest 进程共用(`jyotish_api_server` 还按名字另外导入了一份)。任务跑完后,`_remember_gateway_outcome` 把结果写进「写的那一刻」环境变量指向的缓存目录。上一个测试留下的任务跑完时,环境变量已经指向下一个测试的隔离目录,于是一次失败被记成下一个测试「今天已经失败过」,`_failed_today` 命中,网关一次都没调用。线程池被占满时,任务排队到超时被取消,也会得到同样的 0 次。#130 给每个测试单独建目录,挡不住这种跨测试写入,所以 BUG-1481 那次的判断(共用目录)不是根因。
- 修复:`tests/conftest.py::_isolate_foreground_vedastro_executor`。每个测试换一个新的前台线程池(两个模块里的名字都换);测试结束时丢掉排队的任务,正在跑的最多等 10 秒,所以剩下的任务一定在本测试的目录还有效时写完。生产代码没有改。
- 验证:新增 `tests/test_vedastro_executor_isolation_20261028.py`:第一个测试留下一个 0.3 秒的任务,第二个测试断言它已经在上一个测试收尾时跑完、而且没有写进自己的目录。没有 fixture 时红(`assert 0 == 1`),加上后绿。`test_vedastro_snapshot_cache.py`、`test_vedastro_negative_day_20261028.py`、`test_vedastro_consultation_snapshot.py` 一起 19 项通过;本机完整快速门(`run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime`)通过。
- 防复发:上面那组顺序测试固定了「任务不跨测试」。以后新增模块级线程池的,要放进同一个 fixture。
- 关联:BUG-1481(第一次记下这个失败)、BUG-1479(#130,负缓存与每测试目录)。
- 复发自:BUG-1481 记下的同一项失败。当时的处理(每测试单独目录)没拦住,因为根因是后台任务跨测试写入,不在目录共用上。
- 修复版本:分支 `codex/bug-1498-executor-isolation`。
@@ -0,0 +1,118 @@
{
"record_version": "reported-offset-native-candidate-date-v3",
"frozen_at_utc": "2026-10-28T08:19:09.924155+00:00",
"ayanamsa": "raman",
"node_mode": "mean",
"radii_minutes": [
15,
30,
60
],
"offsets_minutes": [
-30,
-20,
-15,
-10,
-8,
-5,
-3,
0,
3,
5,
8,
10,
15,
20,
30
],
"minute_step": 1,
"dataset": "references/real_case_calibration/minute_rectification_holdout_v3.json",
"dataset_sha256": "45aa76ab79dfc6c6404b6252083e1aa5c7e2e2b7de811fc11042a8ad2da3f0ea",
"dataset_benchmark_id": "minute_rectification_fact_ranker_v4_holdout_v3",
"implementation_sha256": "b44505aa0034e486fcfc29c8611a9559333a97c1005f92e6a5356bd855122205",
"implementation_sha256_prefix": "b44505aa0034e486",
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
"production_scoring_files": [
"scripts/active_rectification_event_engine.py",
"scripts/active_rectification_events.py",
"scripts/ashtakavarga.py",
"scripts/dasha_analyzer.py",
"scripts/divisional_charts_extended.py",
"scripts/domain_calculation_service.py",
"scripts/functional_benefics.py",
"scripts/jaimini.py",
"scripts/minute_rectification_fact_ranker_v4.py",
"scripts/minute_rectification_feature_facts_v4.py",
"scripts/narayana_dasha.py",
"scripts/rectification/api_service.py",
"scripts/rectification/candidate_contrast.py",
"scripts/rectification/candidate_window.py",
"scripts/rectification/case_holdout.py",
"scripts/rectification/contracts.py",
"scripts/rectification/dasha_transition_proximity.py",
"scripts/rectification/decision_policy.py",
"scripts/rectification/event_probes.py",
"scripts/rectification/refinement_packet.py",
"scripts/rectification/scoring_service.py",
"scripts/shadbala.py",
"scripts/varga.py"
],
"production_scoring_sha256": "7a727418651321d1b9073588587050c4dc6292587748cf35734dd76cefb0effe",
"research_files": [
"scripts/research/reported_offset_sweep.py",
"scripts/research/cluster_width_lib.py",
"scripts/research/probe_supply_after_six.py",
"scripts/research/sealed_holdout_rerun.py",
"scripts/minute_rectification_blind_eval.py",
"scripts/minute_rectification_fact_blind_eval_v4.py",
"scripts/minute_rectification_holdout_validator.py"
],
"research_implementation_sha256": "6fa0b0c335ac0cdc25ee887d432ae2f1f6487c665aed9665ad5cc0bc6ef0283b",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
"scripts/ashtakavarga.py": "c524da3f54b2ed9c080b78b33854854a0ca3d5d79b7e536bb1c3bc2be3e02852",
"scripts/dasha_analyzer.py": "3949b96813217fe624899856afec9f0b787c4cb0fea3a9a0d961990dbd11943a",
"scripts/divisional_charts_extended.py": "e9820e80f14fddc86ab5495d11455d16c2d3c9502a9a242fcf9efc41186e0d11",
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
"scripts/jaimini.py": "6b88658da5404da9b136686d92a04be462d61a39da6e8de4255021e2ed2d1173",
"scripts/minute_rectification_blind_eval.py": "07aa30d8bad3278669495d5fea8be937aaf6367c8af980ff4f2fe9aa84ccc6ff",
"scripts/minute_rectification_fact_blind_eval_v4.py": "bdf460e3467208e8b87345c5beb2128f3e22de7eeffd68f72b281b3ad9affc92",
"scripts/minute_rectification_fact_ranker_v4.py": "f1a0d8d2482cd754d2e7f253a9606b0373519435a2bf58cfc3753e2b9ae0fb76",
"scripts/minute_rectification_feature_facts_v4.py": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
"scripts/narayana_dasha.py": "bc667c30c9562fb73fb0b7b4c22a5e377f9a531795141fc629b2cd3cf396773a",
"scripts/rectification/api_service.py": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
"scripts/rectification/dasha_transition_proximity.py": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "8ecc381dfaa3c0e6bea058cfa44c606aabdb1086c137f16da82ee5a3bf5ccc91",
"scripts/research/sealed_holdout_rerun.py": "ebcdae760f2b76c81d5d0c8e2bf347cff4c81cca9074f27ae94b6734408ccd42",
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
"scripts/varga.py": "8ff96b9fc8346ca249abff6109434b6ed57fa150e1a9854d4f73bdf317568368"
},
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock",
"scorer": "native_event_contribution_matrix_not_shadow_fact_ranker",
"delivery": "initial_signature_clusters_peak_gap_lt_8_envelope_no_answers_no_elimination",
"rank": "score_desc_then_sha256(benchmark_id:case_id:candidate_time)",
"grid": "every_minute_inclusive_not_production_two_minute_sampling",
"cross_midnight": "date_aware_candidates_distance_and_native_single_matrix",
"evaluator_sha256": "8ecc381dfaa3c0e6bea058cfa44c606aabdb1086c137f16da82ee5a3bf5ccc91",
"replay_revision": "native_candidate_date_v3",
"supersedes": "candidate_date_grouped_v2_identity_and_path_not_assumed_numerically_wrong",
"is_blind_evaluation": false,
"truth_hidden_from_ranker": true,
"official_valid_independent_blind": false,
"official_blind_trial_count": 0,
"results_previously_seen": true,
"must_not_use_for_tuning": true
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
{
"record_version": "exposed-v3-fixed-protocol-cross-midnight-rerun-v2",
"extended_identity": {
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
"production_scoring_files": [
"scripts/active_rectification_event_engine.py",
"scripts/active_rectification_events.py",
"scripts/ashtakavarga.py",
"scripts/dasha_analyzer.py",
"scripts/divisional_charts_extended.py",
"scripts/domain_calculation_service.py",
"scripts/functional_benefics.py",
"scripts/jaimini.py",
"scripts/minute_rectification_fact_ranker_v4.py",
"scripts/minute_rectification_feature_facts_v4.py",
"scripts/narayana_dasha.py",
"scripts/rectification/api_service.py",
"scripts/rectification/candidate_contrast.py",
"scripts/rectification/candidate_window.py",
"scripts/rectification/case_holdout.py",
"scripts/rectification/contracts.py",
"scripts/rectification/dasha_transition_proximity.py",
"scripts/rectification/decision_policy.py",
"scripts/rectification/event_probes.py",
"scripts/rectification/refinement_packet.py",
"scripts/rectification/scoring_service.py",
"scripts/shadbala.py",
"scripts/varga.py"
],
"production_scoring_sha256": "7a727418651321d1b9073588587050c4dc6292587748cf35734dd76cefb0effe",
"research_files": [
"scripts/research/reported_offset_sweep.py",
"scripts/research/cluster_width_lib.py",
"scripts/research/probe_supply_after_six.py",
"scripts/research/sealed_holdout_rerun.py",
"scripts/minute_rectification_blind_eval.py",
"scripts/minute_rectification_fact_blind_eval_v4.py",
"scripts/minute_rectification_holdout_validator.py"
],
"research_implementation_sha256": "6fa0b0c335ac0cdc25ee887d432ae2f1f6487c665aed9665ad5cc0bc6ef0283b",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
"scripts/ashtakavarga.py": "c524da3f54b2ed9c080b78b33854854a0ca3d5d79b7e536bb1c3bc2be3e02852",
"scripts/dasha_analyzer.py": "3949b96813217fe624899856afec9f0b787c4cb0fea3a9a0d961990dbd11943a",
"scripts/divisional_charts_extended.py": "e9820e80f14fddc86ab5495d11455d16c2d3c9502a9a242fcf9efc41186e0d11",
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
"scripts/jaimini.py": "6b88658da5404da9b136686d92a04be462d61a39da6e8de4255021e2ed2d1173",
"scripts/minute_rectification_blind_eval.py": "07aa30d8bad3278669495d5fea8be937aaf6367c8af980ff4f2fe9aa84ccc6ff",
"scripts/minute_rectification_fact_blind_eval_v4.py": "bdf460e3467208e8b87345c5beb2128f3e22de7eeffd68f72b281b3ad9affc92",
"scripts/minute_rectification_fact_ranker_v4.py": "f1a0d8d2482cd754d2e7f253a9606b0373519435a2bf58cfc3753e2b9ae0fb76",
"scripts/minute_rectification_feature_facts_v4.py": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
"scripts/narayana_dasha.py": "bc667c30c9562fb73fb0b7b4c22a5e377f9a531795141fc629b2cd3cf396773a",
"scripts/rectification/api_service.py": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
"scripts/rectification/dasha_transition_proximity.py": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "8ecc381dfaa3c0e6bea058cfa44c606aabdb1086c137f16da82ee5a3bf5ccc91",
"scripts/research/sealed_holdout_rerun.py": "ebcdae760f2b76c81d5d0c8e2bf347cff4c81cca9074f27ae94b6734408ccd42",
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
"scripts/varga.py": "8ff96b9fc8346ca249abff6109434b6ed57fa150e1a9854d4f73bdf317568368"
},
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock"
},
"production_identity_scope": "context_only_shadow_scorer_does_not_call_transition_proximity",
"frozen_at_utc": "2026-10-28T08:19:06.008600+00:00",
"dataset_path": "references/real_case_calibration/minute_rectification_holdout_v3.json",
"dataset_sha256": "45aa76ab79dfc6c6404b6252083e1aa5c7e2e2b7de811fc11042a8ad2da3f0ea",
"algorithm_version": "birth-time-event-fact-ranker-v4-shadow",
"implementation_sha256": "b44505aa0034e486fcfc29c8611a9559333a97c1005f92e6a5356bd855122205",
"files": [
"scripts/active_rectification_event_engine.py",
"scripts/active_rectification_events.py",
"scripts/ashtakavarga.py",
"scripts/dasha_analyzer.py",
"scripts/divisional_charts_extended.py",
"scripts/domain_calculation_service.py",
"scripts/jaimini.py",
"scripts/minute_rectification_fact_ranker_v4.py",
"scripts/minute_rectification_feature_facts_v4.py",
"scripts/narayana_dasha.py",
"scripts/shadbala.py",
"scripts/varga.py"
],
"historical_frozen_sha256": "f41c298dd6cdcebe7a93e632f7191954be7987f6012ca2a34cecb6e447fbf196",
"evaluator_sha256": "ebcdae760f2b76c81d5d0c8e2bf347cff4c81cca9074f27ae94b6734408ccd42",
"ayanamsa": "raman",
"node_mode": "mean",
"candidate_radius_minutes": [
10
],
"minute_step": 1,
"release_metrics": {
"top_1_rate_minimum": 0.6,
"top_3_rate_minimum": 0.85,
"mean_absolute_minute_error_maximum": 2.0,
"false_confirmation_rate_maximum": 0.05,
"correct_insufficient_evidence_rejection_rate_minimum": 0.9
},
"results_previously_seen": true,
"official_valid_independent_blind": false,
"must_not_use_for_tuning": true,
"tie_breaker": "sha256(benchmark_id:case_id:candidate_time); published minute is never passed to the ranker",
"metric_rank_definition": "competition_rank_1_plus_strictly_higher_scores_legacy_protocol",
"extra_metric_rank_definition": "score_desc_then_existing_opaque_sha256_total_order"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
# 22nd Drekkana: division count versus D3 house count
## Source
- `references/book_corpus/books/Crux of Vedic Astrology – Timing of Events (Sanjay Rath) (z-library.sk, 1lib.sk, z-lib.sk).md`
- Topic: 22nd Drekkana geometry. Corpus page 340, printed page 329.
- Lines 13809–13812: `house on the physical body. Incidentally, the 22nd`
`decanate is also the 8th house in the Drekkana`
`(D-3) chart while the 64th Navamsa is the 4th`
`house from the Moon in the Navamsa chart. The`
- Located by full-corpus search and read using `search_jyotish_books.py --read-unit 340`.
- This review verifies a traditional geometric definition, not health or lifespan prediction.
## Defect and correction
The producer added 21 to the D3 sign index. That confuses a count of zodiacal
ten-degree divisions with a count of signs in the divisional chart. Use D3
ascendant +7 modulo12, the eighth house inclusive.
The old test repeated the same +21 error. Replace it with a fixed Sagittarius
to Cancer counterexample and an independent 36-part zodiac enumeration. Across
each part, count 22 inclusively on the original zodiac, then apply the standard
Parashari D3 mapping to the resulting part. Test starts, interiors and ends.
The 64th Navamsa formula is unchanged: D9 Moon +63 modulo12 equals its fourth
house, as the same source states. `offset_from_lagna_drekkana=22` is an ordinal
label, not the number of D3 signs added.
No customer information is retained here. Recompute sensitive-point consumers
and use a new verified frozen pin before claiming the published report changed.
## Sarpa classification: missing two divisions
Raman, `Hindu Predictive Astrology -- Bangalore Venkat Raman, B V Raman -- 1981 -- IBH Prakashana -- isbn13 9788119394883 -- c9102b89e2f8f7c358a913f7d07d7c39 -- Anna’s Archive.md`:
- Corpus p.279, L10960–10962 explicitly counts five serpent divisions.
- Cancer second and third: p.280, L11013–11020.
- Scorpio first and second: pp.281–282, L11062–11074.
- Pisces third: p.283, L11129–11131.
- Search used `search_jyotish_books.py serpent --book <registered filename>`.
The old map accepted one division per sign and omitted Cancer-3 and Scorpio-2.
It now allows multiple divisions per sign. Independent tests cover all 36
divisions and their boundaries against the source's three degree intervals.
This classifies chart geometry; it does not establish a medical outcome.
@@ -0,0 +1,68 @@
# PROGRESS · 上游同步第九轮乙批(2026-10-28)
- 分支:`codex/upstream-sync9b-20261028`;基线 `origin/staging` `12eb7018`
- issue:#159(#141 乙批);产品 10-28 选 **A**:允许改冻结文件 `scripts/varga.py`
- 上游:`732642856/yinduzhanxing` main `50847e57`(10-16),按语义移植
- 结论:B1 两项完成;密封校正 20 条与报时窗口 900 条与上一份记录逐条相同,已按 ERR-110 另存新冻结;B2(Narayana 小运强度口径)按决策记录不做
## 做了什么
| 项 | 上游 | BUG | 改了什么 |
| --- | --- | --- | --- |
| T1 Sarpa 五段 + 原始经度判段 | `50847e57` | BUG-1494 | `SARPA_DREKKANA_SIGNS` 由单段号改段号元组;`calc_sarpa_drekkana` 去掉四舍五入、先取模;`definition` 改五段 |
| T2 第 22 号 Drekkana 改 D3 第 8 宫 | `50847e57` | BUG-1495 | `calc_22nd_drekkana` 由 `+21` 改 `+7`,字段名与序号 22 不变 |
| T4 上游配套文档 | `50847e57` | — | 原路径放入 `docs/research/sensitive_point_drekkana_count_review_2026_10_14.md` |
| T3 重新冻结 | — | — | 两份新冻结记录 + 两份回放报告(ERR-110),旧记录逐字节保留 |
生产文件只有 `scripts/varga.py` 一个;`git diff --stat` 见下。
## 与任务书的偏离
1. **红线 2 的「先不带 `--freeze` 跑」做不到**:`scripts/varga.py` 在冻结记录的扩展身份里,拿 sync8 的冻结记录回放会在打分前被身份闸门拦下(`frozen_record_mismatch:extended_identity`)。改成 ERR-110 的做法:仓库内建临时冻结记录 → 回放 → 与 sync8 逐条比 → 全部相同后才写正式记录。临时记录 `docs/research/.tmp_sweep_sync9b.freeze.json` 用完已删除,未提交。
2. **报时窗口脚本必须用仓库内路径**:报告里写 `freeze_record_path`(`relative_to(ROOT)`),所以正式记录直接放 `docs/research/`;比较轮的临时记录也在仓库内。
3. **改了前端一个测试文件**:`frontend/tests/rectification-confirmation-gate.test.ts` 写死当前密封报告路径,合同指针一挪就会红。只改这个路径与三栏注释,不动任何产品代码;与第八轮同做法的先例一致。
## 跑过的命令
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 上游两个测试文件 | `.venv\Scripts\python -m pytest tests/test_upstream_sync9b_varga_bphs.py tests/test_upstream_sync9b_sarpa_precision.py -o addopts="" -q` | 23 passed |
| 既有分盘测试一起 | 上一行再加 `tests/test_varga_bphs.py` | 39 passed |
| CLI 敏感点(任务书写 `-k "sarpa or drekkana or special"`) | `.venv\Scripts\python -m pytest tests/test_cli_smoke.py -k "sarpa or drekkana or special"` | 0 条被选中(35 deselected,退出码 5);`-k` 只匹配测试名,改用实际测试名 `-k sensitive_point_modules`:1 passed、34 deselected |
| 分盘 + CLI 敏感点合并 | 4 个文件/节点一起跑 | 40 passed |
| 密封校正 20 条 | `scripts/research/sealed_holdout_rerun.py`(新冻结记录) | `trial_count` 20;8 个字段(`true_rank`、`opaque_true_rank`、`minute_error`、`would_confirm`、`false_confirmation`、`insufficient_evidence_rejected`、`candidate_count`、`event_count`)不一致 0;`metrics` 相同;`changed_trial_count` 仍是 8 |
| 报时窗口 900 条 | `scripts/research/reported_offset_sweep.py --json`(新冻结记录) | `trial_count` 900;trial 全字段不一致 0;`summary` 与 `widening_geometry` 相同;`changed_trial_count` 仍是 581 |
| 冻结契约 | `tests/test_sealed_holdout_contract_freshness.py` | 9 passed |
| 确认门合同 | `tests/test_rectification_confirmation_and.py` | 10 passed |
| 其余分盘测试 | `pytest tests/test_varga*.py` | 53 passed、9 failed;9 条全在 `test_varga_vedastro_mode.py`(D2 起 Ascendant 星座不符)。在基线 `12eb7018` 的一次性工作树上同样 9 failed,与 Sarpa / 22nd 无关,不算本单 |
| 正式记录生成 | `scripts/research/*.py --freeze` 两份 + 回放两份 | `sealed_holdout_rerun_upstream_sync9b_2026_10_28.{freeze.json,json}`、`reported_offset_upstream_sync9b_2026_10_28.{freeze.json,json}` |
Python 快速门、前端 tsc / lint / build、`npm test`、`test:db` 没跑:没有改前端产品代码,没有动表。
## 断言三栏
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `tests/test_varga_bphs.py::test_22nd_drekkana_counts_forward_from_lagna_drekkana` | `expected = (drekkana_ref(asc_lon) + 21) % 12` | 固定 `3`(巨蟹),保留 `offset_from_lagna_drekkana == 22` | 旧断言把「+21」的错口径抄进了测试;换成书上的固定反例(射手 D3 起,第 8 宫为巨蟹) |
| `tests/test_cli_smoke.py::test_full_reading_exposes_sensitive_point_modules` | `definition == "Cancer-2, Scorpio-1, Pisces-3"` | `definition == "Cancer-2, Cancer-3, Scorpio-1, Scorpio-2, Pisces-3"` | BUG-1494 改的就是这个定义字符串;仍逐字比对,没有放宽 |
| `tests/test_sealed_holdout_contract_freshness.py` 排除前缀 | 排除到 `upstream_sync8_2026_10_18` | 新增 `upstream_sync9b_2026_10_28` 两条,原有条目全部保留 | ERR-110 另存新冻结,旧记录留在清单外;第 1 次打回时补回被误删的 sync8 两条 |
| `frontend/tests/rectification-confirmation-gate.test.ts` 读取与断言路径 | `…upstream_sync8_2026_10_18.json` | `…upstream_sync9b_2026_10_28.json` | 合同当前指针移到新报告;注释补第 11 条三栏 |
| `scripts/research/sealed_holdout_rerun.py`、`reported_offset_sweep.py` 的 `FREEZE` / `REPORT` | `…upstream_sync8_2026_10_18` | `…upstream_sync9b_2026_10_28` | 同上,ERR-110 |
## 发现但没改
- 完整解读的敏感点表没有 `drekkana_22nd` 的生产者:`_calc_sensitive_points` 只产 `bhrigu_bindu` 与 `sarpa_drekkana`。所以第 22 号 Drekkana 修好后,报告里暂时看不到变化;这是既有接线缺口,本单不接线(也不把「有函数」写成「报告里已计算」)。
- `frontend/tests/fixtures/report-density-fictional-engine.json` 里还有两处旧 `definition` 字符串。它是前端 fixture,本单不改前端产品代码,留给前端轮次或验收方判断。
## 环境缺口
- Windows 跑 Python 测试要 `PYTHONUTF8=1`,否则子进程按 GBK 编码,遇到警告符号会 `UnicodeEncodeError`,看起来像引擎崩了。
- 前端测试没跑(任务书不要求,且本单没改前端产品代码)。
## 第 1 次打回(2026-10-28)
- 被验 SHA:`5c5f333d4d93fb691c10790988d3d4c986ac1bd3`;修复单 `docs/tasks/TASK-upstream-sync9b-fix-20261028.md`
- 错在哪:改 `IDENTITY_FORBIDDEN_PREFIXES` 时把第八轮的两条**替换**掉了,清单从 4 条变成 2 条;测试照样通过,但守门变弱(第八轮的冻结记录混进打分身份也不会再被发现)
- 改了什么:把第八轮两条加回原来的注释下面,第九轮乙批两条保留 —— 现在清单同时含 sync8 与 sync9b 共 4 条;本文件「断言三栏」对应行改成「新增两条、原有全部保留」
- 跑过:`tests/test_sealed_holdout_contract_freshness.py` 9 passed;`frontend/tests/rectification-confirmation-gate.test.ts` 8 passed(`node node_modules/tsx/dist/cli.mjs --test tests/rectification-confirmation-gate.test.ts`,工作树里 `frontend/node_modules` 已存在,没有重跑 `npm ci`)
- 没有重跑上游两个测试文件和校正 20 / 900:本次不改计算代码
+3
View File
@@ -37,6 +37,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-rectification-d60-two-way-20261028.md` | `PROGRESS-rectification-d60-two-way-20261028.md`(执行方写) | **D60 换格要不要进生时校正:双向对比(issue #149)**:纳迪 / D150 已在 BUG-1240 证伪,本单只测 D60。A 组生产 V9、B 组加 D60 换格判据,同一批 v5 77 例看「有没有帮助」和「有没有伤害」;先登记再跑,G1 三引擎对账、G2 找回记录分钟、G3 不伤真值、G4 安慰剂、G5 换岁差噪声地板;另做 20 例模型参与的端到端对话对比(报告项)。离线研究、生产零改动。预留 BUG-1497 | 待领取 | 本地 Grok 执行 |
| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` |
| `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` |
| `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG |
@@ -544,3 +545,5 @@
| — | `PROGRESS-session-shell-116-20261028.md` | **#116 侧栏会话菜单搬进外壳**:每个页面都能「…」和拖动(直接执行,BUG-1467) | 已推 staging | `codex/session-shell-116-20261027` |
| `TASK-home-crash-saved-people-20261027.md` | `PROGRESS-home-crash-saved-people-20261027.md` | **#115 首页崩溃**:存过别人的账户一进首页就「页面出错了」——人物列表的资料缺两个字段,启程引导一读就报错;入口补齐默认值(BUG-1464) | 待验收 | `codex/home-crash-saved-people-20261027` |
| `TASK-upstream-sync9-20261028.md` | `PROGRESS-upstream-sync9-20261028.md` | **第 1 次打回(快速门 1 败:Darakaraka 旧测试锁错值,见 `TASK-upstream-sync9-fix-20261028.md`)** · **#141 上游同步第九轮**:甲批九项已做完并推 `codex/upstream-sync9-20261028`;校正 20 条和 900 条与上一份回放逐条相同;乙批仍等 #137 | 待验收 | `codex/upstream-sync9-20261028` |
| `TASK-upstream-sync9b-20261028.md` | `PROGRESS-upstream-sync9b-20261028.md` | **第 1 次打回(冻结契约排除清单删了第八轮两条,见 `TASK-upstream-sync9b-fix-20261028.md`)** · **#159 上游同步第九轮乙批(产品选 A)**:Sarpa Drekkana 补成五段、第 22 号 Drekkana 改为 D3 第 8 宫;改冻结文件 `varga.py`,校正 20 + 900 与上一份逐条相同后按 ERR-110 重新冻结。Narayana 小运强度口径(B2)不做,另问产品 | 待验收 | `codex/upstream-sync9b-20261028` |
| — | — | **#134 咨询答题默认低思考量(产品选 A)**:`CONSULT_ANSWER_REASONING_EFFORT` 不设时默认 `low`,设 `off` 恢复原请求体(Claude 直接执行) | 已推 staging | `codex/answer-effort-default-20261029` |
@@ -0,0 +1,114 @@
# TASK · D60 换格要不要进生时校正:双向对比(研究单,2026-10-28,issue #149)
## 基线
- `origin/staging` @ `7721aa9a`(写作时 head;开工时以最新 `origin/staging` 为准,在 PROGRESS 里写下实际 SHA)。
- 分支 `codex/rectification-d60-two-way-20261028`,工作树 `.worktrees/rectification-d60-two-way-20261028`。
- 执行方:本地 Grok(Windows + Docker),可以调用模型。验收方:Claude。
- **离线研究,不改生产代码**:不动 `scripts/research/sealed_holdout_rerun.py::PRODUCTION_FILES` 里的冻结文件,也不动 `scripts/varga.py`、`scripts/active_rectification_event_engine.py`、`scripts/rectification/*`。D60 计分只写在 `scripts/research/` 下的新文件里,生产链路一行不改。
- 数据:`references/real_case_calibration/minute_rectification_holdout_v5.json`(77 例 Rodden AA 公开名人,964 件事,244 件日精度)。口径沿用数据集声明的 `ayanamsa = raman`、`node_mode = mean`,换口径只在 G5 里做。
## 先读
- `docs/research/rectification_nadi_seconds_2026_10_05.md`(BUG-1240):纳迪 / D150 秒级已经证伪。N3:±10 窗口里纳迪找回记录分钟 5/77,生产 25/77;N4:只换岁差,D150 有 73/73 例换段。**本单不重开 BUG-1240**,只测那次没测的 D60。
- `docs/tasks/TASK-rectification-nadi-seconds-research-20261005.md`:本单照它的写法(先登记、真值不可见、安慰剂、负结果照写)。
- `docs/research/holdout_v5_build_2026_09_29.md`:v5 基线;六题回放后头名 0.64、真值在区间内 76/77。
- `skills/jyotish-birth-time-rectification/references/technique-routing.md`:现行口径「D60 只作参考,不驱动结论」。
- `AGENTS.md` §1–§4:这是「技法可靠性判断」,适用高严谨模式和 MEVG。
## 起因
竞品宣传「证据够密时 D60 与纳迪分盘反向校准,收束时刻可以到秒」(#142 评论 0)。纳迪那一半已经在 BUG-1240 证伪。D60 每格 0.5°,上升约 1.5–2.5 分钟走完一格,从没在已知出生时间的人身上测过它能不能帮着分开候选。产品要求先跑对比、出报告,再决定做不做(#149,jesse 2026-10-28)。
## 两个组
- **A 组(现状)**:生产 V9 引擎原样,六题回放协议同 v5 基线。
- **B 组(加 D60)**:A 组之上,多一条 D60 换格判据。窗口内上升的 D60 格每换一次,候选分成两段;用户给出日精度事件时,按预先登记的 D60 规则给两段打分,淘汰规则与 A 组的换升判据同一套。只在研究脚本里实现。
## 「双向」是什么
同一批例子,两个方向都要看:
1. **有没有帮助**:B 组是否更常找回记录分钟、区间是否更窄。
2. **有没有伤害**:B 组是否更常把真值排除在区间外、是否在安慰剂日期上也一样「有帮助」(说明是噪声)、换岁差后结论是否还站得住。
只报第 1 个方向、不报第 2 个方向的数字,一律不算数。
## 过门标准(先登记再跑)
规则、阈值、种子先写进 `docs/research/d60_two_way_preregistration_2026_10_28.json`,单独 commit,然后才能第一次在 v5 上跑。PROGRESS 里写明登记 commit 早于第一次计分。
| 门 | 内容 | 过门条件 |
| --- | --- | --- |
| G1 三引擎对账 | 在 77 例的记录时间上,本仓 D60 上升格与 PyJHora、jyotishganit 逐例比对;VedAstro 能拿到就加上,拿不到写 `blocked` | 本仓与至少一个外部引擎一致 ≥ 95%;不一致的例子逐条列出。G1 不过,后面全部停 |
| G2 找回记录分钟 | ±10 分钟窗口、六题回放后,头名与记录分钟相差 ≤ 2 分钟的比例,B 减 A | 整例 bootstrap 5000 次,提升的下界 > 0 |
| G3 不伤真值 | 真值仍在交付区间内的例数 | B 组不少于 A 组(基线 76/77) |
| G4 安慰剂 | 把日精度事件按 BUG-1240 的 P1(±30–180 天平移)和 P2(跨例换日期)做两套安慰剂,B 组的提升重算一遍 | 真实日期的提升必须显著高于两种安慰剂(单侧置换 9999 次,p < 0.05) |
| G5 噪声地板 | 只换岁差 Raman→Lahiri / KP / True Chitra,记录时间上 D60 上升格变化的例数比例 | 任一换法超过 20%:对外文案不得出现「到秒 / 分钟级精确」,即使 G2–G4 全过 |
取整组(记录分钟是 5 的倍数,52 例)和非取整组(25 例)分开列;判门用合并样本。
## 模型参与的端到端对比(报告项,不判门)
在 G1 通过后做,用来看真实对话里差别有多大:
- 从 77 例里按种子 `20261028` 抽 20 例(取整 / 非取整按比例)。
- 每例跑两次完整校正对话:A 组一次、B 组一次,顺序按种子随机,防止先后影响。
- 校正 agent 用生产同一个模型和同一份提示;「用户」由另一个模型扮演,只能看到该例 v5 里的事件清单(日期、领域、原文摘要),**看不到出生时间**,回答必须来自清单,清单里没有的就答「不记得」。
- 每次记录:轮数、最终区间宽度、头名与记录分钟的差、真值是否在区间内、出现了几次反证题(#148 验收要的三项一并记下)。
- 模型温度固定,种子固定;每例每组跑 1 次,有余量再跑第 2 次看稳定性。
- 只保存对话可见文本和上面的指标,不保存模型的思维链。
## 硬红线
1. **先登记再跑**:G2–G5 的规则、阈值、种子先 commit。看过结果后改的任何东西只能写进「事后」一栏,不能用来判门。
2. **真值不可见**:排序器、D60 打分、扮演用户的模型都不得读取 `true_minute` 或出生资料里的 `time`。新增测试断言这一点,沿用 `truth_hidden_from_ranker` 的检查方式。
3. **必须有安慰剂**:每个「有帮助」的数字都要同时报安慰剂下的同一数字。
4. **不写「秒级准确率」**:准确率只用「与记录时间相差 ≤1 / ≤2 分钟」口径。
5. 生产代码零改动;冻结文件零改动;`PYTHONHASHSEED=0`;所有 JSON 两次复跑逐字节一致(模型对话那部分除外,写明)。
6. 只用 v5 已有的公开名人数据;不新增人物,不访问 astro.com。外部引擎只在本机跑,遵守各自许可证(PyJHora 是 AGPL:只在研究脚本里调用,不复制进生产代码)。
7. 模型密钥只从本机环境变量读,不写进仓库、日志或报告。
8. 负结果完整写出。不过门就写不过门,不调阈值凑通过。
## MEVG(AGENTS.md §3)
报告里必须有这两行,做不到写 `blocked`:
- **MEVG / Global Web Evidence**:收集公开资料里「用 D60 做生时校正」的说法与依据(经典文本出处、知名占星师的方法说明、论坛案例),按 A(经典原文 / 可复现数据)B(有案例的专业说明)C(无依据的宣传)分级。竞品宣传归 C。资料之间冲突时写明怎么取舍。
- **Real Case Calibration**:就是上面的 v5 77 例结果。
## 交付物
- `docs/research/d60_two_way_preregistration_2026_10_28.json`(先 commit)。
- `scripts/research/d60_two_way_run.py` 与 `tests/test_d60_two_way_research.py`(真值不可见、种子固定、三引擎对账函数)。
- `docs/research/d60_two_way_results_2026_10_28.json`。
- 报告 `docs/research/rectification_d60_two_way_2026_10_28.md`:开头一张结论表(G1–G5 过 / 不过 + 关键数字 + 对照),然后是模型端到端对比表、MEVG 两行、事后一栏、建议(做 / 不做 / 只做到哪一步)。
- `docs/tasks/PROGRESS-rectification-d60-two-way-20261028.md`:每一步的 commit 与复跑核对。
- 报告摘要贴到 issue #149。
## 让步顺序
G1 > G2 > G3 > G5 > G4 > 端到端对比 > MEVG 的论坛案例部分。
- G1、G2、G3、G5 缺一项都不能下结论。
- 没做的写 `not_started`,不得写成结论。
## 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-d60-two-way-20261028 .worktrees/rectification-d60-two-way-20261028 origin/staging
cd .worktrees/rectification-d60-two-way-20261028
export PYTHONHASHSEED=0
python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45
python3 -m pytest -q tests/test_nadi_seconds_research.py tests/test_holdout_v5_schema.py tests/test_varga_resolution_research.py tests/test_repo_privacy_markers.py # 基线
python3 -c "import swisseph; print(swisseph.version)"
```
长任务每完成一个门就 commit 一次检查点。研究分支只 commit,不推 staging;推送由验收方完成。
## BUG 编号
开工时核对 `docs/BUG_HISTORY.md` 当前最大号(写作时是 BUG-1496)。本单预留 **BUG-1497**:「D60 换格是否能帮助生时校正,从未在已知出生时间的例子上检验」,执行方以 `investigating` 登记。通过的改为 `resolved` 并另立实现单;不通过的改为 `closed_by_design`,附对外口径。
关联:BUG-1240、BUG-560、BUG-1090、BUG-1091。
@@ -0,0 +1,76 @@
# TASK · 上游同步第九轮乙批:Sarpa Drekkana 与第 22 号 Drekkana(2026-10-28)
- 日期:2026-10-28
- 来源:issue #159(#141 乙批)。产品 10-28 选 **A**:改冻结文件,生时校正 20 条 + 900 条分数逐条相同才重新冻结。
- 执行方:**Grok(Windows + Docker)**,分支 `codex/upstream-sync9b-20261028`,只推这个分支(本任务书就是该分支第一个提交)
- 验收:Claude(Linux)
- BUG 编号:开工时按 `docs/BUG_HISTORY.md` 全文件取最大号 +1(写作时 staging 最大号 BUG-1492;另一会话仍在占号,开工当时再核对)。本单预计 2 个号。
## 1. 基线
- 基线 commit:`origin/staging` `12eb7018`(#141 甲批与 #137 都已在其中)。
- 上游:`732642856/yinduzhanxing` main 的 `50847e57`(10-16)。按语义移植,不整文件覆盖。
## 2. 事故实证(上游测试放到 `12eb7018` 上跑)
| 项 | 上游测试结果 | 我方现状 | 来源 |
| --- | --- | --- | --- |
| B1a Sarpa Drekkana | `test_varga_bphs.py` 3 败 14 过;`test_sarpa_drekkana_precision.py` 4 败 2 过 | `scripts/varga.py::SARPA_DREKKANA_SIGNS` 只有 Cancer-2、Scorpio-1、Pisces-3 三段;`calc_sarpa_drekkana` 先把度数四舍五入到 4 位再算第几个 Drekkana,边界附近会划错 | Raman《Hindu Predictive Astrology》Drekkana 章:五段(Cancer-2、Cancer-3、Scorpio-1、Scorpio-2、Pisces-3) |
| B1b 第 22 号 Drekkana | 同上 | `scripts/varga.py::calc_22nd_drekkana` 取 D3 星座 +21 | 《Crux of Vedic Astrology》p.329:是 D3 的第 8 宫,即 +7。数 21 个十度分段 ≠ 在 D3 里移 21 个星座 |
下游:`jyotish_engine.py` 完整解读的特殊点(`calc_sarpa_drekkana`)→ 前端报告事实表 `report-fact-tables.ts` 的 Sarpa Drekkana 行;第 22 号 Drekkana 进完整解读的敏感点。
## 3. 根因
前八轮同步与 #141 甲批都没有取上游 main 10-16 的这一提交(#141 甲批因冻结文件把它留到乙批)。
## 4. 决策记录
1. **产品 10-28 在 #159 选 A**:允许改冻结文件 `scripts/varga.py`;改完跑生时校正 20 条 + 900 条,**逐条相同**才重新冻结为 `upstream_sync9b_2026_10_28`,沿用第八轮 `79f6ac52` 的做法。有任何一条不同:停,不重新冻结,回报「需拍板」。
2. **B2(Narayana 小运强度口径)本单不做。** 规划时核实:上游 `6859b263` 只是把已选的 `strength_profile` 往下传,但这个强度口径家族(`rath_*`、`pvr_*` 等)来自上游 09-27 的 `5b57949c`,我方从未移植;默认口径下结果不变。是否移植整套口径另由产品决定(已在 #159 说明)。
3. 用户可见变化已知并接受:部分盘 Sarpa Drekkana 标记会多出来(Cancer 第 3、Scorpio 第 2 段);第 22 号 Drekkana 的星座会变。
## 5. 硬红线
1. 只改 `scripts/varga.py` 里 `SARPA_DREKKANA_SIGNS`、`calc_sarpa_drekkana`、`calc_22nd_drekkana` 三处;`varga.py` 其他函数逐字节不变(PROGRESS 贴 `git diff --stat` 与函数级 diff 说明)。
2. 生时校正 20 条(`scripts/research/sealed_holdout_rerun.py`)+ 900 条(`scripts/research/reported_offset_sweep.py --json`):先不带 `--freeze` 跑出结果、与 `docs/research/*upstream_sync8_2026_10_18.json` 逐条比;**全部相同**后才按脚本自带的 `--freeze` 写新冻结记录(不覆盖旧记录)。
3. 不改其他冻结文件;不改 `.gitea/workflows/**`、数据库、前端。
4. 上游断言数值不得放宽;接口名不同只改调用方式。
5. 公开仓库:测试只用公开或虚构出生资料。
## 6. 任务分解
| 任务 | 内容 | 验收标准 |
| --- | --- | --- |
| T1 | Sarpa 五段 + 不经四舍五入判段(`50847e57`) | 上游两个测试文件中 Sarpa 相关全过;`definition` 字段改为五段 |
| T2 | 第 22 号 Drekkana +7(`50847e57`) | 上游相关断言过;完整解读里该敏感点的字段名不变 |
| T3 | 校正回放与重新冻结(红线 2) | 20/20、900/900 相同;新 `.freeze.json` 两份 + 回放 JSON 两份;`tests/test_sealed_holdout_contract_freshness.py` 全过 |
| T4 | 上游配套文档 `docs/research/sensitive_point_drekkana_count_review_2026_10_14.md` 原路径放入;BUG_HISTORY 2 条、CHANGELOG、PROGRESS、`docs/tasks/README.md` 状态板 | — |
## 7. 让步顺序
T1、T3、T4 必做;T2 若与我方敏感点结构冲突,回报原因、不硬改。
## 8. 验收命令
| 检查 | Grok 自跑 | Claude 验收 |
| --- | --- | --- |
| 上游两个测试文件(放 `tests/test_upstream_sync9b_*.py`) | 必跑 | 复验 |
| `tests/test_varga*.py`、`tests/test_cli_smoke.py -k "sarpa or drekkana or special"`、`tests/test_sealed_holdout_contract_freshness.py` | 必跑 | 复验 |
| 校正 20 + 900 回放 | 必跑 | 复验 |
| 前端 `report-fact-tables.test.ts` | 不跑(未改前端) | 跑 |
| Python 快速门 | 不跑 | 以 Claude 为准 |
## 9. 开工前置命令
```
git fetch origin
git worktree add .worktrees/upstream-sync9b-20261028 codex/upstream-sync9b-20261028
python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45
grep -o 'BUG-[0-9]\+' docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
git fetch upstream main && git show 50847e57
```
## 10. 真机
`docs/testing/upstream-sync9b-20261028.md`。
@@ -0,0 +1,45 @@
# TASK · 上游同步第九轮乙批 · 第 1 次打回(2026-10-28)
- 原任务书:`docs/tasks/TASK-upstream-sync9b-20261028.md`(issue #159)
- 被验 SHA:`5c5f333d4d93fb691c10790988d3d4c986ac1bd3`(分支 `codex/upstream-sync9b-20261028`)
- 执行方:Grok,同一分支继续提交;不新开 BUG 号
## 1. 验收结果
| 检查 | 结果 |
| --- | --- |
| 回报 SHA 是分支最新提交,可快进到当前 `origin/staging` | 是 |
| `varga.py` 改动 | 与上游 `50847e57` 一致,只改了任务书点名的三处 |
| 上游测试 + `test_varga_bphs.py` + 冻结契约 + 敏感点冒烟(Linux) | 49 过 |
| 生时校正 20 条 + 900 条(Linux 复跑) | 与第八轮记录逐条相同 |
| 旧冻结记录 | 逐字节未动 |
| 两处偏离(临时记录回放、前端测试路径) | 认可,与第八轮做法一致 |
| 任务书里 `-k "sarpa or drekkana or special"` 选中 0 条 | 这是任务书写错了,你改用真实测试名是对的 |
## 2. 失败项:冻结契约测试的排除清单被删了两条
`tests/test_sealed_holdout_contract_freshness.py::IDENTITY_FORBIDDEN_PREFIXES` 这份清单历来是**追加**的(每次重新冻结都「再排除」新路径,旧路径留着)。本分支把第八轮的两条:
```
"docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18",
"docs/research/reported_offset_upstream_sync8_2026_10_18",
```
**替换**成了第九轮乙批的两条,第八轮那段注释还在、条目没了。这条测试守的是「冻结记录文件不能混进打分身份」,删掉以后第八轮的记录混进去也不会报错——测试仍然通过,但守门变弱了。
## 3. 要做的
1. 把上面两条第八轮路径加回去,放在原来那段注释下面;第九轮乙批两条保留在其后。即清单同时含第八轮与第九轮乙批共 4 条。
2. PROGRESS「断言三栏」这一行改写为「新增两条、原有全部保留」。
3. 跑 `tests/test_sealed_holdout_contract_freshness.py`,回报通过数。
4. 前端:在工作树 `frontend/` 里 `npm ci` 后跑 `npm test -- tests/rectification-confirmation-gate.test.ts`(或该仓等价的单文件命令),回报结果。
## 4. 验收命令
| 检查 | Grok 自跑 | Claude 验收 |
| --- | --- | --- |
| `tests/test_sealed_holdout_contract_freshness.py` | 必跑 | 复验 |
| `frontend/tests/rectification-confirmation-gate.test.ts` | 必跑 | 复验 |
| 上游两个测试文件、校正 20 + 900 | 不必重跑(本次不改代码) | 复验 |
完成后照旧回报新的 head SHA。
@@ -0,0 +1,7 @@
# 真机走查 · 咨询回答默认低思考量(issue #134)
| # | 操作 | 应该看到 |
| --- | --- | --- |
| 1 | 用一张星盘问一个具体问题(如「今年事业怎么样」) | 回答正常出来,比以前快一截(以前常要半分钟以上) |
| 2 | 连问两三个追问 | 每轮都正常回答,引用的宫位、大运和以前一样具体 |
| 3 | 问一个时间段(如「明年上半年」) | 照常出时段分析,没有空答或中途断掉 |
+9
View File
@@ -0,0 +1,9 @@
# 真机走查 · 上游同步第九轮乙批(issue #159)
用虚构或公开出生资料,在 staging 上看:
| # | 操作 | 应该看到 |
| --- | --- | --- |
| 1 | 生成一份完整报告,翻到特殊点 / 敏感点表 | Sarpa Drekkana 行正常显示;没有空白或「undefined」 |
| 2 | 同一张盘看第 22 号 Drekkana | 有星座,不报错 |
| 3 | 发起一次生时校正,走到出结果 | 和以前一样能走完(校正打分没有变) |
+15 -2
View File
@@ -408,9 +408,10 @@ The birth-time rectification session is the consultation transcript plus a house
### Chat header
- One line, 46px on desktop. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`.
- Ordinary consultation, and the chart, ephemeris and report pages: the person is a rounded button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). Light canvas fill, a hairline, and a chevron, so the name reads as something you can press. The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation. When the archive is under 20 people, the list ends with a hairline and `+ 添加一个人`, which opens `/people`. At 20 people that row is absent.
- Ordinary consultation, and the chart, ephemeris and report pages: the person is a rounded button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). Light canvas fill, a hairline, and a chevron, so the name reads as something you can press. The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation. When the archive is under 20 people, the list ends with a hairline and `+ 新增一个人`, which opens `/people?add=1` (the add form). At 20 people that row is absent.
- Issue #150 (2026-10-28): after the name the trigger shows `· {date time · city}` (`.chat-profile-picker-detail`, secondary ink, ellipsizes first) once the selected person is loaded and complete; the accessible name becomes `当前星盘:{名字},{date time · city}`. The phone chat header (≤640px) hides the detail. Each row is a two-column grid (`.chat-profile-picker-row`: the choice, then a 44×44 `⋯` menu `.chat-profile-picker-more`). The row copy is the name plus a pill badge (`.chat-profile-picker-badge`: muted canvas, caption size; 本人 or the relation, empty reads 其他) over `date time · city`. The menu holds `编辑` (`/people?self=1` or `/people?edit=<id>`, which opens that person's edit dialog) and, for others only, `删除`: the same count read, AlertDialog sentence and DELETE as the people page (`lib/person-delete.ts`), falling back to self if the current person was deleted. Delete is disabled while an answer is being written. The chat-side chart panel (`.chat-chart-switcher`) carries the same picker full width above the drawing; the drawing sits behind its own memo so the picker's repaints never redraw it.
- Before the first message the list selects a person and updates that empty session's binding. Self is the visible default. After the first message the current person stays selected; another person offers only `新建会话并使用此人物` and does not rebind the old session. That action waits until the current request ends.
- Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner.
- Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整,补全后才能看盘` and cannot be chosen (its `⋯` still offers 编辑). A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner.
- Rectification and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The person button stays on narrow screens and ellipsizes so the header remains one line.
- The chart page birth line is a button (`.chart-birth-line`) under the person's name in the page head (`.chart-page-head-meta`, 13px tertiary, as in the signed prototype; it left the top bar on 2026-10-28, BUG-1470, because on a phone it wrapped the bar to two lines): `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). The editor is a component-library dialog (2026-10-25, K3). A click inside the form, including its padding, does not close it or drop unsaved edits. Escape and a click on the backdrop do close it. The fields stay the shared profile form used by the people page, so this page does not grow a second set of fields. Below 768px that row wraps onto a second line and the chart header grows to fit it. The editor's code is not in `/chart`'s first load: it loads on the click and is warmed when the pen row is hovered or focused, and nothing is drawn while it arrives (no placeholder; BUG-1469). The people page's add / edit form likewise stays out of `/people`'s first load and is fetched right after the page mounts.
- With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px.
@@ -918,6 +919,8 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
**「已对照 N 件」在手机上不在可见行里,桌面上目前仍在——这是一处未裁决的口径冲突。** 下面的禁列表写明这一项归交付卡、不上条,而它后来(cfb41daf)被加到了条上且没有改本文件。BUG-918 只把它在手机上移出可见行;桌面是否也撤掉需要产品裁决,见 `docs/BUG_HISTORY.md` BUG-918。
**代表分钟只写在条上(2026-10-28,issue #146)。** 盘面标题行不再写时刻,「看盘面」按钮只写这三个字(读屏名称仍带时刻和上升)。下面 D10 那段里「条上与盘面标题行同一次调用」从此只剩条这一处。
**代表分钟上条是 D10(2026-09-16)的产品决定,推翻了「条上只有区间」的原设计。** 理由是窄屏:`.is-compact` 下右栏盘面整体变成默认关闭的 overlay,代表分钟此前只在 `.rectification-board__clock` 出现,于是用户最该一直看着的那个数字恰恰看不见。条上的取值必须与盘面标题行**同一次调用**——`workingRectificationTime(result)`,由 `rectification-chat-view.ts` 的 `deriveRectificationChatView` 算一次(容器 `rectification-agentic-chat.tsx` 每次渲染调用它一次;2026-09-26 代码拆分只搬位置)、两处共用,不允许第二条推导路径。已采用时写「已采用 HH:MM」,否则写「代表分钟 HH:MM」;**采用不是确认**,完整边界句仍只在交付 / 采用旁白里出现一次,条上不写、也不得暗示某一分钟已确认。
**条上不得出现收窄进度(「已从 30 分钟收到 7 分钟」)。** 开场窗口确实在线上,但没有任何投影把这个对比作为字段给出,而 VOICE.md 第 2 条要求进度数字来自服务端。前端自己减还会算错:轴是**当前**窗口且会放宽(BUG-572),相减会把一次放宽报成进度。这句话继续由服务端拼进旁白。缺的字段见 `BLOCKED.md`。
@@ -955,6 +958,14 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
## 11. 区间交付卡
**卡头与按钮(2026-10-28,issue #147,产品同意 https://git.copse.top/root/Jyotisha/issues/147#issuecomment-36056 四条)。** 下文 D3、D4 与「更像这个」按以下改:
- 区分得开(D4:前两列差 ≥ 5 个百分点)且有代表分钟时,卡头是 `.rectification-result-lead`:小字「最可能的出生时间」、30px 衬线时刻、「上升X」(有该分钟的宫位表才写);「目前范围 …(对照了 N 件经历)」降为下一行 `.rectification-range-delivery__range-line`。区分不开时 D3 原样:范围是 `<strong>` 卡头,加「区分不开」那句,不写最可能。
- 区分得开时最可能那列排第一,按钮是实心「采用 HH:MM 并打开星盘」,采用成功(服务端已保存)后去 `/chart`;其余列在小字「还不能排除」下面,按钮是描边「改用这个」,采用后不跳转。区分不开时每列仍是「更像这个」。出生记录冲突时一律「改用校正结果」,不跳转。
- 不显示把握(低 / 中 / 高):引擎的这一档没有校准,BUG-560 同理。
- 可以采用时,卡上多一个文字按钮「我说错了,改一条」,展开用户确认过的事件(与对话里的复述同一说法),点一条只在输入框里写好「我说错了:「…」应该是」,由用户补完发出,现有改记录工具来改;不自动发送、不另扣点。没有确认过的事件就不出这个按钮。不放「再补一件事」:训练门后补打字经历几乎缩不了范围,卡上不得邀请再补(BUG-1084,issue #148 产品 2026-10-28 定)。
- 盘型交付卡(「用哪张盘解读」)不加时刻卡头(BUG-1151:盘型产品不排分钟),按钮文案不变;平常采用后同样去 `/chart`,也有上面那一行。
交付对象是区间里并排的候选分钟,不是支持度数字,也不是逐行空差异。
**相同的句子只写一次。** 两列或三列共有的 D9 / D10 / 月宿性格句提到卡片顶部(「三个时间的事业盘都在巨蟹座:做事以照顾人为主」)。列内只留有差别的句子;某列没有差别句就不显示性格块,不写「同上」。单列不提升共享句。
@@ -973,6 +984,8 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
## 12. 校正右栏盘面
**默认关着(2026-10-28,issue #146)。** 所有宽度上,头部都有「看盘面」按钮(`.rectification-board-peek`),盘面默认不出。桌面点开后在右侧分出一栏(`minmax(18rem, 22.5rem)`,没有候选时 16–18rem),280ms 从右淡入,标题行右侧是关闭按钮;手机仍是底部抽屉。标题行不写时刻。
右栏回答「当前按哪一分钟在看盘」,不回答「选哪一分钟」。顶部不加任何新说明文字。
**本命宫位改北印星盘。** 用 `VedicChartSvg` 渲染代表分钟的 `house_table`。宫位表退到星盘下方的 `<details>`(读屏与复制仍可用)。换分钟时只高亮变化的宫(`changedHouses` / `lagnaChanged`),不做动画。星盘组件在校正面 `dynamic()` 分包里,不进首页首屏。
+18 -2
View File
@@ -63,6 +63,18 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页不写副标题。按钮是「开始合盘」,上面不写价格。点了先确认「会用掉 N 点,余额 M 点。」,按钮是「取消 / 开始」。余额不够时说「需要 N 点,余额只有 M 点。」,主按钮是「去补点数」。N 从计价表读,M 从账户读。计价读不到时不弹确认,只说「计费配置不可用」。这两人已经有写好的合盘、且没有一份正在生成时,按钮下面加「这两人已经合过 N 次,再生成会另扣 X 点」,N、X 都读实际数字。正在生成时不写这句。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页不出现两个人的选择框,也不出现「开始合盘」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
## 生时校正对话:开场说来源、事件没起作用时直说(2026-10-28,issue #148)
- **开场窗口句带上时间来源**,仍是一句:「我们来把你的出生时间缩小到更准的范围,按你填的大概时间,现在先在 HH:MM–HH:MM 之间找。」盘型产品:「……能不能选定用来解读的星盘,按你的出生记录时间,核对窗口是 HH:MM–HH:MM。」来源词只有四种:按你的出生记录时间 / 按你填的大概时间 / 按你填的时间(家人记得的准确时间)/ 按你给的时间段;不知道或旧资料不写。来源已知时开场由服务器写,模型那一版不用。不追问误差(不确定度只在资料里问一次)。
- **事件轮范围没动**:这一轮记下了事件、服务器重算过、又不是出结果的那一轮,范围前后完全一样时,正文后面接一句「这件事在现在的范围里每个时刻都对得上,分不出前后。」范围变了照旧写「范围从 … 变为 …」;没重新比较照旧写「这次没有重新比较……」。这句不邀请再补经历(BUG-1084)。
- 不做:逐层播报上升、月宿、大运变不变(BUG-606、BUG-1253);按精度限制能用哪些层(属于评分);追问离职是主动还是被动(引擎用不上);每轮写还缺什么、补哪类事能再收窄(BUG-1084)。
## 生时校正界面:收束卡与盘面(2026-10-28,issue #146、#147)
- 头部按钮「看盘面」;盘面标题「当前盘面」,不写时刻。
- 收束卡区分得开时:「最可能的出生时间」+ 时刻 + 「上升X」,下一行仍是「目前范围 …(对照了 N 件经历)」。最可能那列按钮「采用 HH:MM 并打开星盘」;其余列上方小字「还不能排除」,按钮「改用这个」。区分不开时不变(「更像这个」、「这几个时刻按现在的方法区分不开。」)。不写「把握」。
- 卡上一个文字按钮「我说错了,改一条」(不放「再补一件事」,BUG-1084);展开后一句「选一条要改的,输入框里会先写好开头:」,点一条后输入框写「我说错了:「事件」应该是」。
## 生时校正独立入口(2026-10-28,issue #145)
产品 2026-10-28 定:校正有自己的入口,不再从对话里开案例。下面这节「生时校正从对话进入」里的卡片文案和「明确要求则直接进入」由本节取代,其余(何时提议、做不了必须说一句、正文怎么提、回来)不变。
@@ -285,7 +297,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
## 星盘页人名与就地改资料(2026-10-21)
- 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 添加一个人」,去星盘档案。满 20 人不出现这一行。
- 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 新增一个人」,直接打开星盘档案的新增表单。满 20 人不出现这一行。
- (issue #150,2026-10-28)按钮上写「称呼 · 出生日期 时间 · 城市」;手机对话顶栏放不下,只写称呼。名单每行:名字 + 灰底标签(本人写「本人」,别人写关系,没填过写「其他」),下面一行「日期 时间 · 城市」。每行右边「⋯」:「编辑」去星盘档案打开这个人的编辑框;别人还有「删除」,确认框和星盘档案同一句「将同时删除 N 个对话和 M 份报告,无法恢复。」回答还没写完时删除项写「这段回答结束后再删」,点不了。本人没有删除。对话右边的盘面顶部也有这颗按钮,规则和对话顶栏一样。
- 星盘页人名旁边只写「日期 时间 · 地点」,末尾一支小笔。整行可点,就在这一页改,不跳走。本人每项都能改。别人已经填过的出生时间不能改,这句话只出现在时间旁边:「填报的出生时间保存后不能再改」。表单说明里不重复。星盘档案里编辑这样的人,也只在时间旁边写这一句。改了日期、时间或地点时写:「改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。」手机上这一行换到第二行。点编辑框内侧的空白边不会关掉框。
- 出生资料下面可以有一句上升会不会换。本人上升会换、或不知道出生时间时,「出生时间校正」是链接:已经有没做完的校正就回到那一次,没有才新开。别人的星盘只说到「(大约 HH:MM 换座)。」或「上升星座定不下来。」,不写「出生时间校正」,也不给这个链接。
- 报告里的星宿,中文名后面的英文名用浅色小字,和中文同一行。速度写到小数点后 4 位。行星位置这类数值表太宽时,在表自己的框里横滑,页面不被撑开。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里,格子里的字可以换行。
@@ -475,7 +488,10 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 列表没读出来 | 人物列表暂时读不出来 |
| 其他人暂时读不到 | 暂时读不到其他人 |
| 重试 | 重试 |
| 资料不完整 | 这份资料还不完整 |
| 资料不完整 | 这份资料还不完整,补全后才能看盘 |
| 每行更多 | 编辑 / 删除(本人只有编辑) |
| 回答还没结束时删除 | 这段回答结束后再删 |
| 名单底部 | 新增一个人 |
| 资料已删除 | 资料已删除 · {原来的名字} |
| 删除后不能再发 | 这份资料已经删除,这段对话只能看。 |
+16 -13
View File
@@ -1215,8 +1215,21 @@ export default function Home() {
},
};
// One picker, two places: the chat header and the top of the chart panel (issue #150).
const subjectPicker = activeSession?.sessionType === "consultation" || activeSession?.sessionType === "synastry_report" ? (
<ChatProfilePicker
session={activeSession}
library={chartLibrary}
status={chatProfileStatus}
requestInFlight={Boolean(pendingSessionId) || cancellationPending}
onBindEmpty={bindEmptyChatSubject}
onOpenNew={openChatBoundToProfile}
onReload={reloadChatProfiles}
/>
) : null;
return (
<ChatChartFrame>
<ChatChartFrame switcher={subjectPicker}>
<DailyStarlanguageBinder
accountId={accountId}
bootstrapPhase={bootstrapPhase}
@@ -1244,19 +1257,9 @@ export default function Home() {
</span>
<div className="chat-header-title">
<strong>{activeSession?.title || "新对话"}</strong>
{activeSession?.sessionType === "consultation" || activeSession?.sessionType === "synastry_report" ? (
<ChatProfilePicker
session={activeSession}
library={chartLibrary}
status={chatProfileStatus}
requestInFlight={Boolean(pendingSessionId) || cancellationPending}
onBindEmpty={bindEmptyChatSubject}
onOpenNew={openChatBoundToProfile}
onReload={reloadChatProfiles}
/>
) : activeSession ? (
{subjectPicker ?? (activeSession ? (
<span className="chat-header-chart">{sessionChartLabel(activeSession, chartLibrary)}</span>
) : null}
) : null)}
</div>
<div className="chat-header-actions">
<OnboardingChecklistSlot />
+110 -3
View File
@@ -1041,7 +1041,11 @@ button.shell-resize-handle:active { position: absolute; top: 0; right: -3px; z-i
inside `.chat-column`. Secondary pages stay direct children of `.chat-panel`. */
.chat-chart-frame { grid-row: 1 / -1; grid-column: 1; min-width: 0; min-height: 0; height: 100%; display: flex; flex-direction: row; }
.chat-chart-frame > .chat-column { flex: 1 1 auto; }
.chat-chart-split { position: relative; height: 100%; min-width: 0; min-height: 0; flex: 0 0 auto; border-left: 1px solid var(--color-border); }
.chat-chart-split { position: relative; height: 100%; min-width: 0; min-height: 0; flex: 0 0 auto; display: flex; flex-direction: column; border-left: 1px solid var(--color-border); }
/* Issue #150: the same picker as the chat header, full width above the drawing. */
.chat-chart-switcher { flex: 0 0 auto; min-width: 0; padding: var(--space-3) var(--space-3) 0; }
.chat-chart-switcher .chat-profile-picker-trigger { width: 100%; max-width: none; border-radius: var(--radius-md); }
.chat-chart-split .chat-natal-chart { flex: 1 1 auto; height: auto; }
/* Same 6px transparent strip as the sidebar edge, on the panel's left edge. */
button.chat-chart-resize-handle,
button.chat-chart-resize-handle:hover,
@@ -1078,6 +1082,10 @@ button.chat-chart-resize-handle:active { position: absolute; top: 0; left: -3px;
.chat-profile-picker-trigger { max-width: 46%; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; gap: 4px; margin: 0; padding: 0 10px 0 12px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-canvas-muted); color: var(--color-ink); cursor: pointer; font: inherit; font-size: var(--type-caption); line-height: 1.35; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, border-color 120ms ease-out; }
.chat-profile-picker-trigger:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; }
.chat-profile-picker-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Issue #150: 「· date time · city」 after the name; it gives way first when space runs out. */
.chat-profile-picker-detail { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-ink-secondary); }
/* A phone chat header has room for the name only; the list still shows the details. */
@media (max-width: 640px) { .chat-header .chat-profile-picker-detail { display: none; } }
.chat-profile-picker-chevron { width: 14px; height: 14px; flex: 0 0 auto; color: var(--color-ink-tertiary); }
.chat-profile-picker-add { margin-top: var(--space-2); padding-top: var(--space-2); border-top: 1px solid var(--color-border); }
.chat-profile-picker-add a { min-height: 44px; display: flex; align-items: center; padding: 0 var(--space-3); border-radius: var(--radius-md); color: var(--color-ink); font-size: var(--type-body-sm); text-decoration: none; }
@@ -1098,6 +1106,12 @@ button.chat-chart-resize-handle:active { position: absolute; top: 0; left: -3px;
.chat-profile-picker-copy { min-width: 0; display: grid; gap: 2px; }
.chat-profile-picker-copy b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: inherit; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; }
.chat-profile-picker-copy small { color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.35; }
.chat-profile-picker-row { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; gap: var(--space-1); }
.chat-profile-picker-heading { min-width: 0; display: flex; align-items: center; gap: 6px; }
.chat-profile-picker-badge { flex: 0 0 auto; padding: 1px 6px; border-radius: 999px; background: var(--color-canvas-muted); color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.35; }
.chat-profile-picker-more { width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-tertiary); cursor: pointer; }
.chat-profile-picker-more svg { width: 18px; height: 18px; }
.chat-profile-picker-more:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; }
.chat-profile-picker-check { width: 18px; height: 18px; opacity: 0; color: var(--color-action); }
.chat-profile-picker-option[data-selected] .chat-profile-picker-check { opacity: 1; }
.chat-header-actions { min-width: max-content; display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); white-space: nowrap; }
@@ -2190,6 +2204,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
.chat-profile-picker-trigger:hover { background: var(--color-canvas); color: var(--color-ink); border-color: var(--color-border-strong); }
.chat-profile-picker-add a:hover { background: var(--color-canvas-soft); }
.chat-profile-picker-more:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
.chat-profile-picker-option:not(:disabled):not([aria-disabled="true"]):hover { background: var(--color-canvas-soft); color: var(--color-ink); }
.button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); }
.danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); }
@@ -2827,14 +2842,26 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
min-height: 0;
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
grid-template-columns: minmax(0, 1fr);
background: var(--color-canvas);
}
/* Issue #146: the board is closed by default; 「看盘面」 in the header opens it
as a right column on wide screens. */
.rectification-workspace.is-board-open:not(.is-compact) {
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
}
/* No candidate yet: the board only carries the declared time, so it takes less
width until there is a house table to show. */
.rectification-workspace.is-board-empty {
.rectification-workspace.is-board-empty.is-board-open:not(.is-compact) {
grid-template-columns: minmax(0, 1fr) minmax(16rem, 18rem);
}
.rectification-workspace:not(.is-compact) .rectification-board {
animation: rectification-board-enter 280ms cubic-bezier(.22, 1, .36, 1) both;
}
@keyframes rectification-board-enter {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; }
}
.rectification-workspace.is-compact {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
@@ -7201,3 +7228,83 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
.starter-rectify-link:hover {
background: var(--color-canvas-muted);
}
/* Result card (issue #147). The most likely minute leads only when the card
says the columns can be told apart (D4); the range drops to the line under
it. Candidates that cannot be ruled out keep their own adopt button. */
.rectification-result-lead {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-1) var(--space-3);
}
.rectification-result-lead__label {
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
}
.rectification-result-lead__time {
color: var(--color-ink);
font-family: var(--font-display);
font-size: 30px;
font-weight: 500;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.rectification-result-lead__lagna {
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.rectification-range-delivery__range-line {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
font-variant-numeric: tabular-nums;
}
.rectification-range-delivery__others {
margin: 0 0 var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-canvas-muted);
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
}
.rectification-result-actions {
display: grid;
gap: var(--space-2);
}
.rectification-result-actions__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.rectification-result-actions__fix {
color: var(--color-action);
text-decoration: underline;
text-underline-offset: 3px;
}
.rectification-result-actions__list {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border-radius: var(--radius-lg);
background: var(--color-canvas-soft);
animation: rectification-result-list-enter 260ms cubic-bezier(.22, 1, .36, 1) both;
}
.rectification-result-actions__list p {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.rectification-result-actions__items {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.rectification-result-actions__items button {
white-space: normal;
text-align: start;
}
@keyframes rectification-result-list-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
+3 -2
View File
@@ -15,6 +15,7 @@ type SplitComponent = (props: {
open: boolean;
width: number;
subjectId: string;
switcher?: ReactNode;
onOpenChange: (open: boolean) => void;
onWidth: (width: number) => void;
}) => ReactNode;
@@ -70,7 +71,7 @@ export function ChatChartToggle() {
* starts open (issue #114 Q8 A). Phones load the bottom sheet instead, the
* first time it is asked for.
*/
export function ChatChartFrame({ children }: { children: ReactNode }) {
export function ChatChartFrame({ children, switcher }: { children: ReactNode; switcher?: ReactNode }) {
const { isMobile, ready } = useSidebar();
const subjectId = useCurrentSubject();
const open = useUiStore((state) => state.chatChartOpen);
@@ -117,7 +118,7 @@ export function ChatChartFrame({ children }: { children: ReactNode }) {
<div className="chat-chart-frame">
{column}
{Split && desktop ? (
<Split open={open} width={width} subjectId={subjectId} onOpenChange={setOpen} onWidth={setWidth} />
<Split open={open} width={width} subjectId={subjectId} switcher={switcher} onOpenChange={setOpen} onWidth={setWidth} />
) : null}
{Sheet && phone ? <Sheet open={sheetOpen} onOpenChange={setSheetOpen} /> : null}
</div>
+9 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { memo } from "react";
import { memo, type ReactNode } from "react";
import { ChatNatalChart } from "@/components/chat-natal-chart";
import { Button } from "@/components/ui/button";
@@ -12,17 +12,23 @@ import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "@/lib/chat-chart-pan
* never remounts the chat (BUG-1448). Its left edge drags or takes the arrow
* keys; dragging below the minimum closes the panel (BUG-1456). Memoized: its
* props are store values, so a Home render alone never redraws the chart (issue #123).
* `switcher` (issue #150) is a fresh element on every Home render, so the drawing
* sits behind its own memo and only the switcher repaints.
*/
const StableNatalChart = memo(ChatNatalChart);
export const ChatChartSplit = memo(function ChatChartSplit({
open,
width,
subjectId,
switcher,
onOpenChange,
onWidth,
}: {
open: boolean;
width: number;
subjectId: string;
switcher?: ReactNode;
onOpenChange: (open: boolean) => void;
onWidth: (width: number) => void;
}) {
@@ -45,7 +51,8 @@ export const ChatChartSplit = memo(function ChatChartSplit({
aria-label="调整本命盘宽度"
{...resize}
/>
<ChatNatalChart subjectId={subjectId} />
{switcher ? <div className="chat-chart-switcher">{switcher}</div> : null}
<StableNatalChart subjectId={subjectId} />
</aside>
);
});
+13 -9
View File
@@ -162,18 +162,22 @@ export function PeoplePage() {
setPeople(nextPeople);
const url = new URL(window.location.href);
const add = url.searchParams.get("add") === "1";
const openSelf = url.searchParams.get("self") === "1";
if (add || openSelf) {
// `?edit=<id>` comes from the switcher's 编辑 (issue #150); an id that is gone opens nothing.
const editId = url.searchParams.get("self") === "1" ? "self" : url.searchParams.get("edit");
const editRow = editId ? nextPeople.find((person) => person.id === editId) : undefined;
if (add || editId) {
url.searchParams.delete("add");
url.searchParams.delete("self");
url.searchParams.delete("edit");
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
if (openSelf) {
const selfRow = nextPeople.find((person) => person.role === "self");
setSelectedId("self");
setDraft(selfRow?.profile ?? emptyProfile);
setEditing(true);
setPendingDelete(null);
setView("detail");
if (editId) {
if (editRow) {
setSelectedId(editRow.id);
setDraft(editRow.profile);
setEditing(true);
setPendingDelete(null);
setView("detail");
}
} else if (nextPeople.filter((person) => person.role === "other").length >= MAX_OTHER_SUBJECTS) {
notifyInfo(LIMIT_COPY);
} else {
@@ -7,6 +7,8 @@ import { createPortal } from "react-dom";
import type { ChatMessage } from "@/lib/chat-message-view";
import { canShowRectificationSelectionCards } from "@/lib/rectification-candidate-result";
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import { useOpenChartAfterAdopt } from "@/hooks/use-open-chart-after-adopt";
import { fixEvidenceDraft } from "@/lib/rectification-fix-evidence";
import { livingDisplayFromReceipt } from "@/lib/rectification-agentic/core/candidate-window";
import {
RECTIFICATION_EMPTY_ACTION_LABEL,
@@ -170,7 +172,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
currentQuestionRef,
applyCaseSnapshot,
loadCaseSnapshot,
fixableEvidence,
} = useRectificationCaseSnapshot({ caseId, sessionId, initialSnapshot });
const { arm: armChartAfterAdopt, onSaved: savedThenChart } = useOpenChartAfterAdopt(onSaved);
const readonly = historyReadonly || Boolean(resultNotice);
const conversationReadonly = historyReadonly || (Boolean(resultNotice) && !canRecompare);
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
@@ -365,30 +369,33 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void send("opening", "");
}, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]);
const acceptCandidate = useCallback(async (candidateId: string) => runRectificationCandidateAccept({
caseId,
sessionId,
busy,
readonly,
candidateResult,
acceptingCandidateId,
keyCounter,
runAbort,
lastRunOutcome,
conversationAnchor,
setError,
setMessages,
setAcceptingCandidateId,
setCandidateResult,
setSavedTime,
setSavedStatus,
setPending,
beginLiveRun,
loadCaseSnapshot,
send,
onCompleted,
onSaved,
}, candidateId), [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
const acceptCandidate = useCallback(async (candidateId: string, openChart = false) => {
armChartAfterAdopt(openChart);
return runRectificationCandidateAccept({
caseId,
sessionId,
busy,
readonly,
candidateResult,
acceptingCandidateId,
keyCounter,
runAbort,
lastRunOutcome,
conversationAnchor,
setError,
setMessages,
setAcceptingCandidateId,
setCandidateResult,
setSavedTime,
setSavedStatus,
setPending,
beginLiveRun,
loadCaseSnapshot,
send,
onCompleted,
onSaved: savedThenChart,
}, candidateId);
}, [acceptingCandidateId, armChartAfterAdopt, savedThenChart, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, readonly, send, sessionId, setPending]);
async function copyMessage(message: RenderMessage) {
await copyRectificationMessage({ setCopiedMessageKey }, message);
@@ -533,7 +540,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
};
});
const boardPeek = compactBoard && !boardOpen ? (
// Issue #146: 「看盘面」 is in the header at every width. On a phone the open
// sheet covers it; on a desktop it stays as the toggle beside the open board.
const boardPeek = !compactBoard || !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
declaredTime={declaredTime}
@@ -598,8 +607,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
result={candidateResult}
acceptingCandidateId={acceptingCandidateId}
readonly={readonly || (busy && !acceptingCandidateId)}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
onAccept={(candidateId, openChart) => void acceptCandidate(candidateId, openChart)}
adoptLocked={liveInterviewBlockingAdopt}
fixableEvidence={fixableEvidence}
onFixEvidence={(line) => {
setDraft(fixEvidenceDraft(line));
composer.current?.focus({ preventScroll: true });
}}
/>
{verifiedIdleCopy && (
<p className="rectification-pending-note" role="status">
+10 -10
View File
@@ -94,12 +94,9 @@ function RectificationBoardBody({
<header className="rectification-board__header">
<div className="rectification-board__title-row">
<h2 id={titleId}>当前盘面</h2>
{workingTime
? <span className="rectification-board__clock">{workingTime}</span>
: emptyCopy.clock
? <span className="rectification-board__clock is-declared">{emptyCopy.clock}</span>
: null}
{compact && (
{/* Issue #146: the working minute is written once, on the timeline
above the conversation. The board no longer repeats it. */}
{(
<Button
className="rectification-board__close"
type="button"
@@ -288,7 +285,8 @@ export function RectificationBoard({
return () => window.removeEventListener("keydown", handleKey);
}, [compact, open, onClose]);
if (compact && !open) return null;
// Issue #146: closed by default at every width; 「看盘面」 opens it.
if (!open) return null;
const sheet = (
<aside
@@ -323,6 +321,9 @@ export function RectificationBoard({
);
}
/** Issue #146: the header button says what it does; the time lives on the timeline. */
export const RECTIFICATION_BOARD_OPEN_LABEL = "看盘面";
export function RectificationBoardPeek({
result,
declaredTime = null,
@@ -344,13 +345,12 @@ export function RectificationBoardPeek({
className="rectification-board-peek"
type="button"
variant="ghost"
aria-label={peek.detail ? `${peek.title},${peek.detail}` : peek.title}
aria-label={peek.detail ? `${RECTIFICATION_BOARD_OPEN_LABEL},${peek.detail}` : RECTIFICATION_BOARD_OPEN_LABEL}
aria-expanded={expanded}
aria-controls={boardId}
onClick={onOpen}
>
<strong>{peek.title}</strong>
<span>{peek.detail}</span>
<strong>{RECTIFICATION_BOARD_OPEN_LABEL}</strong>
</Button>
);
}
@@ -18,6 +18,12 @@ import { RectificationVerificationReport } from "@/components/rectification-veri
import { RectificationSegmentDelivery } from "@/components/rectification-segment-delivery";
import { sessionOutcomeAllowsAdopt } from "@/lib/rectification-agentic/core/rectification-decision";
import { Button } from "@/components/ui/button";
import { RectificationResultActions, RectificationResultLead } from "@/components/rectification-result-parts";
import {
RECTIFICATION_OTHER_CANDIDATES_LABEL,
RECTIFICATION_SWITCH_TO_THIS_LABEL,
adoptAndOpenChartLabel,
} from "@/lib/rectification-fix-evidence";
import { deliveryWindowFromReceipt, protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
@@ -31,12 +37,17 @@ export function RectificationRangeDelivery({
readonly,
onAccept,
adoptLocked,
fixableEvidence = [],
onFixEvidence,
}: Readonly<{
result: RectificationCandidateResult;
acceptingCandidateId: string | null;
readonly: boolean;
onAccept: (candidateId: string) => void;
/** `openChart`: the most likely column's 「采用 HH:MM 并打开星盘」 (issue #147). */
onAccept: (candidateId: string, openChart?: boolean) => void;
adoptLocked?: boolean;
fixableEvidence?: readonly string[];
onFixEvidence?: (line: string) => void;
}>) {
const delivery: RangeDeliveryProjection | null = result.rangeDelivery;
const range = delivery?.range ?? result.credibleRange;
@@ -71,24 +82,48 @@ export function RectificationRangeDelivery({
// D4: per-column numbers only when the first column leads by ≥ 5 points.
const showPercents = rangeDeliveryShowsPercents(columns);
const indistinct = columns.length >= 2 && !showPercents;
// Issue #147: lead with the most likely minute only when D4 says the columns
// can be told apart; otherwise the range stays the headline (D3).
const primaryColumn = mostLikely && !indistinct
? columns.find((column) => column.candidate_id === delivery?.representative_candidate_id)
?? columns.find((column) => column.time === mostLikely)
?? null
: null;
const primaryLagna = primaryColumn
? result.houseTablesByTime[primaryColumn.time]?.lagna
?? (result.houseTable?.time === primaryColumn.time ? result.houseTable.lagna : null)
?? null
: null;
const orderedColumns = primaryColumn
? [primaryColumn, ...columns.filter((column) => column !== primaryColumn)]
: columns;
const showActions = adoptAllowed && !adoptLocked && !readonly;
if (delivery && (delivery.product_delivery || delivery.segment_summary)) {
return <RectificationSegmentDelivery summary={delivery.segment_summary ?? null} delivery={delivery} rangeLabel={rangeLabel}
selection={result.segmentSelection} resultId={result.resultId} busy={Boolean(busy)} accepting={Boolean(acceptingCandidateId)} onAccept={onAccept}
adoptLocked={adoptLocked} markdown={markdown} canAdopt={adoptAllowed}
candidateDate={result.candidates.find((candidate) => candidate.candidateId === delivery.representative_candidate_id)?.candidate_date} />;
candidateDate={result.candidates.find((candidate) => candidate.candidateId === delivery.representative_candidate_id)?.candidate_date}
fixableEvidence={fixableEvidence} onFixEvidence={onFixEvidence} readonly={readonly} />;
}
return (
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正区间">
<div className="rectification-candidates-heading">
<strong>{title}</strong>
{mostLikely && !indistinct ? (
<span className="rectification-range-delivery__most-likely">
{rangeDeliveryMostLikely(mostLikely)}
</span>
) : null}
</div>
{primaryColumn ? (
<>
<RectificationResultLead time={primaryColumn.time} lagna={primaryLagna} />
<p className="rectification-range-delivery__range-line">{title}</p>
</>
) : (
<div className="rectification-candidates-heading">
<strong>{title}</strong>
{mostLikely && !indistinct ? (
<span className="rectification-range-delivery__most-likely">
{rangeDeliveryMostLikely(mostLikely)}
</span>
) : null}
</div>
)}
{vargaBlocked ? (
<p className="rectification-range-delivery__narrow">{windowMinutes === null ? "完整窗口范围暂不可用;暂不推荐分盘解读。" : "分盘解读需要更准的出生时间"}</p>
) : caption ? (
@@ -118,7 +153,8 @@ export function RectificationRangeDelivery({
</ul>
) : null}
<ul className="rectification-range-delivery__columns">
{columns.map((column) => {
{orderedColumns.map((column, index) => {
const isPrimary = column === primaryColumn;
const candidateDate = result.candidates.find((candidate) => candidate.candidateId === column.candidate_id)?.candidate_date;
const relativeDay = candidateDate && result.declaredBirthDate && candidateDate !== result.declaredBirthDate
? candidateDate < result.declaredBirthDate ? "(前一天)" : "(后一天)" : "";
@@ -128,8 +164,11 @@ export function RectificationRangeDelivery({
return (
<li
key={column.candidate_id}
className={`rectification-range-delivery__column${adopted ? " is-adopted" : ""}`}
className={`rectification-range-delivery__column${adopted ? " is-adopted" : ""}${isPrimary ? " is-primary" : ""}`}
>
{primaryColumn && index === 1 ? (
<p className="rectification-range-delivery__others">{RECTIFICATION_OTHER_CANDIDATES_LABEL}</p>
) : null}
<div className="rectification-range-delivery__time">{candidateDate ? `${candidateDate}${relativeDay} ${column.time}` : column.time}</div>
{adoptAllowed && !adoptLocked && candidateDate ? <p className="rectification-range-delivery__muted">采用后,排盘使用这一日期和时间;原填报日期保留。</p> : null}
{showPercents ? (
@@ -162,11 +201,11 @@ export function RectificationRangeDelivery({
<>
<Button
type="button"
variant="outline"
variant={isPrimary ? "default" : "outline"}
className="rectification-range-delivery__accept"
aria-pressed={adopted}
disabled={busy || adopted}
onClick={() => onAccept(column.candidate_id)}
onClick={() => onAccept(column.candidate_id, isPrimary && !recordConflict)}
>
{adopting
? RECTIFICATION_USER_COPY.rangeDeliveryAdopting
@@ -174,7 +213,11 @@ export function RectificationRangeDelivery({
? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
: recordConflict
? SWITCH_TO_RECTIFIED_LABEL
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
: isPrimary
? adoptAndOpenChartLabel(column.time)
: primaryColumn
? RECTIFICATION_SWITCH_TO_THIS_LABEL
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
</Button>
{recordConflict && !adopted ? (
<p className="rectification-range-delivery__switch-note">
@@ -189,6 +232,13 @@ export function RectificationRangeDelivery({
);
})}
</ul>
{showActions ? (
<RectificationResultActions
evidence={fixableEvidence}
disabled={Boolean(busy)}
onFixEvidence={onFixEvidence}
/>
) : null}
{delivery?.more_label ? (
<p className="rectification-range-delivery__more">{delivery.more_label}</p>
) : null}
@@ -0,0 +1,80 @@
"use client";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { RECTIFICATION_FIX_COPY, RECTIFICATION_MOST_LIKELY_LABEL } from "@/lib/rectification-fix-evidence";
/**
* Issue #147: the result card leads with the most likely minute when the
* server says the candidates can be told apart (D4). The card decides that;
* this only draws it.
*/
export function RectificationResultLead({ time, lagna }: Readonly<{ time: string; lagna: string | null }>) {
return (
<div className="rectification-result-lead">
<span className="rectification-result-lead__label">{RECTIFICATION_MOST_LIKELY_LABEL}</span>
<strong className="rectification-result-lead__time">{time}</strong>
{lagna ? <span className="rectification-result-lead__lagna">上升{lagna}</span> : null}
</div>
);
}
/**
* 「我说错了,改一条」 lists the confirmed events and fills the composer with
* the chosen one. Nothing is sent here. There is deliberately no 「再补一件事」:
* typed events barely narrow the range after the training gate, so the card
* must not invite more (BUG-1084, issue #148 decision 2026-10-28).
*/
export function RectificationResultActions({
evidence,
disabled,
onFixEvidence,
}: Readonly<{
evidence: readonly string[];
disabled: boolean;
onFixEvidence?: (line: string) => void;
}>) {
const [fixOpen, setFixOpen] = useState(false);
const listId = useId();
if (!onFixEvidence || evidence.length === 0) return null;
return (
<div className="rectification-result-actions">
<div className="rectification-result-actions__row">
<Button
type="button"
variant="link"
className="rectification-result-actions__fix"
disabled={disabled}
aria-expanded={fixOpen}
aria-controls={listId}
onClick={() => setFixOpen((open) => !open)}
>
{RECTIFICATION_FIX_COPY.fixOne}
</Button>
</div>
{fixOpen ? (
<div id={listId} className="rectification-result-actions__list" role="group" aria-label={RECTIFICATION_FIX_COPY.fixOne}>
<p>{RECTIFICATION_FIX_COPY.pickHint}</p>
<div className="rectification-result-actions__items">
{evidence.map((line) => (
<Button
key={line}
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() => {
setFixOpen(false);
onFixEvidence(line);
}}
>
{line}
</Button>
))}
</div>
</div>
) : null}
</div>
);
}
@@ -8,8 +8,10 @@ import { RectificationVerificationReport } from "@/components/rectification-veri
import { protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
import { unknownProductDeliveryCopy } from "@/lib/rectification-agentic/v9/product-delivery";
import { Button } from "@/components/ui/button";
import { RectificationResultActions } from "@/components/rectification-result-parts";
export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, selection, resultId, busy, accepting, onAccept, adoptLocked, markdown, candidateDate, canAdopt = false }: Readonly<{
export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, selection, resultId, busy, accepting, onAccept, adoptLocked, markdown, candidateDate, canAdopt = false,
fixableEvidence = [], onFixEvidence, readonly = false }: Readonly<{
summary: SegmentSummary | null;
delivery: RangeDeliveryProjection;
rangeLabel: string | null;
@@ -17,11 +19,15 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
resultId: string;
busy: boolean;
accepting: boolean;
onAccept: (candidateId: string) => void;
/** `openChart`: 「采用 HH:MM 并打开星盘」 (issue #147). */
onAccept: (candidateId: string, openChart?: boolean) => void;
adoptLocked?: boolean;
markdown?: string | null;
candidateDate?: string;
canAdopt?: boolean;
fixableEvidence?: readonly string[];
onFixEvidence?: (line: string) => void;
readonly?: boolean;
}>) {
const adoption = summary?.adoption_minute;
const candidateId = delivery.representative_candidate_id;
@@ -36,6 +42,10 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
const blocked = summary?.charts.some((chart) => chart.tier === "blocked") === true;
const reportBlocked = !summary || blocked;
const showAdopt = Boolean(canAdopt && adoption && candidateId && !adoptLocked);
// Issue #147: no minute lead here. The segment product answers "which chart",
// never "which minute" (BUG-1151, product 10-01 option A). A plain adopt still
// opens the chart afterwards; re-adopt and record-conflict adopts do not.
const plainAdopt = !(readopt && priorAdoption) && !delivery.record_conflict;
return (
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正盘型">
<div className="rectification-candidates-heading"><strong>用哪张盘解读</strong></div>
@@ -58,8 +68,8 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
{showAdopt && adoption && candidateId ? (
<>
<p className="rectification-range-delivery__muted">采用后按 {adoption.date ? `${adoption.date} ` : ""}{adoption.time} 排盘;原填报资料保留。</p>
<Button type="button" variant="outline" className="rectification-range-delivery__accept" disabled={busy || adopted}
aria-pressed={adopted} onClick={() => onAccept(candidateId)}>
<Button type="button" variant={plainAdopt ? "default" : "outline"} className="rectification-range-delivery__accept" disabled={busy || adopted}
aria-pressed={adopted} onClick={() => onAccept(candidateId, plainAdopt)}>
{accepting ? RECTIFICATION_USER_COPY.rangeDeliveryAdopting : adopted ? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
: readopt && priorAdoption ? `改用 ${readopt}`
: delivery.record_conflict ? SWITCH_TO_RECTIFIED_LABEL : `改用 ${charts.map((chart) => chart.chart).join(" + ")} 盘解读`}
@@ -76,6 +86,9 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
) : adopted ? (
<p className="rectification-range-delivery__muted">{RECTIFICATION_USER_COPY.rangeDeliveryAdopted}</p>
) : null}
{showAdopt && !readonly ? (
<RectificationResultActions evidence={fixableEvidence} disabled={busy} onFixEvidence={onFixEvidence} />
) : null}
<p className="rectification-range-delivery__boundary">{delivery.boundary || REPRESENTATIVE_MINUTE_DISCLAIMER}</p>
<RectificationVerificationReport markdown={protectVargaReport(markdown === undefined ? delivery.verification_markdown : markdown, reportBlocked, delivery.product_delivery?.targets ?? summary?.targets,
!summary ? "完整窗口扫描暂不可用;不能提供分盘解读推荐。" : undefined)} />
+150 -32
View File
@@ -1,10 +1,34 @@
"use client";
import { Check, ChevronDown, Plus } from "lucide-react";
import { Check, ChevronDown, MoreHorizontal, Plus } from "lucide-react";
import { useId, useRef, useState } from "react";
import { AppLink } from "@/components/app-link";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { navigateInApp } from "@/lib/app-navigation";
import { invalidateSubjectCatalog, readCurrentSubjectId, setCurrentSubject } from "@/lib/current-subject";
import { notifyError } from "@/lib/notify";
import {
PERSON_ADD_HREF,
PERSON_DELETE_COPY,
deletePerson,
fetchPersonDeleteUsage,
personDeleteDescription,
personEditHref,
type PersonDeleteUsage,
} from "@/lib/person-delete";
import {
chatProfilePickerRows,
type ChatProfileCatalogStatus,
@@ -39,11 +63,15 @@ export function SubjectSwitcher({
}: SubjectSwitcherProps) {
const [open, setOpen] = useState(false);
const [pendingId, setPendingId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<{ row: ChatProfilePickerRow; usage: PersonDeleteUsage } | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const listId = useId();
const rows = chatProfilePickerRows({ status, library, selectedId }).slice(0, MAX_SAVED_SUBJECTS);
// Issue #86 said “5 people”. The live archive cap is 20 (`MAX_SAVED_SUBJECTS`).
const showAddPerson = (status === "ready" || status === "degraded") && library.length < MAX_SAVED_SUBJECTS;
// Issue #150: the trigger says whose chart, when and where, not only the name.
const selectedRow = rows.find((row) => row.selected && row.complete);
const triggerDetail = selectedRow?.detail ?? "";
function closePicker() {
setOpen(false);
@@ -63,7 +91,38 @@ export function SubjectSwitcher({
}
}
function edit(row: ChatProfilePickerRow) {
setOpen(false);
navigateInApp(personEditHref(row.id));
}
async function askDelete(row: ChatProfilePickerRow) {
if (row.role === "self" || requestInFlight) return;
const usage = await fetchPersonDeleteUsage(row.id);
if (!usage) {
notifyError(PERSON_DELETE_COPY.usageUnavailable);
return;
}
setOpen(false);
setPendingDelete({ row, usage });
}
async function confirmDelete() {
const target = pendingDelete?.row;
setPendingDelete(null);
if (!target) return;
if (!(await deletePerson(target.id))) {
notifyError(PERSON_DELETE_COPY.failed);
return;
}
// Same order as the people page: fall back to 本人 first, then reread the list.
if (readCurrentSubjectId() === target.id) setCurrentSubject("self");
invalidateSubjectCatalog();
onReload();
}
return (
<>
<Popover
open={open}
onOpenChange={(next) => {
@@ -77,9 +136,10 @@ export function SubjectSwitcher({
type="button"
aria-expanded={open}
aria-controls={listId}
aria-label={`当前星盘:${label}`}
aria-label={`当前星盘:${label}${triggerDetail ? `,${triggerDetail}` : ""}`}
>
<span className="chat-profile-picker-name">{label}</span>
{triggerDetail ? <span className="chat-profile-picker-detail">· {triggerDetail}</span> : null}
<ChevronDown className="chat-profile-picker-chevron" aria-hidden="true" />
</PopoverTrigger>
<PopoverContent
@@ -116,17 +176,75 @@ export function SubjectSwitcher({
requestInFlight={requestInFlight}
pending={pendingId === row.id}
onChoose={() => void choose(row)}
onEdit={() => edit(row)}
onDelete={() => void askDelete(row)}
/>
))}
</div>
) : null}
{showAddPerson ? (
<div className="chat-profile-picker-add">
<a href="/people"><Plus aria-hidden="true" />添加一个人</a>
<AppLink href={PERSON_ADD_HREF} onClick={() => setOpen(false)}><Plus aria-hidden="true" />新增一个人</AppLink>
</div>
) : null}
</PopoverContent>
</Popover>
{pendingDelete ? (
<AlertDialog open onOpenChange={(next) => { if (!next) setPendingDelete(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{PERSON_DELETE_COPY.title}</AlertDialogTitle>
<AlertDialogDescription>{personDeleteDescription(pendingDelete.usage)}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{PERSON_DELETE_COPY.cancel}</AlertDialogCancel>
<AlertDialogAction className="danger-primary" onClick={() => void confirmDelete()}>{PERSON_DELETE_COPY.confirm}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</>
);
}
function RowMenu({
row,
requestInFlight,
onEdit,
onDelete,
}: {
readonly row: ChatProfilePickerRow;
readonly requestInFlight: boolean;
readonly onEdit: () => void;
readonly onDelete: () => void;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger className="chat-profile-picker-more" aria-label={`${row.name} 更多操作`}>
<MoreHorizontal aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>编辑</DropdownMenuItem>
{/* 本人 is never deleted; nobody is while an answer is still being written. */}
{row.role === "other" ? (
<DropdownMenuItem disabled={requestInFlight} onClick={onDelete}>
{requestInFlight ? "这段回答结束后再删" : "删除"}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
function RowCopy({ row, detail }: { readonly row: ChatProfilePickerRow; readonly detail?: string }) {
return (
<span className="chat-profile-picker-copy">
<span className="chat-profile-picker-heading">
<b>{row.name}</b>
<span className="chat-profile-picker-badge">{row.badge}</span>
</span>
<small>{detail ?? row.detail}</small>
</span>
);
}
@@ -136,26 +254,26 @@ function ProfileRow({
requestInFlight,
pending,
onChoose,
onEdit,
onDelete,
}: {
readonly row: ChatProfilePickerRow;
readonly locked: boolean;
readonly requestInFlight: boolean;
readonly pending: boolean;
readonly onChoose: () => void;
readonly onEdit: () => void;
readonly onDelete: () => void;
}) {
let main;
if (!row.complete) {
return (
main = (
<div className="chat-profile-picker-option" aria-disabled="true">
<span className="chat-profile-picker-copy">
<b>{row.name}</b>
<small>{row.detail}</small>
</span>
<RowCopy row={row} />
</div>
);
}
if (locked && !row.selected) {
return (
} else if (locked && !row.selected) {
main = (
<Button
className="chat-profile-picker-option"
type="button"
@@ -164,29 +282,29 @@ function ProfileRow({
aria-label={`${row.name},新建会话并使用此人物`}
onClick={onChoose}
>
<span className="chat-profile-picker-copy">
<b>{row.name}</b>
<small>{requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"}</small>
</span>
<RowCopy row={row} detail={requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"} />
</Button>
);
} else {
main = (
<Button
className="chat-profile-picker-option"
type="button"
variant="ghost"
aria-pressed={row.selected}
data-selected={row.selected ? "" : undefined}
disabled={pending}
onClick={onChoose}
>
<RowCopy row={row} />
<Check className="chat-profile-picker-check" aria-hidden="true" />
</Button>
);
}
return (
<Button
className="chat-profile-picker-option"
type="button"
variant="ghost"
aria-pressed={row.selected}
data-selected={row.selected ? "" : undefined}
disabled={pending}
onClick={onChoose}
>
<span className="chat-profile-picker-copy">
<b>{row.name}</b>
<small>{row.detail}</small>
</span>
<Check className="chat-profile-picker-check" aria-hidden="true" />
</Button>
<div className="chat-profile-picker-row">
{main}
<RowMenu row={row} requestInFlight={requestInFlight} onEdit={onEdit} onDelete={onDelete} />
</div>
);
}
@@ -0,0 +1,26 @@
"use client";
import { useCallback, useRef } from "react";
import { navigateInApp } from "@/lib/app-navigation";
/**
* 「采用 HH:MM 并打开星盘」 (issue #147). The adopt itself is the existing
* accept run; this only goes to /chart once the server has saved the time.
* Armed per click, so a failed adopt or a plain 「改用这个」 never navigates.
*/
export function useOpenChartAfterAdopt(
onSaved: ((time: string, status: "accepted" | "confirmed") => void) | undefined,
) {
const openAfterSave = useRef(false);
const arm = useCallback((open: boolean) => {
openAfterSave.current = open;
}, []);
const saved = useCallback((time: string, status: "accepted" | "confirmed") => {
onSaved?.(time, status);
if (!openAfterSave.current) return;
openAfterSave.current = false;
navigateInApp("/chart");
}, [onSaved]);
return { arm, onSaved: saved };
}
@@ -25,6 +25,7 @@ import {
type CurrentQuestionModel,
} from "@/lib/rectification-chat-snapshot";
import type { CandidateResult } from "@/lib/rectification-chat-snapshot";
import { fixableEvidenceLines } from "@/lib/rectification-fix-evidence";
/**
* Case snapshot sync for the rectification chat surface: the state that the
@@ -63,6 +64,8 @@ export function useRectificationCaseSnapshot({
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
const [questionRepairing, setQuestionRepairing] = useState(false);
const [snapshotFailure, setSnapshotFailure] = useState<RectificationSnapshotFailure | null>(null);
// Issue #147: the confirmed events 「我说错了,改一条」 lists.
const [fixableEvidence, setFixableEvidence] = useState<readonly string[]>(() => fixableEvidenceLines(initialSnapshot?.evidence));
const snapshotAbort = useRef<AbortController | null>(null);
const currentQuestionRef = useRef(currentQuestion);
@@ -93,6 +96,7 @@ export function useRectificationCaseSnapshot({
const snapshot = payload as RectificationCaseSnapshotPayload;
setResultNotice(snapshot.case?.result_notice ?? null);
setCanRecompare(snapshot.case?.result_identity?.can_recompare === true);
if (Array.isArray(snapshot.evidence)) setFixableEvidence(fixableEvidenceLines(snapshot.evidence));
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
@@ -174,6 +178,7 @@ export function useRectificationCaseSnapshot({
}, [applyCaseSnapshot, caseId, sessionId]);
return {
fixableEvidence,
resultNotice,
canRecompare,
savedTime,
+19 -6
View File
@@ -1,7 +1,8 @@
import { readCurrentSubjectId } from "@/lib/current-subject";
import { isConsultationSubjectProfileComplete } from "@/lib/consultation-subject-resolver";
import { sessionChartLabel } from "@/lib/home-profile";
import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types";
import { selectedBirthPlace, sessionChartLabel } from "@/lib/home-profile";
import type { ChartLibraryRecord, ChatSession, Profile } from "@/lib/home-types";
import { personRelationLabel } from "@/lib/person-relation";
export type ChatProfileCatalogStatus = "idle" | "loading" | "ready" | "failed" | "degraded";
@@ -19,6 +20,9 @@ export type ChatProfilePickerRow = {
readonly id: string;
readonly role: "self" | "other";
readonly name: string;
/** 本人, or the relation label (empty shows 其他). Issue #150. */
readonly badge: string;
/** Complete: 「date time · city」. Incomplete: why it can't be picked. */
readonly detail: string;
readonly complete: boolean;
readonly selected: boolean;
@@ -98,6 +102,15 @@ export function chatSubjectSendBlocked(input: {
return !input.library.some((record) => record.id === id && record.role === "other");
}
export const INCOMPLETE_ROW_DETAIL = "这份资料还不完整,补全后才能看盘";
/** 「1990-03-12 07:30 · 杭州」; parts that are missing are left out. */
export function subjectBirthLine(profile: Profile): string {
const when = [profile.date.trim(), profile.time.trim()].filter(Boolean).join(" ");
const place = selectedBirthPlace(profile)?.label.trim() ?? "";
return [when, place].filter(Boolean).join(" · ");
}
function rowName(record: ChartLibraryRecord): string {
return record.profile.name.trim() || (record.role === "self" ? "我" : "未命名资料");
}
@@ -117,7 +130,8 @@ export function chatProfilePickerRows(input: {
id: "self",
role: "self",
name: "我",
detail: "这份资料还不完整",
badge: "本人",
detail: INCOMPLETE_ROW_DETAIL,
complete: false,
selected: input.selectedId === "self",
});
@@ -134,9 +148,8 @@ function toRow(record: ChartLibraryRecord, selectedId: string): ChatProfilePicke
id,
role: record.role,
name: rowName(record),
detail: complete
? (record.role === "self" ? "本人" : (record.profile.date || "其他人"))
: "这份资料还不完整",
badge: record.role === "self" ? "本人" : personRelationLabel(record.profile.relation),
detail: complete ? subjectBirthLine(record.profile) : INCOMPLETE_ROW_DETAIL,
complete,
selected: id === selectedId,
};
+54
View File
@@ -0,0 +1,54 @@
/**
* Deleting someone from outside the people page (issue #150, the switcher's 删除).
* Same reads, writes and words as the people page's own delete: count first, then
* confirm with the count, then DELETE. 本人 is never deletable.
*/
export type PersonDeleteUsage = { readonly sessions: number; readonly reports: number; readonly synastry?: number };
export const PERSON_DELETE_COPY = {
title: "确认删除",
usageUnavailable: "暂时查不到这个人的对话数量,稍后再试",
failed: "删除失败,请重试",
cancel: "取消",
confirm: "确认删除",
} as const;
export function personDeleteDescription(usage: PersonDeleteUsage): string {
const synastry = usage.synastry ? `和 ${usage.synastry} 份合盘` : "";
return `将同时删除 ${usage.sessions} 个对话和 ${usage.reports} 份报告${synastry},无法恢复。`;
}
function countOk(value: unknown): value is number {
return Number.isInteger(value) && (value as number) >= 0;
}
/** Null when the count can't be read; the caller says so and deletes nothing. */
export async function fetchPersonDeleteUsage(id: string): Promise<PersonDeleteUsage | null> {
if (!id || id === "self") return null;
try {
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(id)}`, { cache: "no-store" });
const payload = await response.json().catch(() => null) as { usage?: PersonDeleteUsage } | null;
const usage = payload?.usage;
if (!response.ok || !usage || !countOk(usage.sessions) || !countOk(usage.reports)) return null;
return usage;
} catch {
return null;
}
}
export async function deletePerson(id: string): Promise<boolean> {
if (!id || id === "self") return false;
try {
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(id)}`, { method: "DELETE" });
return response.ok;
} catch {
return false;
}
}
export function personEditHref(id: string): string {
return id === "self" ? "/people?self=1" : `/people?edit=${encodeURIComponent(id)}`;
}
export const PERSON_ADD_HREF = "/people?add=1";
@@ -119,12 +119,16 @@ export function openingSpokenBody(
range: readonly [string, string] | null,
productDelivery?: ProductDelivery | null,
facts?: OpeningBoundaryFacts | null,
source?: string | null,
): string {
const window = formatClockRange(range);
// Issue #148: the window sentence also says where the time came from. Still
// one sentence, so the opening stays three plain layers.
const from = window ? openingSourcePhrase(source) : null;
const head = productDelivery
? `我们先判断现在的出生时间能不能选定用来解读的星盘${window ? `,核对窗口是 ${window}` : ""}。`
? `我们先判断现在的出生时间能不能选定用来解读的星盘${window ? `,${from ? `${from},` : ""}核对窗口是 ${window}` : ""}。`
: window
? `我们来把你的出生时间缩小到更准的范围,现在先在 ${window} 之间找。`
? `我们来把你的出生时间缩小到更准的范围,${from ? `${from},` : ""}现在先在 ${window} 之间找。`
: "我们来把你的出生时间缩小到更准的范围。";
const how = "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。";
const extra = openingBoundarySentence(facts);
@@ -269,6 +273,8 @@ export const RECTIFICATION_USER_COPY = {
forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。",
compareFailedRetry: "这次比较没跑成,你说下一句的时候我会再试一次。",
rescoreSkipped: "这次没有重新比较,等下会再比一次。",
/** Issue #148: a recorded event that left the range exactly where it was. */
evidenceNoSplit: "这件事在现在的范围里每个时刻都对得上,分不出前后。",
/** BUG-1178: an event dated after today is not recorded or scored. */
futureDatedEvent: "有一件事的日期在今天之后,这件先不算;如果年份打错了,直接告诉我正确的年份就行。",
lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。",
@@ -516,6 +522,39 @@ export function rangeChangedAfterEvidence(
return `范围从 ${fromText} 变为 ${toText}。`;
}
/**
* Issue #148: say plainly that the new event did not tell the candidates
* apart. Only when the server rescored and the range is unchanged; it never
* invites more events (BUG-1084).
*/
export function withEvidenceNoSplit(
body: string,
from: readonly [string, string] | null | undefined,
to: readonly [string, string] | null | undefined,
): string {
const fromText = formatClockRange(from);
const toText = formatClockRange(to);
if (!fromText || !toText || fromText !== toText) return body;
const notice = RECTIFICATION_USER_COPY.evidenceNoSplit;
const spoken = body.trim();
if (!spoken) return notice;
if (spoken.includes(notice)) return spoken;
const prefix = /[。!?]$/.test(spoken) ? spoken : `${spoken}。`;
return `${prefix}${notice}`;
}
/**
* Issue #148: how the reader's own time was given, said once in the opening.
* Only for sources where the words are true; unknown or legacy get none.
*/
export function openingSourcePhrase(source: string | null | undefined): string | null {
if (source === "hospital_record") return "按你的出生记录时间";
if (source === "approximate") return "按你填的大概时间";
if (source === "family_exact") return "按你填的时间";
if (source === "period_only") return "按你给的时间段";
return null;
}
export function withRangeChangedAfterEvidence(
body: string,
from: readonly [string, string] | null | undefined,
@@ -25,6 +25,7 @@ import {
openingSpokenBody,
stripRangeProgressClaims,
type OpeningBoundaryFacts,
openingSourcePhrase,
} from "../user-copy";
import { resolveOpeningBoundaryFacts } from "../opening-boundary.ts";
import { attemptTimeoutForRemainingBudget, canStartRetryAttempt } from "../../rectification-run-budget.ts";
@@ -53,6 +54,7 @@ import {
batchResultFromToolChunk,
batchHasFutureDatedItem,
batchRescoreFailed,
batchRescoredWithoutDelivery,
acceptedRecapLines,
composeHostFallbackNarration,
lastCompletedPublicTool,
@@ -640,8 +642,11 @@ export async function streamV9Attempt(
const openingRange = openingRangeFromCandidateRange(latestDossier.case.candidateRange);
const productDelivery = productDeliveryForDomain(latestDossier.case.rectificationDomain);
const required = openingBoundarySentence(openingFacts);
if (required || !isAcceptableOpeningBody(answerText, openingRange, productDelivery, openingFacts)) {
answerText = openingSpokenBody(openingRange, productDelivery, openingFacts);
// Issue #148: a known source is a required clause of the window sentence,
// so the server writes the opening whenever it applies.
const sourceRequired = Boolean(openingRange && openingSourcePhrase(dossier.case.birthTimeSource));
if (required || sourceRequired || !isAcceptableOpeningBody(answerText, openingRange, productDelivery, openingFacts)) {
answerText = openingSpokenBody(openingRange, productDelivery, openingFacts, dossier.case.birthTimeSource);
}
}
const latestInference = previousInferenceFromReceipt(
@@ -656,6 +661,7 @@ export async function streamV9Attempt(
futureDated: batchHasFutureDatedItem(batchToolResult),
rangeBefore: rangeBeforeCompare,
rangeAfter: rangeAfterEvidence,
evidenceRescored: batchRescoredWithoutDelivery(batchToolResult),
};
// P3: the only clocks and percentages a model sentence may carry are this
// turn's server facts (range, representative minute, event fit rate).
@@ -12,6 +12,7 @@ import type { SpokenFactWhitelist } from "./spoken-grounding";
import {
RECTIFICATION_USER_COPY,
withCompareFailedRetryNotice,
withEvidenceNoSplit,
withRangeChangedAfterEvidence,
withRescoreSkippedNotice,
} from "../user-copy";
@@ -56,6 +57,8 @@ export type TurnServerFacts = Readonly<{
futureDated?: boolean;
rangeBefore: readonly [string, string] | null;
rangeAfter: readonly [string, string] | null;
/** Issue #148: events were recorded and rescored this turn (not the delivery turn). */
evidenceRescored?: boolean;
}>;
/** Join the server fact sentences after an already trimmed and whitelisted body. */
@@ -69,7 +72,10 @@ export function composeSpokenWithServerFacts(
const withFuture = (text: string) => !future || text.includes(future) ? text : text ? `${text}${/[。!?]$/.test(text) ? "" : "。"}${future}` : future;
if (facts.compareFailed) spoken = withCompareFailedRetryNotice(spoken);
if (facts.rescoreSkipped) return withFuture(withRescoreSkippedNotice(spoken));
return withFuture(withRangeChangedAfterEvidence(spoken, facts.rangeBefore, facts.rangeAfter));
const changed = withRangeChangedAfterEvidence(spoken, facts.rangeBefore, facts.rangeAfter);
return withFuture(facts.evidenceRescored === true
? withEvidenceNoSplit(changed, facts.rangeBefore, facts.rangeAfter)
: changed);
}
export const MAX_ATTEMPTS = 2;
@@ -71,11 +71,16 @@ export function lastCompletedPublicTool(
return last;
}
const PHRASE_HAS_OWN_DATE = /\d{4}\s*年|\d{4}[-/.]\d{1,2}/;
export function evidenceRecapLine(item: HostFallbackRecap): string {
const label = typeof item.display_date_label === "string" ? item.display_date_label.trim() : "";
const phrase = typeof item.event_phrase === "string" ? item.event_phrase.trim() : "";
if (!phrase) return label;
if (/\d{4}年/.test(phrase) || (label.length > 0 && phrase.startsWith(label))) {
// BUG-1496: the phrase already carries its own date, so the label would say
// it twice (「2016-09 2016 年 9 月上大学」). Spaced years and ISO / slash dates
// count too, not only an unspaced 「2016年」.
if (PHRASE_HAS_OWN_DATE.test(phrase) || (label.length > 0 && phrase.startsWith(label))) {
return phrase;
}
// BUG-1136: the recap is now the server's on every evidence turn; a
@@ -128,6 +133,23 @@ export function batchHasFutureDatedItem(batchResult: unknown): boolean {
));
}
/**
* Issue #148: the batch recorded at least one event, the rescore ran to
* completion, and this is not the delivery turn. Only then may an unchanged
* range be spoken as 「分不出前后」.
*/
export function batchRescoredWithoutDelivery(batchResult: unknown): boolean {
if (!batchResult || typeof batchResult !== "object" || Array.isArray(batchResult)) return false;
const row = batchResult as {
accepted_count?: unknown;
rescore?: { status?: unknown };
range_after_rescore?: { delivers_range_this_turn?: unknown } | null;
};
return typeof row.accepted_count === "number" && row.accepted_count > 0
&& row.rescore?.status === "completed"
&& row.range_after_rescore?.delivers_range_this_turn !== true;
}
export function batchRescoreFailed(batchResult: unknown): boolean {
if (!batchResult || typeof batchResult !== "object" || Array.isArray(batchResult)) return false;
const rescore = (batchResult as { rescore?: { status?: unknown } }).rescore;
+2 -3
View File
@@ -118,9 +118,8 @@ export function deriveRectificationChatView(input: RectificationChatViewInput) {
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
inferenceMarks: candidateResult?.inferenceMarks ?? [],
stage: caseStage,
// The same call the board's title-row clock makes, on the same result, so
// the bar and `.rectification-board__clock` are one number by construction
// rather than by two code paths that happen to agree today.
// The one place the working minute is shown (issue #146 took it off the
// board's title row). The board's peek label reads the same call.
workingTime: workingRectificationTime(candidateResult),
workingAdopted: Boolean(candidateResult?.selectedTime),
segmentSummary: candidateResult?.rangeDelivery?.segment_summary,
@@ -0,0 +1,51 @@
/**
* 「我说错了,改一条」 on the result card (issue #147). The list is the case's
* confirmed events, worded with the same recap line the conversation already
* shows. Picking one only fills the composer; the user finishes the sentence
* and sends it, and the agent revises that event with its existing tool. No
* new route, nothing sent or charged by the pick itself.
*/
import { displayDateLabel, eventPhraseFromSummary } from "./rectification-agentic/v9/evidence-model.ts";
import { evidenceRecapLine } from "./rectification-agentic/v9/host-fallback.ts";
export const RECTIFICATION_FIX_COPY = {
fixOne: "我说错了,改一条",
pickHint: "选一条要改的,输入框里会先写好开头:",
} as const;
/** Confirmed events only, oldest first as stored, each once. */
export function fixableEvidenceLines(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const lines: string[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
if (row.status !== "confirmed") continue;
const summary = typeof row.summary === "string" ? row.summary : "";
if (!summary.trim()) continue;
const precision = typeof row.datePrecision === "string" ? row.datePrecision : "unknown";
const from = typeof row.occurredFrom === "string" ? row.occurredFrom : null;
const to = typeof row.occurredTo === "string" ? row.occurredTo : null;
const line = evidenceRecapLine({
display_date_label: displayDateLabel(precision, from, to),
event_phrase: eventPhraseFromSummary(summary),
}).trim();
if (line && !lines.includes(line)) lines.push(line);
}
return lines;
}
/** The composer's opening words; the user writes what it should be. */
export function fixEvidenceDraft(line: string): string {
return `我说错了:「${line}」应该是`;
}
/** The primary adopt label on the most likely column. */
export function adoptAndOpenChartLabel(time: string): string {
return `采用 ${time} 并打开星盘`;
}
export const RECTIFICATION_OTHER_CANDIDATES_LABEL = "还不能排除";
export const RECTIFICATION_SWITCH_TO_THIS_LABEL = "改用这个";
export const RECTIFICATION_MOST_LIKELY_LABEL = "最可能的出生时间";
@@ -112,6 +112,8 @@ export type RectificationCaseSnapshotPayload = Readonly<{
birth_time_source?: unknown;
}>;
turns?: unknown;
/** The case's evidence rows; the result card lists the confirmed ones (issue #147). */
evidence?: unknown;
}>;
/** `candidate_range` as `[start, end]`; null when absent or malformed. */
+8 -4
View File
@@ -177,22 +177,26 @@ export const MAX_EVIDENCE_LOOKUPS_PER_TURN = 3;
export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation";
/**
* Answer-step reasoning effort. Unset or blank keeps today's request body.
* An illegal value warns once and is treated as unset.
* Answer-step reasoning effort. Unset or blank means "low" (issue #134: the
* A/B halved reasoning tokens and cut answer time ~37% with no quality loss).
* "off" sends no reasoning_effort, i.e. the pre-#134 request body.
* An illegal value warns once and is treated as "off".
*/
const CONSULT_ANSWER_REASONING_EFFORTS = ["low", "medium", "high", "max"] as const;
export const CONSULT_ANSWER_REASONING_EFFORT_DEFAULT: ProviderReasoningEffort = "low";
let consultAnswerReasoningEffortWarned = false;
export function consultAnswerReasoningEffort(): ProviderReasoningEffort | undefined {
const raw = process.env.CONSULT_ANSWER_REASONING_EFFORT;
if (raw === undefined || raw.trim() === "") return undefined;
if (raw === undefined || raw.trim() === "") return CONSULT_ANSWER_REASONING_EFFORT_DEFAULT;
const value = raw.trim();
if (value === "off") return undefined;
if ((CONSULT_ANSWER_REASONING_EFFORTS as readonly string[]).includes(value)) {
return value as ProviderReasoningEffort;
}
if (!consultAnswerReasoningEffortWarned) {
consultAnswerReasoningEffortWarned = true;
console.warn(`CONSULT_ANSWER_REASONING_EFFORT=${value} is not low, medium, high, or max; leaving reasoning effort unset.`);
console.warn(`CONSULT_ANSWER_REASONING_EFFORT=${value} is not off, low, medium, high, or max; leaving reasoning effort unset.`);
}
return undefined;
}
@@ -51,9 +51,12 @@ test("an illegal effort warns once and stays unset", () => {
}
});
test("unset effort keeps the pre-change request bodies", async () => {
// 原值: 不设环境变量时请求体与改动前逐字节相同
// 新值: 设成 "off" 时请求体与改动前逐字节相同;不设时默认 "low"(见下一条)
// 原因: issue #134 产品选 A,答题步默认低思考量;"off" 是一行回退
test("off keeps the pre-change request bodies", async () => {
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
process.env.CONSULT_ANSWER_REASONING_EFFORT = "off";
try {
const natal = await captureStreamBodies({
settings: consultationGenerationSettings(MODEL),
@@ -76,6 +79,43 @@ test("unset effort keeps the pre-change request bodies", async () => {
}
});
test("unset or blank effort defaults to low", () => {
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
try {
delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
assert.equal(consultAnswerReasoningEffort(), "low");
process.env.CONSULT_ANSWER_REASONING_EFFORT = " ";
assert.equal(consultAnswerReasoningEffort(), "low");
process.env.CONSULT_ANSWER_REASONING_EFFORT = "off";
assert.equal(consultAnswerReasoningEffort(), undefined);
} finally {
if (previous === undefined) delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
else process.env.CONSULT_ANSWER_REASONING_EFFORT = previous;
}
});
test("unset effort puts low on the answer step only", async () => {
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
try {
const natal = await captureStreamBodies({
settings: consultationGenerationSettings(MODEL),
prepareStep: (input) => consultationNatalPrepareStep({
stepNumber: input.stepNumber,
answerReady: input.stepNumber >= 1,
model: MODEL,
}),
steps: 2,
}) as Record<string, unknown>[];
assert.deepEqual(natal[0], fixture.natal[0]);
assert.equal(natal[1]?.reasoning_effort, "low");
assert.deepEqual(withoutEffort(natal[1]), fixture.natal[1]);
} finally {
if (previous === undefined) delete process.env.CONSULT_ANSWER_REASONING_EFFORT;
else process.env.CONSULT_ANSWER_REASONING_EFFORT = previous;
}
});
test("low effort is only on the answer step", async () => {
const previous = process.env.CONSULT_ANSWER_REASONING_EFFORT;
process.env.CONSULT_ANSWER_REASONING_EFFORT = "low";
@@ -98,9 +98,12 @@ test("the person control is a button, and add-person follows the live cap of 20"
// 新值:Lucide `Plus` 加文案「添加一个人」
// 原因:K1 去掉手写加号。入口仍去人物页,人数上限仍是 MAX_SAVED_SUBJECTS
assert.match(switcher, /<Plus /);
assert.match(switcher, /添加一个人/);
assert.doesNotMatch(switcher, /+ 添加一个人/);
assert.match(switcher, /href="\/people"/);
// 原值:「添加一个人」,`href="/people"`
// 新值:「新增一个人」,`href={PERSON_ADD_HREF}`(/people?add=1,直接打开新增表单)
// 原因:issue #150 原型(2026-10-28 jesse 同意)第 4 条
assert.match(switcher, /新增一个人/);
assert.doesNotMatch(switcher, /+ (添加|新增)一个人/);
assert.match(switcher, /href=\{PERSON_ADD_HREF\}/);
assert.match(switcher, /MAX_SAVED_SUBJECTS/);
assert.doesNotMatch(switcher, /library\.length < 5/);
assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*min-height:\s*44px/);
@@ -65,7 +65,10 @@ test("the chat page loads the natal split only when the panel opens, and phones
const frame = readFileSync(new URL("../src/components/chat-chart-frame.tsx", import.meta.url), "utf8");
const split = readFileSync(new URL("../src/components/chat-chart-split.tsx", import.meta.url), "utf8");
assert.match(page, /<ChatChartToggle \/>/);
assert.match(page, /<ChatChartFrame>/);
// 原值:`<ChatChartFrame>`
// 新值:`<ChatChartFrame switcher={subjectPicker}>`
// 原因:issue #150 第 5 条,盘面顶部放同一个人物切换
assert.match(page, /<ChatChartFrame switcher=\{subjectPicker\}>/);
assert.doesNotMatch(page, /react-resizable-panels|vedic-chart-svg|chat-chart-split/);
assert.match(frame, /打开本命盘/);
assert.match(frame, /收起本命盘/);
+12 -4
View File
@@ -86,10 +86,15 @@ test("the picker lists self and owned others, with self selected by default", ()
const rows = chatProfilePickerRows({ status: "ready", library: [self, other, incomplete], selectedId: "self" });
assert.deepEqual(rows.map((row) => row.id), ["self", "other-1", "other-2"]);
assert.equal(rows[0]?.selected, true);
assert.equal(rows[0]?.detail, "本人");
assert.equal(rows[1]?.detail, "1990-01-02");
// 原值:detail 是「本人」/「1990-01-02」/「这份资料还不完整」
// 新值:身份挪进 badge;detail 是「日期 时间 · 城市」,不完整的说明补全后才能看盘
// 原因:issue #150 第 2 条,每一行写关系和出生时间、城市
assert.equal(rows[0]?.badge, "本人");
assert.equal(rows[0]?.detail, "1990-01-02 08:30 · 示例城市");
assert.equal(rows[1]?.badge, "其他");
assert.equal(rows[1]?.detail, "1990-01-02 08:30 · 示例城市");
assert.equal(rows[2]?.complete, false);
assert.equal(rows[2]?.detail, "这份资料还不完整");
assert.equal(rows[2]?.detail, "这份资料还不完整,补全后才能看盘");
});
test("an empty consultation binds the chosen person and does not open another session", () => {
@@ -251,7 +256,10 @@ test("the header control is a button with popover keyboard, focus return, and a
assert.match(pickerSource, /type="button"/);
assert.match(pickerSource, /aria-expanded=\{open\}/);
assert.match(pickerSource, /aria-controls=\{listId\}/);
assert.match(pickerSource, /aria-label=\{`当前星盘:\$\{label\}`\}/);
// 原值:aria-label 只有称呼
// 新值:称呼后接「,出生日期时间 · 城市」(选中的资料完整时)
// 原因:issue #150 第 1 条,按钮上不只写名字
assert.match(pickerSource, /aria-label=\{`当前星盘:\$\{label\}\$\{triggerDetail \? `,\$\{triggerDetail\}` : ""\}`\}/);
assert.match(pickerSource, /event\.key !== "Escape"/);
// 原值:triggerRef.current?.focus()。
// 新值:triggerRef.current?.focus({ preventScroll: true })。
@@ -635,7 +635,10 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
assert.match(styles, /\.rectification-board__close \{[\s\S]*width: 44px/);
assert.match(styles, /\.rectification-board__close \{[\s\S]*min-height: 44px/);
assert.match(board, /<X aria-hidden="true"/);
assert.match(chat, /compactBoard && !boardOpen \? \(/);
// 原值:只在手机且盘面没打开时出「看盘面」入口(compactBoard && !boardOpen)。
// 新值:桌面上也出,手机上盘面打开时才收起。
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
assert.match(chat, /!compactBoard \|\| !boardOpen \? \(/);
assert.match(chat, /headerSlot && boardPeek \? createPortal\(boardPeek, headerSlot\)/);
assert.doesNotMatch(chat, /querySelector\(/);
assert.doesNotMatch(chat, /setHeaderSlot/);
@@ -111,8 +111,11 @@ const diagnosticReport = JSON.parse(readFileSync(
// 原值(10):docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.json
// 新值(10):docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json
// 原因(10):sync8(BUG-1380~1390)改了冻结身份文件,输出不变,ERR-110 另存新记录。确认门行为与阈值不变。
// 原值(11):docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json
// 新值(11):docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.json
// 原因(11):sync9b(BUG-1494~1495)改了冻结身份里的 scripts/varga.py,输出不变,ERR-110 另存新记录。确认门行为与阈值不变。
const currentTreeRerun = JSON.parse(readFileSync(
new URL("../../docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json", import.meta.url),
new URL("../../docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.json", import.meta.url),
"utf8",
)) as {
frozen_record: { implementation_sha256: string };
@@ -260,9 +263,10 @@ test("sealed holdout aggregates match the v3 report and still block confirmation
// 原值(7):…upstream_sync6_2026_10_07.json / 新值(7):…ephemeris_nutation_2026_10_08.json / 原因(7):章动修正与 scoring-12 重新冻结(ERR-110,BUG-1275)。
// 原值(8):…ephemeris_nutation_2026_10_08.json / 新值(8):…scoring_perf_2026_10_13.json / 原因(8):BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录。
// 原值(9):…scoring_perf_2026_10_13.json / 新值(9):…upstream_sync8_2026_10_18.json / 原因(9):sync8 改了冻结身份文件,输出不变,ERR-110 另存新记录。
// 原值(10):…upstream_sync8_2026_10_18.json / 新值(10):…upstream_sync9b_2026_10_28.json / 原因(10):sync9b 改了冻结身份里的 varga.py,输出不变,ERR-110 另存新记录。
assert.equal(
productHoldout.current_tree_scorer.source_report,
"docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json",
"docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.json",
);
assert.equal(currentTreeRerun.official_valid_independent_blind, false);
assert.equal(productHoldout.current_tree_scorer.matches_metrics_scorer, false);
@@ -553,7 +553,8 @@ test("the minute card hides the adopt button and the chart-clock line when adopt
const allowed = renderRange(cardResult("adopt_representative", true, minuteDelivery()));
assert.match(allowed, /<button/);
assert.match(allowed, /采用后,排盘使用这一日期和时间/);
assert.match(allowed, /更像这个/);
// 原值:/更像这个/。新值:区分得开时最可能那列是「采用 HH:MM 并打开星盘」。原因:issue #147。
assert.match(allowed, /更像这个|采用 \d{2}:\d{2} 并打开星盘/);
});
test("a refused adoption refreshes the snapshot and says so in Chinese", async () => {
@@ -734,3 +735,21 @@ test("answering widen A with a failed rescore never delivers or adopts on the ol
assert.notEqual(receipt.nextAction.type, "ready_to_adopt");
assert.notEqual(receipt.nextAction.type, "complete_with_range");
});
// Issue #147: the segment card answers "which chart" (BUG-1151), so it gets the
// actions row but never a minute lead, and keeps its own adopt label.
test("the segment card gets 改一条 but no minute lead and no 再补一件事", () => {
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: cardResult("adopt_representative", true, segmentDelivery()),
acceptingCandidateId: null,
readonly: false,
onAccept: () => {},
fixableEvidence: ["2016-09 升学"],
onFixEvidence() {},
}));
assert.match(html, /用哪张盘解读/);
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: the card does not invite more events");
assert.match(html, /我说错了,改一条/);
assert.doesNotMatch(html, /最可能|rectification-result-lead|并打开星盘/);
assert.match(html, /盘解读<\/button>/);
});
@@ -146,10 +146,17 @@ test("D4 threshold is 5 points between the first and second column", () => {
});
test("D3 range is the title and the representative minute is the subtitle", () => {
// 原值:前两列差距 ≥ 5 时仍以「目前范围 …」为 <strong> 卡头,「最可能 HH:MM」作副标题。
// 新值:区分得开时卡头改为「最可能的出生时间 HH:MM」,范围降为下一行;区分不开时仍以范围为卡头(D3 只在这时成立)。
// 原因:issue #147(产品 2026-10-28 同意:区分得开才以最可能时刻为卡头)。
const html = render([0.45, 0.3, 0.25]);
assert.match(html, /<strong>目前范围 05:00–05:15(对照了 2 件经历)<\/strong>/);
assert.match(html, /rectification-range-delivery__most-likely">最可能 05:07</);
assert.ok(html.indexOf("最可能 05:07") < html.indexOf("rectification-range-delivery__columns"));
assert.match(html, /rectification-result-lead__time">05:07</);
assert.match(html, /rectification-range-delivery__range-line">目前范围 05:00–05:15(对照了 2 件经历)</);
assert.ok(html.indexOf("rectification-result-lead") < html.indexOf("rectification-range-delivery__range-line"));
assert.ok(html.indexOf("rectification-range-delivery__range-line") < html.indexOf("rectification-range-delivery__columns"));
const flat = render([0.28, 0.26, 0.24]);
assert.match(flat, /<strong>目前范围 05:00–05:15(对照了 2 件经历)<\/strong>/);
assert.doesNotMatch(flat, /rectification-result-lead/);
assert.equal(rangeDeliveryMostLikely("05:07"), "最可能 05:07");
});
@@ -315,3 +322,30 @@ test("D2 a delivered card does not carry an unasked guided window as the next qu
const collecting = buildMethodFollowupPlan({ ...base, sessionOutcome: "collect_evidence" });
assert.doesNotMatch(collecting.next_followup?.collection_key ?? "", /^collect:guided:window:/);
});
// Issue #147: a distinct result leads with the most likely minute; its column
// adopts and opens the chart, the others stay adoptable as 「改用这个」.
test("a distinct result puts the most likely column first with 采用并打开星盘 and keeps the others adoptable", () => {
const calls: Array<[string, boolean | undefined]> = [];
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: result([0.45, 0.3, 0.25]),
acceptingCandidateId: null,
readonly: false,
onAccept: (candidateId: string, openChart?: boolean) => { calls.push([candidateId, openChart]); },
fixableEvidence: ["2016-09 升学"],
onFixEvidence() {},
}));
assert.match(html, /采用 05:07 并打开星盘/);
assert.equal([...html.matchAll(/改用这个/g)].length, 2);
assert.match(html, /还不能排除/);
assert.ok(html.indexOf("采用 05:07 并打开星盘") < html.indexOf("还不能排除"));
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: the card does not invite more events");
assert.match(html, /我说错了,改一条/);
assert.doesNotMatch(html, /把握/, "no confidence label (issue #147, BUG-560)");
});
test("an indistinct result keeps 更像这个 on every column and no lead", () => {
const html = render([0.28, 0.26, 0.24]);
assert.doesNotMatch(html, /并打开星盘|改用这个|还不能排除|rectification-result-lead/);
assert.match(html, /更像这个/);
});
@@ -104,3 +104,19 @@ test("case snapshot receipts expose host_fallback origin from the durable phase"
assert.match(route, /answer_origin: "host_fallback"/);
assert.match(route, /answer\.host_fallback/);
});
// BUG-1496: a phrase that already carries its date must not get the label in
// front again. Staging showed 「记下了:2016-09 2016 年 9 月上大学。」.
test("the recap does not repeat a date the event phrase already carries", async () => {
const { evidenceRecapLine } = await import("../src/lib/rectification-agentic/v9/host-fallback.ts");
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "2016 年 9 月上大学" }), "2016 年 9 月上大学");
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "2016年9月上大学" }), "2016年9月上大学");
assert.equal(evidenceRecapLine({ display_date_label: "2024-04-07", event_phrase: "2024-04-07 入职" }), "2024-04-07 入职");
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "2016/9 上大学" }), "2016/9 上大学");
// Unchanged: a phrase without its own date still gets the label.
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "上大学" }), "2016-09 上大学");
assert.equal(evidenceRecapLine({ display_date_label: "2016年", event_phrase: "入学" }), "2016年入学");
assert.equal(evidenceRecapLine({ display_date_label: "2016 年 9 月", event_phrase: "去北京工作" }), "2016 年 9 月去北京工作");
// A number that is not a year is not a date.
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "考了 600 分" }), "2016-09 考了 600 分");
});
@@ -58,7 +58,10 @@ test("the bar's working minute is the board clock's own value, not a second deri
// passes the result of that same call into the bar, so the two surfaces
// cannot drift apart — there is one expression, not two that agree today.
assert.match(board, /const workingTime = workingRectificationTime\(result\);/);
assert.match(board, /className="rectification-board__clock">\{workingTime\}/);
// 原值:盘面标题行渲染 rectification-board__clock,与时间轴同一个值。
// 新值:盘面不再写时刻,代表分钟只在时间轴上出现一次。
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
assert.doesNotMatch(board, /className="rectification-board__clock"/);
assert.match(chat, /workingTime: workingRectificationTime\(candidateResult\)/);
const result = candidateSnapshot();
@@ -395,7 +395,8 @@ test("range-delivery DOM is one button per row and adopted is aria-pressed", ()
assert.doesNotMatch(html, /说不好/);
assert.doesNotMatch(html, /排盘用/);
const source = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8");
assert.match(source, /onAccept\(column\.candidate_id\)/);
// 原值:onAccept(column.candidate_id)。新值:带第二个参数,最可能那列(且无记录冲突)采用后打开星盘。原因:issue #147。
assert.match(source, /onAccept\(column\.candidate_id, isPrimary && !recordConflict\)/);
assert.match(source, /aria-pressed=\{adopted\}/);
});
@@ -191,7 +191,13 @@ test("non-conflict states keep 更像这个 and show no switch note", () => {
[RECORD_OUTSIDE, "period_only"],
] as const) {
const markup = html(reported, source);
assert.ok(markup.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), `${reported}/${source}`);
// 原值:每列都是「更像这个」。
// 新值:区分得开时最可能那列是「采用 HH:MM 并打开星盘」、其余「改用这个」;区分不开时仍是「更像这个」。
// 原因:issue #147。无冲突时仍然不出现「改用校正结果」和换时间小字。
assert.ok(
markup.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis) || /采用 \d{2}:\d{2} 并打开星盘/.test(markup),
`${reported}/${source}`,
);
assert.equal(markup.includes(SWITCH_TO_RECTIFIED_LABEL), false, `${reported}/${source}`);
assert.equal(markup.includes("换成 04:53"), false, `${reported}/${source}`);
}
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { RECTIFICATION_BOARD_OPEN_LABEL, RectificationBoard, RectificationBoardPeek } from "../src/components/rectification-board.tsx";
import { RectificationResultActions, RectificationResultLead } from "../src/components/rectification-result-parts.tsx";
import { diffRectificationBoard } from "../src/lib/rectification-board-model.ts";
import {
RECTIFICATION_FIX_COPY,
adoptAndOpenChartLabel,
fixEvidenceDraft,
fixableEvidenceLines,
} from "../src/lib/rectification-fix-evidence.ts";
// Issues #146 and #147: one working minute on the timeline, the board behind
// 「看盘面」, and the result card's lead and actions. Fictional data only.
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
test("only confirmed events are listed to fix, once each, in the conversation's recap wording", () => {
const lines = fixableEvidenceLines([
{ status: "confirmed", summary: "升学", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ status: "confirmed", summary: "升学", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ status: "pending_confirmation", summary: "搬家", datePrecision: "month", occurredFrom: "2023-08-01", occurredTo: null },
{ status: "superseded", summary: "入职", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
{ status: "confirmed", summary: "入职", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
{ status: "confirmed", summary: " ", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
null,
"x",
]);
assert.equal(lines.length, 2);
assert.match(lines[0]!, /2016-09/);
assert.match(lines[1]!, /2024-04-07/);
assert.deepEqual(fixableEvidenceLines(undefined), []);
assert.equal(fixEvidenceDraft("2016-09 升学"), "我说错了:「2016-09 升学」应该是");
assert.equal(adoptAndOpenChartLabel("07:24"), "采用 07:24 并打开星盘");
});
test("the actions row offers only 改一条, and only with a callback and a confirmed event", () => {
assert.equal(renderToStaticMarkup(createElement(RectificationResultActions, { evidence: ["2016-09 升学"], disabled: false })), "");
const html = renderToStaticMarkup(createElement(RectificationResultActions, {
evidence: ["2016-09 升学"],
disabled: false,
onFixEvidence() {},
}));
assert.match(html, new RegExp(RECTIFICATION_FIX_COPY.fixOne));
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: no invitation to add more events");
assert.match(html, /aria-expanded="false"/);
assert.doesNotMatch(html, /2016-09 升学/, "the event list opens on click");
const none = renderToStaticMarkup(createElement(RectificationResultActions, {
evidence: [],
disabled: false,
onFixEvidence() {},
}));
assert.equal(none, "", "nothing to fix without a confirmed event");
});
test("the lead shows the minute and the Lagna when known", () => {
assert.match(renderToStaticMarkup(createElement(RectificationResultLead, { time: "07:24", lagna: "巨蟹座" })), /最可能的出生时间[\s\S]*07:24[\s\S]*上升巨蟹座/);
assert.doesNotMatch(renderToStaticMarkup(createElement(RectificationResultLead, { time: "07:24", lagna: null })), /上升/);
});
test("the board is closed by default at every width and has no clock in its title row", () => {
const props = {
result: null,
declaredTime: "05:10",
savedStatus: null,
diff: diffRectificationBoard(null, null),
boardId: "board",
titleId: "title",
onClose() {},
};
assert.equal(renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: false, open: false })), "");
assert.equal(renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: true, open: false })), "");
const open = renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: false, open: true }));
assert.match(open, /当前盘面/);
assert.doesNotMatch(open, /rectification-board__clock/);
assert.match(open, /aria-label="关闭盘面"/, "the desktop column closes too");
});
test("看盘面 says what it does; the time stays in its accessible name only", () => {
const html = renderToStaticMarkup(createElement(RectificationBoardPeek, {
result: null,
declaredTime: "05:10",
expanded: false,
boardId: "board",
onOpen() {},
}));
assert.equal(RECTIFICATION_BOARD_OPEN_LABEL, "看盘面");
assert.match(html, /<strong>看盘面<\/strong>/);
assert.doesNotMatch(html, /<span>/);
assert.match(html, /aria-label="看盘面,[^"]*05:10"/);
});
test("adopting from the most likely column opens the chart only after the server saved it", () => {
const hook = read("../src/hooks/use-open-chart-after-adopt.ts");
assert.match(hook, /onSaved\?\.\(time, status\);\s*if \(!openAfterSave\.current\) return;\s*openAfterSave\.current = false;\s*navigateInApp\("\/chart"\);/);
const chat = read("../src/components/rectification-agentic-chat.tsx");
assert.match(chat, /armChartAfterAdopt\(openChart\);/);
assert.match(chat, /onSaved: savedThenChart,/);
assert.match(chat, /onFixEvidence=\{\(line\) => \{\s*setDraft\(fixEvidenceDraft\(line\)\);/);
const segment = read("../src/components/rectification-segment-delivery.tsx");
assert.doesNotMatch(segment, /RectificationResultLead/, "the segment product never leads with a minute (BUG-1151)");
});
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
OPENING_BODY_JARGON,
RECTIFICATION_USER_COPY,
isAcceptableOpeningBody,
openingSourcePhrase,
openingSpokenBody,
withEvidenceNoSplit,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { composeSpokenWithServerFacts } from "../src/lib/rectification-agentic/v9/agent-run-support.ts";
import { batchRescoredWithoutDelivery } from "../src/lib/rectification-agentic/v9/host-fallback.ts";
import { productDeliveryForDomain } from "../src/lib/rectification-agentic/v9/product-delivery.ts";
// Issue #148 (product 2026-10-28): the opening's window sentence says where the
// reader's time came from, and an event that left the range unchanged is said
// to have not told the candidates apart. No invitation to add more (BUG-1084).
const RANGE = ["07:00", "08:00"] as const;
test("the opening names the time source inside the window sentence, only where the words are true", () => {
assert.equal(openingSourcePhrase("hospital_record"), "按你的出生记录时间");
assert.equal(openingSourcePhrase("approximate"), "按你填的大概时间");
assert.equal(openingSourcePhrase("family_exact"), "按你填的时间");
assert.equal(openingSourcePhrase("period_only"), "按你给的时间段");
for (const source of ["unknown", "legacy_import", "", null, undefined]) {
assert.equal(openingSourcePhrase(source), null, String(source));
}
const plain = openingSpokenBody(RANGE);
const sourced = openingSpokenBody(RANGE, null, null, "approximate");
assert.match(sourced, /我们来把你的出生时间缩小到更准的范围,按你填的大概时间,现在先在 07:00–08:00 之间找。/);
assert.equal(sourced.split("。").length, plain.split("。").length, "same sentence count: still three plain layers");
assert.equal(openingSpokenBody(RANGE, null, null, "unknown"), plain);
assert.equal(openingSpokenBody(null, null, null, "approximate"), openingSpokenBody(null), "no window, no source clause");
for (const word of OPENING_BODY_JARGON) assert.equal(sourced.includes(word), false, word);
assert.equal(isAcceptableOpeningBody(sourced, RANGE), true);
});
test("the segment product's opening carries the source before the check window", () => {
const product = productDeliveryForDomain("career");
const body = openingSpokenBody(RANGE, product, null, "hospital_record");
assert.match(body, /能不能选定用来解读的星盘,按你的出生记录时间,核对窗口是 07:00–08:00。/);
assert.equal(isAcceptableOpeningBody(body, RANGE, product), true);
});
test("an event that left the range unchanged gets one 分不出前后 sentence, and nothing else changes", () => {
const notice = RECTIFICATION_USER_COPY.evidenceNoSplit;
assert.doesNotMatch(notice, /再补|再说一件|补一件/, "BUG-1084: never invite more events");
const same = composeSpokenWithServerFacts("记下了。", { compareFailed: false, rescoreSkipped: false, rangeBefore: RANGE, rangeAfter: RANGE, evidenceRescored: true });
assert.equal(same, `记下了。${notice}`);
const moved = composeSpokenWithServerFacts("记下了。", { compareFailed: false, rescoreSkipped: false, rangeBefore: RANGE, rangeAfter: ["07:10", "07:50"], evidenceRescored: true });
assert.doesNotMatch(moved, new RegExp(notice));
assert.match(moved, /范围从 07:00–08:00 变为 07:10–07:50。/);
const noEvent = composeSpokenWithServerFacts("好。", { compareFailed: false, rescoreSkipped: false, rangeBefore: RANGE, rangeAfter: RANGE });
assert.equal(noEvent, "好。");
const skipped = composeSpokenWithServerFacts("记下了。", { compareFailed: false, rescoreSkipped: true, rangeBefore: RANGE, rangeAfter: RANGE, evidenceRescored: true });
assert.doesNotMatch(skipped, new RegExp(notice), "no rescore, no claim about the range");
assert.equal(withEvidenceNoSplit(`记下了。${notice}`, RANGE, RANGE), `记下了。${notice}`, "said once");
assert.equal(withEvidenceNoSplit("记下了。", null, RANGE), "记下了。");
});
test("only a completed rescore of accepted events outside the delivery turn counts", () => {
const ok = { accepted_count: 1, rescore: { status: "completed" }, range_after_rescore: { delivers_range_this_turn: false } };
assert.equal(batchRescoredWithoutDelivery(ok), true);
assert.equal(batchRescoredWithoutDelivery({ ...ok, accepted_count: 0 }), false);
assert.equal(batchRescoredWithoutDelivery({ ...ok, rescore: { status: "skipped" } }), false);
assert.equal(batchRescoredWithoutDelivery({ ...ok, rescore: { status: "failed" } }), false);
assert.equal(batchRescoredWithoutDelivery({ ...ok, range_after_rescore: { delivers_range_this_turn: true } }), false);
assert.equal(batchRescoredWithoutDelivery(null), false);
assert.equal(batchRescoredWithoutDelivery([]), false);
});
test("the attempt wires the source into the opening and the rescore flag into the server facts", () => {
const attempt = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run-attempt.ts", import.meta.url), "utf8");
assert.match(attempt, /const sourceRequired = Boolean\(openingRange && openingSourcePhrase\(dossier\.case\.birthTimeSource\)\);/);
assert.match(attempt, /openingSpokenBody\(openingRange, productDelivery, openingFacts, dossier\.case\.birthTimeSource\)/);
assert.match(attempt, /evidenceRescored: batchRescoredWithoutDelivery\(batchToolResult\),/);
});
@@ -226,7 +226,9 @@ test("the choice card confirms the tap and the board's first state shows the dec
assert.match(board, /rectificationBoardPeekCopy\(result, declaredTime, birthTimeSource\)/);
assert.doesNotMatch(board, /补充经历后,这里会显示当前本命宫位和换升时刻/);
assert.match(chat, /\$\{candidateResult \? "" : " is-board-empty"\}/);
assert.match(styles, /\.rectification-workspace\.is-board-empty \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
// The empty modifier is declared before `.is-compact`, so the single-column layout still wins.
assert.ok(styles.indexOf(".rectification-workspace.is-board-empty {") < styles.indexOf(".rectification-workspace.is-compact {"));
// 原值:.rectification-workspace.is-board-empty 常驻两栏(右栏 16–18rem)。
// 新值:只有盘面打开且不是手机布局时才分出右栏,空盘面仍用窄的那档。
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
assert.match(styles, /\.rectification-workspace\.is-board-empty\.is-board-open:not\(\.is-compact\) \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
assert.match(styles, /\.rectification-workspace \{[^}]*grid-template-columns: minmax\(0, 1fr\);/);
});
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { SubjectSwitcher } from "../src/components/subject-switcher.tsx";
import { INCOMPLETE_ROW_DETAIL, chatProfilePickerRows, subjectBirthLine } from "../src/lib/chat-profile-picker-model.ts";
import { emptyProfile, type ChartLibraryRecord, type Profile } from "../src/lib/home-types.ts";
import {
PERSON_ADD_HREF,
PERSON_DELETE_COPY,
deletePerson,
fetchPersonDeleteUsage,
personDeleteDescription,
personEditHref,
} from "../src/lib/person-delete.ts";
// Issue #150: the person switcher says when and where, carries 编辑 / 删除, and
// also sits on top of the chat-side chart. Every person here is made up.
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const switcherSource = read("../src/components/subject-switcher.tsx");
const peopleSource = read("../src/components/people/people-page.tsx");
const splitSource = read("../src/components/chat-chart-split.tsx");
const pageSource = read("../src/app/(app)/page.tsx");
const css = read("../src/app/globals.css");
function person(name: string, patch: Partial<Profile> = {}): Profile {
return {
...emptyProfile,
name,
date: "1991-04-05",
birthTimeSource: "family_exact",
birthTimeStatus: "confirmed",
time: "07:30",
birthPlaceLabel: "示例市",
latitude: 30.2,
longitude: 120.1,
timezoneId: "Asia/Shanghai",
timezoneOffset: 8,
...patch,
};
}
function record(id: string, role: "self" | "other", profile: Profile): ChartLibraryRecord {
return { id, role, relationship: role === "self" ? "self" : "other", updatedAt: 1, profile };
}
const library = [
record("self", "self", person("小甲")),
record("p-1", "other", person("小乙", { relation: "partner" })),
record("p-2", "other", person("小丙")),
record("p-3", "other", person("小丁", { date: "", time: "" })),
];
test("each row carries a relation badge and 「date time · city」; an incomplete one says why it can't be picked", () => {
const rows = chatProfilePickerRows({ status: "ready", library, selectedId: "p-1" });
assert.deepEqual(rows.map((row) => [row.name, row.badge, row.detail]), [
["小甲", "本人", "1991-04-05 07:30 · 示例市"],
["小乙", "伴侣", "1991-04-05 07:30 · 示例市"],
["小丙", "其他", "1991-04-05 07:30 · 示例市"],
["小丁", "其他", INCOMPLETE_ROW_DETAIL],
]);
assert.equal(INCOMPLETE_ROW_DETAIL, "这份资料还不完整,补全后才能看盘");
// Missing parts drop out instead of leaving a dangling separator.
assert.equal(subjectBirthLine(person("x", { time: "" })), "1991-04-05 · 示例市");
assert.equal(subjectBirthLine(person("x", { birthPlaceLabel: "" })), "1991-04-05 07:30");
});
test("the trigger names the person, then when and where", () => {
const markup = renderToStaticMarkup(
<SubjectSwitcher library={library} status="ready" selectedId="p-1" label="小乙" onChoose={() => true} onReload={() => {}} />,
);
assert.match(markup, /aria-label="当前星盘:小乙,1991-04-05 07:30 · 示例市"/);
assert.match(markup, /chat-profile-picker-name">小乙<\/span><span class="chat-profile-picker-detail">· 1991-04-05 07:30 · 示例市<\/span>/);
// No detail while the list is still loading, so the trigger never shows a stale person's birth.
const loading = renderToStaticMarkup(
<SubjectSwitcher library={[]} status="loading" selectedId="p-1" label="小乙" onChoose={() => true} onReload={() => {}} />,
);
assert.doesNotMatch(loading, /chat-profile-picker-detail/);
});
test("the row menu edits anyone, deletes only others, and holds delete while an answer is written", () => {
assert.match(switcherSource, /aria-label=\{`\$\{row\.name\} 更多操作`\}/);
assert.match(switcherSource, /<DropdownMenuItem onClick=\{onEdit\}>编辑<\/DropdownMenuItem>/);
assert.match(switcherSource, /row\.role === "other" \? \(\s*<DropdownMenuItem disabled=\{requestInFlight\}/);
assert.match(switcherSource, /if \(row\.role === "self" \|\| requestInFlight\) return;/);
assert.equal(personEditHref("self"), "/people?self=1");
assert.equal(personEditHref("p-1"), "/people?edit=p-1");
assert.equal(PERSON_ADD_HREF, "/people?add=1");
});
test("delete from the switcher uses the people page's count, words and order", () => {
// The same sentence the people page renders inline.
assert.match(peopleSource, /将同时删除 \{pendingDelete\.sessions\} 个对话和 \{pendingDelete\.reports\} 份报告\{pendingDelete\.synastry \? `和 \$\{pendingDelete\.synastry\} 份合盘` : ""\},无法恢复。/);
assert.equal(personDeleteDescription({ sessions: 3, reports: 1 }), "将同时删除 3 个对话和 1 份报告,无法恢复。");
assert.equal(personDeleteDescription({ sessions: 0, reports: 0, synastry: 2 }), "将同时删除 0 个对话和 0 份报告和 2 份合盘,无法恢复。");
assert.match(peopleSource, /const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍后再试";/);
assert.equal(PERSON_DELETE_COPY.usageUnavailable, "暂时查不到这个人的对话数量,稍后再试");
assert.match(peopleSource, /notifyError\("删除失败,请重试"\)/);
assert.equal(PERSON_DELETE_COPY.failed, "删除失败,请重试");
// Fall back to 本人 before rereading the list, as the people page does.
assert.match(switcherSource, /if \(readCurrentSubjectId\(\) === target\.id\) setCurrentSubject\("self"\);\s*invalidateSubjectCatalog\(\);\s*onReload\(\);/);
});
test("the count read and the DELETE refuse 本人 and bad answers", async () => {
const original = globalThis.fetch;
const calls: string[] = [];
try {
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${String(input)}`);
if (String(input).endsWith("/bad")) return new Response(JSON.stringify({ usage: { sessions: -1, reports: 0 } }), { status: 200 });
if (init?.method === "DELETE") return new Response(null, { status: 204 });
return new Response(JSON.stringify({ usage: { sessions: 2, reports: 1 } }), { status: 200 });
}) as typeof fetch;
assert.deepEqual(await fetchPersonDeleteUsage("p-1"), { sessions: 2, reports: 1 });
assert.equal(await fetchPersonDeleteUsage("bad"), null);
assert.equal(await fetchPersonDeleteUsage("self"), null);
assert.equal(await deletePerson("self"), false);
assert.equal(await deletePerson("p-1"), true);
assert.deepEqual(calls, ["GET /api/chart-profiles/p-1", "GET /api/chart-profiles/bad", "DELETE /api/chart-profiles/p-1"]);
} finally {
globalThis.fetch = original;
}
});
test("the people page opens someone's editor from ?edit=<id>", () => {
assert.match(peopleSource, /url\.searchParams\.get\("self"\) === "1" \? "self" : url\.searchParams\.get\("edit"\)/);
assert.match(peopleSource, /url\.searchParams\.delete\("edit"\);/);
assert.match(peopleSource, /if \(editRow\) \{\s*setSelectedId\(editRow\.id\);\s*setDraft\(editRow\.profile\);\s*setEditing\(true\);/);
});
test("the chat-side chart carries the same picker as the header, and the drawing stays memoized", () => {
assert.match(pageSource, /const subjectPicker = activeSession\?\.sessionType === "consultation" \|\| activeSession\?\.sessionType === "synastry_report" \? \(\s*<ChatProfilePicker/);
assert.match(pageSource, /\{subjectPicker \?\? \(activeSession \? \(/);
assert.match(splitSource, /const StableNatalChart = memo\(ChatNatalChart\);/);
assert.match(splitSource, /<div className="chat-chart-switcher">\{switcher\}<\/div>/);
});
test("phone widths: 44px row actions, the header keeps only the name, nothing scrolls sideways", () => {
assert.match(css, /\.chat-profile-picker-more \{ width: 44px; height: 44px;/);
assert.match(css, /\.chat-profile-picker-row \{ min-width: 0; display: grid; grid-template-columns: minmax\(0, 1fr\) 44px;/);
assert.match(css, /@media \(max-width: 640px\) \{ \.chat-header \.chat-profile-picker-detail \{ display: none; \} \}/);
assert.match(css, /\.chat-profile-picker-detail \{[^}]*min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;/);
assert.match(css, /\.chat-profile-picker-popup \{[^}]*max-width: min\(320px, calc\(100vw - var\(--space-6\)\)\)/);
});
+130 -115
View File
@@ -9,7 +9,7 @@
"dataset_path": "references/real_case_calibration/minute_rectification_holdout_v3.json",
"report_path": "references/real_case_calibration/minute_rectification_holdout_v3_report.json",
"source_audit_status": "corrected_known_date_errors",
"evaluated_on": "2026-10-18",
"evaluated_on": "2026-10-28",
"evaluated_on_scope": "latest_fixed_protocol_rerun_not_historical_runtime_metrics",
"historical_source_audit_status": "invalidated_after_replay",
"report_status": "invalidated_after_source_audit",
@@ -24,7 +24,7 @@
"evaluated_on": "2026-07-21"
},
"current_tree_scorer": {
"implementation_sha256": "bf8981b5c41702f9b333444deee2ab7378b9e98b111240e85ea62e6aae02fda8",
"implementation_sha256": "b44505aa0034e486fcfc29c8611a9559333a97c1005f92e6a5356bd855122205",
"extended_identity": {
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
"production_scoring_files": [
@@ -52,7 +52,7 @@
"scripts/shadbala.py",
"scripts/varga.py"
],
"production_scoring_sha256": "c2c23bcbde5de58e519ae1d6545620edd0f738ce8dc2e872e659b598137a728f",
"production_scoring_sha256": "7a727418651321d1b9073588587050c4dc6292587748cf35734dd76cefb0effe",
"research_files": [
"scripts/research/reported_offset_sweep.py",
"scripts/research/cluster_width_lib.py",
@@ -62,7 +62,7 @@
"scripts/minute_rectification_fact_blind_eval_v4.py",
"scripts/minute_rectification_holdout_validator.py"
],
"research_implementation_sha256": "9df579bdacd7dec43abd92c9abc1831771feb800fc29e6864dc315d083d52a03",
"research_implementation_sha256": "6fa0b0c335ac0cdc25ee887d432ae2f1f6487c665aed9665ad5cc0bc6ef0283b",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
@@ -90,17 +90,17 @@
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "e10537ca2caba3a4e6a8d2460265543b0cca55935afa8004108e45bef4bc3de7",
"scripts/research/sealed_holdout_rerun.py": "7945353014b93b6d7641f278779111d85b1773293781343b802a9b4b655ca4b5",
"scripts/research/reported_offset_sweep.py": "8ecc381dfaa3c0e6bea058cfa44c606aabdb1086c137f16da82ee5a3bf5ccc91",
"scripts/research/sealed_holdout_rerun.py": "ebcdae760f2b76c81d5d0c8e2bf347cff4c81cca9074f27ae94b6734408ccd42",
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
"scripts/varga.py": "c1f06967c3d4d214edadff139c24aa31e59fb628477174235abe14f799a99076"
"scripts/varga.py": "8ff96b9fc8346ca249abff6109434b6ed57fa150e1a9854d4f73bdf317568368"
},
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock"
},
"matches_metrics_scorer": false,
"official_eval_implementation_hash_matches": false,
"official_eval_trial_count": 0,
"source_report": "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json",
"source_report": "docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.json",
"fixed_protocol_rerun_trial_count": 20,
"fixed_protocol_rerun_hash_matches": true,
"metrics": {
@@ -113,6 +113,106 @@
}
},
"current_tree_fixed_protocol_rerun": {
"evaluated_on": "2026-10-28",
"report_path": "docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.json",
"freeze_record_path": "docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.freeze.json",
"implementation_sha256": "b44505aa0034e486fcfc29c8611a9559333a97c1005f92e6a5356bd855122205",
"extended_identity": {
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
"production_scoring_files": [
"scripts/active_rectification_event_engine.py",
"scripts/active_rectification_events.py",
"scripts/ashtakavarga.py",
"scripts/dasha_analyzer.py",
"scripts/divisional_charts_extended.py",
"scripts/domain_calculation_service.py",
"scripts/functional_benefics.py",
"scripts/jaimini.py",
"scripts/minute_rectification_fact_ranker_v4.py",
"scripts/minute_rectification_feature_facts_v4.py",
"scripts/narayana_dasha.py",
"scripts/rectification/api_service.py",
"scripts/rectification/candidate_contrast.py",
"scripts/rectification/candidate_window.py",
"scripts/rectification/case_holdout.py",
"scripts/rectification/contracts.py",
"scripts/rectification/dasha_transition_proximity.py",
"scripts/rectification/decision_policy.py",
"scripts/rectification/event_probes.py",
"scripts/rectification/refinement_packet.py",
"scripts/rectification/scoring_service.py",
"scripts/shadbala.py",
"scripts/varga.py"
],
"production_scoring_sha256": "7a727418651321d1b9073588587050c4dc6292587748cf35734dd76cefb0effe",
"research_files": [
"scripts/research/reported_offset_sweep.py",
"scripts/research/cluster_width_lib.py",
"scripts/research/probe_supply_after_six.py",
"scripts/research/sealed_holdout_rerun.py",
"scripts/minute_rectification_blind_eval.py",
"scripts/minute_rectification_fact_blind_eval_v4.py",
"scripts/minute_rectification_holdout_validator.py"
],
"research_implementation_sha256": "6fa0b0c335ac0cdc25ee887d432ae2f1f6487c665aed9665ad5cc0bc6ef0283b",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
"scripts/ashtakavarga.py": "c524da3f54b2ed9c080b78b33854854a0ca3d5d79b7e536bb1c3bc2be3e02852",
"scripts/dasha_analyzer.py": "3949b96813217fe624899856afec9f0b787c4cb0fea3a9a0d961990dbd11943a",
"scripts/divisional_charts_extended.py": "e9820e80f14fddc86ab5495d11455d16c2d3c9502a9a242fcf9efc41186e0d11",
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
"scripts/jaimini.py": "6b88658da5404da9b136686d92a04be462d61a39da6e8de4255021e2ed2d1173",
"scripts/minute_rectification_blind_eval.py": "07aa30d8bad3278669495d5fea8be937aaf6367c8af980ff4f2fe9aa84ccc6ff",
"scripts/minute_rectification_fact_blind_eval_v4.py": "bdf460e3467208e8b87345c5beb2128f3e22de7eeffd68f72b281b3ad9affc92",
"scripts/minute_rectification_fact_ranker_v4.py": "f1a0d8d2482cd754d2e7f253a9606b0373519435a2bf58cfc3753e2b9ae0fb76",
"scripts/minute_rectification_feature_facts_v4.py": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
"scripts/narayana_dasha.py": "bc667c30c9562fb73fb0b7b4c22a5e377f9a531795141fc629b2cd3cf396773a",
"scripts/rectification/api_service.py": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
"scripts/rectification/dasha_transition_proximity.py": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "8ecc381dfaa3c0e6bea058cfa44c606aabdb1086c137f16da82ee5a3bf5ccc91",
"scripts/research/sealed_holdout_rerun.py": "ebcdae760f2b76c81d5d0c8e2bf347cff4c81cca9074f27ae94b6734408ccd42",
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
"scripts/varga.py": "8ff96b9fc8346ca249abff6109434b6ed57fa150e1a9854d4f73bdf317568368"
},
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock"
},
"scorer_frozen_before_rerun": true,
"source_audit_status": "corrected_known_date_errors",
"trial_count": 20,
"official_valid_independent_blind": false,
"is_blind_evaluation": false,
"truth_hidden_from_ranker": true,
"results_previously_seen": true,
"must_not_claim_as_release_metrics": true,
"must_not_use_for_tuning": true,
"verified_minute_claim_allowed": false,
"metric_gates_passed": false,
"events_per_case": 3,
"boundary": "Previously exposed v3 shadow fixed-protocol rerun after upstream sync9b (ERR-110, BUG-1494 to BUG-1495). Trials and metrics match the 2026-10-18 upstream sync8 report. Versus the 2026-09-20 historical report, changed_trial_count stays 8. Not a release metric. Prior sync8 report and freeze preserved byte-for-byte.",
"report_sha256": "ef20e751a746a4ec3b368d6860123602172a9af9195a97ec5f4d3c2cdf4823ce",
"freeze_record_sha256": "32cad06f25c84891a24725110c9737489b8d79ed7c2d33ae959cb8eb16798dab",
"replay_started_at_utc": "2026-10-28T08:19:06.261911+00:00",
"replay_finished_at_utc": "2026-10-28T08:19:09.621910+00:00",
"previous_fixed_protocol_rerun": {
"report_path": "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json",
"freeze_record_path": "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.freeze.json",
"superseded_reason": "Upstream sync9b (BUG-1494 to BUG-1495) changed scripts/varga.py, which is part of the extended identity. Output is unchanged, so ERR-110 stored a new record. Prior sync8 report and freeze preserved byte-for-byte."
}
},
"previous_fixed_protocol_rerun": {
"evaluated_on": "2026-10-18",
"report_path": "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json",
"freeze_record_path": "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.freeze.json",
@@ -212,106 +312,6 @@
"superseded_reason": "Upstream sync8 (BUG-1380 to BUG-1390) changed frozen identity files. Output is unchanged, so ERR-110 stored a new record. Prior scoring_perf report and freeze preserved byte-for-byte."
}
},
"previous_fixed_protocol_rerun": {
"evaluated_on": "2026-10-03",
"report_path": "docs/research/sealed_holdout_rerun_astrologer_rulings_batch1_2026_10_03.json",
"freeze_record_path": "docs/research/sealed_holdout_rerun_astrologer_rulings_batch1_2026_10_03.freeze.json",
"implementation_sha256": "114c60c45fa35a77bb6bfe2c177bbcc90b2e0748e2a33e61975f7393802f96f2",
"extended_identity": {
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
"production_scoring_files": [
"scripts/active_rectification_event_engine.py",
"scripts/active_rectification_events.py",
"scripts/ashtakavarga.py",
"scripts/dasha_analyzer.py",
"scripts/divisional_charts_extended.py",
"scripts/domain_calculation_service.py",
"scripts/functional_benefics.py",
"scripts/jaimini.py",
"scripts/minute_rectification_fact_ranker_v4.py",
"scripts/minute_rectification_feature_facts_v4.py",
"scripts/narayana_dasha.py",
"scripts/rectification/api_service.py",
"scripts/rectification/candidate_contrast.py",
"scripts/rectification/candidate_window.py",
"scripts/rectification/case_holdout.py",
"scripts/rectification/contracts.py",
"scripts/rectification/dasha_transition_proximity.py",
"scripts/rectification/decision_policy.py",
"scripts/rectification/event_probes.py",
"scripts/rectification/refinement_packet.py",
"scripts/rectification/scoring_service.py",
"scripts/shadbala.py",
"scripts/varga.py"
],
"production_scoring_sha256": "788745f7b7aba1d551a73a67a56daff3e730c1d408cd77dde155e410ad9d00bb",
"research_files": [
"scripts/research/reported_offset_sweep.py",
"scripts/research/cluster_width_lib.py",
"scripts/research/probe_supply_after_six.py",
"scripts/research/sealed_holdout_rerun.py",
"scripts/minute_rectification_blind_eval.py",
"scripts/minute_rectification_fact_blind_eval_v4.py",
"scripts/minute_rectification_holdout_validator.py"
],
"research_implementation_sha256": "fa74b52f52ca7beeb1c6c6647b11ef075c16bcb6fadb656196770d91004cd0ad",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "cdc0e68edc770a2a1810fbdb68cbfd46dcd50b267c739c496fcc078c5e26b5b4",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
"scripts/ashtakavarga.py": "9bfc0b8a06af6122fa6c3881706e64f28b9ba58b53d98ff55957d4741d820f3f",
"scripts/dasha_analyzer.py": "5e53b1b4d4c7414c08dd0e7716f120a63c70ec3428ec93547f64d55c580da0e1",
"scripts/divisional_charts_extended.py": "73bd50a83ac444830940184d0988cea621855f91db76a3f1a232014b992eacea",
"scripts/domain_calculation_service.py": "9ee955874eea806c7b56f4246d90009c02ee8318b8f3dfe1d7132b1c0b4262e1",
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
"scripts/jaimini.py": "6b88658da5404da9b136686d92a04be462d61a39da6e8de4255021e2ed2d1173",
"scripts/minute_rectification_blind_eval.py": "07aa30d8bad3278669495d5fea8be937aaf6367c8af980ff4f2fe9aa84ccc6ff",
"scripts/minute_rectification_fact_blind_eval_v4.py": "bdf460e3467208e8b87345c5beb2128f3e22de7eeffd68f72b281b3ad9affc92",
"scripts/minute_rectification_fact_ranker_v4.py": "f1a0d8d2482cd754d2e7f253a9606b0373519435a2bf58cfc3753e2b9ae0fb76",
"scripts/minute_rectification_feature_facts_v4.py": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
"scripts/narayana_dasha.py": "7ff2c3238cd113b11b815e14b41967bd8e486ff62e36650e55613b9c79d1bfe3",
"scripts/rectification/api_service.py": "e316ced0f3796d0378a0fe1aad950ee25922ae8afd53add578ed59ca8b2f0f79",
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
"scripts/rectification/dasha_transition_proximity.py": "0afba494aa12899621173c3cc941c6c1d0be9cec0bdf4c4a1c9bcd004ab22de8",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "9bbadc246e6b8e94bb047522c3e5645613f508b3473ef40b38a207c77fdad58c",
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
"scripts/rectification/scoring_service.py": "4f13c6a78d2dda55454ac20e4091fce280e3626ecfe8a9d723217ef3ebc05d02",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "f63c1c58d334e083577736397b4acc00757550fa3b863b02784c9d6f0f018c80",
"scripts/research/sealed_holdout_rerun.py": "1178ebfb9dd61d4aefe9961d83f19e7a943a4ca3de9fa2efffff6f8ba8c09161",
"scripts/shadbala.py": "e949ff1cc925ccbc9a25b68981091d1d621b80a5b3839a3b3ade655d082424d6",
"scripts/varga.py": "4331de5a25ea08729af91aae863943c4223183937f419d52dc63fd6fe7f27be6"
},
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock"
},
"scorer_frozen_before_rerun": true,
"source_audit_status": "corrected_known_date_errors",
"trial_count": 20,
"official_valid_independent_blind": false,
"is_blind_evaluation": false,
"truth_hidden_from_ranker": true,
"results_previously_seen": true,
"must_not_claim_as_release_metrics": true,
"must_not_use_for_tuning": true,
"verified_minute_claim_allowed": false,
"metric_gates_passed": false,
"events_per_case": 3,
"boundary": "Previously exposed v3 shadow fixed-protocol rerun after the 2026-10-03 astrologer rulings batch 1 re-freeze. Superseded by the 2026-10-07 upstream sync 6 re-freeze (ERR-110: scripts/varga.py, scripts/divisional_charts_extended.py, scripts/narayana_dasha.py and scripts/rectification/scoring_service.py bytes changed by the PVR 6.2 D5/D6/D8/D11 maps and the scoring-11 identity, BUG-1260/1264). Shadow trial fields versus the 2026-09-20 historical report are unchanged (changed_trial_count 0). Report and freeze preserved byte-for-byte.",
"report_sha256": "b39b53c1f2aa0ad6ac363b4c359116c30bc89b446cb670e6172c8cee5ccc5efd",
"freeze_record_sha256": "c1c11e7abc03b9174aeb1e0b280f550808f887818f578e5d10ad667e0da67e24",
"replay_started_at_utc": "2026-10-03T06:53:04.851491+00:00",
"replay_finished_at_utc": "2026-10-03T06:53:07.509845+00:00",
"previous_fixed_protocol_rerun": {
"report_path": "docs/research/sealed_holdout_rerun_upstream_sync5_2026_10_02.json",
"freeze_record_path": "docs/research/sealed_holdout_rerun_upstream_sync5_2026_10_02.freeze.json",
"superseded_reason": "Upstream sync round 6 (ERR-110). Prior sync5 report and freeze preserved byte-for-byte."
}
},
"current_tree_unfrozen_diagnostic": {
"is_blind_evaluation": false,
"results_already_seen": true,
@@ -330,6 +330,28 @@
"verified_minute_claim_allowed": false
},
"current_tree_reported_offset_replay": {
"report_path": "docs/research/reported_offset_upstream_sync9b_2026_10_28.json",
"report_sha256": "880bebc2e217100994a1442e47ead3f9d33e48aa3ac20f69d993f38def3ef25c",
"freeze_record_path": "docs/research/reported_offset_upstream_sync9b_2026_10_28.freeze.json",
"freeze_record_sha256": "385817bb27f04afeba230af5e547dd10dc8cf6db4b25c18bebe4f4e674cab972",
"scope": "reported_offset_sensitivity_not_product_accuracy",
"trial_count": 900,
"official_valid_independent_blind": false,
"official_blind_trial_count": 0,
"must_not_use_for_tuning": true,
"must_not_claim_as_release_metrics": true,
"replay_started_at_utc": "2026-10-28T08:19:10.206540+00:00",
"replay_finished_at_utc": "2026-10-28T08:23:34.348635+00:00",
"boundary": "Previously exposed reported-offset sensitivity after upstream sync9b (ERR-110, BUG-1494 to BUG-1495). Summary and trials match the 2026-10-18 upstream sync8 report. Versus the 2026-09-20 historical report, changed_trial_count stays 581 of 900. Not a release metric. No tuning. Prior sync8 report and freeze preserved byte-for-byte.",
"previous_reported_offset_replay": {
"report_path": "docs/research/reported_offset_upstream_sync8_2026_10_18.json",
"report_sha256": "a51aad2e743dccba7726d1a0a5e44c5e431ff429fe1725c9be2f7fa5e9282da6",
"freeze_record_path": "docs/research/reported_offset_upstream_sync8_2026_10_18.freeze.json",
"freeze_record_sha256": "117ab0bfa4a283e9d52073808f71dd8317f613c96b27ec271d887ef40087f4f5",
"superseded_reason": "Upstream sync9b (BUG-1494 to BUG-1495) changed scripts/varga.py, which is part of the extended identity. Output is unchanged, so ERR-110 stored a new record. Prior sync8 report and freeze preserved byte-for-byte."
}
},
"previous_reported_offset_replay": {
"report_path": "docs/research/reported_offset_upstream_sync8_2026_10_18.json",
"report_sha256": "a51aad2e743dccba7726d1a0a5e44c5e431ff429fe1725c9be2f7fa5e9282da6",
"freeze_record_path": "docs/research/reported_offset_upstream_sync8_2026_10_18.freeze.json",
@@ -350,12 +372,5 @@
"freeze_record_sha256": "1560f24ffea19116c904272907ff9cce24904960406676f3aada3c8c174cf98a",
"superseded_reason": "Upstream sync8 (BUG-1380 to BUG-1390) changed frozen identity files. Output is unchanged, so ERR-110 stored a new record. Versus the 2026-09-20 historical report, changed_trial_count stays 581 of 900. Prior scoring_perf report and freeze preserved byte-for-byte."
}
},
"previous_reported_offset_replay": {
"report_path": "docs/research/reported_offset_astrologer_rulings_batch1_2026_10_03.json",
"report_sha256": "480eb771c2c7e5a300589561dc66be950a711ffa205a8fa370db6a11f1e04270",
"freeze_record_path": "docs/research/reported_offset_astrologer_rulings_batch1_2026_10_03.freeze.json",
"freeze_record_sha256": "6be6a997f610c341aecc1f782fa27a5b86b66d43d00fcfa10750c81189c34748",
"superseded_reason": "Upstream sync 6 (ERR-110: scripts/varga.py, scripts/divisional_charts_extended.py, scripts/narayana_dasha.py and scripts/rectification/scoring_service.py). 5 of 900 trials differ from the batch1 replay (delivery_width_minutes on 4, top_1_minute_error on 1). Versus the 2026-09-20 historical report, changed_trial_count is 235 (batch1 was 230). Prior report and freeze preserved byte-for-byte."
}
}
+5 -5
View File
@@ -30,11 +30,11 @@ from scripts.research.sealed_holdout_rerun import (
OFFSETS = (-30, -20, -15, -10, -8, -5, -3, 0, 3, 5, 8, 10, 15, 20, 30)
RADII = (15, 30, 60)
MINUTE_STEP = 1
# 原值: docs/research/reported_offset_scoring_perf_2026_10_13.{freeze.json,json}
# 新值: docs/research/reported_offset_upstream_sync8_2026_10_18.{freeze.json,json}
# 原因: sync8(BUG-1380~1389)改了 ashtakavarga / dasha_analyzer / narayana_dasha,输出不变,ERR-110 另存新记录
FREEZE = ROOT / "docs/research/reported_offset_upstream_sync8_2026_10_18.freeze.json"
REPORT = ROOT / "docs/research/reported_offset_upstream_sync8_2026_10_18.json"
# 原值: docs/research/reported_offset_upstream_sync8_2026_10_18.{freeze.json,json}
# 新值: docs/research/reported_offset_upstream_sync9b_2026_10_28.{freeze.json,json}
# 原因: sync9b(BUG-1494~1495)改了 scripts/varga.py,它在扩展身份里;报时窗口输出不变,ERR-110 另存新记录
FREEZE = ROOT / "docs/research/reported_offset_upstream_sync9b_2026_10_28.freeze.json"
REPORT = ROOT / "docs/research/reported_offset_upstream_sync9b_2026_10_28.json"
LEGACY_REPORT = ROOT / "docs/research/reported_offset_2026_09_20.json"
+5 -5
View File
@@ -27,11 +27,11 @@ from scripts.minute_rectification_feature_facts_v4 import build_feature_fact_row
from scripts.minute_rectification_holdout_validator import validate
DATASET = ROOT / "references/real_case_calibration/minute_rectification_holdout_v3.json"
# 原值: docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.{freeze.json,json}
# 新值: docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.{freeze.json,json}
# 原因: sync8(BUG-1380~1389)改了 ashtakavarga / dasha_analyzer / narayana_dasha,输出不变,ERR-110 另存新记录
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.freeze.json"
REPORT = ROOT / "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json"
# 原值: docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.{freeze.json,json}
# 新值: docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.{freeze.json,json}
# 原因: sync9b(BUG-1494~1495)改了 scripts/varga.py,它在扩展身份里;切盘输出不变,ERR-110 另存新记录
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.freeze.json"
REPORT = ROOT / "docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.json"
LEGACY_REPORT = ROOT / "docs/research/sealed_holdout_rerun_2026_09_20.json"
ARCHIVE = ROOT / "docs/research/history/rectification_pre_cross_midnight_2026_09_20"
PRODUCTION_FILES = [
+12 -8
View File
@@ -246,11 +246,12 @@ def calc_64th_navamsa(moon_lon: float) -> Dict:
def calc_22nd_drekkana(lagna_lon: float) -> Dict:
"""Compute the 22nd Drekkana from the Lagna's Drekkana position.
Traditional counting is inclusive, so the 22nd point is +21 signs from the
Lagna Drekkana anchor.
Crux of Vedic Astrology, printed p.329 (corpus page 340), identifies
this as the eighth house in D3. Counting 21 ten-degree divisions is
not the same as moving 21 signs in the D3 chart.
"""
base = calc_varga(lagna_lon, 3)
sign_idx = (base['sign_idx'] + 21) % 12
sign_idx = (base['sign_idx'] + 7) % 12
return {
'base_drekkana_sign_idx': base['sign_idx'],
'base_drekkana_sign': base['sign'],
@@ -279,16 +280,19 @@ def calc_bhrigu_bindu(moon_lon: float, rahu_lon: float) -> Dict:
}
SARPA_DREKKANA_SIGNS = {'Cancer': 2, 'Scorpio': 1, 'Pisces': 3}
# Raman, Hindu Predictive Astrology, Drekkanas chapter, corpus pp.279–283:
# five serpent divisions, including both Cancer-3 and Scorpio-2.
SARPA_DREKKANA_SIGNS = {'Cancer': (2, 3), 'Scorpio': (1, 2), 'Pisces': (3,)}
def calc_sarpa_drekkana(lon: float) -> Dict:
"""Classify Sarpa Drekkana using the classical water-sign sensitive drekkanas."""
lon = lon % 360
base = calc_varga(lon, 3)
sign = _sn(_si(lon))
degree_in_sign = round((lon % 30), 4)
drekkana_number = int(degree_in_sign / 10) + 1
expected = SARPA_DREKKANA_SIGNS.get(sign)
drekkana_number = int((lon % 30) / 10) + 1
expected = SARPA_DREKKANA_SIGNS.get(sign, ())
return {
'longitude': round(lon % 360, 4),
'sign': sign,
@@ -297,8 +301,8 @@ def calc_sarpa_drekkana(lon: float) -> Dict:
'drekkana_number': drekkana_number,
'd3_sign': base['sign'],
'd3_sign_idx': base['sign_idx'],
'is_sarpa_drekkana': bool(expected == drekkana_number),
'definition': 'Cancer-2, Scorpio-1, Pisces-3',
'is_sarpa_drekkana': drekkana_number in expected,
'definition': 'Cancer-2, Cancer-3, Scorpio-1, Scorpio-2, Pisces-3',
}
def dignity(planet, sign_idx):
+36
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import sys
import time
from pathlib import Path
import pytest
@@ -58,6 +59,41 @@ def _isolate_vedastro_snapshot_cache(tmp_path_factory, monkeypatch) -> None:
clear_validate_memory()
_FOREGROUND_EXECUTOR_MODULES = (
"scripts.vedastro_foreground",
"vedastro_foreground",
"scripts.jyotish_api_server",
"jyotish_api_server",
)
@pytest.fixture(autouse=True)
def _isolate_foreground_vedastro_executor(monkeypatch):
"""A fresh foreground pool per test, drained at teardown (BUG-1498).
A task left running stores its outcome under whatever cache directory the
environment names when it finishes, which would be the next test's.
"""
from concurrent.futures import ThreadPoolExecutor
from scripts import vedastro_foreground
executor = ThreadPoolExecutor(
max_workers=vedastro_foreground._FOREGROUND_VEDASTRO_WORKERS,
thread_name_prefix="jyotish-vedastro-fg-test",
)
for name in _FOREGROUND_EXECUTOR_MODULES:
module = sys.modules.get(name)
if module is not None and hasattr(module, "_FOREGROUND_VEDASTRO_EXECUTOR"):
monkeypatch.setattr(module, "_FOREGROUND_VEDASTRO_EXECUTOR", executor)
yield
# Queued work is dropped; running work gets a bounded wait so a stuck stub cannot hang the run.
executor.shutdown(wait=False, cancel_futures=True)
deadline = time.monotonic() + 10
for thread in list(getattr(executor, "_threads", ())):
thread.join(max(deadline - time.monotonic(), 0))
def pytest_collection_modifyitems(items) -> None:
for item in items:
if item.fspath.basename == "test_api_server_security.py" and item.name.startswith(SLOW_API_SECURITY_PREFIXES):
+1 -1
View File
@@ -701,7 +701,7 @@ def test_full_reading_exposes_sensitive_point_modules() -> None:
assert sensitive["bhrigu_bindu"]["sign"]
assert isinstance(sensitive["sarpa_drekkana"], dict)
for payload in sensitive["sarpa_drekkana"].values():
assert payload["definition"] == "Cancer-2, Scorpio-1, Pisces-3"
assert payload["definition"] == "Cancer-2, Cancer-3, Scorpio-1, Scorpio-2, Pisces-3"
assert payload["is_sarpa_drekkana"] is True
@@ -53,6 +53,11 @@ IDENTITY_FORBIDDEN_PREFIXES = (
# 原因: sync8 改了冻结身份文件,输出不变,ERR-110 另存新记录
"docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18",
"docs/research/reported_offset_upstream_sync8_2026_10_18",
# 原值: 排除到 upstream_sync8_2026_10_18
# 新值: 再排除 upstream_sync9b_2026_10_28 两份冻结路径(追加,不替换上面的)
# 原因: sync9b 改了扩展身份里的 scripts/varga.py,回放输出不变,ERR-110 另存新记录
"docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28",
"docs/research/reported_offset_upstream_sync9b_2026_10_28",
"docs/research/history/",
"artifacts/",
)
@@ -0,0 +1,16 @@
"""Classification must use raw longitude, never rounded display degrees."""
import pytest
from scripts.varga import calc_sarpa_drekkana
@pytest.mark.parametrize('longitude,part,is_sarpa', [
(99.99999, 1, False), (100.0, 2, True),
(219.99999, 1, True), (220.0, 2, True),
(359.99999, 3, True), (360.0, 1, False),
])
def test_sarpa_partition_uses_unrounded_longitude(longitude, part, is_sarpa):
row = calc_sarpa_drekkana(longitude)
assert row['drekkana_number'] == part
assert row['is_sarpa_drekkana'] is is_sarpa
assert 1 <= row['drekkana_number'] <= 3
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Regression tests for BPHS divisional chart mappings."""
from __future__ import annotations
import pytest
pytest.importorskip(
"hypothesis",
reason="hypothesis is not installed; see requirements-dev.txt. "
"The guard keeps this file collectable: without it the whole file "
"fails to collect and its tests vanish from the run instead of failing.",
)
from hypothesis import given
from hypothesis import strategies as st
from varga import (
SIGNS,
calc_22nd_drekkana,
calc_64th_navamsa,
calc_bhrigu_bindu,
calc_all_vargas,
calc_sarpa_drekkana,
calc_varga,
varga_map,
)
def navamsa_ref(lon: float) -> int:
sign_index = int((lon % 360) / 30) % 12
degree_in_sign = (lon % 360) - sign_index * 30
part_index = int(degree_in_sign / (30 / 9))
if sign_index % 3 == 0:
start = sign_index
elif sign_index % 3 == 1:
start = (sign_index + 8) % 12
else:
start = (sign_index + 4) % 12
return (start + part_index) % 12
def dasamsa_ref(lon: float) -> int:
sign_index = int((lon % 360) / 30) % 12
degree_in_sign = (lon % 360) - sign_index * 30
part_index = int(degree_in_sign / 3)
start = sign_index if sign_index % 2 == 0 else (sign_index + 8) % 12
return (start + part_index) % 12
def drekkana_ref(lon: float) -> int:
sign_index = int((lon % 360) / 30) % 12
degree_in_sign = (lon % 360) - sign_index * 30
part_index = int(degree_in_sign / 10)
return (sign_index + part_index * 4) % 12
def chaturthamsa_ref(lon: float) -> int:
sign_index = int((lon % 360) / 30) % 12
degree_in_sign = (lon % 360) - sign_index * 30
part_index = int(degree_in_sign / (30 / 4))
return (sign_index + part_index * 3) % 12
@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False))
def test_navamsa_matches_bphs_reference(lon: float) -> None:
result = calc_varga(lon, 9)
assert result["sign_idx"] == navamsa_ref(lon)
assert result["sign"] == SIGNS[navamsa_ref(lon)]
assert 0 <= result["degree_in_sign"] < 30
@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False))
def test_dasamsa_matches_bphs_reference(lon: float) -> None:
result = calc_varga(lon, 10)
assert result["sign_idx"] == dasamsa_ref(lon)
assert result["sign"] == SIGNS[dasamsa_ref(lon)]
assert 0 <= result["degree_in_sign"] < 30
@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False))
def test_drekkana_uses_same_plus_four_plus_eight(lon: float) -> None:
result = calc_varga(lon, 3)
assert result["sign_idx"] == drekkana_ref(lon)
@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False))
def test_chaturthamsa_matches_pyjhora_parashara_reference(lon: float) -> None:
result = calc_varga(lon, 4)
assert result["sign_idx"] == chaturthamsa_ref(lon)
assert result["sign"] == SIGNS[chaturthamsa_ref(lon)]
assert 0 <= result["degree_in_sign"] < 30
def test_chaturthamsa_reference_boundary_examples() -> None:
assert [varga_map(0, part, 4) for part in range(4)] == [0, 3, 6, 9]
assert [varga_map(8, part, 4) for part in range(4)] == [8, 11, 2, 5]
assert [varga_map(11, part, 4) for part in range(4)] == [11, 2, 5, 8]
def test_varga_map_boundary_examples() -> None:
assert varga_map(0, 0, 9) == 0 # Aries Navamsa starts Aries
assert varga_map(1, 0, 9) == 9 # Taurus Navamsa starts Capricorn (9th from sign)
assert varga_map(2, 0, 9) == 6 # Gemini Navamsa starts Libra (5th from sign)
assert varga_map(1, 0, 10) == 9 # Taurus Dasamsa starts Capricorn
assert varga_map(0, 2, 3) == 8 # Aries third Drekkana = Sagittarius
def test_rudramsa_d11_is_supported_by_native_varga_map() -> None:
assert varga_map(0, 0, 11) == 0
assert varga_map(0, 10, 11) == 10
# PVR 6.2.11: anti-zodiacal seed from Aries, zodiacal parts.
assert varga_map(1, 0, 11) == 11
assert varga_map(1, 10, 11) == 9
result = calc_varga(35.0, 11)
assert result["sign"] == "Aries"
assert 0 <= result["degree_in_sign"] < 30
def test_native_varga_map_supports_declared_high_value_vargas() -> None:
for div in (2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 24, 60, 81, 108, 144):
result = calc_varga(35.0, div)
assert result["sign"] in SIGNS
assert 0 <= result["degree_in_sign"] < 30
def test_default_varga_batch_includes_declared_vargas() -> None:
charts = calc_all_vargas({"Moon": 35.0}, 10.0)
for key in ("D5_Panchamsa", "D6_Shashthamsa", "D8_Ashtamsa", "D11_Rudramsa", "D81_Navamsa-Navamsa", "D108_Dwadasamsa-Navamsa", "D144_Dwadasamsa-Dwadasamsa"):
assert key in charts
assert charts["D11_Rudramsa"]["Moon"]["sign"] == "Aries"
def test_navamsa_matches_user_jhora_pdf_reference_chart() -> None:
"""Regression from private_chart_reference.pdf: JHora-style D9 table for public sample birth datetime San Francisco."""
expected = {
132.355025: "Cancer", # Ascendant 12 Leo 21'18.09"
3.5226611111111112: "Taurus",
311.78995555555554: "Capricorn",
91.33091388888889: "Cancer",
338.5488: "Virgo",
163.83150833333335: "Taurus",
340.5554638888889: "Libra",
304.3033805555556: "Scorpio",
231.04509444444443: "Capricorn",
51.045094444444445: "Cancer",
}
for longitude, expected_sign in expected.items():
assert calc_varga(longitude, 9)["sign"] == expected_sign
def test_64th_navamsa_counts_forward_from_moon_navamsa() -> None:
moon_lon = 42.3
result = calc_64th_navamsa(moon_lon)
expected_sign_idx = (navamsa_ref(moon_lon) + 63) % 12
assert result["sign_idx"] == expected_sign_idx
assert result["sign"] == SIGNS[expected_sign_idx]
assert result["offset_from_moon_navamsa"] == 64
def test_22nd_drekkana_counts_forward_from_lagna_drekkana() -> None:
asc_lon = 25.5
result = calc_22nd_drekkana(asc_lon)
# Sagittarius D3 rising: its eighth house is Cancer (Crux p.329).
expected_sign_idx = 3
assert result["sign_idx"] == expected_sign_idx
assert result["sign"] == SIGNS[expected_sign_idx]
assert result["offset_from_lagna_drekkana"] == 22
def test_22nd_drekkana_independent_zodiac_partition_count() -> None:
# Enumerate all 36 zodiacal ten-degree divisions, then count 22 inclusively.
# This oracle does not add an offset to the calculated D3 sign.
for division in range(36):
target = (division + 21) % 36
target_rasi, target_part = divmod(target, 3)
expected = (target_rasi + 4 * target_part) % 12
for within in (0.0, 0.001, 5.0, 9.999):
result = calc_22nd_drekkana(division * 10 + within)
assert result['sign_idx'] == expected
def test_bhrigu_bindu_uses_rahu_to_moon_arc_midpoint() -> None:
result = calc_bhrigu_bindu(270.5, 160.2333333333)
assert result["longitude"] == 215.3667
assert result["sign"] == "Scorpio"
assert result["degree_in_sign"] == 5.3667
def test_bhrigu_bindu_handles_wraparound_across_zero_aries() -> None:
result = calc_bhrigu_bindu(10.0, 350.0)
assert result["longitude"] == 0.0
assert result["sign"] == "Aries"
assert result["degree_in_sign"] == 0.0
def test_sarpa_drekkana_detects_classical_sensitive_ranges() -> None:
cancer_second = calc_sarpa_drekkana(105.0)
scorpio_first = calc_sarpa_drekkana(215.0)
pisces_third = calc_sarpa_drekkana(355.0)
safe_aries = calc_sarpa_drekkana(15.0)
assert cancer_second["is_sarpa_drekkana"] is True
assert cancer_second["sign"] == "Cancer"
assert cancer_second["drekkana_number"] == 2
assert scorpio_first["is_sarpa_drekkana"] is True
assert scorpio_first["sign"] == "Scorpio"
assert scorpio_first["drekkana_number"] == 1
assert pisces_third["is_sarpa_drekkana"] is True
assert pisces_third["sign"] == "Pisces"
assert pisces_third["drekkana_number"] == 3
assert safe_aries["is_sarpa_drekkana"] is False
def test_sarpa_contains_all_five_book_ranges_and_no_others() -> None:
# Zodiac degree intervals transcribed from Raman, not the production map.
intervals = ((100, 120), (210, 230), (350, 360))
for segment in range(36):
for offset in (0.0, 0.001, 5.0, 9.999):
longitude = segment * 10 + offset
expected = any(start <= longitude < end for start, end in intervals)
assert calc_sarpa_drekkana(longitude)['is_sarpa_drekkana'] == expected
+2 -1
View File
@@ -175,7 +175,8 @@ def test_64th_navamsa_counts_forward_from_moon_navamsa() -> None:
def test_22nd_drekkana_counts_forward_from_lagna_drekkana() -> None:
asc_lon = 25.5
result = calc_22nd_drekkana(asc_lon)
expected_sign_idx = (drekkana_ref(asc_lon) + 21) % 12
# Sagittarius D3 rising: its eighth house is Cancer (Crux p.329).
expected_sign_idx = 3
assert result["sign_idx"] == expected_sign_idx
assert result["sign"] == SIGNS[expected_sign_idx]
assert result["offset_from_lagna_drekkana"] == 22
@@ -0,0 +1,32 @@
"""BUG-1498: a foreground VedAstro task never outlives the test that started it.
The two tests run in file order. The first leaves a slow task on the foreground
executor; the second checks that the task finished before it began, so it could
not write into the second test's cache directory.
"""
from __future__ import annotations
import os
import time
from scripts import vedastro_foreground as foreground
_SEEN: list[str] = []
def _slow_task() -> None:
time.sleep(0.3)
_SEEN.append(os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR", ""))
def test_a_leaves_slow_work_on_the_foreground_executor() -> None:
_SEEN.clear()
foreground._FOREGROUND_VEDASTRO_EXECUTOR.submit(_slow_task)
def test_b_does_not_see_the_previous_tests_work() -> None:
mine = os.environ["JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR"]
assert len(_SEEN) == 1, "the earlier test's task finished during its own teardown"
time.sleep(0.5)
assert mine not in _SEEN, "a leftover task wrote into this test's cache directory"