Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98be2649ac | ||
|
|
3bbf58e984 |
@@ -1,17 +1,5 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-28 — 切换人物时看得出是谁的盘
|
||||
|
||||
- 「当前星盘」按钮上写名字、出生日期时间和城市;名单每行写关系和出生时间、城市。
|
||||
- 每一行右边「⋯」可以直接编辑这个人,或删除别人(确认框和星盘档案一样);底部「新增一个人」直接打开新增表单。
|
||||
- 对话右边的盘面顶部也能切换,规则和对话顶栏一样:已经说过话再换人会新开一个对话。
|
||||
- Skill 版本不变(issue #150)。
|
||||
|
||||
## 2026-10-28 — 生时校正复述不再把日期说两遍
|
||||
|
||||
- 说「2016 年 9 月上大学」这类自带日期的事,回复以前会写成「2016-09 2016 年 9 月上大学」,现在只说一遍。
|
||||
- Skill 版本不变(BUG-1496,issue #161)。
|
||||
|
||||
## 2026-10-28 — 生时校正对话说得更清楚
|
||||
|
||||
- 开场那句会说明是按什么时间开始找的(出生记录、你填的大概时间,或你给的时间段)。
|
||||
|
||||
+28
-26
@@ -20448,33 +20448,35 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync9b-20261028`。
|
||||
|
||||
## BUG-1496 | 生时校正复述把日期说了两遍(「2016-09 2016 年 9 月上大学」)
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;待部署后 staging 复测)
|
||||
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #161;issue #148 验收时在 staging 实测发现)
|
||||
- 影响面:`frontend/src/lib/rectification-agentic/v9/host-fallback.ts::evidenceRecapLine`。所有证据轮复述都经过它:本轮入账复述(`acceptedRecapLines`)、每条回复的入账清单(`recordedEvidenceByTurn`)、收束卡「我说错了,改一条」的事件列表(`fixableEvidenceLines`)。
|
||||
- 现象:用户说「2016 年 9 月上大学。」,回复「记下了:2016-09 2016 年 9 月上大学。」
|
||||
- 触发条件:事件说法里自带日期,且年份和「年」之间有空格,或写成 ISO / 斜杠日期。
|
||||
- 根因:拼复述时,判断「事件说法已带日期」的规则只认不带空格的 `\d{4}年`(BUG-1136 加的拼接规则),漏掉「2016 年」「2016-09」「2016/9」,于是又在前面加了一次日期标签。
|
||||
- 修复:改为识别 `\d{4}\s*年` 或 `\d{4}[-/.]\d{1,2}`;事件说法里有这样的日期就只用事件说法,没带日期的照旧加标签。
|
||||
- 验证:`tests/rectification-host-fallback.test.ts` 新增「the recap does not repeat a date the event phrase already carries」,修复前红、修复后绿;覆盖带空格年份、不带空格、ISO、斜杠四种写法,以及不带日期、中文标签、非年份数字(「600 分」)三种不变的情况。`rectification-message-cleanup-20261001`、`rectification-grounding-20260927`、`rectification-event-date-bounds-20261002` 同时通过。
|
||||
- 防复发:上面那条测试固定了「带空格的年份」这一例。拼复述只在 `evidenceRecapLine` 一处做。
|
||||
- 关联:BUG-1136。
|
||||
## BUG-1497 | D60 换格能不能帮生时校正,从没在已知出生时间的人身上测过
|
||||
|
||||
- 状态:investigating
|
||||
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #149,来源 #142 竞品宣传)
|
||||
- 影响面:生时校正的技法路由与对外文案。`skills/jyotish-birth-time-rectification/references/technique-routing.md` 现在写「D60 只作参考,不驱动结论」。
|
||||
- 现象:竞品宣传「证据够密时 D60 与纳迪分盘反向校准,收束时刻可以到秒」。纳迪那一半已在 BUG-1240 证伪(N3 ±10 窗口找回记录分钟 5/77,生产 25/77;N4 只换岁差 73/73 换段);D60 这一半从未在 v5 77 例已知出生时间上检验。
|
||||
- 触发条件:用 D60 换格给候选分段打分,或对外宣称「到秒」。
|
||||
- 根因:未验证的技法被当成能力宣传;我方没有 D60 换格的评分实现,也没有对照实验。
|
||||
- 修复:(进行中)G1 已跑:生产 D60 上升与 PyJHora 只一致 33/77(42.9%),本仓 `mode='vedastro'` 的 D60 与 PyJHora 77/77。
|
||||
失败原因是本仓生产 D60 映射本身(见 BUG-1498),不是「D60 没有信号」;G2–G5 暂缓,先把映射口径定下来再重跑 G1。结论出来前生产代码、技法路由、对外文案都不动。
|
||||
- 验证:待研究单 `docs/tasks/TASK-rectification-d60-two-way-20261028.md` 的 G1–G5 全部有结论后回填;不过门就写 `closed_by_design` 并附对外口径。
|
||||
- 防复发:研究单写死「先登记再跑、真值不可见、必须有安慰剂、负结果照写」。
|
||||
- 关联:BUG-1240、BUG-560、BUG-1090、BUG-1091。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rectification-recap-date-dup-20261028`。
|
||||
- 修复版本:分支 `codex/rectification-d60-two-way-20261028`(研究单,未合入)。
|
||||
|
||||
## BUG-1498 | staging 门禁 Python 快速门偶发失败:`test_daily_starlanguage_does_not_eat_yesterday_cache` 网关调用 0 次
|
||||
|
||||
- 状态:investigating(只记录已确认事实与假设;未改代码)
|
||||
- 首次发现 / 最近更新:2026-10-28(BUG-1481 验证段首次提到)/ 2026-10-28(staging 门禁 run 2017)
|
||||
- 影响面:只影响 CI 门禁,不影响线上。`tests/test_vedastro_snapshot_cache.py::test_daily_starlanguage_does_not_eat_yesterday_cache`。
|
||||
- 现象:`assert calls["gateway"] == 1` 得到 `0 == 1`;同一轮其余 1094 项通过。run 2016(同一份 Python 代码)这一项通过;本机单独连跑 5 次全部通过。
|
||||
- 触发条件:整套快速门一起跑时偶发,单独跑不出现,跟测试先后顺序或机器负载有关。
|
||||
- 已排除:BUG-1481 当时认为是测试之间共用负缓存目录;`tests/conftest.py::_isolate_vedastro_snapshot_cache` 现在每个测试都另建快照目录和负缓存目录,这次复发不是那个原因。
|
||||
- 假设(未证实):`scripts/vedastro_foreground.py` 的 `_FOREGROUND_VEDASTRO_EXECUTOR` 是模块级线程池,整个 pytest 进程共用,conftest 不清空它。前面的测试如果留下还在跑的任务占满线程,本测试的任务排在后面,前台 1.5 秒等待到期后被取消,网关一次都没调用。
|
||||
- 修复:未修。下一步:在 conftest 里每个测试后排空或重建这个线程池,或者本测试把等待时间设大,再在全套顺序下复跑确认。
|
||||
- 验证:未完成。
|
||||
- 防复发:未完成。
|
||||
- 关联:BUG-1481(第一次记录这个失败)。
|
||||
- 复发自:BUG-1481 记下的同一项失败。当时的处理(隔离负缓存目录)没拦住,因为根因很可能不在目录上。
|
||||
- 修复版本:无。
|
||||
## BUG-1498 | 生产 D60 映射把 1-based 偶数星座整体后移一个星座
|
||||
|
||||
- 状态:investigating
|
||||
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #149,G1 三引擎对账)
|
||||
- 影响面:`scripts/varga.py::varga_map` 的 D60 分支(生产默认 `classical_local` 模式)。技法路由目前写「D60 只作参考」,不进校正打分。
|
||||
- 现象:v5 的 77 例已知出生时间里,生产 D60 上升星座与 PyJHora(traditional Parasara,from sign)只一致 33 例;其余 44 例全部差 +1 个星座,且 44 例全是 1-based 偶数星座(金牛、巨蟹、处女、天蝎、摩羯、双鱼)。本仓 `mode='vedastro'` 的 D60 映射与 PyJHora 77/77 一致。
|
||||
- 触发条件:读取生产 D60(`calc_varga(lon, 60)` 默认模式)。
|
||||
- 根因:`varga_map` 的 D60 分支是 `(si+pi)%12 if o else (si+1+pi)%12`(`o` 为 1-based 奇数星座),对 1-based 偶数星座加 1;同文件 vedastro 映射是 `(si+pi)%12`。两套内部映射不一致,外部引擎与 vedastro 那套一致。哪一套符合来源尚未裁决,不预设 classical 就是错。
|
||||
- 修复:未修。研究单红线要求生产代码零改动;要先按经典来源(BPHS / Raman)确认偶数星座是否真的从下一星座起算,再决定改映射还是改文档。
|
||||
- 验证:`docs/research/d60_two_way_results_2026_10_28.json`(77 例逐条:上升经度、两套本仓 D60、PyJHora D60、是否一致)。
|
||||
- 防复发:给 D60 补一条映射测试,锁定与外部引擎一致的规则;classical 与 vedastro 两种模式要么一致,要么在注释里写明来源差异。
|
||||
- 关联:BUG-1497。
|
||||
- 复发自:无。
|
||||
- 修复版本:未修。
|
||||
|
||||
@@ -0,0 +1,833 @@
|
||||
{
|
||||
"task": "docs/tasks/TASK-rectification-d60-two-way-20261028.md",
|
||||
"issue": 149,
|
||||
"bug": "BUG-1497",
|
||||
"gate": "G1 three-engine D60 reconciliation",
|
||||
"generated_at_utc": "2026-10-28T12:02:20.260551+00:00",
|
||||
"dataset": {
|
||||
"path": "references/real_case_calibration/minute_rectification_holdout_v5.json",
|
||||
"sha256": "ab365a3e3f4953d4b538f2c0889dc98ab89444a28340c3a7b29e2a864cdd687a",
|
||||
"cases": 77
|
||||
},
|
||||
"profile": {
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
"house_system": "production compute_chart_data",
|
||||
"pyjhora_ayanamsa_mode": "Raman",
|
||||
"pyjhora_chart_method": 1
|
||||
},
|
||||
"engines": {
|
||||
"own_classical_local": "varga.calc_varga(lon, 60) (production default)",
|
||||
"own_vedastro_mode": "varga.calc_varga(lon, 60, mode='vedastro') (VEDASTRO_RAW_VERIFIED_DIVISIONS includes 60)",
|
||||
"pyjhora": "jhora.horoscope.chart.charts.divisional_chart(jd, place, divisional_chart_factor=60), Lagna entry",
|
||||
"jyotishganit": "blocked: not installed in this environment",
|
||||
"vedastro": "blocked: no live call made in this run"
|
||||
},
|
||||
"result": {
|
||||
"cases": 77,
|
||||
"classical_matches": 33,
|
||||
"classical_rate": 0.4286,
|
||||
"vedastro_matches": 77,
|
||||
"vedastro_rate": 1.0,
|
||||
"mismatch_delta_histogram": {
|
||||
"0": 33,
|
||||
"1": 44,
|
||||
"2": 0,
|
||||
"3": 0,
|
||||
"4": 0,
|
||||
"5": 0,
|
||||
"6": 0,
|
||||
"7": 0,
|
||||
"8": 0,
|
||||
"9": 0,
|
||||
"10": 0,
|
||||
"11": 0
|
||||
},
|
||||
"mismatches_by_asc_sign_parity": {
|
||||
"odd_1based": {
|
||||
"cases": 33,
|
||||
"matches": 33
|
||||
},
|
||||
"even_1based": {
|
||||
"cases": 44,
|
||||
"matches": 0
|
||||
}
|
||||
},
|
||||
"gate_passed": false,
|
||||
"gate_threshold": ">=95% agreement with at least one external engine",
|
||||
"stop_rule": "G1 failing stops G2-G5 per the brief"
|
||||
},
|
||||
"finding": "The production classical D60 mapping shifts every 1-based even sign by one sign relative to PyJHora; the repository's own vedastro-mode D60 mapping agrees with PyJHora on all 77 cases. No scoring gate was run.",
|
||||
"instances": [
|
||||
{
|
||||
"case_id": "albert_brooks_1947_aa_v4_holdout",
|
||||
"asc_sign_idx": 2,
|
||||
"ascendant_longitude": 71.1909,
|
||||
"d60_classical": 0,
|
||||
"d60_vedastro_mode": 0,
|
||||
"d60_pyjhora_method1": 0,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "andrew_windsor_1960_aa_v4_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 109.6959,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 6,
|
||||
"d60_pyjhora_method1": 6,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "angelina_jolie_1975_aa_v4_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 96.8001,
|
||||
"d60_classical": 5,
|
||||
"d60_vedastro_mode": 4,
|
||||
"d60_pyjhora_method1": 4,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "barack_obama_1961_aa_v4_holdout",
|
||||
"asc_sign_idx": 9,
|
||||
"ascendant_longitude": 296.1798,
|
||||
"d60_classical": 2,
|
||||
"d60_vedastro_mode": 1,
|
||||
"d60_pyjhora_method1": 1,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "bernd_eichinger_1949_aa_v4_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 107.0009,
|
||||
"d60_classical": 2,
|
||||
"d60_vedastro_mode": 1,
|
||||
"d60_pyjhora_method1": 1,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "chad_everett_1937_aa_v4_holdout",
|
||||
"asc_sign_idx": 8,
|
||||
"ascendant_longitude": 269.6959,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 7,
|
||||
"d60_pyjhora_method1": 7,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "elizabeth_montgomery_1933_aa_v4_holdout",
|
||||
"asc_sign_idx": 5,
|
||||
"ascendant_longitude": 162.7012,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 6,
|
||||
"d60_pyjhora_method1": 6,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "elizabeth_taylor_1932_aa_v4_holdout",
|
||||
"asc_sign_idx": 7,
|
||||
"ascendant_longitude": 231.7983,
|
||||
"d60_classical": 3,
|
||||
"d60_vedastro_mode": 2,
|
||||
"d60_pyjhora_method1": 2,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "iwao_takamoto_1925_aa_v4_holdout",
|
||||
"asc_sign_idx": 7,
|
||||
"ascendant_longitude": 216.2833,
|
||||
"d60_classical": 8,
|
||||
"d60_vedastro_mode": 7,
|
||||
"d60_pyjhora_method1": 7,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "john_robbins_1947_aa_v4_holdout",
|
||||
"asc_sign_idx": 4,
|
||||
"ascendant_longitude": 149.6725,
|
||||
"d60_classical": 3,
|
||||
"d60_vedastro_mode": 3,
|
||||
"d60_pyjhora_method1": 3,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "joseph_kennedy_iii_1980_aa_v4_holdout",
|
||||
"asc_sign_idx": 1,
|
||||
"ascendant_longitude": 59.9029,
|
||||
"d60_classical": 1,
|
||||
"d60_vedastro_mode": 0,
|
||||
"d60_pyjhora_method1": 0,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "kurt_cobain_1967_aa_v4_holdout",
|
||||
"asc_sign_idx": 5,
|
||||
"ascendant_longitude": 151.3987,
|
||||
"d60_classical": 8,
|
||||
"d60_vedastro_mode": 7,
|
||||
"d60_pyjhora_method1": 7,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "pablo_picasso_1881_aa_v4_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 104.8722,
|
||||
"d60_classical": 9,
|
||||
"d60_vedastro_mode": 8,
|
||||
"d60_pyjhora_method1": 8,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "paul_ryan_1970_aa_v4_holdout",
|
||||
"asc_sign_idx": 7,
|
||||
"ascendant_longitude": 218.6428,
|
||||
"d60_classical": 1,
|
||||
"d60_vedastro_mode": 0,
|
||||
"d60_pyjhora_method1": 0,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "salma_hayek_1966_aa_v4_holdout",
|
||||
"asc_sign_idx": 4,
|
||||
"ascendant_longitude": 145.4799,
|
||||
"d60_classical": 6,
|
||||
"d60_vedastro_mode": 6,
|
||||
"d60_pyjhora_method1": 6,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "sean_lennon_1975_aa_v4_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 113.2594,
|
||||
"d60_classical": 2,
|
||||
"d60_vedastro_mode": 1,
|
||||
"d60_pyjhora_method1": 1,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "sigmund_freud_1856_aa_v4_holdout",
|
||||
"asc_sign_idx": 6,
|
||||
"ascendant_longitude": 199.7737,
|
||||
"d60_classical": 9,
|
||||
"d60_vedastro_mode": 9,
|
||||
"d60_pyjhora_method1": 9,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "steve_reich_1936_aa_v4_holdout",
|
||||
"asc_sign_idx": 0,
|
||||
"ascendant_longitude": 9.9031,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 7,
|
||||
"d60_pyjhora_method1": 7,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "tiger_woods_1975_aa_v4_holdout",
|
||||
"asc_sign_idx": 5,
|
||||
"ascendant_longitude": 152.3435,
|
||||
"d60_classical": 10,
|
||||
"d60_vedastro_mode": 9,
|
||||
"d60_pyjhora_method1": 9,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "tom_kennedy_1927_aa_v4_holdout",
|
||||
"asc_sign_idx": 4,
|
||||
"ascendant_longitude": 135.3649,
|
||||
"d60_classical": 10,
|
||||
"d60_vedastro_mode": 10,
|
||||
"d60_pyjhora_method1": 10,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "adriana_lima_1981_aa_v5_holdout",
|
||||
"asc_sign_idx": 7,
|
||||
"ascendant_longitude": 234.5927,
|
||||
"d60_classical": 9,
|
||||
"d60_vedastro_mode": 8,
|
||||
"d60_pyjhora_method1": 8,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "agatha_christie_1890_aa_v5_holdout",
|
||||
"asc_sign_idx": 8,
|
||||
"ascendant_longitude": 242.6992,
|
||||
"d60_classical": 1,
|
||||
"d60_vedastro_mode": 1,
|
||||
"d60_pyjhora_method1": 1,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "albert_einstein_1879_aa_v5_holdout",
|
||||
"asc_sign_idx": 2,
|
||||
"ascendant_longitude": 80.9181,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 7,
|
||||
"d60_pyjhora_method1": 7,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "alberto_fujimori_1938_aa_v5_holdout",
|
||||
"asc_sign_idx": 1,
|
||||
"ascendant_longitude": 30.0607,
|
||||
"d60_classical": 2,
|
||||
"d60_vedastro_mode": 1,
|
||||
"d60_pyjhora_method1": 1,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "arthur_conan_doyle_1859_aa_v5_holdout",
|
||||
"asc_sign_idx": 1,
|
||||
"ascendant_longitude": 59.3382,
|
||||
"d60_classical": 0,
|
||||
"d60_vedastro_mode": 11,
|
||||
"d60_pyjhora_method1": 11,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "audrey_hepburn_1929_aa_v5_holdout",
|
||||
"asc_sign_idx": 10,
|
||||
"ascendant_longitude": 307.1967,
|
||||
"d60_classical": 0,
|
||||
"d60_vedastro_mode": 0,
|
||||
"d60_pyjhora_method1": 0,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "ayrton_senna_1960_aa_v5_holdout",
|
||||
"asc_sign_idx": 9,
|
||||
"ascendant_longitude": 289.3969,
|
||||
"d60_classical": 0,
|
||||
"d60_vedastro_mode": 11,
|
||||
"d60_pyjhora_method1": 11,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "barbra_streisand_1942_aa_v5_holdout",
|
||||
"asc_sign_idx": 11,
|
||||
"ascendant_longitude": 346.7636,
|
||||
"d60_classical": 9,
|
||||
"d60_vedastro_mode": 8,
|
||||
"d60_pyjhora_method1": 8,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "beyonce_1981_aa_v5_holdout",
|
||||
"asc_sign_idx": 0,
|
||||
"ascendant_longitude": 6.8595,
|
||||
"d60_classical": 1,
|
||||
"d60_vedastro_mode": 1,
|
||||
"d60_pyjhora_method1": 1,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "bob_dylan_1941_aa_v5_holdout",
|
||||
"asc_sign_idx": 7,
|
||||
"ascendant_longitude": 238.7367,
|
||||
"d60_classical": 5,
|
||||
"d60_vedastro_mode": 4,
|
||||
"d60_pyjhora_method1": 4,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "brigitte_bardot_1934_aa_v5_holdout",
|
||||
"asc_sign_idx": 7,
|
||||
"ascendant_longitude": 233.8797,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 6,
|
||||
"d60_pyjhora_method1": 6,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "bruce_lee_1940_aa_v5_holdout",
|
||||
"asc_sign_idx": 7,
|
||||
"ascendant_longitude": 224.6155,
|
||||
"d60_classical": 1,
|
||||
"d60_vedastro_mode": 0,
|
||||
"d60_pyjhora_method1": 0,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "catherine_deneuve_1943_aa_v5_holdout",
|
||||
"asc_sign_idx": 8,
|
||||
"ascendant_longitude": 259.5116,
|
||||
"d60_classical": 11,
|
||||
"d60_vedastro_mode": 11,
|
||||
"d60_pyjhora_method1": 11,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "charles_de_gaulle_1890_aa_v5_holdout",
|
||||
"asc_sign_idx": 6,
|
||||
"ascendant_longitude": 181.278,
|
||||
"d60_classical": 8,
|
||||
"d60_vedastro_mode": 8,
|
||||
"d60_pyjhora_method1": 8,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "cher_1946_aa_v5_holdout",
|
||||
"asc_sign_idx": 2,
|
||||
"ascendant_longitude": 76.9514,
|
||||
"d60_classical": 11,
|
||||
"d60_vedastro_mode": 11,
|
||||
"d60_pyjhora_method1": 11,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "clint_eastwood_1930_aa_v5_holdout",
|
||||
"asc_sign_idx": 6,
|
||||
"ascendant_longitude": 207.1468,
|
||||
"d60_classical": 0,
|
||||
"d60_vedastro_mode": 0,
|
||||
"d60_pyjhora_method1": 0,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "daniel_barenboim_1942_aa_v5_holdout",
|
||||
"asc_sign_idx": 9,
|
||||
"ascendant_longitude": 292.2015,
|
||||
"d60_classical": 6,
|
||||
"d60_vedastro_mode": 5,
|
||||
"d60_pyjhora_method1": 5,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "diane_cilento_1932_aa_v5_holdout",
|
||||
"asc_sign_idx": 9,
|
||||
"ascendant_longitude": 290.6112,
|
||||
"d60_classical": 3,
|
||||
"d60_vedastro_mode": 2,
|
||||
"d60_pyjhora_method1": 2,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "dingdong_dantes_1980_aa_v5_holdout",
|
||||
"asc_sign_idx": 6,
|
||||
"ascendant_longitude": 181.5986,
|
||||
"d60_classical": 9,
|
||||
"d60_vedastro_mode": 9,
|
||||
"d60_pyjhora_method1": 9,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "donald_trump_1946_aa_v5_holdout",
|
||||
"asc_sign_idx": 4,
|
||||
"ascendant_longitude": 128.3089,
|
||||
"d60_classical": 8,
|
||||
"d60_vedastro_mode": 8,
|
||||
"d60_pyjhora_method1": 8,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "edith_piaf_1915_aa_v5_holdout",
|
||||
"asc_sign_idx": 7,
|
||||
"ascendant_longitude": 212.402,
|
||||
"d60_classical": 0,
|
||||
"d60_vedastro_mode": 11,
|
||||
"d60_pyjhora_method1": 11,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "elvis_presley_1935_aa_v5_holdout",
|
||||
"asc_sign_idx": 7,
|
||||
"ascendant_longitude": 230.8658,
|
||||
"d60_classical": 1,
|
||||
"d60_vedastro_mode": 0,
|
||||
"d60_pyjhora_method1": 0,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "francois_hollande_1954_aa_v5_holdout",
|
||||
"asc_sign_idx": 1,
|
||||
"ascendant_longitude": 46.0748,
|
||||
"d60_classical": 10,
|
||||
"d60_vedastro_mode": 9,
|
||||
"d60_pyjhora_method1": 9,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "frida_kahlo_1907_aa_v5_holdout",
|
||||
"asc_sign_idx": 4,
|
||||
"ascendant_longitude": 122.4798,
|
||||
"d60_classical": 8,
|
||||
"d60_vedastro_mode": 8,
|
||||
"d60_pyjhora_method1": 8,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "george_w_bush_1946_aa_v5_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 105.4441,
|
||||
"d60_classical": 10,
|
||||
"d60_vedastro_mode": 9,
|
||||
"d60_pyjhora_method1": 9,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "gisele_bundchen_1980_aa_v5_holdout",
|
||||
"asc_sign_idx": 8,
|
||||
"ascendant_longitude": 263.6478,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 7,
|
||||
"d60_pyjhora_method1": 7,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "gustavo_kuerten_1976_aa_v5_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 92.6966,
|
||||
"d60_classical": 9,
|
||||
"d60_vedastro_mode": 8,
|
||||
"d60_pyjhora_method1": 8,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "imelda_marcos_1929_aa_v5_holdout",
|
||||
"asc_sign_idx": 2,
|
||||
"ascendant_longitude": 77.1545,
|
||||
"d60_classical": 0,
|
||||
"d60_vedastro_mode": 0,
|
||||
"d60_pyjhora_method1": 0,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "janis_joplin_1943_aa_v5_holdout",
|
||||
"asc_sign_idx": 10,
|
||||
"ascendant_longitude": 303.9231,
|
||||
"d60_classical": 5,
|
||||
"d60_vedastro_mode": 5,
|
||||
"d60_pyjhora_method1": 5,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "jennifer_aniston_1969_aa_v5_holdout",
|
||||
"asc_sign_idx": 6,
|
||||
"ascendant_longitude": 182.6838,
|
||||
"d60_classical": 11,
|
||||
"d60_vedastro_mode": 11,
|
||||
"d60_pyjhora_method1": 11,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "jimi_hendrix_1942_aa_v5_holdout",
|
||||
"asc_sign_idx": 8,
|
||||
"ascendant_longitude": 243.0694,
|
||||
"d60_classical": 2,
|
||||
"d60_vedastro_mode": 2,
|
||||
"d60_pyjhora_method1": 2,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "johnny_depp_1963_aa_v5_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 110.5385,
|
||||
"d60_classical": 9,
|
||||
"d60_vedastro_mode": 8,
|
||||
"d60_pyjhora_method1": 8,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "judy_garland_1922_aa_v5_holdout",
|
||||
"asc_sign_idx": 2,
|
||||
"ascendant_longitude": 79.5603,
|
||||
"d60_classical": 5,
|
||||
"d60_vedastro_mode": 5,
|
||||
"d60_pyjhora_method1": 5,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "juscelino_kubitschek_1902_aa_v5_holdout",
|
||||
"asc_sign_idx": 9,
|
||||
"ascendant_longitude": 274.8708,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 6,
|
||||
"d60_pyjhora_method1": 6,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "kamala_harris_1964_aa_v5_holdout",
|
||||
"asc_sign_idx": 2,
|
||||
"ascendant_longitude": 62.5086,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 7,
|
||||
"d60_pyjhora_method1": 7,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "leonardo_dicaprio_1974_aa_v5_holdout",
|
||||
"asc_sign_idx": 5,
|
||||
"ascendant_longitude": 161.0553,
|
||||
"d60_classical": 4,
|
||||
"d60_vedastro_mode": 3,
|
||||
"d60_pyjhora_method1": 3,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "lionel_messi_1987_aa_v5_holdout",
|
||||
"asc_sign_idx": 9,
|
||||
"ascendant_longitude": 282.7709,
|
||||
"d60_classical": 11,
|
||||
"d60_vedastro_mode": 10,
|
||||
"d60_pyjhora_method1": 10,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "lou_diamond_phillips_1962_aa_v5_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 116.2537,
|
||||
"d60_classical": 8,
|
||||
"d60_vedastro_mode": 7,
|
||||
"d60_pyjhora_method1": 7,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "manuel_puig_1932_aa_v5_holdout",
|
||||
"asc_sign_idx": 6,
|
||||
"ascendant_longitude": 186.6657,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 7,
|
||||
"d60_pyjhora_method1": 7,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "marilyn_monroe_1926_aa_v5_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 111.6899,
|
||||
"d60_classical": 11,
|
||||
"d60_vedastro_mode": 10,
|
||||
"d60_pyjhora_method1": 10,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "meryl_streep_1949_aa_v5_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 101.0442,
|
||||
"d60_classical": 2,
|
||||
"d60_vedastro_mode": 1,
|
||||
"d60_pyjhora_method1": 1,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "miley_cyrus_1992_aa_v5_holdout",
|
||||
"asc_sign_idx": 1,
|
||||
"ascendant_longitude": 36.3265,
|
||||
"d60_classical": 2,
|
||||
"d60_vedastro_mode": 1,
|
||||
"d60_pyjhora_method1": 1,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "muhammad_ali_1942_aa_v5_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 117.9238,
|
||||
"d60_classical": 11,
|
||||
"d60_vedastro_mode": 10,
|
||||
"d60_pyjhora_method1": 10,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "nicolas_sarkozy_1955_aa_v5_holdout",
|
||||
"asc_sign_idx": 5,
|
||||
"ascendant_longitude": 154.3377,
|
||||
"d60_classical": 2,
|
||||
"d60_vedastro_mode": 1,
|
||||
"d60_pyjhora_method1": 1,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "pele_1940_aa_v5_holdout",
|
||||
"asc_sign_idx": 4,
|
||||
"ascendant_longitude": 137.9234,
|
||||
"d60_classical": 3,
|
||||
"d60_vedastro_mode": 3,
|
||||
"d60_pyjhora_method1": 3,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "piolo_pascual_1977_aa_v5_holdout",
|
||||
"asc_sign_idx": 4,
|
||||
"ascendant_longitude": 132.9944,
|
||||
"d60_classical": 5,
|
||||
"d60_vedastro_mode": 5,
|
||||
"d60_pyjhora_method1": 5,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "prince_1958_aa_v5_holdout",
|
||||
"asc_sign_idx": 6,
|
||||
"ascendant_longitude": 204.8647,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 7,
|
||||
"d60_pyjhora_method1": 7,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "rafael_nadal_1986_aa_v5_holdout",
|
||||
"asc_sign_idx": 6,
|
||||
"ascendant_longitude": 197.6444,
|
||||
"d60_classical": 5,
|
||||
"d60_vedastro_mode": 5,
|
||||
"d60_pyjhora_method1": 5,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "rudy_fernandez_1952_aa_v5_holdout",
|
||||
"asc_sign_idx": 5,
|
||||
"ascendant_longitude": 178.9455,
|
||||
"d60_classical": 3,
|
||||
"d60_vedastro_mode": 2,
|
||||
"d60_pyjhora_method1": 2,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "salvador_dali_1904_aa_v5_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 91.157,
|
||||
"d60_classical": 6,
|
||||
"d60_vedastro_mode": 5,
|
||||
"d60_pyjhora_method1": 5,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "serena_williams_1981_aa_v5_holdout",
|
||||
"asc_sign_idx": 0,
|
||||
"ascendant_longitude": 11.0106,
|
||||
"d60_classical": 10,
|
||||
"d60_vedastro_mode": 10,
|
||||
"d60_pyjhora_method1": 10,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "silvio_santos_1930_aa_v5_holdout",
|
||||
"asc_sign_idx": 5,
|
||||
"ascendant_longitude": 152.8918,
|
||||
"d60_classical": 11,
|
||||
"d60_vedastro_mode": 10,
|
||||
"d60_pyjhora_method1": 10,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "sophia_loren_1934_aa_v5_holdout",
|
||||
"asc_sign_idx": 8,
|
||||
"ascendant_longitude": 253.9547,
|
||||
"d60_classical": 11,
|
||||
"d60_vedastro_mode": 11,
|
||||
"d60_pyjhora_method1": 11,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "venus_williams_1980_aa_v5_holdout",
|
||||
"asc_sign_idx": 5,
|
||||
"ascendant_longitude": 171.3938,
|
||||
"d60_classical": 0,
|
||||
"d60_vedastro_mode": 11,
|
||||
"d60_pyjhora_method1": 11,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "vincent_van_gogh_1853_aa_v5_holdout",
|
||||
"asc_sign_idx": 3,
|
||||
"ascendant_longitude": 90.7335,
|
||||
"d60_classical": 5,
|
||||
"d60_vedastro_mode": 4,
|
||||
"d60_pyjhora_method1": 4,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "whitney_houston_1963_aa_v5_holdout",
|
||||
"asc_sign_idx": 10,
|
||||
"ascendant_longitude": 316.5233,
|
||||
"d60_classical": 7,
|
||||
"d60_vedastro_mode": 7,
|
||||
"d60_pyjhora_method1": 7,
|
||||
"classical_matches_pyjhora": true,
|
||||
"vedastro_matches_pyjhora": true
|
||||
},
|
||||
{
|
||||
"case_id": "zinedine_zidane_1972_aa_v5_holdout",
|
||||
"asc_sign_idx": 1,
|
||||
"ascendant_longitude": 36.9097,
|
||||
"d60_classical": 3,
|
||||
"d60_vedastro_mode": 2,
|
||||
"d60_pyjhora_method1": 2,
|
||||
"classical_matches_pyjhora": false,
|
||||
"vedastro_matches_pyjhora": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# PROGRESS · D60 换格双向对比(2026-10-28,issue #149)
|
||||
|
||||
- 分支:`codex/rectification-d60-two-way-20261028`,基于 `origin/staging` `88a03808`(写作时 `7721aa9a`,开工时以实际 head 为准)
|
||||
- 执行:本地 Grok(Windows);验收:Claude
|
||||
- BUG:BUG-1497 已登记为 `investigating`
|
||||
- 生产代码、冻结文件、技法路由、对外文案:**零改动**
|
||||
|
||||
## 开工前置(已完成)
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 开工预检 | `scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` | 退出码 1。`focused_tests` 里 `tests/test_preflight_fragment_scan.py::test_preflight_fragment_scan_reports_authority_layers_and_risk_buckets` 失败:断言 `.workbuddy/skills/jyotish-vedic-astrology` 镜像路径存在。Python 运行时、碎片扫描、外部引擎适配器、远端可见性四项通过;不把预检写成通过 |
|
||||
| 基线四个测试文件 | `tests/test_nadi_seconds_research.py`、`test_holdout_v5_schema.py`、`test_varga_resolution_research.py`、`test_repo_privacy_markers.py` | 98 passed |
|
||||
| 星历 | `python -c "import swisseph; print(swisseph.version)"` | 2.10.03 |
|
||||
| PyJHora | 导入 `jhora`,`jhora.horoscope.chart.charts` 有 `shashtyamsa_chart` | 可用 |
|
||||
| jyotishganit | `import jyotishganit` | **未安装**,G1 里记为 `blocked` |
|
||||
| v5 数据集 | `references/real_case_calibration/minute_rectification_holdout_v5.json` | 77 例,存在 |
|
||||
|
||||
环境:`PYTHONHASHSEED=0`、`PYTHONUTF8=1`(Windows 控制台编码)。
|
||||
|
||||
## 卡点:B 组的 D60 打分规则需要先定死
|
||||
|
||||
任务书 §两个组 只写了「窗口内上升的 D60 格每换一次,候选分成两段;用户给出日精度事件时,按预先登记的 D60 规则给两段打分,淘汰规则与 A 组的换升判据同一套」。
|
||||
|
||||
读了 A 组的实现(`scripts/research/probe_supply_after_six.py`):六题来自**日期级**边界(Vimshottari / Narayana / 上升换座),答案是拿事件日期与边界日期比先后(`union_boundary_dates`、`generate_refresh_probes`、`apply_answer`)。D60 换格是**分钟级**的,一天里会换很多次;把日期级答案直接套到分钟级边界上,同一天内几乎没有区分度,会一边倒淘汰。
|
||||
|
||||
两个可落地方案(已贴到 #149 请 Claude 定;我推荐 2):
|
||||
|
||||
1. **D60 当后置分段**:先跑完 A 组六题,再把幸存候选按 D60 换格切段,用日精度事件的领域—宫位映射给每段打分,只留最高分段(同分不淘汰,其余不动)。改动大,且把 nadi 研究的方法族混进来,报告里要单独解释。
|
||||
2. **D60 换格题只认同日事件**:把窗口内每次 D60 换格当一条「事件 E 在换格之前还是之后」的题;只有事件日期与换格日期同一天时才作答,没有同日事件就跳过、不淘汰。与 A 组共用 `apply_answer`,只换边界来源。改动小、口径干净、可复现;代价是可用题数取决于同日事件,报告里要给出每题实际用了几条。
|
||||
|
||||
规则定了之后才写 `docs/research/d60_two_way_preregistration_2026_10_28.json` 并单独 commit,然后才第一次在 v5 上跑(红线 1)。
|
||||
|
||||
## G1 三引擎 D60 对账(已跑,门不过)
|
||||
|
||||
结果 JSON:`docs/research/d60_two_way_results_2026_10_28.json`(77 例逐条:上升经度、本仓两套 D60、PyJHora D60、是否一致)。
|
||||
|
||||
| 引擎 | 与生产 D60 一致的例数 | 比例 |
|
||||
| --- | ---: | ---: |
|
||||
| 本仓生产 `classical_local`(默认) | 33 / 77 | 42.9% |
|
||||
| 本仓 `mode='vedastro'` | 77 / 77 | 100% |
|
||||
| PyJHora(traditional Parasara, from sign) | 作为对账基准 | — |
|
||||
| jyotishganit | `blocked`:本机未安装 | — |
|
||||
| VedAstro | `blocked`:本次没有联网调用 | — |
|
||||
|
||||
- 差的 44 例全部是 **1-based 偶数星座**(金牛、巨蟹、处女、天蝎、摩羯、双鱼),差值恒为 +1 个星座;一致的 33 例全部是 1-based 奇数星座。
|
||||
- 根因在 `scripts/varga.py::varga_map` 的 D60 分支(`(si+pi)%12 if o else (si+1+pi)%12`),同文件的 vedastro 映射是 `(si+pi)%12`,与 PyJHora 一致。**这是生产映射的口径问题,不是「D60 没有信号」**,已登记 BUG-1498(investigating)。
|
||||
- 门要求「与至少一个外部引擎一致 ≥ 95%」,实际 42.9%(生产口径)→ **G1 判不过**,按任务书 G2–G5 全部停。没有跑任何计分,也没有据此改规则。
|
||||
|
||||
## 还没做
|
||||
|
||||
G2–G5、模型端到端对比、MEVG、报告:全部 `not_started`(G1 不过,按门停止)。
|
||||
|
||||
下一步要产品/规划定:D60 映射是修 `varga.py`(改生产、要重新冻结)还是改文档承认这是另一种口径。定了之后重跑 G1;G1 过了才写 G2–G5 的先登记 JSON。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- `jyotishganit` 未安装;G1 的外部引擎只剩 PyJHora(门要求「与至少一个外部引擎一致 ≥95%」,仍可判)。
|
||||
- 预检的 `focused_tests` 有一条与本单无关的失败(上面已列)。
|
||||
+2
-3
@@ -408,10 +408,9 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
### Chat header
|
||||
|
||||
- One line, 46px on desktop. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`.
|
||||
- Ordinary consultation, and the chart, ephemeris and report pages: the person is a rounded button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). Light canvas fill, a hairline, and a chevron, so the name reads as something you can press. The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation. When the archive is under 20 people, the list ends with a hairline and `+ 新增一个人`, which opens `/people?add=1` (the add form). At 20 people that row is absent.
|
||||
- Issue #150 (2026-10-28): after the name the trigger shows `· {date time · city}` (`.chat-profile-picker-detail`, secondary ink, ellipsizes first) once the selected person is loaded and complete; the accessible name becomes `当前星盘:{名字},{date time · city}`. The phone chat header (≤640px) hides the detail. Each row is a two-column grid (`.chat-profile-picker-row`: the choice, then a 44×44 `⋯` menu `.chat-profile-picker-more`). The row copy is the name plus a pill badge (`.chat-profile-picker-badge`: muted canvas, caption size; 本人 or the relation, empty reads 其他) over `date time · city`. The menu holds `编辑` (`/people?self=1` or `/people?edit=<id>`, which opens that person's edit dialog) and, for others only, `删除`: the same count read, AlertDialog sentence and DELETE as the people page (`lib/person-delete.ts`), falling back to self if the current person was deleted. Delete is disabled while an answer is being written. The chat-side chart panel (`.chat-chart-switcher`) carries the same picker full width above the drawing; the drawing sits behind its own memo so the picker's repaints never redraw it.
|
||||
- Ordinary consultation, and the chart, ephemeris and report pages: the person is a rounded button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). Light canvas fill, a hairline, and a chevron, so the name reads as something you can press. The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation. When the archive is under 20 people, the list ends with a hairline and `+ 添加一个人`, which opens `/people`. At 20 people that row is absent.
|
||||
- Before the first message the list selects a person and updates that empty session's binding. Self is the visible default. After the first message the current person stays selected; another person offers only `新建会话并使用此人物` and does not rebind the old session. That action waits until the current request ends.
|
||||
- Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整,补全后才能看盘` and cannot be chosen (its `⋯` still offers 编辑). A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner.
|
||||
- Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner.
|
||||
- Rectification and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The person button stays on narrow screens and ellipsizes so the header remains one line.
|
||||
- The chart page birth line is a button (`.chart-birth-line`) under the person's name in the page head (`.chart-page-head-meta`, 13px tertiary, as in the signed prototype; it left the top bar on 2026-10-28, BUG-1470, because on a phone it wrapped the bar to two lines): `日期 时间 · 地点` and a pen. The name is not repeated. The whole row opens an editor on this page. Self can change every field. Someone else's already-saved birth time stays locked. The sentence `填报的出生时间保存后不能再改` appears once, beside that field, and is not repeated in the form description. Changing the date, time, or place shows `改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。` Saving uses the shared other-person write (self still goes through the account). The editor is a component-library dialog (2026-10-25, K3). A click inside the form, including its padding, does not close it or drop unsaved edits. Escape and a click on the backdrop do close it. The fields stay the shared profile form used by the people page, so this page does not grow a second set of fields. Below 768px that row wraps onto a second line and the chart header grows to fit it. The editor's code is not in `/chart`'s first load: it loads on the click and is warmed when the pen row is hovered or focused, and nothing is drawn while it arrives (no placeholder; BUG-1469). The people page's add / edit form likewise stays out of `/people`'s first load and is fetched right after the page mounts.
|
||||
- With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px.
|
||||
|
||||
@@ -297,8 +297,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
## 星盘页人名与就地改资料(2026-10-21)
|
||||
|
||||
- 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 新增一个人」,直接打开星盘档案的新增表单。满 20 人不出现这一行。
|
||||
- (issue #150,2026-10-28)按钮上写「称呼 · 出生日期 时间 · 城市」;手机对话顶栏放不下,只写称呼。名单每行:名字 + 灰底标签(本人写「本人」,别人写关系,没填过写「其他」),下面一行「日期 时间 · 城市」。每行右边「⋯」:「编辑」去星盘档案打开这个人的编辑框;别人还有「删除」,确认框和星盘档案同一句「将同时删除 N 个对话和 M 份报告,无法恢复。」回答还没写完时删除项写「这段回答结束后再删」,点不了。本人没有删除。对话右边的盘面顶部也有这颗按钮,规则和对话顶栏一样。
|
||||
- 星盘、星历、报告顶栏的人名是一颗圆角按钮,带下拉箭头。点开后,名单没到 20 人时,底部一行是「+ 添加一个人」,去星盘档案。满 20 人不出现这一行。
|
||||
- 星盘页人名旁边只写「日期 时间 · 地点」,末尾一支小笔。整行可点,就在这一页改,不跳走。本人每项都能改。别人已经填过的出生时间不能改,这句话只出现在时间旁边:「填报的出生时间保存后不能再改」。表单说明里不重复。星盘档案里编辑这样的人,也只在时间旁边写这一句。改了日期、时间或地点时写:「改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。」手机上这一行换到第二行。点编辑框内侧的空白边不会关掉框。
|
||||
- 出生资料下面可以有一句上升会不会换。本人上升会换、或不知道出生时间时,「出生时间校正」是链接:已经有没做完的校正就回到那一次,没有才新开。别人的星盘只说到「(大约 HH:MM 换座)。」或「上升星座定不下来。」,不写「出生时间校正」,也不给这个链接。
|
||||
- 报告里的星宿,中文名后面的英文名用浅色小字,和中文同一行。速度写到小数点后 4 位。行星位置这类数值表太宽时,在表自己的框里横滑,页面不被撑开。作用说明、建议这类整句表在格子里换行。打印时每张表都落在纸宽里,格子里的字可以换行。
|
||||
@@ -488,10 +487,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 列表没读出来 | 人物列表暂时读不出来 |
|
||||
| 其他人暂时读不到 | 暂时读不到其他人 |
|
||||
| 重试 | 重试 |
|
||||
| 资料不完整 | 这份资料还不完整,补全后才能看盘 |
|
||||
| 每行更多 | 编辑 / 删除(本人只有编辑) |
|
||||
| 回答还没结束时删除 | 这段回答结束后再删 |
|
||||
| 名单底部 | 新增一个人 |
|
||||
| 资料不完整 | 这份资料还不完整 |
|
||||
| 资料已删除 | 资料已删除 · {原来的名字} |
|
||||
| 删除后不能再发 | 这份资料已经删除,这段对话只能看。 |
|
||||
|
||||
|
||||
@@ -1215,21 +1215,8 @@ export default function Home() {
|
||||
},
|
||||
};
|
||||
|
||||
// One picker, two places: the chat header and the top of the chart panel (issue #150).
|
||||
const subjectPicker = activeSession?.sessionType === "consultation" || activeSession?.sessionType === "synastry_report" ? (
|
||||
<ChatProfilePicker
|
||||
session={activeSession}
|
||||
library={chartLibrary}
|
||||
status={chatProfileStatus}
|
||||
requestInFlight={Boolean(pendingSessionId) || cancellationPending}
|
||||
onBindEmpty={bindEmptyChatSubject}
|
||||
onOpenNew={openChatBoundToProfile}
|
||||
onReload={reloadChatProfiles}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<ChatChartFrame switcher={subjectPicker}>
|
||||
<ChatChartFrame>
|
||||
<DailyStarlanguageBinder
|
||||
accountId={accountId}
|
||||
bootstrapPhase={bootstrapPhase}
|
||||
@@ -1257,9 +1244,19 @@ export default function Home() {
|
||||
</span>
|
||||
<div className="chat-header-title">
|
||||
<strong>{activeSession?.title || "新对话"}</strong>
|
||||
{subjectPicker ?? (activeSession ? (
|
||||
{activeSession?.sessionType === "consultation" || activeSession?.sessionType === "synastry_report" ? (
|
||||
<ChatProfilePicker
|
||||
session={activeSession}
|
||||
library={chartLibrary}
|
||||
status={chatProfileStatus}
|
||||
requestInFlight={Boolean(pendingSessionId) || cancellationPending}
|
||||
onBindEmpty={bindEmptyChatSubject}
|
||||
onOpenNew={openChatBoundToProfile}
|
||||
onReload={reloadChatProfiles}
|
||||
/>
|
||||
) : activeSession ? (
|
||||
<span className="chat-header-chart">{sessionChartLabel(activeSession, chartLibrary)}</span>
|
||||
) : null)}
|
||||
) : null}
|
||||
</div>
|
||||
<div className="chat-header-actions">
|
||||
<OnboardingChecklistSlot />
|
||||
|
||||
@@ -1041,11 +1041,7 @@ button.shell-resize-handle:active { position: absolute; top: 0; right: -3px; z-i
|
||||
inside `.chat-column`. Secondary pages stay direct children of `.chat-panel`. */
|
||||
.chat-chart-frame { grid-row: 1 / -1; grid-column: 1; min-width: 0; min-height: 0; height: 100%; display: flex; flex-direction: row; }
|
||||
.chat-chart-frame > .chat-column { flex: 1 1 auto; }
|
||||
.chat-chart-split { position: relative; height: 100%; min-width: 0; min-height: 0; flex: 0 0 auto; display: flex; flex-direction: column; border-left: 1px solid var(--color-border); }
|
||||
/* Issue #150: the same picker as the chat header, full width above the drawing. */
|
||||
.chat-chart-switcher { flex: 0 0 auto; min-width: 0; padding: var(--space-3) var(--space-3) 0; }
|
||||
.chat-chart-switcher .chat-profile-picker-trigger { width: 100%; max-width: none; border-radius: var(--radius-md); }
|
||||
.chat-chart-split .chat-natal-chart { flex: 1 1 auto; height: auto; }
|
||||
.chat-chart-split { position: relative; height: 100%; min-width: 0; min-height: 0; flex: 0 0 auto; border-left: 1px solid var(--color-border); }
|
||||
/* Same 6px transparent strip as the sidebar edge, on the panel's left edge. */
|
||||
button.chat-chart-resize-handle,
|
||||
button.chat-chart-resize-handle:hover,
|
||||
@@ -1082,10 +1078,6 @@ button.chat-chart-resize-handle:active { position: absolute; top: 0; left: -3px;
|
||||
.chat-profile-picker-trigger { max-width: 46%; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; gap: 4px; margin: 0; padding: 0 10px 0 12px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-canvas-muted); color: var(--color-ink); cursor: pointer; font: inherit; font-size: var(--type-caption); line-height: 1.35; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, border-color 120ms ease-out; }
|
||||
.chat-profile-picker-trigger:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; }
|
||||
.chat-profile-picker-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Issue #150: 「· date time · city」 after the name; it gives way first when space runs out. */
|
||||
.chat-profile-picker-detail { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-ink-secondary); }
|
||||
/* A phone chat header has room for the name only; the list still shows the details. */
|
||||
@media (max-width: 640px) { .chat-header .chat-profile-picker-detail { display: none; } }
|
||||
.chat-profile-picker-chevron { width: 14px; height: 14px; flex: 0 0 auto; color: var(--color-ink-tertiary); }
|
||||
.chat-profile-picker-add { margin-top: var(--space-2); padding-top: var(--space-2); border-top: 1px solid var(--color-border); }
|
||||
.chat-profile-picker-add a { min-height: 44px; display: flex; align-items: center; padding: 0 var(--space-3); border-radius: var(--radius-md); color: var(--color-ink); font-size: var(--type-body-sm); text-decoration: none; }
|
||||
@@ -1106,12 +1098,6 @@ button.chat-chart-resize-handle:active { position: absolute; top: 0; left: -3px;
|
||||
.chat-profile-picker-copy { min-width: 0; display: grid; gap: 2px; }
|
||||
.chat-profile-picker-copy b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: inherit; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; }
|
||||
.chat-profile-picker-copy small { color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.35; }
|
||||
.chat-profile-picker-row { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; gap: var(--space-1); }
|
||||
.chat-profile-picker-heading { min-width: 0; display: flex; align-items: center; gap: 6px; }
|
||||
.chat-profile-picker-badge { flex: 0 0 auto; padding: 1px 6px; border-radius: 999px; background: var(--color-canvas-muted); color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.35; }
|
||||
.chat-profile-picker-more { width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-tertiary); cursor: pointer; }
|
||||
.chat-profile-picker-more svg { width: 18px; height: 18px; }
|
||||
.chat-profile-picker-more:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 1px; }
|
||||
.chat-profile-picker-check { width: 18px; height: 18px; opacity: 0; color: var(--color-action); }
|
||||
.chat-profile-picker-option[data-selected] .chat-profile-picker-check { opacity: 1; }
|
||||
.chat-header-actions { min-width: max-content; display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); white-space: nowrap; }
|
||||
@@ -2204,7 +2190,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
.chat-profile-picker-trigger:hover { background: var(--color-canvas); color: var(--color-ink); border-color: var(--color-border-strong); }
|
||||
.chat-profile-picker-add a:hover { background: var(--color-canvas-soft); }
|
||||
.chat-profile-picker-more:hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
.chat-profile-picker-option:not(:disabled):not([aria-disabled="true"]):hover { background: var(--color-canvas-soft); color: var(--color-ink); }
|
||||
.button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); }
|
||||
.danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); }
|
||||
|
||||
@@ -15,7 +15,6 @@ type SplitComponent = (props: {
|
||||
open: boolean;
|
||||
width: number;
|
||||
subjectId: string;
|
||||
switcher?: ReactNode;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWidth: (width: number) => void;
|
||||
}) => ReactNode;
|
||||
@@ -71,7 +70,7 @@ export function ChatChartToggle() {
|
||||
* starts open (issue #114 Q8 A). Phones load the bottom sheet instead, the
|
||||
* first time it is asked for.
|
||||
*/
|
||||
export function ChatChartFrame({ children, switcher }: { children: ReactNode; switcher?: ReactNode }) {
|
||||
export function ChatChartFrame({ children }: { children: ReactNode }) {
|
||||
const { isMobile, ready } = useSidebar();
|
||||
const subjectId = useCurrentSubject();
|
||||
const open = useUiStore((state) => state.chatChartOpen);
|
||||
@@ -118,7 +117,7 @@ export function ChatChartFrame({ children, switcher }: { children: ReactNode; sw
|
||||
<div className="chat-chart-frame">
|
||||
{column}
|
||||
{Split && desktop ? (
|
||||
<Split open={open} width={width} subjectId={subjectId} switcher={switcher} onOpenChange={setOpen} onWidth={setWidth} />
|
||||
<Split open={open} width={width} subjectId={subjectId} onOpenChange={setOpen} onWidth={setWidth} />
|
||||
) : null}
|
||||
{Sheet && phone ? <Sheet open={sheetOpen} onOpenChange={setSheetOpen} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { memo } from "react";
|
||||
|
||||
import { ChatNatalChart } from "@/components/chat-natal-chart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,23 +12,17 @@ import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "@/lib/chat-chart-pan
|
||||
* never remounts the chat (BUG-1448). Its left edge drags or takes the arrow
|
||||
* keys; dragging below the minimum closes the panel (BUG-1456). Memoized: its
|
||||
* props are store values, so a Home render alone never redraws the chart (issue #123).
|
||||
* `switcher` (issue #150) is a fresh element on every Home render, so the drawing
|
||||
* sits behind its own memo and only the switcher repaints.
|
||||
*/
|
||||
const StableNatalChart = memo(ChatNatalChart);
|
||||
|
||||
export const ChatChartSplit = memo(function ChatChartSplit({
|
||||
open,
|
||||
width,
|
||||
subjectId,
|
||||
switcher,
|
||||
onOpenChange,
|
||||
onWidth,
|
||||
}: {
|
||||
open: boolean;
|
||||
width: number;
|
||||
subjectId: string;
|
||||
switcher?: ReactNode;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onWidth: (width: number) => void;
|
||||
}) {
|
||||
@@ -51,8 +45,7 @@ export const ChatChartSplit = memo(function ChatChartSplit({
|
||||
aria-label="调整本命盘宽度"
|
||||
{...resize}
|
||||
/>
|
||||
{switcher ? <div className="chat-chart-switcher">{switcher}</div> : null}
|
||||
<StableNatalChart subjectId={subjectId} />
|
||||
<ChatNatalChart subjectId={subjectId} />
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -162,22 +162,18 @@ export function PeoplePage() {
|
||||
setPeople(nextPeople);
|
||||
const url = new URL(window.location.href);
|
||||
const add = url.searchParams.get("add") === "1";
|
||||
// `?edit=<id>` comes from the switcher's 编辑 (issue #150); an id that is gone opens nothing.
|
||||
const editId = url.searchParams.get("self") === "1" ? "self" : url.searchParams.get("edit");
|
||||
const editRow = editId ? nextPeople.find((person) => person.id === editId) : undefined;
|
||||
if (add || editId) {
|
||||
const openSelf = url.searchParams.get("self") === "1";
|
||||
if (add || openSelf) {
|
||||
url.searchParams.delete("add");
|
||||
url.searchParams.delete("self");
|
||||
url.searchParams.delete("edit");
|
||||
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
if (editId) {
|
||||
if (editRow) {
|
||||
setSelectedId(editRow.id);
|
||||
setDraft(editRow.profile);
|
||||
setEditing(true);
|
||||
setPendingDelete(null);
|
||||
setView("detail");
|
||||
}
|
||||
if (openSelf) {
|
||||
const selfRow = nextPeople.find((person) => person.role === "self");
|
||||
setSelectedId("self");
|
||||
setDraft(selfRow?.profile ?? emptyProfile);
|
||||
setEditing(true);
|
||||
setPendingDelete(null);
|
||||
setView("detail");
|
||||
} else if (nextPeople.filter((person) => person.role === "other").length >= MAX_OTHER_SUBJECTS) {
|
||||
notifyInfo(LIMIT_COPY);
|
||||
} else {
|
||||
|
||||
@@ -1,34 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronDown, MoreHorizontal, Plus } from "lucide-react";
|
||||
import { Check, ChevronDown, Plus } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { navigateInApp } from "@/lib/app-navigation";
|
||||
import { invalidateSubjectCatalog, readCurrentSubjectId, setCurrentSubject } from "@/lib/current-subject";
|
||||
import { notifyError } from "@/lib/notify";
|
||||
import {
|
||||
PERSON_ADD_HREF,
|
||||
PERSON_DELETE_COPY,
|
||||
deletePerson,
|
||||
fetchPersonDeleteUsage,
|
||||
personDeleteDescription,
|
||||
personEditHref,
|
||||
type PersonDeleteUsage,
|
||||
} from "@/lib/person-delete";
|
||||
import {
|
||||
chatProfilePickerRows,
|
||||
type ChatProfileCatalogStatus,
|
||||
@@ -63,15 +39,11 @@ export function SubjectSwitcher({
|
||||
}: SubjectSwitcherProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<{ row: ChatProfilePickerRow; usage: PersonDeleteUsage } | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listId = useId();
|
||||
const rows = chatProfilePickerRows({ status, library, selectedId }).slice(0, MAX_SAVED_SUBJECTS);
|
||||
// Issue #86 said “5 people”. The live archive cap is 20 (`MAX_SAVED_SUBJECTS`).
|
||||
const showAddPerson = (status === "ready" || status === "degraded") && library.length < MAX_SAVED_SUBJECTS;
|
||||
// Issue #150: the trigger says whose chart, when and where, not only the name.
|
||||
const selectedRow = rows.find((row) => row.selected && row.complete);
|
||||
const triggerDetail = selectedRow?.detail ?? "";
|
||||
|
||||
function closePicker() {
|
||||
setOpen(false);
|
||||
@@ -91,38 +63,7 @@ export function SubjectSwitcher({
|
||||
}
|
||||
}
|
||||
|
||||
function edit(row: ChatProfilePickerRow) {
|
||||
setOpen(false);
|
||||
navigateInApp(personEditHref(row.id));
|
||||
}
|
||||
|
||||
async function askDelete(row: ChatProfilePickerRow) {
|
||||
if (row.role === "self" || requestInFlight) return;
|
||||
const usage = await fetchPersonDeleteUsage(row.id);
|
||||
if (!usage) {
|
||||
notifyError(PERSON_DELETE_COPY.usageUnavailable);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setPendingDelete({ row, usage });
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const target = pendingDelete?.row;
|
||||
setPendingDelete(null);
|
||||
if (!target) return;
|
||||
if (!(await deletePerson(target.id))) {
|
||||
notifyError(PERSON_DELETE_COPY.failed);
|
||||
return;
|
||||
}
|
||||
// Same order as the people page: fall back to 本人 first, then reread the list.
|
||||
if (readCurrentSubjectId() === target.id) setCurrentSubject("self");
|
||||
invalidateSubjectCatalog();
|
||||
onReload();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
@@ -136,10 +77,9 @@ export function SubjectSwitcher({
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={`当前星盘:${label}${triggerDetail ? `,${triggerDetail}` : ""}`}
|
||||
aria-label={`当前星盘:${label}`}
|
||||
>
|
||||
<span className="chat-profile-picker-name">{label}</span>
|
||||
{triggerDetail ? <span className="chat-profile-picker-detail">· {triggerDetail}</span> : null}
|
||||
<ChevronDown className="chat-profile-picker-chevron" aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -176,75 +116,17 @@ export function SubjectSwitcher({
|
||||
requestInFlight={requestInFlight}
|
||||
pending={pendingId === row.id}
|
||||
onChoose={() => void choose(row)}
|
||||
onEdit={() => edit(row)}
|
||||
onDelete={() => void askDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{showAddPerson ? (
|
||||
<div className="chat-profile-picker-add">
|
||||
<AppLink href={PERSON_ADD_HREF} onClick={() => setOpen(false)}><Plus aria-hidden="true" />新增一个人</AppLink>
|
||||
<a href="/people"><Plus aria-hidden="true" />添加一个人</a>
|
||||
</div>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{pendingDelete ? (
|
||||
<AlertDialog open onOpenChange={(next) => { if (!next) setPendingDelete(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{PERSON_DELETE_COPY.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{personDeleteDescription(pendingDelete.usage)}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{PERSON_DELETE_COPY.cancel}</AlertDialogCancel>
|
||||
<AlertDialogAction className="danger-primary" onClick={() => void confirmDelete()}>{PERSON_DELETE_COPY.confirm}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RowMenu({
|
||||
row,
|
||||
requestInFlight,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
readonly row: ChatProfilePickerRow;
|
||||
readonly requestInFlight: boolean;
|
||||
readonly onEdit: () => void;
|
||||
readonly onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="chat-profile-picker-more" aria-label={`${row.name} 更多操作`}>
|
||||
<MoreHorizontal aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit}>编辑</DropdownMenuItem>
|
||||
{/* 本人 is never deleted; nobody is while an answer is still being written. */}
|
||||
{row.role === "other" ? (
|
||||
<DropdownMenuItem disabled={requestInFlight} onClick={onDelete}>
|
||||
{requestInFlight ? "这段回答结束后再删" : "删除"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function RowCopy({ row, detail }: { readonly row: ChatProfilePickerRow; readonly detail?: string }) {
|
||||
return (
|
||||
<span className="chat-profile-picker-copy">
|
||||
<span className="chat-profile-picker-heading">
|
||||
<b>{row.name}</b>
|
||||
<span className="chat-profile-picker-badge">{row.badge}</span>
|
||||
</span>
|
||||
<small>{detail ?? row.detail}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,26 +136,26 @@ function ProfileRow({
|
||||
requestInFlight,
|
||||
pending,
|
||||
onChoose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
readonly row: ChatProfilePickerRow;
|
||||
readonly locked: boolean;
|
||||
readonly requestInFlight: boolean;
|
||||
readonly pending: boolean;
|
||||
readonly onChoose: () => void;
|
||||
readonly onEdit: () => void;
|
||||
readonly onDelete: () => void;
|
||||
}) {
|
||||
let main;
|
||||
if (!row.complete) {
|
||||
main = (
|
||||
return (
|
||||
<div className="chat-profile-picker-option" aria-disabled="true">
|
||||
<RowCopy row={row} />
|
||||
<span className="chat-profile-picker-copy">
|
||||
<b>{row.name}</b>
|
||||
<small>{row.detail}</small>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else if (locked && !row.selected) {
|
||||
main = (
|
||||
}
|
||||
|
||||
if (locked && !row.selected) {
|
||||
return (
|
||||
<Button
|
||||
className="chat-profile-picker-option"
|
||||
type="button"
|
||||
@@ -282,29 +164,29 @@ function ProfileRow({
|
||||
aria-label={`${row.name},新建会话并使用此人物`}
|
||||
onClick={onChoose}
|
||||
>
|
||||
<RowCopy row={row} detail={requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"} />
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
main = (
|
||||
<Button
|
||||
className="chat-profile-picker-option"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-pressed={row.selected}
|
||||
data-selected={row.selected ? "" : undefined}
|
||||
disabled={pending}
|
||||
onClick={onChoose}
|
||||
>
|
||||
<RowCopy row={row} />
|
||||
<Check className="chat-profile-picker-check" aria-hidden="true" />
|
||||
<span className="chat-profile-picker-copy">
|
||||
<b>{row.name}</b>
|
||||
<small>{requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"}</small>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-profile-picker-row">
|
||||
{main}
|
||||
<RowMenu row={row} requestInFlight={requestInFlight} onEdit={onEdit} onDelete={onDelete} />
|
||||
</div>
|
||||
<Button
|
||||
className="chat-profile-picker-option"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-pressed={row.selected}
|
||||
data-selected={row.selected ? "" : undefined}
|
||||
disabled={pending}
|
||||
onClick={onChoose}
|
||||
>
|
||||
<span className="chat-profile-picker-copy">
|
||||
<b>{row.name}</b>
|
||||
<small>{row.detail}</small>
|
||||
</span>
|
||||
<Check className="chat-profile-picker-check" aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { readCurrentSubjectId } from "@/lib/current-subject";
|
||||
import { isConsultationSubjectProfileComplete } from "@/lib/consultation-subject-resolver";
|
||||
import { selectedBirthPlace, sessionChartLabel } from "@/lib/home-profile";
|
||||
import type { ChartLibraryRecord, ChatSession, Profile } from "@/lib/home-types";
|
||||
import { personRelationLabel } from "@/lib/person-relation";
|
||||
import { sessionChartLabel } from "@/lib/home-profile";
|
||||
import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types";
|
||||
|
||||
export type ChatProfileCatalogStatus = "idle" | "loading" | "ready" | "failed" | "degraded";
|
||||
|
||||
@@ -20,9 +19,6 @@ export type ChatProfilePickerRow = {
|
||||
readonly id: string;
|
||||
readonly role: "self" | "other";
|
||||
readonly name: string;
|
||||
/** 本人, or the relation label (empty shows 其他). Issue #150. */
|
||||
readonly badge: string;
|
||||
/** Complete: 「date time · city」. Incomplete: why it can't be picked. */
|
||||
readonly detail: string;
|
||||
readonly complete: boolean;
|
||||
readonly selected: boolean;
|
||||
@@ -102,15 +98,6 @@ export function chatSubjectSendBlocked(input: {
|
||||
return !input.library.some((record) => record.id === id && record.role === "other");
|
||||
}
|
||||
|
||||
export const INCOMPLETE_ROW_DETAIL = "这份资料还不完整,补全后才能看盘";
|
||||
|
||||
/** 「1990-03-12 07:30 · 杭州」; parts that are missing are left out. */
|
||||
export function subjectBirthLine(profile: Profile): string {
|
||||
const when = [profile.date.trim(), profile.time.trim()].filter(Boolean).join(" ");
|
||||
const place = selectedBirthPlace(profile)?.label.trim() ?? "";
|
||||
return [when, place].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function rowName(record: ChartLibraryRecord): string {
|
||||
return record.profile.name.trim() || (record.role === "self" ? "我" : "未命名资料");
|
||||
}
|
||||
@@ -130,8 +117,7 @@ export function chatProfilePickerRows(input: {
|
||||
id: "self",
|
||||
role: "self",
|
||||
name: "我",
|
||||
badge: "本人",
|
||||
detail: INCOMPLETE_ROW_DETAIL,
|
||||
detail: "这份资料还不完整",
|
||||
complete: false,
|
||||
selected: input.selectedId === "self",
|
||||
});
|
||||
@@ -148,8 +134,9 @@ function toRow(record: ChartLibraryRecord, selectedId: string): ChatProfilePicke
|
||||
id,
|
||||
role: record.role,
|
||||
name: rowName(record),
|
||||
badge: record.role === "self" ? "本人" : personRelationLabel(record.profile.relation),
|
||||
detail: complete ? subjectBirthLine(record.profile) : INCOMPLETE_ROW_DETAIL,
|
||||
detail: complete
|
||||
? (record.role === "self" ? "本人" : (record.profile.date || "其他人"))
|
||||
: "这份资料还不完整",
|
||||
complete,
|
||||
selected: id === selectedId,
|
||||
};
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Deleting someone from outside the people page (issue #150, the switcher's 删除).
|
||||
* Same reads, writes and words as the people page's own delete: count first, then
|
||||
* confirm with the count, then DELETE. 本人 is never deletable.
|
||||
*/
|
||||
|
||||
export type PersonDeleteUsage = { readonly sessions: number; readonly reports: number; readonly synastry?: number };
|
||||
|
||||
export const PERSON_DELETE_COPY = {
|
||||
title: "确认删除",
|
||||
usageUnavailable: "暂时查不到这个人的对话数量,稍后再试",
|
||||
failed: "删除失败,请重试",
|
||||
cancel: "取消",
|
||||
confirm: "确认删除",
|
||||
} as const;
|
||||
|
||||
export function personDeleteDescription(usage: PersonDeleteUsage): string {
|
||||
const synastry = usage.synastry ? `和 ${usage.synastry} 份合盘` : "";
|
||||
return `将同时删除 ${usage.sessions} 个对话和 ${usage.reports} 份报告${synastry},无法恢复。`;
|
||||
}
|
||||
|
||||
function countOk(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
/** Null when the count can't be read; the caller says so and deletes nothing. */
|
||||
export async function fetchPersonDeleteUsage(id: string): Promise<PersonDeleteUsage | null> {
|
||||
if (!id || id === "self") return null;
|
||||
try {
|
||||
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(id)}`, { cache: "no-store" });
|
||||
const payload = await response.json().catch(() => null) as { usage?: PersonDeleteUsage } | null;
|
||||
const usage = payload?.usage;
|
||||
if (!response.ok || !usage || !countOk(usage.sessions) || !countOk(usage.reports)) return null;
|
||||
return usage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePerson(id: string): Promise<boolean> {
|
||||
if (!id || id === "self") return false;
|
||||
try {
|
||||
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function personEditHref(id: string): string {
|
||||
return id === "self" ? "/people?self=1" : `/people?edit=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
export const PERSON_ADD_HREF = "/people?add=1";
|
||||
@@ -71,16 +71,11 @@ export function lastCompletedPublicTool(
|
||||
return last;
|
||||
}
|
||||
|
||||
const PHRASE_HAS_OWN_DATE = /\d{4}\s*年|\d{4}[-/.]\d{1,2}/;
|
||||
|
||||
export function evidenceRecapLine(item: HostFallbackRecap): string {
|
||||
const label = typeof item.display_date_label === "string" ? item.display_date_label.trim() : "";
|
||||
const phrase = typeof item.event_phrase === "string" ? item.event_phrase.trim() : "";
|
||||
if (!phrase) return label;
|
||||
// BUG-1496: the phrase already carries its own date, so the label would say
|
||||
// it twice (「2016-09 2016 年 9 月上大学」). Spaced years and ISO / slash dates
|
||||
// count too, not only an unspaced 「2016年」.
|
||||
if (PHRASE_HAS_OWN_DATE.test(phrase) || (label.length > 0 && phrase.startsWith(label))) {
|
||||
if (/\d{4}年/.test(phrase) || (label.length > 0 && phrase.startsWith(label))) {
|
||||
return phrase;
|
||||
}
|
||||
// BUG-1136: the recap is now the server's on every evidence turn; a
|
||||
|
||||
@@ -98,12 +98,9 @@ test("the person control is a button, and add-person follows the live cap of 20"
|
||||
// 新值:Lucide `Plus` 加文案「添加一个人」
|
||||
// 原因:K1 去掉手写加号。入口仍去人物页,人数上限仍是 MAX_SAVED_SUBJECTS
|
||||
assert.match(switcher, /<Plus /);
|
||||
// 原值:「添加一个人」,`href="/people"`
|
||||
// 新值:「新增一个人」,`href={PERSON_ADD_HREF}`(/people?add=1,直接打开新增表单)
|
||||
// 原因:issue #150 原型(2026-10-28 jesse 同意)第 4 条
|
||||
assert.match(switcher, /新增一个人/);
|
||||
assert.doesNotMatch(switcher, /+ (添加|新增)一个人/);
|
||||
assert.match(switcher, /href=\{PERSON_ADD_HREF\}/);
|
||||
assert.match(switcher, /添加一个人/);
|
||||
assert.doesNotMatch(switcher, /+ 添加一个人/);
|
||||
assert.match(switcher, /href="\/people"/);
|
||||
assert.match(switcher, /MAX_SAVED_SUBJECTS/);
|
||||
assert.doesNotMatch(switcher, /library\.length < 5/);
|
||||
assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*min-height:\s*44px/);
|
||||
|
||||
@@ -65,10 +65,7 @@ test("the chat page loads the natal split only when the panel opens, and phones
|
||||
const frame = readFileSync(new URL("../src/components/chat-chart-frame.tsx", import.meta.url), "utf8");
|
||||
const split = readFileSync(new URL("../src/components/chat-chart-split.tsx", import.meta.url), "utf8");
|
||||
assert.match(page, /<ChatChartToggle \/>/);
|
||||
// 原值:`<ChatChartFrame>`
|
||||
// 新值:`<ChatChartFrame switcher={subjectPicker}>`
|
||||
// 原因:issue #150 第 5 条,盘面顶部放同一个人物切换
|
||||
assert.match(page, /<ChatChartFrame switcher=\{subjectPicker\}>/);
|
||||
assert.match(page, /<ChatChartFrame>/);
|
||||
assert.doesNotMatch(page, /react-resizable-panels|vedic-chart-svg|chat-chart-split/);
|
||||
assert.match(frame, /打开本命盘/);
|
||||
assert.match(frame, /收起本命盘/);
|
||||
|
||||
@@ -86,15 +86,10 @@ test("the picker lists self and owned others, with self selected by default", ()
|
||||
const rows = chatProfilePickerRows({ status: "ready", library: [self, other, incomplete], selectedId: "self" });
|
||||
assert.deepEqual(rows.map((row) => row.id), ["self", "other-1", "other-2"]);
|
||||
assert.equal(rows[0]?.selected, true);
|
||||
// 原值:detail 是「本人」/「1990-01-02」/「这份资料还不完整」
|
||||
// 新值:身份挪进 badge;detail 是「日期 时间 · 城市」,不完整的说明补全后才能看盘
|
||||
// 原因:issue #150 第 2 条,每一行写关系和出生时间、城市
|
||||
assert.equal(rows[0]?.badge, "本人");
|
||||
assert.equal(rows[0]?.detail, "1990-01-02 08:30 · 示例城市");
|
||||
assert.equal(rows[1]?.badge, "其他");
|
||||
assert.equal(rows[1]?.detail, "1990-01-02 08:30 · 示例城市");
|
||||
assert.equal(rows[0]?.detail, "本人");
|
||||
assert.equal(rows[1]?.detail, "1990-01-02");
|
||||
assert.equal(rows[2]?.complete, false);
|
||||
assert.equal(rows[2]?.detail, "这份资料还不完整,补全后才能看盘");
|
||||
assert.equal(rows[2]?.detail, "这份资料还不完整");
|
||||
});
|
||||
|
||||
test("an empty consultation binds the chosen person and does not open another session", () => {
|
||||
@@ -256,10 +251,7 @@ test("the header control is a button with popover keyboard, focus return, and a
|
||||
assert.match(pickerSource, /type="button"/);
|
||||
assert.match(pickerSource, /aria-expanded=\{open\}/);
|
||||
assert.match(pickerSource, /aria-controls=\{listId\}/);
|
||||
// 原值:aria-label 只有称呼
|
||||
// 新值:称呼后接「,出生日期时间 · 城市」(选中的资料完整时)
|
||||
// 原因:issue #150 第 1 条,按钮上不只写名字
|
||||
assert.match(pickerSource, /aria-label=\{`当前星盘:\$\{label\}\$\{triggerDetail \? `,\$\{triggerDetail\}` : ""\}`\}/);
|
||||
assert.match(pickerSource, /aria-label=\{`当前星盘:\$\{label\}`\}/);
|
||||
assert.match(pickerSource, /event\.key !== "Escape"/);
|
||||
// 原值:triggerRef.current?.focus()。
|
||||
// 新值:triggerRef.current?.focus({ preventScroll: true })。
|
||||
|
||||
@@ -104,19 +104,3 @@ test("case snapshot receipts expose host_fallback origin from the durable phase"
|
||||
assert.match(route, /answer_origin: "host_fallback"/);
|
||||
assert.match(route, /answer\.host_fallback/);
|
||||
});
|
||||
|
||||
// BUG-1496: a phrase that already carries its date must not get the label in
|
||||
// front again. Staging showed 「记下了:2016-09 2016 年 9 月上大学。」.
|
||||
test("the recap does not repeat a date the event phrase already carries", async () => {
|
||||
const { evidenceRecapLine } = await import("../src/lib/rectification-agentic/v9/host-fallback.ts");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "2016 年 9 月上大学" }), "2016 年 9 月上大学");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "2016年9月上大学" }), "2016年9月上大学");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2024-04-07", event_phrase: "2024-04-07 入职" }), "2024-04-07 入职");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "2016/9 上大学" }), "2016/9 上大学");
|
||||
// Unchanged: a phrase without its own date still gets the label.
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "上大学" }), "2016-09 上大学");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016年", event_phrase: "入学" }), "2016年入学");
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016 年 9 月", event_phrase: "去北京工作" }), "2016 年 9 月去北京工作");
|
||||
// A number that is not a year is not a date.
|
||||
assert.equal(evidenceRecapLine({ display_date_label: "2016-09", event_phrase: "考了 600 分" }), "2016-09 考了 600 分");
|
||||
});
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { SubjectSwitcher } from "../src/components/subject-switcher.tsx";
|
||||
import { INCOMPLETE_ROW_DETAIL, chatProfilePickerRows, subjectBirthLine } from "../src/lib/chat-profile-picker-model.ts";
|
||||
import { emptyProfile, type ChartLibraryRecord, type Profile } from "../src/lib/home-types.ts";
|
||||
import {
|
||||
PERSON_ADD_HREF,
|
||||
PERSON_DELETE_COPY,
|
||||
deletePerson,
|
||||
fetchPersonDeleteUsage,
|
||||
personDeleteDescription,
|
||||
personEditHref,
|
||||
} from "../src/lib/person-delete.ts";
|
||||
|
||||
// Issue #150: the person switcher says when and where, carries 编辑 / 删除, and
|
||||
// also sits on top of the chat-side chart. Every person here is made up.
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const switcherSource = read("../src/components/subject-switcher.tsx");
|
||||
const peopleSource = read("../src/components/people/people-page.tsx");
|
||||
const splitSource = read("../src/components/chat-chart-split.tsx");
|
||||
const pageSource = read("../src/app/(app)/page.tsx");
|
||||
const css = read("../src/app/globals.css");
|
||||
|
||||
function person(name: string, patch: Partial<Profile> = {}): Profile {
|
||||
return {
|
||||
...emptyProfile,
|
||||
name,
|
||||
date: "1991-04-05",
|
||||
birthTimeSource: "family_exact",
|
||||
birthTimeStatus: "confirmed",
|
||||
time: "07:30",
|
||||
birthPlaceLabel: "示例市",
|
||||
latitude: 30.2,
|
||||
longitude: 120.1,
|
||||
timezoneId: "Asia/Shanghai",
|
||||
timezoneOffset: 8,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function record(id: string, role: "self" | "other", profile: Profile): ChartLibraryRecord {
|
||||
return { id, role, relationship: role === "self" ? "self" : "other", updatedAt: 1, profile };
|
||||
}
|
||||
|
||||
const library = [
|
||||
record("self", "self", person("小甲")),
|
||||
record("p-1", "other", person("小乙", { relation: "partner" })),
|
||||
record("p-2", "other", person("小丙")),
|
||||
record("p-3", "other", person("小丁", { date: "", time: "" })),
|
||||
];
|
||||
|
||||
test("each row carries a relation badge and 「date time · city」; an incomplete one says why it can't be picked", () => {
|
||||
const rows = chatProfilePickerRows({ status: "ready", library, selectedId: "p-1" });
|
||||
assert.deepEqual(rows.map((row) => [row.name, row.badge, row.detail]), [
|
||||
["小甲", "本人", "1991-04-05 07:30 · 示例市"],
|
||||
["小乙", "伴侣", "1991-04-05 07:30 · 示例市"],
|
||||
["小丙", "其他", "1991-04-05 07:30 · 示例市"],
|
||||
["小丁", "其他", INCOMPLETE_ROW_DETAIL],
|
||||
]);
|
||||
assert.equal(INCOMPLETE_ROW_DETAIL, "这份资料还不完整,补全后才能看盘");
|
||||
// Missing parts drop out instead of leaving a dangling separator.
|
||||
assert.equal(subjectBirthLine(person("x", { time: "" })), "1991-04-05 · 示例市");
|
||||
assert.equal(subjectBirthLine(person("x", { birthPlaceLabel: "" })), "1991-04-05 07:30");
|
||||
});
|
||||
|
||||
test("the trigger names the person, then when and where", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SubjectSwitcher library={library} status="ready" selectedId="p-1" label="小乙" onChoose={() => true} onReload={() => {}} />,
|
||||
);
|
||||
assert.match(markup, /aria-label="当前星盘:小乙,1991-04-05 07:30 · 示例市"/);
|
||||
assert.match(markup, /chat-profile-picker-name">小乙<\/span><span class="chat-profile-picker-detail">· 1991-04-05 07:30 · 示例市<\/span>/);
|
||||
// No detail while the list is still loading, so the trigger never shows a stale person's birth.
|
||||
const loading = renderToStaticMarkup(
|
||||
<SubjectSwitcher library={[]} status="loading" selectedId="p-1" label="小乙" onChoose={() => true} onReload={() => {}} />,
|
||||
);
|
||||
assert.doesNotMatch(loading, /chat-profile-picker-detail/);
|
||||
});
|
||||
|
||||
test("the row menu edits anyone, deletes only others, and holds delete while an answer is written", () => {
|
||||
assert.match(switcherSource, /aria-label=\{`\$\{row\.name\} 更多操作`\}/);
|
||||
assert.match(switcherSource, /<DropdownMenuItem onClick=\{onEdit\}>编辑<\/DropdownMenuItem>/);
|
||||
assert.match(switcherSource, /row\.role === "other" \? \(\s*<DropdownMenuItem disabled=\{requestInFlight\}/);
|
||||
assert.match(switcherSource, /if \(row\.role === "self" \|\| requestInFlight\) return;/);
|
||||
assert.equal(personEditHref("self"), "/people?self=1");
|
||||
assert.equal(personEditHref("p-1"), "/people?edit=p-1");
|
||||
assert.equal(PERSON_ADD_HREF, "/people?add=1");
|
||||
});
|
||||
|
||||
test("delete from the switcher uses the people page's count, words and order", () => {
|
||||
// The same sentence the people page renders inline.
|
||||
assert.match(peopleSource, /将同时删除 \{pendingDelete\.sessions\} 个对话和 \{pendingDelete\.reports\} 份报告\{pendingDelete\.synastry \? `和 \$\{pendingDelete\.synastry\} 份合盘` : ""\},无法恢复。/);
|
||||
assert.equal(personDeleteDescription({ sessions: 3, reports: 1 }), "将同时删除 3 个对话和 1 份报告,无法恢复。");
|
||||
assert.equal(personDeleteDescription({ sessions: 0, reports: 0, synastry: 2 }), "将同时删除 0 个对话和 0 份报告和 2 份合盘,无法恢复。");
|
||||
assert.match(peopleSource, /const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍后再试";/);
|
||||
assert.equal(PERSON_DELETE_COPY.usageUnavailable, "暂时查不到这个人的对话数量,稍后再试");
|
||||
assert.match(peopleSource, /notifyError\("删除失败,请重试"\)/);
|
||||
assert.equal(PERSON_DELETE_COPY.failed, "删除失败,请重试");
|
||||
// Fall back to 本人 before rereading the list, as the people page does.
|
||||
assert.match(switcherSource, /if \(readCurrentSubjectId\(\) === target\.id\) setCurrentSubject\("self"\);\s*invalidateSubjectCatalog\(\);\s*onReload\(\);/);
|
||||
});
|
||||
|
||||
test("the count read and the DELETE refuse 本人 and bad answers", async () => {
|
||||
const original = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
try {
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${String(input)}`);
|
||||
if (String(input).endsWith("/bad")) return new Response(JSON.stringify({ usage: { sessions: -1, reports: 0 } }), { status: 200 });
|
||||
if (init?.method === "DELETE") return new Response(null, { status: 204 });
|
||||
return new Response(JSON.stringify({ usage: { sessions: 2, reports: 1 } }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
assert.deepEqual(await fetchPersonDeleteUsage("p-1"), { sessions: 2, reports: 1 });
|
||||
assert.equal(await fetchPersonDeleteUsage("bad"), null);
|
||||
assert.equal(await fetchPersonDeleteUsage("self"), null);
|
||||
assert.equal(await deletePerson("self"), false);
|
||||
assert.equal(await deletePerson("p-1"), true);
|
||||
assert.deepEqual(calls, ["GET /api/chart-profiles/p-1", "GET /api/chart-profiles/bad", "DELETE /api/chart-profiles/p-1"]);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("the people page opens someone's editor from ?edit=<id>", () => {
|
||||
assert.match(peopleSource, /url\.searchParams\.get\("self"\) === "1" \? "self" : url\.searchParams\.get\("edit"\)/);
|
||||
assert.match(peopleSource, /url\.searchParams\.delete\("edit"\);/);
|
||||
assert.match(peopleSource, /if \(editRow\) \{\s*setSelectedId\(editRow\.id\);\s*setDraft\(editRow\.profile\);\s*setEditing\(true\);/);
|
||||
});
|
||||
|
||||
test("the chat-side chart carries the same picker as the header, and the drawing stays memoized", () => {
|
||||
assert.match(pageSource, /const subjectPicker = activeSession\?\.sessionType === "consultation" \|\| activeSession\?\.sessionType === "synastry_report" \? \(\s*<ChatProfilePicker/);
|
||||
assert.match(pageSource, /\{subjectPicker \?\? \(activeSession \? \(/);
|
||||
assert.match(splitSource, /const StableNatalChart = memo\(ChatNatalChart\);/);
|
||||
assert.match(splitSource, /<div className="chat-chart-switcher">\{switcher\}<\/div>/);
|
||||
});
|
||||
|
||||
test("phone widths: 44px row actions, the header keeps only the name, nothing scrolls sideways", () => {
|
||||
assert.match(css, /\.chat-profile-picker-more \{ width: 44px; height: 44px;/);
|
||||
assert.match(css, /\.chat-profile-picker-row \{ min-width: 0; display: grid; grid-template-columns: minmax\(0, 1fr\) 44px;/);
|
||||
assert.match(css, /@media \(max-width: 640px\) \{ \.chat-header \.chat-profile-picker-detail \{ display: none; \} \}/);
|
||||
assert.match(css, /\.chat-profile-picker-detail \{[^}]*min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;/);
|
||||
assert.match(css, /\.chat-profile-picker-popup \{[^}]*max-width: min\(320px, calc\(100vw - var\(--space-6\)\)\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user