Compare commits

..
Author SHA1 Message Date
jesseandClaude Opus 5.5 cbfb37f7c8 test: list /rectification in the capability audit app routes (issue #145)
Independent Staging Quality Gate / validate (push) Successful in 20m54s
Independent Staging Quality Gate / publish (push) Successful in 5m43s
Staging gate run 2009 failed on test_capability_audit_scans_registry_and_local_sources:
the new app route app/(app)/rectification was not in the expected list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:23:17 +08:00
jesseandClaude Opus 5.5 dfd3208979 feat: standalone rectification entry at /rectification (issue #145)
Independent Staging Quality Gate / validate (push) Failing after 13m25s
Independent Staging Quality Gate / publish (push) Skipped
- Sidebar 「生时校正」 under 星盘, behind a new account `rectification` flag.
- /rectification page: status card from entry-summary (not started / in
  progress with window and confirmed event count / adopted time), a short
  data check, then the home rectification surface (?rectify=1, or
  ?rectify=new to start over; ?c=<id> to continue or view). No price on
  the page.
- entry-summary gains session id, candidate range, confirmed evidence count
  and adopted times (add-only migration 20261028170000).
- The offer card under a consultation answer becomes one line and a link
  to /rectification; an explicit request in chat also goes there. No case
  is opened from a conversation any more.
- Starter home shows a rectification prompt for self charts with an
  estimated time; onboarding checklist gains 做一次生时校正 for them.
- Chart-page link points to /rectification. Shell registers its router so
  home hooks can navigate client-side without useRouter in Home().
- Docs: VOICE, DESIGN, CHANGELOG, device checklist.

Self profile only; other people are issue #160.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:50:55 +08:00
54 changed files with 1337 additions and 45472 deletions
+5 -5
View File
@@ -1,11 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 两处分盘敏感点按来源修正
## 2026-10-28 — 生时校正有了自己的入口
- Sarpa Drekkana 从三段补成书上的五段:巨蟹第 3 段、天蝎第 2 段以前漏掉,现在会标出来;正好落在 10 度边界上的盘不再划到上一段。
- 第 22 号 Drekkana 改为 D3 的第 8 宫(以前多移了 14 个星座)。这个点目前还没接进完整解读的敏感点表,所以报告上看不到差别。
- 生时校正 20 条密封案例与 900 个报时窗口,名次与分钟误差与上一份回放逐条相同。
- Skill 版本不变(BUG-1494、BUG-1495,issue #159)。
- 侧栏「星盘」下面新增「生时校正」,打开一页就能看到现在的状态:没做过就开始,没做完就继续,做完了看结果或再校正一次。开始前先核对一遍出生资料。
- 对话里提到出生时间不确定时,回答下面只给一句提示和「去做生时校正」的链接,不再从这段对话里开校正;明说要做校正时也跳到这一页。
- 建完档、出生时间是估的,首页起步问题下面多一句提示;启程清单对这类账户多一项「做一次生时校正」。
- 生时校正仍只支持本人,给家人做校正另见 issue #160。Skill 版本不变(issue #145)。
## 2026-10-28 — 两人同时做校正或生成报告时不再互相拖慢
-31
View File
@@ -20416,34 +20416,3 @@
- 关联: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`。
@@ -1,118 +0,0 @@
{
"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
@@ -1,116 +0,0 @@
{
"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
@@ -1,45 +0,0 @@
# 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.
@@ -1,68 +0,0 @@
# 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:本次不改计算代码
-1
View File
@@ -544,4 +544,3 @@
| — | `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` |
@@ -1,76 +0,0 @@
# 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`。
@@ -1,45 +0,0 @@
# 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,29 @@
# 真机清单:生时校正独立入口(issue #145,2026-10-28)
本机没有 Docker 和后端,下面这些都没在浏览器里走过。部署到 staging 后由产品负责人逐条核对。
前置:staging 已应用迁移 `20261028170000_rectification_entry_summary_detail.sql`。
## 入口
- [ ] 侧栏「星盘」下面有「生时校正」,桌面和手机抽屉都有;点了进 `/rectification`,当前项高亮。
- [ ] 后台关掉 `product.rectification` 后刷新:侧栏没有这一项;直接打开 `/rectification` 显示「生时校正这会儿还做不了。」
- [ ] 未登录打开 `/rectification`:「登录后可以做生时校正。」和「去登录」。
## 三种状态(本人档案)
- [ ] 没做过:显示现在用的出生时间,「开始」→ 核对页 → 「开始校正」→ 首页打开校正面,自动发出开场。
- [ ] 进行中:显示时间窗和已收大事数(只数确认过的),「继续」打开那次校正,不新开。
- [ ] 已完成:显示采用的时刻;「查看结果」打开那次校正;「再校正一次」→ 核对 → 新开一次。
- [ ] 核对页「改这份」打开本人资料编辑。
- [ ] 本人资料不全(帮别人看的账户):提示先填资料,「去填资料」打开本人编辑。
- [ ] 页面上任何地方都没有点数或价格。
## 其他入口
- [ ] 咨询回答下面出现提议时,是一句「出生时间还不确定。」加链接「去做生时校正」,没有题数和点数;点了去 `/rectification`,不开案例;回答生成中链接置灰。
- [ ] 在对话里明说「帮我做生时纠正」:回答一句过渡后跳到 `/rectification`。
- [ ] 星盘页「出生时间校正」链接去 `/rectification`。
- [ ] 新账户建档时出生时间选「家人记得」或「时段」或「不知道」:天空过场后首页起步问题下面有提示和「去校正」;选「出生证明」的账户没有。帮别人看的账户没有。
- [ ] 同上的账户,启程清单 8 项,第三项「做一次生时校正」,「去做」到 `/rectification`;采用时间后打勾。出生证明账户仍是 7 项。
## 手机和减少动态效果
- [ ] 375 px 宽:`/rectification`、核对页、首页提示都没有横向滚动,按钮不小于 44 px。
- [ ] 打开系统「减少动态效果」:页面只淡入,没有位移。
-9
View File
@@ -1,9 +0,0 @@
# 真机走查 · 上游同步第九轮乙批(issue #159)
用虚构或公开出生资料,在 staging 上看:
| # | 操作 | 应该看到 |
| --- | --- | --- |
| 1 | 生成一份完整报告,翻到特殊点 / 敏感点表 | Sarpa Drekkana 行正常显示;没有空白或「undefined」 |
| 2 | 同一张盘看第 22 号 Drekkana | 有星座,不报错 |
| 3 | 发起一次生时校正,走到出结果 | 和以前一样能走完(校正打分没有变) |
+12 -1
View File
@@ -853,7 +853,7 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小
**今日星语改为固定句库,并随次级页预取(2026-09-30,BUG-1109 / BUG-1110)。** 首页问候下面那句不再由模型写:`lib/daily-starlanguage-library.ts` 按「今日月亮落在本命第几宫(1–12,只在出生时间已确认时用)× 月相(新月前后 / 渐盈 / 满月前后 / 渐亏,按平均朔望月推算,误差约 ±1 天)× 按日期轮换的变体」拼出 trend,action / caution 按宫各两条交替;没有宫位时按月亮所在星座写一句,再没有就用原来的证据句。服务端不再在后台让模型改写并覆盖缓存,所以同一天同一个人看到的永远是同一句。次级页空闲时的预取(BUG-1104)同时取本人今天这句,写进同一个暖快照槽位与本地存储;从星盘等页点「新建对话」进首页,首帧就是真句子,不再先显示「今天的星语还没写出来。」再换掉。他人档案作为当前人物时暂不预取(其资料在人物目录里),首帧仍按原规则。
首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到时问候下方副行显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),内容到达后静默替换。登录后的空首页是问候语、今日星语和输入框,没有主题卡,也没有生时校正卡(2026-10-07,BUG-1247)。本人还没有出生资料时,今日星语的按钮和问候下那一行都不出现(2026-10-24)。建档是四步:称呼、看谁的盘、出生日期与时间、出生地,进度条四格。选「帮别人看」时出生资料记在那一位名下,本人可以后补。首问还没用掉、并且当前是空白首页时,问候和输入框之间有一行引导、一枚「这一次不扣点」和三个起步问题;帮别人看再加一枚「正在看:名字 · 关系」。用掉首问或离开空白首页后,这三问不再出现。第一问里的上升和月亮星座先取今日星语;没有今日星语时(出生时间不到分钟,或在看别人),读一次已算好的星盘,和星盘页共用同一份结果。上升只在出生时间范围内不会换座时才写;读到之前、读不到时,问题保持「从我的盘看……」的说法,不转圈(BUG-1459)。给别人建档后的天空过场,副句「那一刻,{名字}的上升在……」同样来自这一份,上升会换或读不到就不写这一句。已经建完档、还没点过「先不看了」、并且不是进站时就已经七项都做过时,顶栏右侧有「启程 n/N」。尺寸、抽屉和提示互斥见文首「启程清单与一次性提示」。过场结束仍不弹浮层。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到时问候下方副行显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),内容到达后静默替换。登录后的空首页是问候语、今日星语和输入框,没有主题卡,也没有生时校正卡(2026-10-07,BUG-1247)。本人出生时间是估的、首问还没用掉时,起步三问下面多一行去生时校正的提示(2026-10-28,issue #145,见「生时校正独立页」)。本人还没有出生资料时,今日星语的按钮和问候下那一行都不出现(2026-10-24)。建档是四步:称呼、看谁的盘、出生日期与时间、出生地,进度条四格。选「帮别人看」时出生资料记在那一位名下,本人可以后补。首问还没用掉、并且当前是空白首页时,问候和输入框之间有一行引导、一枚「这一次不扣点」和三个起步问题;帮别人看再加一枚「正在看:名字 · 关系」。用掉首问或离开空白首页后,这三问不再出现。第一问里的上升和月亮星座先取今日星语;没有今日星语时(出生时间不到分钟,或在看别人),读一次已算好的星盘,和星盘页共用同一份结果。上升只在出生时间范围内不会换座时才写;读到之前、读不到时,问题保持「从我的盘看……」的说法,不转圈(BUG-1459)。给别人建档后的天空过场,副句「那一刻,{名字}的上升在……」同样来自这一份,上升会换或读不到就不写这一句。已经建完档、还没点过「先不看了」、并且不是进站时就已经七项都做过时,顶栏右侧有「启程 n/N」。尺寸、抽屉和提示互斥见文首「启程清单与一次性提示」。过场结束仍不弹浮层。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
### 首屏死屏兜底
@@ -1346,6 +1346,17 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
**明确要求则直接打开(2026-10-12,BUG-1306)**:用户这一轮明说要做校正时,不再先出卡再点。回答只留一句过渡,界面沿用既有一次揭幕直接打开校正面。没有新的按钮、输入框、等待动画或 Home 状态。推得不准时的主动提议仍用 `.rectification-offer-card`。做不了时只显示一句话,不静默,也不在对话里假装校正。开案还没成功时,同一张提议卡仍是普通提议(不说「已经开始」,按钮仍是「开始核对出生时间」)。开案成功并绑定来源后才换成已开始的文案。打开失败时按钮仍可点。
## 生时校正独立页(2026-10-28,issue #145)
推翻 2026-10-07「校正只从对话里的提议卡进入」(BUG-1247):产品要求校正单独有入口。
- **侧栏**:导航里「星盘」下面一项「生时校正」(Lucide `Clock3`),链接 `/rectification`。账户的 `rectification` 开关为 true 才出现;开关没到之前不出现,和「婚恋合盘」同一规则。
- **`/rectification`**:沿用二级页外壳(`SecondaryPageShell`),标题旁不放人物切换(校正只支持本人,他人见 #160)。正文一栏宽 `min(100%, 680px)` 居中,上边距 32px(手机 20px,左右 16px)。一句说明,下面一张状态卡(`.rectification-page-card`:细边、`--radius-lg`、24px 内边距,手机 20px)。状态卡分没做过 / 进行中 / 已完成三种,状态只来自服务端 `entry-summary`(含会话 id、时间窗、确认过的大事数、采用时间)。进行中是两列数字(时间窗、已收大事);已完成时刻 28px 衬线数字。主按钮是组件库默认按钮,次按钮描边,都是真链接(`AppLink`)。「开始」「再校正一次」先进核对:两行资料表(手机上标签和值上下排),「开始校正」+ 链接「改这份」(`/people?self=1`)。开始后进首页校正面(`/?rectify=1` 续上或新开;做完之后再校正用 `/?rectify=new`),「继续」「查看结果」打开那次校正的会话(`/?c=<id>`)。页面上不写价格。读取中用二级页现有的等待文案,不另做骨架。
- **对话回答下面**(`.rectification-offer-card`):改成一行,不再是带底色的卡片:灰字一句 + 主色下划线链接「去做生时校正」和 Lucide `ArrowRight`。点链接去 `/rectification`,不从这段对话开案例;对话进行中链接置灰不可点。回来后的分隔线、「按校正前的时间」不变(旧的已绑定案例照常显示)。
- **明确要求**:过渡句之后跳到 `/rectification`,不再直接打开校正面。
- **建完档的首页**:起步三问下面一行浅底圆角条(`.starter-rectify-hint`,`--color-canvas-soft`),一句 + 描边胶囊「去校正」。只在本人出生时间是估的、首问还没用掉时出现,按启程进场依次浮出。
- **启程清单**:本人时间是估的账户在「问第一个问题」后多一项「做一次生时校正」,清单变成 8 项;其他账户仍是 7 项。
## 用量页分段计时(2026-10-05,在途)
`/admin/usage` 在原表格上多一列「分段」。格子里四行:分类耗时、第 0 步、第 1 步、第 1 步开始到第一个正文字符。每步带推理、输出、输入、缓存输入 token。供应商没返回的数显示「—」。不新开页面,不改表,数字来自 `usage_ledger.metadata` 里已有的 JSON。没有新的等待动画或输入框。
+16
View File
@@ -63,6 +63,22 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页不写副标题。按钮是「开始合盘」,上面不写价格。点了先确认「会用掉 N 点,余额 M 点。」,按钮是「取消 / 开始」。余额不够时说「需要 N 点,余额只有 M 点。」,主按钮是「去补点数」。N 从计价表读,M 从账户读。计价读不到时不弹确认,只说「计费配置不可用」。这两人已经有写好的合盘、且没有一份正在生成时,按钮下面加「这两人已经合过 N 次,再生成会另扣 X 点」,N、X 都读实际数字。正在生成时不写这句。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页不出现两个人的选择框,也不出现「开始合盘」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
## 生时校正独立入口(2026-10-28,issue #145)
产品 2026-10-28 定:校正有自己的入口,不再从对话里开案例。下面这节「生时校正从对话进入」里的卡片文案和「明确要求则直接进入」由本节取代,其余(何时提议、做不了必须说一句、正文怎么提、回来)不变。
- **侧栏**:「生时校正」,在「星盘」下面。
- **页面**:标题「生时校正」,下面一句「出生时间不确定时,用几件记得日期的大事,把钟点一步步收窄。」页面上不写点数和价格。
- 没做过:小字「你现在用的时间」,下面是出生日期和时间(与建档回读同一句),按钮「开始」。
- 进行中:小字「进行中」,「时间窗」HH:MM – HH:MM,「已收大事」N 件(只数用户确认过的),按钮「继续」。
- 已完成:小字「已采用」,时刻 HH:MM,「之后排盘、咨询和报告都用这个时间。」按钮「查看结果」「再校正一次」。
- 核对:标题「先核对一下资料」,两行「出生时间」「出生城市」,按钮「开始校正」,链接「改这份」。返回按钮「返回」。
- 读不到:「校正记录这会儿读不到。」按钮「再试一次」。未登录:「登录后可以做生时校正。」「去登录」。开关关着:「生时校正这会儿还做不了。」本人资料不全:「先把你自己的出生日期、时间和地点填好,才能做生时校正。」「去填资料」。
- **对话回答下面**:一句「出生时间还不确定。」加链接「去做生时校正」,指向 `/rectification`。不写题数和点数,开没开过案都是这一句。
- **明确要求**:过渡句仍是「好,我们开始核对出生时间。」/「核对还没做完,我们接着做。」,之后跳到 `/rectification`,由页面给「开始」或「继续」。
- **建完档的首页**:本人出生时间是估的(家人记得、时段、不知道),且首问还没用掉时,起步三问下面一行「出生时间还不确定,可以先做生时校正。」加按钮「去校正」。帮别人看时不出现。
- **启程清单**:同样只对本人时间是估的账户多一项「做一次生时校正」,采用过时间就打勾。
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
- **何时提议**:校正就在这个对话里能开。用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,要先看过盘再提议。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。还没看盘就顺口提议,不算已经提议过。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
+8 -2
View File
@@ -1,6 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, type ReactNode } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
@@ -12,7 +12,7 @@ import { ShellFrame } from "@/components/shell-frame";
import { SessionDeleteDialog } from "@/components/session-delete-dialog";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { useShellSessionActions } from "@/hooks/use-shell-session-actions";
import { navigateAppPath } from "@/lib/app-navigation";
import { navigateAppPath, registerShellNavigate } from "@/lib/app-navigation";
import { sessionHref } from "@/lib/chat-session-url";
import { OnboardingJourneyProvider } from "@/lib/onboarding-journey";
import { SessionListProvider, sessionListSidebarModel, useSessionList, useShellRegistration } from "@/hooks/use-session-list";
@@ -33,7 +33,12 @@ function AppShell({ children }: { children: ReactNode }) {
const registration = useShellRegistration();
const { sessions, account } = sessionListSidebarModel(list, registration);
const compatibilityEnabled = list.account?.compatibility === true;
const rectificationEnabled = list.account?.rectification === true;
const router = useRouter();
useEffect(() => {
registerShellNavigate((href) => router.push(href));
return () => registerShellNavigate(null);
}, [router]);
// The rows' menu, delete confirmation and drag sorting, on every page (issue #116).
const sessionActions = useShellSessionActions(list, registration?.sessionHooks ?? null);
@@ -51,6 +56,7 @@ function AppShell({ children }: { children: ReactNode }) {
settled={list.settled}
signedOut={list.signedOut}
compatibilityEnabled={compatibilityEnabled}
rectificationEnabled={rectificationEnabled}
controls={registration?.controls}
sessionControls={sessionActions.controls}
onOpenSession={(sessionId) => navigateAppPath(sessionHref("", sessionId), router.push)}
+10 -4
View File
@@ -7,6 +7,9 @@ import type { FormEvent, KeyboardEvent } from "react";
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
import { CreditBalanceButton } from "@/components/credit-balance-button";
import { Button } from "@/components/ui/button";
import { navigateInApp } from "@/lib/app-navigation";
import { RECTIFICATION_PAGE_HREF } from "@/lib/chat-session-url";
import { birthTimeNeedsRectification } from "@/lib/rectification-page";
import { ProfilePanel } from "@/components/profile-panel";
import { AccountDeletionSection } from "@/components/account-deletion-section";
import { ThemePreferencePanel } from "@/components/theme-preference-menu";
@@ -206,6 +209,8 @@ function clearNudgeComposerTimer() {
export default function Home() {
const sessionList = useSessionList();
// Every chat path into rectification lands on the standalone page (issue #145).
const openRectificationPage = () => navigateInApp(RECTIFICATION_PAGE_HREF);
const {
sessions,
setSessions,
@@ -462,7 +467,7 @@ export default function Home() {
const {
setRectificationHeaderSlot, rectificationPanel, resumeRectificationSession,
openRectificationFromChat, openRectificationSession, reopenRectificationSession,
openRectificationSession, reopenRectificationSession,
} = useRectificationSurface({
account, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
persistSession, profile, rectification, setRectification,
@@ -790,7 +795,7 @@ export default function Home() {
startNewChat,
continueInNewChat,
refreshAccount,
openAccountDialog, openRectificationSession, openRectificationFromChat,
openAccountDialog, openRectificationSession, openRectificationPage,
onboardingPaywallShown, setOnboardingPaywallOpen,
});
const jumpToLatestVisible = !rectificationSurfaceOpen
@@ -1096,8 +1101,8 @@ export default function Home() {
onFollowUp(question) {
void send(question, activeSession?.theme);
},
onStartRectification(sourceSessionId) {
void openRectificationFromChat(sourceSessionId);
onStartRectification() {
openRectificationPage();
},
};
});
@@ -1347,6 +1352,7 @@ export default function Home() {
chartSignsFallback={dailyStarlanguage.kind === "unavailable"}
onAsk={(text) => void startSuggestedConsultation(text)}
onNudge={nudgeComposer}
rectifyHint={currentSubjectId === "self" && account.rectification === true && birthTimeNeedsRectification(profile)}
/>
) : null}
{jumpToLatestVisible && (
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { RectificationEntryPage } from "@/components/rectification-page/rectification-entry-page";
export const metadata: Metadata = {
title: "生时校正 · Jyotisha",
robots: { index: false, follow: false },
};
export default function RectificationRoute() {
return <RectificationEntryPage />;
}
+5 -1
View File
@@ -136,7 +136,10 @@ export async function GET() {
const activeSubscription = activeSubscriptions?.[0] ?? null;
const isAdmin = await isAdminUser(user);
const adminUrl = isAdmin ? adminEntryUrl() : null;
const compatibility = await isProductEnabled("compatibility");
const [compatibility, rectification] = await Promise.all([
isProductEnabled("compatibility"),
isProductEnabled("rectification"),
]);
return NextResponse.json({
user: { id: user.id, email: user.email ?? null },
@@ -173,6 +176,7 @@ export async function GET() {
},
chartBirth: resolveServerOwnedChartBirth(profile),
compatibility,
rectification,
firstConsultFree: firstConsultFreeFromProfile(profile),
});
} catch (error) {
+196 -6
View File
@@ -6967,15 +6967,13 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
sits under the answer that offered it, at the same inset as the follow-ups;
the return divider is a quiet centred line, not a message; answers written
before the birth time changed get one caption above them. */
/* Issue #145: one line and a link to /rectification, not a box with a button. */
.rectification-offer-card {
display: grid;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
justify-items: start;
margin: var(--space-2) 0 var(--space-3) var(--assistant-content-inset);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas-muted);
}
.rectification-offer-card p {
margin: 0;
@@ -6983,6 +6981,28 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
font-size: var(--type-body-sm);
line-height: 1.6;
}
.rectification-offer-link {
display: inline-flex;
gap: 0;
align-items: center;
min-height: 32px;
color: var(--color-action);
font-size: var(--type-body-sm);
text-decoration: underline;
text-underline-offset: 3px;
}
.rectification-offer-link svg {
width: 14px;
height: 14px;
margin-left: 4px;
}
.rectification-offer-link:hover {
color: var(--color-action-hover);
}
.rectification-offer-link[aria-disabled="true"] {
color: var(--color-ink-tertiary);
pointer-events: none;
}
/* The divider's entry carries no actions row, so it needs no trailing gap of its own. */
.message-entry--divider {
padding: 0;
@@ -7011,3 +7031,173 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
justify-content: flex-start;
padding-bottom: var(--space-2);
}
/* /rectification (issue #145): one status card, then a short check before the
case opens. Same column width as the conversation; no price on this page. */
.rectification-page {
display: grid;
gap: var(--space-5);
width: min(100%, 680px);
margin: 0 auto;
padding: var(--space-8) var(--space-6) var(--space-10);
box-sizing: border-box;
}
.rectification-page-intro {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-md);
line-height: 1.75;
}
.rectification-page-card,
.rectification-page-check {
display: grid;
gap: var(--space-3);
}
.rectification-page-card {
padding: var(--space-6);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
}
.rectification-page-label {
margin: 0;
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
}
.rectification-page-label--done {
color: var(--color-success);
}
.rectification-page-value,
.rectification-page-heading {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-title-md);
font-weight: 500;
line-height: 1.4;
}
.rectification-page-heading {
font-size: var(--type-title-lg);
}
.rectification-page-time {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: 28px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.rectification-page-note {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.7;
}
.rectification-page-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
margin: 0;
}
.rectification-page-stats dt {
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
}
.rectification-page-stats dd {
margin: var(--space-1) 0 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-title-lg);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.rectification-page-facts {
display: grid;
margin: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
}
.rectification-page-facts > div {
display: grid;
grid-template-columns: 6rem minmax(0, 1fr);
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
}
.rectification-page-facts > div + div {
border-top: 1px solid var(--color-canvas-muted);
}
.rectification-page-facts dt {
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
}
.rectification-page-facts dd {
margin: 0;
color: var(--color-ink);
font-size: var(--type-body-md);
overflow-wrap: anywhere;
}
.rectification-page-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
}
.rectification-page-action {
text-decoration: none;
}
.rectification-page-edit {
display: inline-flex;
align-items: center;
min-height: 44px;
color: var(--color-action);
font-size: var(--type-body-sm);
}
.rectification-page-back {
justify-self: start;
color: var(--color-ink-secondary);
}
@media (max-width: 767px) {
.rectification-page {
padding: var(--space-5) var(--space-4) var(--space-8);
}
.rectification-page-card {
padding: var(--space-5);
}
.rectification-page-facts > div {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-1);
}
}
/* Onboarding prompt under the starter questions (issue #145): self chart with an
estimated time only. A quiet row, not a card; the link goes to /rectification. */
.starter-rectify-hint {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-canvas-soft);
}
.starter-rectify-hint p {
margin: 0;
color: var(--color-ink-strong);
font-size: var(--type-body-sm);
line-height: 1.6;
}
.starter-rectify-link {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 var(--space-4);
border: 1px solid var(--color-border-strong);
border-radius: 999px;
color: var(--color-ink);
font-size: var(--type-body-sm);
text-decoration: none;
}
.starter-rectify-link:hover {
background: var(--color-canvas-muted);
}
+6 -1
View File
@@ -13,6 +13,7 @@ import {
import {
CalendarDays,
ChevronRight,
Clock3,
FileText,
Heart,
LogOut,
@@ -113,6 +114,8 @@ export type AppSidebarProps = {
signedOut?: boolean;
/** Product flag from the shell. Absent means hidden, so the entry does not flash before the flag arrives. */
compatibilityEnabled?: boolean;
/** Same rule for the /rectification entry (issue #145). */
rectificationEnabled?: boolean;
controls?: AppSidebarControls;
/** The rows' menu and drag sorting, from the shell on every page. */
sessionControls?: SidebarSessionControls | null;
@@ -122,6 +125,7 @@ export type AppSidebarProps = {
const NAV_PAGES = [
{ href: "/chart", label: "星盘", icon: Orbit },
{ href: "/rectification", label: "生时校正", icon: Clock3 },
{ href: "/ephemeris", label: "星历", icon: CalendarDays },
{ href: "/reports", label: "我的报告", icon: FileText },
{ href: "/synastry", label: "婚恋合盘", icon: Heart },
@@ -136,6 +140,7 @@ export function AppSidebar({
settled = true,
signedOut = false,
compatibilityEnabled = false,
rectificationEnabled = false,
controls,
sessionControls,
onOpenSession,
@@ -350,7 +355,7 @@ export function AppSidebar({
</SidebarMenuLink>
)}
</SidebarMenuItem>
{NAV_PAGES.filter((page) => page.href !== "/synastry" || compatibilityEnabled).map(({ href, label, icon: Icon }) => {
{NAV_PAGES.filter((page) => (page.href !== "/synastry" || compatibilityEnabled) && (page.href !== "/rectification" || rectificationEnabled)).map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<SidebarMenuItem key={href} className="sidebar-nav-item">
@@ -35,7 +35,7 @@ import {
type ChartViewResponse,
type ChartViewTabId,
} from "@/lib/chart-view-contract";
import { rectificationEntryHref } from "@/lib/chat-session-url";
import { RECTIFICATION_PAGE_HREF } from "@/lib/chat-session-url";
import { setComposerDraft } from "@/lib/composer-draft";
import { lagnaPrecheckLine } from "@/lib/lagna-precheck";
import type { ChartViewLayer } from "@/lib/chart-view-engine";
@@ -84,7 +84,7 @@ function ChartLagnaPrecheckNote({ value, self }: { value: ChartViewLagnaPrecheck
return (
<p className="chart-lagna-precheck">
{line.before}
{line.linked ? <a href={rectificationEntryHref()}>出生时间校正</a> : null}
{line.linked ? <a href={RECTIFICATION_PAGE_HREF}>出生时间校正</a> : null}
{line.after}
</p>
);
@@ -1,12 +1,14 @@
"use client";
import type { MouseEvent } from "react";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { RECTIFICATION_PAGE_HREF } from "@/lib/chat-session-url";
import {
RECTIFICATION_BEFORE_CHANGE_LABEL,
RECTIFICATION_OFFER_CONTINUE_LABEL,
RECTIFICATION_OFFER_OPENED_COPY,
RECTIFICATION_OFFER_START_LABEL,
rectificationOfferCardCopy,
RECTIFICATION_OFFER_HINT_COPY,
RECTIFICATION_OFFER_LINK_LABEL,
rectificationReturnDividerText,
rectificationRevisitLabel,
type RectificationOfferRecord,
@@ -14,11 +16,10 @@ import {
} from "@/lib/rectification-in-chat";
/**
* The card under a consultation answer that offered birth-time rectification
* (BUG-1248). The server decided it may be shown and wrote the price; the
* click opens the case (the charge comes with the first answered question).
* A card whose case is open says so and offers to go back in; a card whose
* case has returned is gone (the divider below says what happened).
* Under a consultation answer whose reading was held back by the birth time
* (BUG-1248). Issue #145: one line and a link to /rectification. The page
* shows 开始 or 继续; nothing is opened or charged from this conversation.
* A card whose case has returned is gone (the divider below says what happened).
*/
export function RectificationOfferCard({
offer,
@@ -26,15 +27,18 @@ export function RectificationOfferCard({
onStart,
}: Readonly<{ offer: RectificationOfferRecord; disabled: boolean; onStart: () => void }>) {
if (offer.status === "returned") return null;
// 「已经开始」 / 继续核对 only after the case is bound. autoOpen still shows
// the ordinary offer and 「开始核对出生时间」 until status is opened.
const opened = offer.status === "opened";
const follow = (event: MouseEvent<HTMLAnchorElement>) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
if (!disabled) onStart();
};
return (
<div className="rectification-offer-card" role="group" aria-label="核对出生时间">
<p>{opened ? RECTIFICATION_OFFER_OPENED_COPY : rectificationOfferCardCopy(offer)}</p>
<Button type="button" variant={opened ? "outline" : "default"} size="sm" disabled={disabled} onClick={onStart}>
{opened ? RECTIFICATION_OFFER_CONTINUE_LABEL : RECTIFICATION_OFFER_START_LABEL}
</Button>
<div className="rectification-offer-card" role="group" aria-label="出生时间">
<p>{RECTIFICATION_OFFER_HINT_COPY}</p>
<a className="rectification-offer-link" href={RECTIFICATION_PAGE_HREF} aria-disabled={disabled || undefined} onClick={follow}>
{RECTIFICATION_OFFER_LINK_LABEL}
<ArrowRight aria-hidden="true" />
</a>
</div>
);
}
@@ -0,0 +1,232 @@
"use client";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { ChevronLeft } from "lucide-react";
import { AppLink } from "@/components/app-link";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { Button, buttonVariants } from "@/components/ui/button";
import { useSessionList } from "@/hooks/use-session-list";
import { formatBirthMoment, profilePlaceLabel, readProfile, selfChartComplete } from "@/lib/home-profile";
import { entrySummaryFromResponse, type RectificationEntrySummary } from "@/lib/rectification-entry";
import {
RECTIFICATION_PAGE_COPY as COPY,
evidenceCountLabel,
rectificationPageState,
rectificationSessionHref,
rectificationStartHref,
type RectificationPageState,
} from "@/lib/rectification-page";
import { cn } from "@/lib/utils";
type SummaryRead =
| { readonly status: "loading" }
| { readonly status: "failed" }
| { readonly status: "ready"; readonly summary: RectificationEntrySummary };
const EMPTY_SUMMARY: RectificationEntrySummary = {
hasResumableCase: false,
hasTerminalCaseWithTime: false,
latestResumable: null,
latestTerminal: null,
};
async function readEntrySummary(): Promise<SummaryRead> {
try {
const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" });
if (!response.ok) return { status: "failed" };
return { status: "ready", summary: entrySummaryFromResponse(await response.json()) };
} catch {
return { status: "failed" };
}
}
/** A real link styled as a button; plain clicks stay on the client router (AppLink). */
function PageLink({
href,
variant = "default",
children,
}: {
readonly href: string;
readonly variant?: "default" | "outline" | "link";
readonly children: ReactNode;
}) {
return (
<AppLink href={href} data-onboarding-press="" className={cn(buttonVariants({ variant }), "rectification-page-action")}>
{children}
</AppLink>
);
}
export function RectificationEntryPage() {
const list = useSessionList();
const signedIn = Boolean(list.account) && !list.signedOut;
const enabled = list.account?.rectification;
const profile = readProfile(list.account?.profile);
const selfComplete = selfChartComplete(profile);
const [read, setRead] = useState<SummaryRead>({ status: "loading" });
const [checking, setChecking] = useState(false);
const needsSummary = signedIn && enabled === true && selfComplete;
const load = useCallback(() => {
setRead({ status: "loading" });
void readEntrySummary().then(setRead);
}, []);
useEffect(() => {
if (!needsSummary) return;
let live = true;
void readEntrySummary().then((next) => {
if (live) setRead(next);
});
return () => {
live = false;
};
}, [needsSummary]);
if (!list.settled && !list.signedOut) {
return <SecondaryPageShell title={COPY.title} besideTitle={<span />} waiting={COPY.waiting} />;
}
if (needsSummary && read.status === "loading") {
return <SecondaryPageShell title={COPY.title} besideTitle={<span />} waiting={COPY.waiting} />;
}
const state: RectificationPageState = rectificationPageState({
signedOut: !signedIn,
enabled,
selfComplete,
summary: read.status === "ready" ? read.summary : EMPTY_SUMMARY,
});
return (
<SecondaryPageShell title={COPY.title} besideTitle={<span />}>
<div className="rectification-page">
{checking && (state.kind === "none" || state.kind === "done") ? (
<section className="rectification-page-check" aria-labelledby="rectification-check-title">
<Button type="button" variant="ghost" className="rectification-page-back" onClick={() => setChecking(false)}>
<ChevronLeft aria-hidden="true" />
{COPY.back}
</Button>
<h2 id="rectification-check-title" className="rectification-page-heading" data-onboarding-enter="rectification-check" data-onboarding-index={0}>
{COPY.checkTitle}
</h2>
<dl className="rectification-page-facts" data-onboarding-enter="rectification-facts" data-onboarding-index={1}>
<div>
<dt>{COPY.birthTime}</dt>
<dd>{formatBirthMoment(profile)}</dd>
</div>
<div>
<dt>{COPY.birthPlace}</dt>
<dd>{profilePlaceLabel(profile)}</dd>
</div>
</dl>
<div className="rectification-page-actions" data-onboarding-enter="rectification-check-actions" data-onboarding-index={2}>
<PageLink href={rectificationStartHref(state)}>{COPY.confirmStart}</PageLink>
<PageLink href="/people?self=1" variant="link">{COPY.editProfile}</PageLink>
</div>
</section>
) : (
<>
<p className="rectification-page-intro" data-onboarding-enter="rectification-intro" data-onboarding-index={0}>{COPY.intro}</p>
<StatusCard state={state} failed={needsSummary && read.status === "failed"} onRetry={load} onStart={() => setChecking(true)} />
</>
)}
</div>
</SecondaryPageShell>
);
}
function StatusCard({
state,
failed,
onRetry,
onStart,
}: {
readonly state: RectificationPageState;
readonly failed: boolean;
readonly onRetry: () => void;
readonly onStart: () => void;
}) {
const list = useSessionList();
const profile = readProfile(list.account?.profile);
const enter = { "data-onboarding-enter": "rectification-status", "data-onboarding-index": 1 } as const;
if (failed) {
return (
<section className="rectification-page-card" {...enter}>
<p className="rectification-page-note">{COPY.loadFailed}</p>
<div className="rectification-page-actions">
<Button type="button" variant="outline" onClick={onRetry}>{COPY.retry}</Button>
</div>
</section>
);
}
switch (state.kind) {
case "signed-out":
return (
<section className="rectification-page-card" {...enter}>
<p className="rectification-page-note">{COPY.signedOut}</p>
<div className="rectification-page-actions"><PageLink href="/login">{COPY.signIn}</PageLink></div>
</section>
);
case "unavailable":
return (
<section className="rectification-page-card" {...enter}>
<p className="rectification-page-note">{COPY.unavailable}</p>
</section>
);
case "self-missing":
return (
<section className="rectification-page-card" {...enter}>
<p className="rectification-page-note">{COPY.selfMissing}</p>
<div className="rectification-page-actions"><PageLink href="/people?self=1">{COPY.fillSelf}</PageLink></div>
</section>
);
case "none":
return (
<section className="rectification-page-card" aria-label={COPY.currentTime} {...enter}>
<p className="rectification-page-label">{COPY.currentTime}</p>
<p className="rectification-page-value">{formatBirthMoment(profile)}</p>
<div className="rectification-page-actions">
<Button type="button" data-onboarding-press="" onClick={onStart}>{COPY.start}</Button>
</div>
</section>
);
case "active":
return (
<section className="rectification-page-card" aria-label={COPY.activeLabel} {...enter}>
<p className="rectification-page-label">{COPY.activeLabel}</p>
<dl className="rectification-page-stats">
{state.range ? (
<div>
<dt>{COPY.windowLabel}</dt>
<dd>{state.range.start} – {state.range.end}</dd>
</div>
) : null}
{state.evidenceCount !== null ? (
<div>
<dt>{COPY.evidenceLabel}</dt>
<dd>{evidenceCountLabel(state.evidenceCount)}</dd>
</div>
) : null}
</dl>
<div className="rectification-page-actions">
<PageLink href={rectificationSessionHref(state.sessionId)}>{COPY.resume}</PageLink>
</div>
</section>
);
case "done":
return (
<section className="rectification-page-card" aria-label={COPY.doneLabel} {...enter}>
<p className="rectification-page-label rectification-page-label--done">{COPY.doneLabel}</p>
<p className="rectification-page-time">{state.time}</p>
<p className="rectification-page-note">{COPY.doneNote}</p>
<div className="rectification-page-actions">
{state.sessionId ? <PageLink href={rectificationSessionHref(state.sessionId)}>{COPY.viewResult}</PageLink> : null}
<Button type="button" variant="outline" data-onboarding-press="" onClick={onStart}>{COPY.again}</Button>
</div>
</section>
);
}
}
@@ -12,6 +12,9 @@ import {
type StarterAudience,
type StarterChartSigns,
} from "@/lib/starter-questions";
import { AppLink } from "@/components/app-link";
import { RECTIFICATION_PAGE_HREF } from "@/lib/chat-session-url";
import { RECTIFICATION_HOME_HINT } from "@/lib/rectification-page";
export function StarterQuestions({
audience,
@@ -20,6 +23,7 @@ export function StarterQuestions({
chartSignsFallback,
onAsk,
onNudge,
rectifyHint = false,
}: {
readonly audience: StarterAudience;
readonly subjectId: string;
@@ -28,6 +32,8 @@ export function StarterQuestions({
readonly chartSignsFallback: boolean;
readonly onAsk: (question: string) => void;
readonly onNudge: () => void;
/** Self chart with an estimated time (issue #145): one line and a link to /rectification. */
readonly rectifyHint?: boolean;
}) {
const questions = starterQuestions(audience, signs);
const moonKnown = Boolean(signs.moonSign);
@@ -52,6 +58,12 @@ export function StarterQuestions({
<Button type="button" variant="outline" data-onboarding-press="" data-onboarding-enter="starter-1" data-onboarding-index={1} data-onboarding-follow-source="" onClick={() => onAsk(questions[1])}>{questions[1]}</Button>
<Button type="button" variant="outline" data-onboarding-press="" data-onboarding-enter="starter-2" data-onboarding-index={2} onClick={onNudge}>{questions[2]}</Button>
</div>
{rectifyHint ? (
<div className="starter-rectify-hint" data-onboarding-enter="starter-rectify" data-onboarding-index={3}>
<p>{RECTIFICATION_HOME_HINT.text}</p>
<AppLink className="starter-rectify-link" href={RECTIFICATION_PAGE_HREF} data-onboarding-press="">{RECTIFICATION_HOME_HINT.link}</AppLink>
</div>
) : null}
</div>
);
}
+9 -14
View File
@@ -155,8 +155,11 @@ export type ConsultationRunParams = {
onboardingPaywallShown?: MutableRefObject<boolean>;
setOnboardingPaywallOpen?: (open: boolean) => void;
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
/** Direct entry from this consultation (BUG-1306). Same opener as the offer card. */
openRectificationFromChat: (sourceSessionId: string) => Promise<unknown> | void;
/**
* A turn that asked for rectification (BUG-1306) goes to the standalone
* /rectification page (issue #145). No case is opened from the chat.
*/
openRectificationPage: () => void;
};
export function useConsultationRun(params: ConsultationRunParams) {
@@ -217,7 +220,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
onboardingPaywallShown,
setOnboardingPaywallOpen,
openRectificationSession,
openRectificationFromChat,
openRectificationPage,
} = params;
// First refusal for credits on this page → redeem paywall; later ones → billing (P4, BUG-1080).
const openRedeemPaywallOnce = () => tryOpenRedeemPaywall({
@@ -1088,17 +1091,9 @@ export function useConsultationRun(params: ConsultationRunParams) {
updatedAt: timestamp(),
};
updateSession(sessionId, () => completedSession);
if (rectificationDirectOpen?.mode === "resume" && rectificationDirectOpen.resumeSessionId) {
void openRectificationSession(rectificationDirectOpen.resumeSessionId);
} else if (rectificationDirectOpen?.mode === "new") {
// Stored offer stays offered. bindOfferToCase marks opened only after
// the case opens. A failed open must not mark it opened here.
void Promise.resolve(openRectificationFromChat(rectificationDirectOpen.sourceSessionId)).then((opened) => {
if (opened) return;
}, () => {
// The opener threw. Leave the stored offer offered.
});
}
// Resume or new, the page decides: it shows 继续 for an open case and
// 开始 otherwise. The stored offer stays offered.
if (rectificationDirectOpen) openRectificationPage();
try {
await persistSession(completedSession);
} catch (error) {
@@ -6,7 +6,7 @@ import { resolveSessionTitle } from "@/lib/agent-reply";
import { fetchSessionDetail, mergeHydratedSession } from "@/lib/home-cloud-sync";
import { rectificationOpenIdentity } from "@/lib/rectification-session-open";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { parseRectificationEntryIntent, RECTIFICATION_ENTRY_QUERY_KEY, writeSessionUrl } from "@/lib/chat-session-url";
import { RECTIFICATION_ENTRY_QUERY_KEY, rectificationEntryIntent, writeSessionUrl } from "@/lib/chat-session-url";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
import { readCurrentSubjectId } from "@/lib/current-subject";
import { chartSnapshotForSession, missingProfileStep } from "@/lib/home-profile";
@@ -127,8 +127,9 @@ export type RectificationSurfaceParams = {
rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise<void>>;
};
/** The live chart-page opener. `/?rectify=1` queues one homepage open after the hook is ready. */
/** The live openers. `/?rectify=1` queues one homepage open, `/?rectify=new` one new case, after the hook is ready. */
let latestChartRectifyStart: () => Promise<unknown> = async () => undefined;
let latestNewRectifyStart: () => Promise<unknown> = async () => undefined;
export function useRectificationSurface(params: RectificationSurfaceParams) {
const {
@@ -475,17 +476,19 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
rectificationSessionOpenerRef.current = openRectificationSession;
// Latest starter, including after a strict-mode remount, so the queued open hits the live hook.
latestChartRectifyStart = openChartPageRectification;
latestNewRectifyStart = startNewRectification;
if (chartRectifyConsumed.current) return;
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationMutationPending) return;
const search = globalThis.location?.search ?? "";
if (!parseRectificationEntryIntent(search)) return;
const intent = rectificationEntryIntent(search);
if (!intent) return;
chartRectifyConsumed.current = true;
const url = new URL(globalThis.location.href);
url.searchParams.delete(RECTIFICATION_ENTRY_QUERY_KEY);
globalThis.history.replaceState(null, "", `${url.pathname}${url.search}`);
sessionSelectionSource.current = "user";
queueMicrotask(() => {
void latestChartRectifyStart();
void (intent === "new" ? latestNewRectifyStart() : latestChartRectifyStart());
});
});
+15
View File
@@ -97,6 +97,21 @@ function isModifiedClick(event: AppNavigationClick): boolean {
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
}
/**
* The app shell's client router, registered by `(app)/layout.tsx`. Lets code
* that runs without its own router (the home page's hooks) still move on the
* client. Without a shell (tests, other roots) it is a document load.
*/
let shellNavigate: ((href: string) => void) | null = null;
export function registerShellNavigate(navigate: ((href: string) => void) | null): void {
shellNavigate = navigate;
}
export function navigateInApp(href: string): void {
navigateAppPath(href, shellNavigate ?? undefined);
}
export function interceptAppNavigation(event: AppNavigationClick, href: string): void {
if (event.defaultPrevented || isModifiedClick(event)) return;
if (!href.startsWith("/") || href.startsWith("//")) return;
+19 -5
View File
@@ -38,14 +38,28 @@ export function parseNewChatIntent(search: string): boolean {
return params.has(NEW_CHAT_QUERY_KEY);
}
/** Home URL the chart-page sentence uses. The case itself is still the self profile only. */
export function rectificationEntryHref(): string {
return `/?${RECTIFICATION_ENTRY_QUERY_KEY}=1`;
/** The standalone entry (issue #145). Every link that offers rectification points here. */
export const RECTIFICATION_PAGE_HREF = "/rectification";
/**
* Home URL that opens the rectification surface. `homepage` resumes an open
* case or creates one; `new` starts a fresh case ("再校正一次"). The case is
* still the self profile only.
*/
export function rectificationEntryHref(intent: "homepage" | "new" = "homepage"): string {
return `/?${RECTIFICATION_ENTRY_QUERY_KEY}=${intent === "new" ? "new" : "1"}`;
}
export function rectificationEntryIntent(search: string): "homepage" | "new" | null {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
const value = params.get(RECTIFICATION_ENTRY_QUERY_KEY);
if (value === "1") return "homepage";
if (value === "new") return "new";
return null;
}
export function parseRectificationEntryIntent(search: string): boolean {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
return params.get(RECTIFICATION_ENTRY_QUERY_KEY) === "1";
return rectificationEntryIntent(search) !== null;
}
export function parseSessionUrlQuery(search: string): SessionUrlQuery {
@@ -47,16 +47,26 @@ export type OpenRectificationCaseResponse = Omit<OpenRectificationCaseResponseWi
readonly status: RectificationCaseStatus;
};
/** Range, count and times feed the /rectification status card (issue #145); older payloads omit them. */
export type RectificationEntryRange = Readonly<{ startTime: string; endTime: string }>;
type LatestResumableWire = Readonly<{
caseId: string;
status: string;
lastActivityAt: string;
sessionId?: string | null;
candidateRange?: RectificationEntryRange | null;
acceptedTime?: string | null;
evidenceCount?: number | null;
}>;
type LatestTerminalWire = Readonly<{
caseId: string;
status: string;
hasUsableTime: boolean;
sessionId?: string | null;
acceptedTime?: string | null;
confirmedTime?: string | null;
}>;
export type RectificationEntrySummaryWire = Readonly<{
+2
View File
@@ -149,6 +149,8 @@ export type Account = {
profile: unknown;
chartBirth?: { fingerprint?: string | null } | null;
compatibility?: boolean;
/** Product flag for the /rectification entry (issue #145). Missing means hidden. */
rectification?: boolean;
/** True while this account's one free standard question is still available. Missing means the paywall stays. */
firstConsultFree?: boolean;
};
+15 -2
View File
@@ -1,6 +1,6 @@
import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint";
export const CHECKLIST_ITEM_IDS = ["self", "ask", "daily", "chart", "person", "syn", "report"] as const;
export const CHECKLIST_ITEM_IDS = ["self", "ask", "rectify", "daily", "chart", "person", "syn", "report"] as const;
export type ChecklistItemId = (typeof CHECKLIST_ITEM_IDS)[number];
@@ -42,6 +42,11 @@ export type ChecklistFacts = {
readonly synastryReportCount: number;
readonly personalReportCount: number;
readonly flags: ChecklistFlags;
/**
* Issue #145. Present only when the rectification product is on and the
* self birth time was an estimate; `done` once a time was adopted.
*/
readonly rectification?: { readonly done: boolean } | null;
};
export type AnswerTurn = {
@@ -62,7 +67,7 @@ export const CHECKLIST_CELEBRATION_TITLE = "都走过一遍了";
export const CHECKLIST_CELEBRATION_BODY = "以后有新东西,我会在用到的时候再告诉你。";
export const CHECKLIST_DISMISS_LABEL = "先不看了";
const SELF_ORDER: readonly ChecklistItemId[] = ["self", "ask", "daily", "chart", "person", "syn", "report"];
const SELF_ORDER: readonly ChecklistItemId[] = ["self", "ask", "rectify", "daily", "chart", "person", "syn", "report"];
const OTHER_ORDER: readonly ChecklistItemId[] = ["self", "ask", "person", "daily", "chart", "syn", "report"];
export function isCompleteNonSmalltalkAnswer(turn: AnswerTurn): boolean {
@@ -144,6 +149,7 @@ export function buildChecklist(facts: ChecklistFacts): ChecklistItem[] {
for (const id of order) {
if (id === "syn" && facts.flags.compatibility !== true) continue;
if (id === "report" && facts.flags.reportCenter !== true) continue;
if (id === "rectify" && !facts.rectification) continue;
items.push(itemFor(id, path, name, synBase, facts));
}
return items;
@@ -166,6 +172,13 @@ function itemFor(
};
case "ask":
return { id, label: "问第一个问题", done: facts.hasNonSmalltalkAnswer, go: { kind: "compose" } };
case "rectify":
return {
id,
label: "做一次生时校正",
done: facts.rectification?.done === true,
go: { kind: "href", href: "/rectification" },
};
case "daily":
return {
id,
+9
View File
@@ -43,6 +43,7 @@ import {
} from "@/lib/onboarding-hints";
import { missingOtherProfileStep, readProfile, selfChartComplete } from "@/lib/home-profile";
import type { Profile } from "@/lib/home-types";
import { birthTimeWasEstimated } from "@/lib/rectification-page";
import { parseOnboardingState, type OnboardingHintId, type OnboardingState } from "@/lib/onboarding-state";
import {
setOnboardingDailyOpenedListener,
@@ -453,6 +454,12 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
streamingSessionId: live.streamingSessionId,
firstConsultFree: list.account?.firstConsultFree,
});
const rectificationShown = list.account?.rectification === true && birthTimeWasEstimated(selfProfile);
const rectificationDone = selfProfile.birthTimeStatus === "accepted" || selfProfile.birthTimeStatus === "confirmed";
const rectificationItem = useMemo(
() => (rectificationShown ? { done: rectificationDone } : null),
[rectificationShown, rectificationDone],
);
const items = useMemo(() => buildChecklist({
people,
profileComplete,
@@ -467,7 +474,9 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea
synastryCreditCost: remote.synastryCreditCost,
reportCreditCost: remote.reportCreditCost,
},
rectification: rectificationItem,
}), [
rectificationItem,
people,
profileComplete,
hasAnswer,
@@ -511,6 +511,10 @@ export async function getRectificationEntrySummary(
: "draft",
lastActivityAt:
typeof latestResumable.last_activity_at === "string" ? latestResumable.last_activity_at : "",
sessionId: textOrNull(latestResumable.session_id),
candidateRange: entryRange(latestResumable.candidate_range),
acceptedTime: textOrNull(latestResumable.accepted_time),
evidenceCount: typeof latestResumable.evidence_count === "number" ? latestResumable.evidence_count : null,
}
: null,
latestTerminal:
@@ -521,11 +525,26 @@ export async function getRectificationEntrySummary(
? latestTerminal.status
: ("closed" as RectificationCaseStatus),
hasUsableTime: latestTerminal.has_usable_time === true,
sessionId: textOrNull(latestTerminal.session_id),
acceptedTime: textOrNull(latestTerminal.accepted_time),
confirmedTime: textOrNull(latestTerminal.confirmed_time),
}
: null,
};
}
function textOrNull(value: unknown): string | null {
return typeof value === "string" && value ? value : null;
}
function entryRange(value: unknown): { startTime: string; endTime: string } | null {
if (!value || typeof value !== "object") return null;
const range = value as { start_time?: unknown; end_time?: unknown };
return typeof range.start_time === "string" && typeof range.end_time === "string"
? { startTime: range.start_time, endTime: range.end_time }
: null;
}
function caseView(row: Record<string, unknown>): RectificationCaseView {
const range = row.candidate_range && typeof row.candidate_range === "object"
? (row.candidate_range as { start_time?: unknown; end_time?: unknown })
+23
View File
@@ -60,6 +60,10 @@ export function entrySummaryFromResponse(value: unknown): RectificationEntrySumm
caseId: resumableCaseId,
status: String(latestResumable.status ?? ""),
lastActivityAt: String(field(latestResumable, "lastActivityAt", "last_activity_at") ?? ""),
sessionId: textOrNull(field(latestResumable, "sessionId", "session_id")),
candidateRange: rangeOrNull(field(latestResumable, "candidateRange", "candidate_range")),
acceptedTime: textOrNull(field(latestResumable, "acceptedTime", "accepted_time")),
evidenceCount: countOrNull(field(latestResumable, "evidenceCount", "evidence_count")),
}
: null,
latestTerminal: latestTerminal && typeof terminalCaseId === "string"
@@ -67,11 +71,30 @@ export function entrySummaryFromResponse(value: unknown): RectificationEntrySumm
caseId: terminalCaseId,
status: String(latestTerminal.status ?? ""),
hasUsableTime: field(latestTerminal, "hasUsableTime", "has_usable_time") === true,
sessionId: textOrNull(field(latestTerminal, "sessionId", "session_id")),
acceptedTime: textOrNull(field(latestTerminal, "acceptedTime", "accepted_time")),
confirmedTime: textOrNull(field(latestTerminal, "confirmedTime", "confirmed_time")),
}
: null,
};
}
function textOrNull(value: unknown): string | null {
return typeof value === "string" && value ? value : null;
}
function countOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.trunc(value) : null;
}
function rangeOrNull(value: unknown): { startTime: string; endTime: string } | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
const start = field(row, "startTime", "start_time");
const end = field(row, "endTime", "end_time");
return typeof start === "string" && typeof end === "string" ? { startTime: start, endTime: end } : null;
}
export function openResponseFromPayload(value: unknown): OpenRectificationCaseResponse | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
+6 -7
View File
@@ -66,13 +66,12 @@ export type RectificationOfferDecision =
// ---- copy (VOICE.md「生时校正并入对话」) ----
export function rectificationOfferCardCopy(offer: Pick<RectificationOfferRecord, "creditCost" | "questionEstimate">): string {
return `可以用几道过去经历的题,核对一下你的出生时间。大约 ${offer.questionEstimate} 题,会用掉 ${offer.creditCost} 点;开始答第一题时才扣。`;
}
export const RECTIFICATION_OFFER_START_LABEL = "开始核对出生时间";
export const RECTIFICATION_OFFER_CONTINUE_LABEL = "继续核对出生时间";
export const RECTIFICATION_OFFER_OPENED_COPY = "出生时间核对已经开始。";
/**
* Issue #145: the card under an answer is a hint that links to /rectification.
* It no longer opens a case from this conversation, and it carries no price.
*/
export const RECTIFICATION_OFFER_HINT_COPY = "出生时间还不确定。";
export const RECTIFICATION_OFFER_LINK_LABEL = "去做生时校正";
export const RECTIFICATION_RETURN_BUTTON_LABEL = "回到刚才的对话";
export const RECTIFICATION_BEFORE_CHANGE_LABEL = "按校正前的时间";
+136
View File
@@ -0,0 +1,136 @@
/**
* The standalone /rectification page (issue #145). Which card it shows comes
* from the server's entry summary and the account; the page never guesses
* from session lists. Self profile only (other people: issue #160).
*/
import { rectificationEntryHref, sessionHref } from "./chat-session-url.ts";
import { blocksNewRectification } from "./rectification-in-chat.ts";
import type { RectificationEntrySummary } from "./rectification-entry.ts";
export type RectificationPageState =
| { readonly kind: "signed-out" }
| { readonly kind: "unavailable" }
| { readonly kind: "self-missing" }
| { readonly kind: "none" }
| {
readonly kind: "active";
readonly sessionId: string | null;
readonly range: { readonly start: string; readonly end: string } | null;
readonly evidenceCount: number | null;
}
| { readonly kind: "done"; readonly sessionId: string | null; readonly time: string };
/**
* The birth time is only an estimate and no time has been adopted yet. Drives
* the onboarding prompt and the checklist item (issue #145). A certificate or
* a self-declared exact minute does not count; nor does an adopted time.
*/
export function birthTimeNeedsRectification(profile: {
readonly birthTimeSource: string;
readonly birthTimeStatus: string;
}): boolean {
if (profile.birthTimeStatus === "accepted" || profile.birthTimeStatus === "confirmed") return false;
return birthTimeWasEstimated(profile);
}
/** The self birth time came from an estimate, adopted since or not (checklist item, issue #145). */
export function birthTimeWasEstimated(profile: { readonly birthTimeSource: string }): boolean {
return profile.birthTimeSource === "approximate"
|| profile.birthTimeSource === "period_only"
|| profile.birthTimeSource === "unknown";
}
export const RECTIFICATION_HOME_HINT = {
text: "出生时间还不确定,可以先做生时校正。",
link: "去校正",
} as const;
export const RECTIFICATION_PAGE_COPY = {
title: "生时校正",
intro: "出生时间不确定时,用几件记得日期的大事,把钟点一步步收窄。",
waiting: "校正记录还在读",
loadFailed: "校正记录这会儿读不到。",
retry: "再试一次",
signedOut: "登录后可以做生时校正。",
signIn: "去登录",
unavailable: "生时校正这会儿还做不了。",
selfMissing: "先把你自己的出生日期、时间和地点填好,才能做生时校正。",
fillSelf: "去填资料",
currentTime: "你现在用的时间",
start: "开始",
activeLabel: "进行中",
windowLabel: "时间窗",
evidenceLabel: "已收大事",
resume: "继续",
doneLabel: "已采用",
doneNote: "之后排盘、咨询和报告都用这个时间。",
viewResult: "查看结果",
again: "再校正一次",
back: "返回",
checkTitle: "先核对一下资料",
birthDate: "出生日期",
birthTime: "出生时间",
birthPlace: "出生城市",
confirmStart: "开始校正",
editProfile: "改这份",
} as const;
/** `07:42:00` → `07:42`. Anything else is returned trimmed. */
export function clockLabel(value: string): string {
const match = /^(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/.exec(value.trim());
return match ? `${match[1]}:${match[2]}` : value.trim();
}
export function evidenceCountLabel(count: number): string {
return `${count} 件`;
}
export function rectificationPageState(input: {
readonly signedOut: boolean;
/** `account.rectification`; missing means the flag did not arrive, so the page stays closed. */
readonly enabled: boolean | undefined;
readonly selfComplete: boolean;
readonly summary: RectificationEntrySummary;
}): RectificationPageState {
if (input.signedOut) return { kind: "signed-out" };
if (input.enabled !== true) return { kind: "unavailable" };
if (!input.selfComplete) return { kind: "self-missing" };
const open = input.summary.latestResumable;
if (open && blocksNewRectification(open.status)) {
const range = open.candidateRange
? { start: clockLabel(open.candidateRange.startTime), end: clockLabel(open.candidateRange.endTime) }
: null;
return {
kind: "active",
sessionId: open.sessionId ?? null,
range,
evidenceCount: open.evidenceCount ?? null,
};
}
// An accepted case is a finished stop (BUG-1423), even though the shared
// resumable set still lists its status.
if (open && open.status === "candidate_accepted" && open.acceptedTime) {
return { kind: "done", sessionId: open.sessionId ?? null, time: clockLabel(open.acceptedTime) };
}
const finished = input.summary.latestTerminal;
const finishedTime = finished?.confirmedTime ?? finished?.acceptedTime ?? null;
if (finished && finished.hasUsableTime && finishedTime) {
return { kind: "done", sessionId: finished.sessionId ?? null, time: clockLabel(finishedTime) };
}
return { kind: "none" };
}
/** Where 开始校正 goes: a fresh case after a finished one, otherwise the homepage opener. */
export function rectificationStartHref(state: RectificationPageState): string {
return rectificationEntryHref(state.kind === "done" ? "new" : "homepage");
}
/**
* 继续 / 查看结果: the case's own conversation. Without a session id (an older
* server) the homepage opener still resumes the open case.
*/
export function rectificationSessionHref(sessionId: string | null): string {
return sessionId ? sessionHref("", sessionId) : rectificationEntryHref("homepage");
}
@@ -0,0 +1,79 @@
begin;
-- Issue #145. Add-only. The /rectification status card reads the latest open
-- and finished case in one call: session id (to reopen it), the current
-- candidate range, how many events the user confirmed, and the adopted time.
-- Existing keys are unchanged. Never returns the birth snapshot.
create or replace function public.get_agentic_rectification_entry_summary(
p_user_id uuid
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_resumable public.agentic_rectification_cases%rowtype;
v_terminal public.agentic_rectification_cases%rowtype;
begin
if p_user_id is null then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
select * into v_resumable
from public.agentic_rectification_cases
where user_id = p_user_id
and status = any (public.agentic_rectification_resumable_statuses())
order by last_activity_at desc, id
limit 1;
select * into v_terminal
from public.agentic_rectification_cases
where user_id = p_user_id
and status in ('confirmed', 'closed', 'abandoned', 'superseded')
order by last_activity_at desc, id
limit 1;
return jsonb_build_object(
'has_resumable_case', v_resumable.id is not null,
'has_terminal_case_with_time', v_terminal.id is not null
and (v_terminal.accepted_time is not null or v_terminal.confirmed_time is not null),
'latest_resumable', case
when v_resumable.id is null then null
else jsonb_build_object(
'case_id', v_resumable.id,
'status', v_resumable.status,
'last_activity_at', v_resumable.last_activity_at,
'session_id', v_resumable.session_id,
'candidate_range', v_resumable.candidate_range,
'accepted_time', v_resumable.accepted_time,
'evidence_count', (
select count(*)
from public.agentic_rectification_evidence as evidence
where evidence.case_id = v_resumable.id
and evidence.status = 'confirmed'
)
)
end,
'latest_terminal', case
when v_terminal.id is null then null
else jsonb_build_object(
'case_id', v_terminal.id,
'status', v_terminal.status,
'has_usable_time', v_terminal.accepted_time is not null or v_terminal.confirmed_time is not null,
'session_id', v_terminal.session_id,
'accepted_time', v_terminal.accepted_time,
'confirmed_time', v_terminal.confirmed_time
)
end
);
end;
$$;
revoke all on function public.get_agentic_rectification_entry_summary(uuid)
from public, anon, authenticated;
grant execute on function public.get_agentic_rectification_entry_summary(uuid)
to service_role;
end;
+6 -5
View File
@@ -130,7 +130,8 @@ test("the birth line is followed by whether the ascendant changes inside the sta
} } }));
assert.match(fixed, /chart-lagna-precheck/);
assert.match(fixed, /按你给的时间精度(前后约 2 分钟),上升星座都是白羊座,这一点不会因为时间误差而变。/);
assert.doesNotMatch(fixed, /出生时间校正|href="\/\?rectify=1"/);
// 原值: href="/?rectify=1";新值: href="/rectification";原因: issue #145 独立入口
assert.doesNotMatch(fixed, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"/);
const two = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, lagnaPrecheck: {
state: "may_change",
@@ -142,7 +143,7 @@ test("the birth line is followed by whether the ascendant changes inside the sta
afterMinutes: 15,
} } }));
assert.match(two, /按你给的时间精度,上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。想定下来可以做一次/);
assert.match(two, /href="\/\?rectify=1"/);
assert.match(two, /href="\/rectification"/);
assert.match(two, />出生时间校正</);
const many = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, lagnaPrecheck: {
@@ -199,12 +200,12 @@ test("someone else's chart states the ascendant without a rectification link", a
};
const otherTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: mayChange } }));
assert.match(otherTwo, /上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。/);
assert.doesNotMatch(otherTwo, /出生时间校正|href="\/\?rectify=1"|想定下来/);
assert.doesNotMatch(otherTwo, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"|想定下来/);
const otherUnknown = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: unknown } }));
assert.match(otherUnknown, /不知道出生时间时,上升星座定不下来。/);
assert.doesNotMatch(otherUnknown, /出生时间校正|href="\/\?rectify=1"|可以缩小范围/);
assert.doesNotMatch(otherUnknown, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"|可以缩小范围/);
const selfTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...self, lagnaPrecheck: mayChange } }));
assert.match(selfTwo, /href="\/\?rectify=1"/);
assert.match(selfTwo, /href="\/rectification"/);
assert.match(selfTwo, />出生时间校正</);
const selfUnknown = withSidebarProvider(React.createElement(ChartPageView, { view: { ...self, lagnaPrecheck: unknown } }));
assert.match(selfUnknown, />出生时间校正</);
@@ -157,8 +157,12 @@ test("ordinary product drafts keep the public question and clear hidden routing
// 原值: 发送时 consultEntrypoint === "birth_time_rectification"(文字口令)→ openRectificationFromHomepage(pendingQuestion, currentSession.id)
// 新值: 发送路径不再开校正;提议卡 → openRectificationFromChat(sourceSessionId)
// 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
// 原值(2): 提议卡 → openRectificationFromChat(sourceSessionId)
// 新值(2): 提议卡与明说要校正都去独立页 /rectification(openRectificationPage),不从这段对话开案例
// 原因(2): issue #145(产品 2026-10-28 定:入口单独拆出,对话里只给链接)
assert.doesNotMatch(source, /isRectificationHandoffQuestion\(question\)/);
assert.match(source, /openRectificationFromChat\(sourceSessionId\)/);
assert.match(source, /openRectificationPage\(\)/);
assert.doesNotMatch(source, /openRectificationFromChat\(sourceSessionId\)/);
assert.doesNotMatch(source, /personalChartAvailable \? "daily_starlanguage" : null/);
assert.match(source, /messages: questionAlreadyPresent \? preservedMessages : \[\.\.\.preservedMessages, \{ role: "user", text: question \}\]/);
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*consultEntrypoint \?\? undefined,[\s\S]*?question,/);
@@ -111,11 +111,8 @@ 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_sync9b_2026_10_28.json", import.meta.url),
new URL("../../docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json", import.meta.url),
"utf8",
)) as {
frozen_record: { implementation_sha256: string };
@@ -263,10 +260,9 @@ 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_sync9b_2026_10_28.json",
"docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json",
);
assert.equal(currentTreeRerun.official_valid_independent_blind, false);
assert.equal(productHoldout.current_tree_scorer.matches_metrics_scorer, false);
@@ -0,0 +1,233 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { StarterQuestions } from "../src/components/starter-questions.tsx";
import {
RECTIFICATION_PAGE_HREF,
parseRectificationEntryIntent,
rectificationEntryHref,
rectificationEntryIntent,
} from "../src/lib/chat-session-url.ts";
import { buildChecklist, type ChecklistFacts } from "../src/lib/onboarding-checklist.ts";
import { entrySummaryFromResponse, type RectificationEntrySummary } from "../src/lib/rectification-entry.ts";
import {
RECTIFICATION_HOME_HINT,
RECTIFICATION_PAGE_COPY,
birthTimeNeedsRectification,
birthTimeWasEstimated,
clockLabel,
rectificationPageState,
rectificationSessionHref,
rectificationStartHref,
} from "../src/lib/rectification-page.ts";
// Issue #145: the standalone /rectification entry. Fictional ids only.
const SESSION = "11111111-1111-4111-8111-111111111111";
const CASE = "22222222-2222-4222-8222-222222222222";
const EMPTY: RectificationEntrySummary = {
hasResumableCase: false,
hasTerminalCaseWithTime: false,
latestResumable: null,
latestTerminal: null,
};
function state(summary: RectificationEntrySummary, patch: Partial<{ signedOut: boolean; enabled: boolean | undefined; selfComplete: boolean }> = {}) {
return rectificationPageState({ signedOut: false, enabled: true, selfComplete: true, summary, ...patch });
}
test("the page refuses before reading cases: signed out, product off or unknown, no self chart", () => {
assert.equal(state(EMPTY, { signedOut: true }).kind, "signed-out");
assert.equal(state(EMPTY, { enabled: false }).kind, "unavailable");
assert.equal(state(EMPTY, { enabled: undefined }).kind, "unavailable", "a flag that did not arrive keeps the page closed");
assert.equal(state(EMPTY, { selfComplete: false }).kind, "self-missing");
assert.equal(state(EMPTY).kind, "none");
});
test("an open case shows its window and confirmed events; an accepted case is finished (BUG-1423)", () => {
const active = state({
...EMPTY,
hasResumableCase: true,
latestResumable: {
caseId: CASE,
status: "collecting_evidence",
lastActivityAt: "2026-10-26T00:00:00Z",
sessionId: SESSION,
candidateRange: { startTime: "07:00:00", endTime: "08:00" },
evidenceCount: 3,
},
});
assert.deepEqual(active, { kind: "active", sessionId: SESSION, range: { start: "07:00", end: "08:00" }, evidenceCount: 3 });
const accepted = state({
...EMPTY,
hasResumableCase: true,
latestResumable: { caseId: CASE, status: "candidate_accepted", lastActivityAt: "", sessionId: SESSION, acceptedTime: "07:42:00" },
});
assert.deepEqual(accepted, { kind: "done", sessionId: SESSION, time: "07:42" });
});
test("a finished case prefers the confirmed time; a finished case without a time is not done", () => {
const confirmed = state({
...EMPTY,
hasTerminalCaseWithTime: true,
latestTerminal: { caseId: CASE, status: "confirmed", hasUsableTime: true, sessionId: SESSION, acceptedTime: "07:40:00", confirmedTime: "07:42:10" },
});
assert.deepEqual(confirmed, { kind: "done", sessionId: SESSION, time: "07:42" });
const empty = state({
...EMPTY,
latestTerminal: { caseId: CASE, status: "abandoned", hasUsableTime: false, sessionId: SESSION },
});
assert.equal(empty.kind, "none");
});
test("start goes to the homepage opener, or a new case after a finished one; resume uses the case's conversation", () => {
assert.equal(rectificationStartHref({ kind: "none" }), "/?rectify=1");
assert.equal(rectificationStartHref({ kind: "done", sessionId: SESSION, time: "07:42" }), "/?rectify=new");
assert.equal(rectificationSessionHref(SESSION), `/?c=${SESSION}`);
assert.equal(rectificationSessionHref(null), "/?rectify=1", "an older server without session ids still resumes");
assert.equal(rectificationEntryHref(), "/?rectify=1");
assert.equal(rectificationEntryIntent("?rectify=1"), "homepage");
assert.equal(rectificationEntryIntent("?rectify=new"), "new");
assert.equal(rectificationEntryIntent("?rectify=yes"), null);
assert.equal(parseRectificationEntryIntent("?rectify=new"), true);
assert.equal(RECTIFICATION_PAGE_HREF, "/rectification");
});
test("the summary parser reads the new fields in either case and keeps older payloads valid", () => {
const camel = entrySummaryFromResponse({
hasResumableCase: true,
hasTerminalCaseWithTime: false,
latestResumable: { caseId: CASE, status: "paused", lastActivityAt: "x", sessionId: SESSION, candidateRange: { startTime: "06:30", endTime: "07:30" }, evidenceCount: 2 },
latestTerminal: null,
});
assert.equal(camel.latestResumable?.sessionId, SESSION);
assert.deepEqual(camel.latestResumable?.candidateRange, { startTime: "06:30", endTime: "07:30" });
assert.equal(camel.latestResumable?.evidenceCount, 2);
const snake = entrySummaryFromResponse({
has_resumable_case: false,
has_terminal_case_with_time: true,
latest_resumable: null,
latest_terminal: { case_id: CASE, status: "confirmed", has_usable_time: true, session_id: SESSION, confirmed_time: "07:42:00" },
});
assert.equal(snake.latestTerminal?.sessionId, SESSION);
assert.equal(snake.latestTerminal?.confirmedTime, "07:42:00");
const old = entrySummaryFromResponse({ hasResumableCase: true, latestResumable: { caseId: CASE, status: "draft", lastActivityAt: "x" } });
assert.equal(old.latestResumable?.sessionId, null);
assert.equal(old.latestResumable?.candidateRange, null);
assert.equal(old.latestResumable?.evidenceCount, null);
});
test("clock labels drop seconds only", () => {
assert.equal(clockLabel("07:42:00"), "07:42");
assert.equal(clockLabel("07:42"), "07:42");
assert.equal(clockLabel(" 07:42:05.5 "), "07:42");
});
test("the page never mentions credits (product 2026-10-28)", () => {
for (const text of Object.values(RECTIFICATION_PAGE_COPY)) {
assert.doesNotMatch(text, /点数|扣点|\d+\s*点|会用掉/);
}
const page = readFileSync(new URL("../src/components/rectification-page/rectification-entry-page.tsx", import.meta.url), "utf8");
assert.doesNotMatch(page, /credit|Credit|扣/);
});
test("an estimated time needs rectification until a time is adopted; an exact minute never does", () => {
for (const source of ["approximate", "period_only", "unknown"]) {
assert.equal(birthTimeNeedsRectification({ birthTimeSource: source, birthTimeStatus: "reported" }), true, source);
assert.equal(birthTimeNeedsRectification({ birthTimeSource: source, birthTimeStatus: "accepted" }), false, source);
assert.equal(birthTimeWasEstimated({ birthTimeSource: source }), true, source);
}
for (const source of ["hospital_record", "family_exact", "legacy_import", ""]) {
assert.equal(birthTimeNeedsRectification({ birthTimeSource: source, birthTimeStatus: "" }), false, source);
assert.equal(birthTimeWasEstimated({ birthTimeSource: source }), false, source);
}
});
function facts(rectification: ChecklistFacts["rectification"], selfComplete = true): ChecklistFacts {
return {
people: [{ id: "self", role: "self", name: "小林", complete: selfComplete, createdAt: null }],
profileComplete: true,
hasNonSmalltalkAnswer: true,
dailyCount: 0,
chartVisited: false,
synastryReportCount: 0,
personalReportCount: 0,
flags: { compatibility: true, reportCenter: true, synastryCreditCost: 19, reportCreditCost: null },
rectification,
};
}
test("the checklist adds 做一次生时校正 after the first question only when given, and ticks it once a time is adopted", () => {
const without = buildChecklist(facts(null));
assert.equal(without.some((item) => item.id === "rectify"), false);
assert.equal(without.length, 7);
const open = buildChecklist(facts({ done: false }));
assert.deepEqual(open.slice(0, 3).map((item) => item.id), ["self", "ask", "rectify"]);
const row = open.find((item) => item.id === "rectify")!;
assert.equal(row.label, "做一次生时校正");
assert.equal(row.done, false);
assert.deepEqual(row.go, { kind: "href", href: "/rectification" });
assert.equal(open.length, 8);
assert.equal(buildChecklist(facts({ done: true })).find((item) => item.id === "rectify")?.done, true);
assert.equal(buildChecklist(facts({ done: false }, false)).some((item) => item.id === "rectify"), false, "help-other path never lists it");
});
test("the provider gives the checklist item only with the product on and an estimated self time", () => {
const provider = readFileSync(new URL("../src/lib/onboarding-journey.tsx", import.meta.url), "utf8");
assert.match(provider, /list\.account\?\.rectification === true && birthTimeWasEstimated\(selfProfile\)/);
assert.match(provider, /rectification: rectificationItem,/);
});
test("the starter home shows the rectification prompt only when asked, linking to /rectification", () => {
const props = {
audience: { kind: "self" } as const,
subjectId: "self",
signs: { ascendantSign: null, moonSign: null },
chartSignsFallback: false,
onAsk() {},
onNudge() {},
};
const plain = renderToString(createElement(StarterQuestions, props));
assert.doesNotMatch(plain, /starter-rectify-hint/);
const hinted = renderToString(createElement(StarterQuestions, { ...props, rectifyHint: true }));
assert.match(hinted, /starter-rectify-hint/);
assert.match(hinted, new RegExp(RECTIFICATION_HOME_HINT.text));
assert.match(hinted, /href="\/rectification"/);
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(page, /rectifyHint=\{currentSubjectId === "self" && account\.rectification === true && birthTimeNeedsRectification\(profile\)\}/);
});
test("the sidebar lists 生时校正 under 星盘, behind the account flag", () => {
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const chart = sidebar.indexOf('{ href: "/chart", label: "星盘"');
const entry = sidebar.indexOf('{ href: "/rectification", label: "生时校正"');
const ephemeris = sidebar.indexOf('{ href: "/ephemeris", label: "星历"');
assert.ok(chart >= 0 && chart < entry && entry < ephemeris);
assert.match(sidebar, /page\.href !== "\/rectification" \|\| rectificationEnabled/);
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
assert.match(layout, /rectificationEnabled=\{rectificationEnabled\}/);
assert.match(layout, /registerShellNavigate\(/);
const account = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
assert.match(account, /isProductEnabled\("rectification"\)/);
assert.match(account, /\n\s+rectification,\n/);
});
test("the entry-summary migration is add-only and counts confirmed events only", () => {
const sql = readFileSync(new URL("../supabase/migrations/20261028170000_rectification_entry_summary_detail.sql", import.meta.url), "utf8");
assert.match(sql, /create or replace function public\.get_agentic_rectification_entry_summary\(/);
for (const key of ["'session_id', v_resumable.session_id", "'candidate_range', v_resumable.candidate_range", "'session_id', v_terminal.session_id", "'confirmed_time', v_terminal.confirmed_time"]) {
assert.ok(sql.includes(key), key);
}
assert.match(sql, /evidence\.status = 'confirmed'/);
assert.doesNotMatch(sql, /baseline_birth_snapshot/);
assert.doesNotMatch(sql, /\bdrop (table|column|function)\b/i);
assert.match(sql, /grant execute on function public\.get_agentic_rectification_entry_summary\(uuid\)\s+to service_role;/);
});
@@ -16,14 +16,11 @@ import {
RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_OPENED_COPY,
RECTIFICATION_OFFER_START_LABEL,
RECTIFICATION_OFFER_TOOL_ID,
attachOpenedOfferToFreeMessage,
bindOfferToCase,
offerForCase,
parseRectificationOfferRecord,
rectificationOfferCardCopy,
sourceFromSessionRows,
type RectificationOfferRecord,
} from "../src/lib/rectification-in-chat.ts";
@@ -737,16 +734,19 @@ test("the offer rule is defined once, and direct entry settles with the free com
assert.doesNotMatch(stream, /请你帮我进行生时纠正/);
const hook = src("../src/hooks/use-consultation-run.ts");
assert.match(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
assert.match(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
assert.equal(hook.match(/openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/g)?.length, 1);
const directOpen = hook.slice(hook.indexOf("rectificationDirectOpen?.mode"), hook.indexOf("await persistSession(completedSession)"));
assert.match(directOpen, /if \(opened\) return/);
// 原值:直达在浏览器里 openRectificationFromChat(sourceSessionId) 新开,或 openRectificationSession(resumeSessionId) 续上。
// 新值:新开和续上都只跳到 /rectification(openRectificationPage),页面自己给「开始」或「继续」。
// 原因:issue #145(产品 2026-10-28 定:入口单独拆出,不从对话开案例)。
assert.equal(hook.match(/if \(rectificationDirectOpen\) openRectificationPage\(\);/g)?.length, 1);
assert.doesNotMatch(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
assert.doesNotMatch(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
const directOpen = hook.slice(hook.indexOf("if (rectificationDirectOpen)"), hook.indexOf("await persistSession(completedSession)"));
assert.doesNotMatch(directOpen, /status:\s*"opened"/);
assert.match(hook, /responseKind === "smalltalk" \? \{[\s\S]*?rectificationOffer/);
const page = src("../src/app/(app)/page.tsx");
assert.match(page, /openAccountDialog, openRectificationSession, openRectificationFromChat,/);
assert.match(page, /openAccountDialog, openRectificationSession, openRectificationPage,/);
assert.match(page, /navigateInApp\(RECTIFICATION_PAGE_HREF\)/);
assert.doesNotMatch(page, /请你帮我进行生时纠正/);
const server = src("../src/lib/rectification-in-chat-server.ts");
@@ -779,13 +779,16 @@ function cardHtml(record: RectificationOfferRecord) {
}
test("a direct-open offer still shows the ordinary card until the case is bound", () => {
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
const pending = offer("offered", { autoOpen: true });
const html = cardHtml(pending);
assert.match(html, /大约 8 题,会用掉 3 点/);
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
assert.equal(html.includes("继续核对出生时间"), false);
assert.match(html, new RegExp(rectificationOfferCardCopy(pending).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(html, /出生时间还不确定。/);
assert.match(html, /href="\/rectification"/);
assert.match(html, /去做生时校正/);
assert.doesNotMatch(html, /会用掉|点;/);
assert.equal(html.includes("开始核对出生时间"), false);
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
@@ -794,9 +797,8 @@ test("a direct-open offer still shows the ordinary card until the case is bound"
assert.equal(opened.autoOpen, true);
assert.equal(opened.caseId, CASE_ID);
const openedHtml = cardHtml(opened);
assert.match(openedHtml, new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
assert.match(openedHtml, /继续核对出生时间/);
assert.equal(openedHtml.includes(RECTIFICATION_OFFER_START_LABEL), false);
assert.match(openedHtml, /href="\/rectification"/);
assert.equal(openedHtml.includes("出生时间核对已经开始"), false);
});
test("a failed direct open leaves the offer offered and clickable", () => {
@@ -808,8 +810,9 @@ test("a failed direct open leaves the offer offered and clickable", () => {
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).status, "offered");
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
const html = cardHtml(pending);
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
assert.equal(html.includes("出生时间核对已经开始"), false);
assert.match(html, /href="\/rectification"/);
assert.equal(html.includes("aria-disabled"), false);
assert.equal(html.includes("disabled=\"\""), false);
assert.equal(html.includes("disabled=\"disabled\""), false);
});
@@ -826,8 +829,8 @@ test("stored offers offered, opened, and returned still parse; returned hides th
assert.equal(flagged?.autoOpen, true);
assert.equal(parseRectificationOfferRecord({ ...offer("opened"), extra: true }), null);
assert.equal(cardHtml(offer("returned")), "");
assert.match(cardHtml(offer("opened")), new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
assert.match(cardHtml(offer("offered")), new RegExp(RECTIFICATION_OFFER_START_LABEL));
assert.match(cardHtml(offer("opened")), /href="\/rectification"/);
assert.match(cardHtml(offer("offered")), /href="\/rectification"/);
});
test("the free reply stores the direct-open offer still offered, with autoOpen", () => {
@@ -249,9 +249,13 @@ function transcriptProps(messages: ChatMessage[], calls: string[]): ChatTranscri
test("the transcript shows the offer card, the divider, the old-time caption and the revisit button (P2, BUG-1248/1250)", () => {
const offered = conversation() as ChatMessage[];
const card = renderToString(createElement(ChatTranscript, transcriptProps(offered, [])));
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
assert.match(card, /rectification-offer-card/);
assert.match(card, /大约 8 题,会用掉 3 点;开始答第一题时才扣。/);
assert.match(card, /开始核对出生时间/);
assert.match(card, /出生时间还不确定。/);
assert.match(card, /href="\/rectification"/);
assert.doesNotMatch(card, /会用掉/);
assert.doesNotMatch(card, /按校正前的时间/);
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
@@ -310,15 +314,17 @@ test("an auto-open offer shows the ordinary card until the case is bound, and a
{ role: "assistant" as const, text: "好,我们开始核对出生时间。", responseKind: "smalltalk" as const, rectificationOffer: pending },
];
const html = renderToString(createElement(ChatTranscript, transcriptProps(waiting, [])));
assert.match(html, /开始核对出生时间/);
assert.match(html, /大约 8 题,会用掉 3 点;开始答第一题时才扣。/);
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
assert.match(html, /href="\/rectification"/);
assert.doesNotMatch(html, /会用掉/);
assert.doesNotMatch(html, /出生时间核对已经开始/);
assert.doesNotMatch(html, /继续核对出生时间/);
const bound = bindOfferToCase(waiting, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
const openedHtml = renderToString(createElement(ChatTranscript, transcriptProps(bound as ChatMessage[], [])));
assert.match(openedHtml, /出生时间核对已经开始/);
assert.match(openedHtml, /继续核对出生时间/);
assert.match(openedHtml, /href="\/rectification"/);
assert.doesNotMatch(openedHtml, /出生时间核对已经开始/);
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).status, "opened");
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
+115 -130
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-28",
"evaluated_on": "2026-10-18",
"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": "b44505aa0034e486fcfc29c8611a9559333a97c1005f92e6a5356bd855122205",
"implementation_sha256": "bf8981b5c41702f9b333444deee2ab7378b9e98b111240e85ea62e6aae02fda8",
"extended_identity": {
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
"production_scoring_files": [
@@ -52,7 +52,7 @@
"scripts/shadbala.py",
"scripts/varga.py"
],
"production_scoring_sha256": "7a727418651321d1b9073588587050c4dc6292587748cf35734dd76cefb0effe",
"production_scoring_sha256": "c2c23bcbde5de58e519ae1d6545620edd0f738ce8dc2e872e659b598137a728f",
"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": "6fa0b0c335ac0cdc25ee887d432ae2f1f6487c665aed9665ad5cc0bc6ef0283b",
"research_implementation_sha256": "9df579bdacd7dec43abd92c9abc1831771feb800fc29e6864dc315d083d52a03",
"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": "8ecc381dfaa3c0e6bea058cfa44c606aabdb1086c137f16da82ee5a3bf5ccc91",
"scripts/research/sealed_holdout_rerun.py": "ebcdae760f2b76c81d5d0c8e2bf347cff4c81cca9074f27ae94b6734408ccd42",
"scripts/research/reported_offset_sweep.py": "e10537ca2caba3a4e6a8d2460265543b0cca55935afa8004108e45bef4bc3de7",
"scripts/research/sealed_holdout_rerun.py": "7945353014b93b6d7641f278779111d85b1773293781343b802a9b4b655ca4b5",
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
"scripts/varga.py": "8ff96b9fc8346ca249abff6109434b6ed57fa150e1a9854d4f73bdf317568368"
"scripts/varga.py": "c1f06967c3d4d214edadff139c24aa31e59fb628477174235abe14f799a99076"
},
"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_sync9b_2026_10_28.json",
"source_report": "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json",
"fixed_protocol_rerun_trial_count": 20,
"fixed_protocol_rerun_hash_matches": true,
"metrics": {
@@ -113,106 +113,6 @@
}
},
"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",
@@ -312,6 +212,106 @@
"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,28 +330,6 @@
"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",
@@ -372,5 +350,12 @@
"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_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"
# 原值: 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"
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_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"
# 原值: 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"
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 = [
+8 -12
View File
@@ -246,12 +246,11 @@ 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.
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.
Traditional counting is inclusive, so the 22nd point is +21 signs from the
Lagna Drekkana anchor.
"""
base = calc_varga(lagna_lon, 3)
sign_idx = (base['sign_idx'] + 7) % 12
sign_idx = (base['sign_idx'] + 21) % 12
return {
'base_drekkana_sign_idx': base['sign_idx'],
'base_drekkana_sign': base['sign'],
@@ -280,19 +279,16 @@ def calc_bhrigu_bindu(moon_lon: float, rahu_lon: float) -> Dict:
}
# 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,)}
SARPA_DREKKANA_SIGNS = {'Cancer': 2, 'Scorpio': 1, '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((lon % 30) / 10) + 1
expected = SARPA_DREKKANA_SIGNS.get(sign, ())
drekkana_number = int(degree_in_sign / 10) + 1
expected = SARPA_DREKKANA_SIGNS.get(sign)
return {
'longitude': round(lon % 360, 4),
'sign': sign,
@@ -301,8 +297,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': drekkana_number in expected,
'definition': 'Cancer-2, Cancer-3, Scorpio-1, Scorpio-2, Pisces-3',
'is_sarpa_drekkana': bool(expected == drekkana_number),
'definition': 'Cancer-2, Scorpio-1, Pisces-3',
}
def dignity(planet, sign_idx):
+4
View File
@@ -1637,6 +1637,10 @@ def test_capability_audit_scans_registry_and_local_sources() -> None:
# 新值: 加入 privacy(people 与 reports 之间)和 terms(末尾)
# 原因: 隐私政策与用户协议是未登录也能打开的公开页面(2026-09-30 合规轮)
'privacy',
# 原值: privacy 后面直接是 reports
# 新值: 中间加入 rectification
# 原因: 生时校正独立入口 app/(app)/rectification(issue #145)
'rectification',
'reports',
'reports/[reportId]',
# 原值: reports/[reportId] 后面直接是 terms
+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, Cancer-3, Scorpio-1, Scorpio-2, Pisces-3"
assert payload["definition"] == "Cancer-2, Scorpio-1, Pisces-3"
assert payload["is_sarpa_drekkana"] is True
@@ -53,11 +53,6 @@ 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/",
)
@@ -1,16 +0,0 @@
"""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
@@ -1,225 +0,0 @@
#!/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
+1 -2
View File
@@ -175,8 +175,7 @@ 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)
# Sagittarius D3 rising: its eighth house is Cancer (Crux p.329).
expected_sign_idx = 3
expected_sign_idx = (drekkana_ref(asc_lon) + 21) % 12
assert result["sign_idx"] == expected_sign_idx
assert result["sign"] == SIGNS[expected_sign_idx]
assert result["offset_from_lagna_drekkana"] == 22