Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbfb37f7c8 | ||
|
|
dfd3208979 |
+5
-5
@@ -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 — 两人同时做校正或生成报告时不再互相拖慢
|
||||
|
||||
|
||||
@@ -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:本次不改计算代码
|
||||
@@ -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。
|
||||
- [ ] 打开系统「减少动态效果」:页面只淡入,没有位移。
|
||||
@@ -1,9 +0,0 @@
|
||||
# 真机走查 · 上游同步第九轮乙批(issue #159)
|
||||
|
||||
用虚构或公开出生资料,在 staging 上看:
|
||||
|
||||
| # | 操作 | 应该看到 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 生成一份完整报告,翻到特殊点 / 敏感点表 | Sarpa Drekkana 行正常显示;没有空白或「undefined」 |
|
||||
| 2 | 同一张盘看第 22 号 Drekkana | 有星座,不报错 |
|
||||
| 3 | 发起一次生时校正,走到出结果 | 和以前一样能走完(校正打分没有变) |
|
||||
+12
-1
@@ -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。没有新的等待动画或输入框。
|
||||
|
||||
@@ -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`。不用关键词判断。
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 = "按校正前的时间";
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user