From c899e365098ccfacf40db8dc6779d48300108899 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Fri, 23 Oct 2026 23:55:06 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=E7=8B=AC=E7=AB=8B=E5=B9=B4=E5=BA=A6?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E5=8F=82=E8=80=83=E7=AE=97=E8=BF=9B=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E6=8C=87=E7=BA=B9=EF=BC=8C=E6=A0=87=E5=87=86=E7=89=88?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=99=BD=E7=AE=97=E8=BF=99=E4=B8=80=E7=AB=A0?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跑了修复单点名的测试、两次真算 PyJHora,以及 200 张英文扫描。 Co-Authored-By: Grok (agent loop) --- deploy/README.md | 2 +- docs/BUG_HISTORY.md | 16 ++++ .../PROGRESS-full-data-parity-3-20261022.md | 39 ++++++++++ docs/tasks/README.md | 2 +- scripts/jyotish_engine.py | 23 ++---- scripts/professional_report_reference.py | 1 + .../report_render/annual_method_reference.py | 20 +++++ .../test_professional_report_reference_api.py | 2 +- tests/test_pyjhora_annual_method_reference.py | 77 +++++++++++++++++-- tests/test_report_english_edition.py | 2 +- tests/test_report_reader_main.py | 2 +- tests/test_report_reference_perf.py | 2 +- 12 files changed, 161 insertions(+), 27 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index f014055b..5495971a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -39,7 +39,7 @@ The volume is not a backup domain. Chart cache is a TTL'd compute cache (default Heavy rectification scans and high-rigor workflows share a process-wide concurrency gate (`JYOTISH_HEAVY_COMPUTE_CONCURRENCY`, default `2` to match the 2 vCPU host). A saturated request waits up to 8 seconds if fewer than 4 requests are already waiting, then returns HTTP 429 with `Retry-After`. Health checks and other light routes do not take a slot. -The API image installs PyJHora for one chapter only: the full-data report's last section, 「独立年度方法参考」. That chapter runs PyJHora in a child process. The API process does not import it. Chat cards, rectification scoring, and the rest of the report keep the previous no-PyJHora results. AGPL for this chapter is the product decision of 2026-10-21 (issue #93). Do not set `JYOTISH_YEAR_LORD_PYJHORA_REFERENCE`. +The API image installs PyJHora for one chapter only: the full-data report's last section, 「独立年度方法参考」. That chapter runs PyJHora in a child process. The API process does not import it. Chat cards, rectification scoring, and the rest of the report keep the previous no-PyJHora results. AGPL for this chapter is the product decision of 2026-10-21 (issue #93). Do not turn on the year-lord PyJHora comparison switch. ## DNS and Supabase Auth diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 302778d2..ca5c891d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19383,3 +19383,19 @@ - 复发自:无 - 修复版本:分支 `codex/full-data-parity-3-20261022`。 +## BUG-1429 | 独立年度方法参考不进结果指纹,重算对不上 + +- 状态:resolved +- 首次发现:2026-10-23 +- 最近更新:2026-10-23 +- 影响面:完整数据版资料包的结果指纹。阅读页正文不直接显示这串指纹。 +- 用户现象:报告里已经有「独立年度方法参考」,按同一份资料包重算指纹却对不上。防篡改核对会把这份报告看成被改过。 +- 触发条件:完整数据版组装资料包时挂上这一章,绑定指纹之前先把这一章摘掉,绑定后再放回去。 +- 根因:指纹故意不算这一章。允许排除的只有三类字段,这一章不在其中。重算时本章还在,所以两边不一致。 +- 修复:删掉摘除再放回的做法,恢复直接绑定。这一章算进指纹。标准版在已经知道版本之后才决定要不要算这一章,避免标准版白起一次计算;完整数据版仍在绑定之前挂上,指纹才算得到它。 +- 验证:`tests/test_pyjhora_annual_method_reference.py` 中两个不同标记的指纹不再相同。`tests/test_calculation_profile_contract.py` 仍要求绑定写在净化之后。`tests/test_report_reference_perf.py` 重算指纹与包上的指纹一致。 +- 防复发:上面三条。再把本章摘出指纹,标记不同却指纹相同的测试会失败,重算也对不上。 +- 相关记录:BUG-693、BUG-694。issue #93 第 1 次打回。 +- 复发自:无 +- 修复版本:分支 `codex/full-data-parity-3-20261022`。 + diff --git a/docs/tasks/PROGRESS-full-data-parity-3-20261022.md b/docs/tasks/PROGRESS-full-data-parity-3-20261022.md index 87014139..577f688a 100644 --- a/docs/tasks/PROGRESS-full-data-parity-3-20261022.md +++ b/docs/tasks/PROGRESS-full-data-parity-3-20261022.md @@ -85,3 +85,42 @@ - 子进程超时是 120 秒。45 秒会在这台机器上把已经算完的三年掐掉。 - 没做容器内存曲线。 - 开工前的基线对照没有形成可用数字。 + +## 第 1 次打回(2026-10-23) + +合并了当时的 `origin/staging`(含修复单 `4d0e398ab`)。没有重做上一轮已经通过的隔离和三年表。 + +这一章现在算进结果指纹。不再先摘掉、绑定后再放回去。两份只差这一章内容的资料包,指纹不再相同。报告里有这一章时,按同一份资料包重算,能对上。 + +比较「去掉这一章之后,装与不装是否逐字相同」时,先把下面两行换成同一个占位符 `FINGERPRINT`,再比: + +- `- 报告 ID: \`...\`` +- `- 计算结果摘要: \`...\`` + +这两行来自结果指纹。指纹把这一章算进去之后,它们本来就会变。其余行不抹。 + +标准版在已经知道「不是完整数据版」之后,不再去算这一章。完整数据版仍在写入指纹之前挂上这一章,所以指纹里有它。直接生成完整数据资料包的旧入口默认仍会算,阅读页和扫描不变。 + +部署说明不再写出年主对照开关的变量名,只说不要打开这个开关。 + +子进程超时仍是 120 秒。这是上限,不是目标。前端整份报告最多等 180 秒;120 秒加上其余排盘和排版,在 Linux 上实测整份约 20 秒,没有顶到 180 秒。这台 Windows 电脑上中英一起最高约 59 秒,也没有顶到。 + +### 改过的断言 + +| 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 结果指纹是否包含这一章 | 两个标记的指纹相同 | 两个标记的指纹不同,本章仍留在资料包里 | 交付包里有这一章时,重算必须对得上(BUG-1429) | + +编号:BUG-1429。 + +## 这一轮跑过的检查 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 修复单点名的测试,去掉两次真算 PyJHora | 指纹、合同、阅读页、英文版、年主、Tajika、增长、卫生等 | 退出码 0,约 460 秒 | +| 三张虚构盘真算 PyJHora,以及父进程月亮位置不变 | 两个隔离测试 | 退出码 0,129 秒 | +| 200 张英文扫描 | 扫描里不启动 PyJHora 子进程 | 退出码 0,1362 秒 | +| 全量 pytest 和快速门 | 未跑 | 留给验收 | +| 前端 | 本轮没改页面 | 没跑 | + +依赖的 staging 提交是合并进来的 `4d0e398ab`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 6d416f6d..f32fddf0 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -528,7 +528,7 @@ | `TASK-goel-jaimini-strength-20261022.md` | `PROGRESS-goel-jaimini-strength-20261022.md` | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 已合入 staging | `codex/goel-jaimini-strength-20261022` | | `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-fix-20261022.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 已合入 staging | `codex/chart-subject-table-20261021` | | `TASK-full-data-parity-3-20261022.md` | `PROGRESS-full-data-parity-3-20261022.md` | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 默认,未收到「照上游原样」) | 第 1 次打回,见修复单 | `codex/full-data-parity-3-20261022` | -| `TASK-full-data-parity-3-fix-20261023.md` | `PROGRESS-full-data-parity-3-20261022.md` | **#93 第 1 次打回**:功能、隔离、速度都过(Linux 约 20 秒,worker 约 60 MB);结果指纹排除本章致重算对不上(违反 D2)、源码合同测试没跟改、自己的新测试与 deploy README 打架 | 执行中 | `codex/full-data-parity-3-20261022` | +| `TASK-full-data-parity-3-fix-20261023.md` | `PROGRESS-full-data-parity-3-20261022.md` | **#93 第 1 次打回**:结果指纹算进独立年度方法参考;标准版不再白算这一章;部署说明不点名年主开关(BUG-1429) | 待验收 | `codex/full-data-parity-3-20261022` | | `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-fix-20261022.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 已合入 staging | `codex/synastry-staging-fix-20261021` | | `TASK-report-english-han-leak-fix-20261022.md` | `PROGRESS-report-english-han-leak-fix-20261022.md` | **#89 第 1 次打回**:改了冻结的校正打分文件 `jaimini.py`;测试的 PyJHora 屏蔽没传到子进程(测的不是生产那份报告,也是慢的原因);缺英文时留空藏住漏洞 | 已合入 staging | `codex/report-english-han-leak-20261021` | | `TASK-full-data-parity-1-fix-20261022.md` | `PROGRESS-full-data-parity-1-fix-20261022.md` | **#91 A 段第 1 次打回**:新六章 69 节里 16 节在三张虚构盘上整节「未判定」,其中 12 节数据在资料包里、是读错了字段;测试抓不到整节未判定;新六章绕过了报告口径替换;中文附录悄悄丢列和瑜伽条目要量出来 | 已合入 staging | `codex/full-data-parity-1-20261021` | diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 14345307..c724536a 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -2341,20 +2341,6 @@ def _attach_pyjhora_annual_method_reference(packet: dict, args): return attach_pyjhora_annual_method_reference(packet, args) -def _bind_result_without_annual_method_reference(packet: dict, profile: dict) -> dict: - """Keep the new chapter out of result_hash. The chart result itself is unchanged.""" - worksheets = packet.get("worksheets") if isinstance(packet, dict) else None - timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets, dict) else None - detached = None - if isinstance(timing, dict) and "pyjhora_annual_method_reference" in timing: - detached = timing.pop("pyjhora_annual_method_reference") - try: - return bind_result_to_profile(packet, profile) - finally: - if isinstance(timing, dict) and detached is not None: - timing["pyjhora_annual_method_reference"] = detached - - def _uncertainty_minutes_from_args(args): explicit = getattr(args, 'uncertainty_minutes', None) if explicit not in (None, ''): @@ -18101,12 +18087,17 @@ def build_professional_report_reference_packet( full_reading: dict, args, selected_pack_ids: list[str] | None = None, + *, + include_annual_method_reference: bool = True, ) -> dict: """Build the public, non-writer professional reference packet for CLI and API.""" packet = build_pl9_style_export_packet(full_reading, include_raw=True) packet = attach_calculation_profile(packet, args) packet = _attach_annual_tajika_pack(packet, args) - packet = _attach_pyjhora_annual_method_reference(packet, args) + # The flag is decided by the API after it knows the edition. Attach before + # bind_result_to_profile so this chapter stays inside result_hash. + if include_annual_method_reference: + packet = _attach_pyjhora_annual_method_reference(packet, args) packet = _attach_report_governance_contracts(packet, args) packet = _attach_base_charts_pack(packet) packet = _attach_visual_chart_pack(packet, args) @@ -18124,7 +18115,7 @@ def build_professional_report_reference_packet( and isinstance(profile.get('input_hash'), str) and len(profile['input_hash']) == 64 ): - delivered = _bind_result_without_annual_method_reference(delivered, profile) + delivered = bind_result_to_profile(delivered, profile) if isinstance(delivered, dict) and delivered.get('selected_report_scope') == 'full': evaluate_full_report = _try_attr_import('full_report_quality_gate', 'evaluate_full_report') if evaluate_full_report is None: diff --git a/scripts/professional_report_reference.py b/scripts/professional_report_reference.py index d4ccc8b8..7bf14215 100644 --- a/scripts/professional_report_reference.py +++ b/scripts/professional_report_reference.py @@ -245,6 +245,7 @@ def _assemble_professional_report_reference( full_reading, _export_args(birth), packs, + include_annual_method_reference=edition == FULL_DATA_EDITION, ) except ValueError as exc: raise ProfessionalReportReferenceInputError(str(exc)) from exc diff --git a/scripts/report_render/annual_method_reference.py b/scripts/report_render/annual_method_reference.py index bb1ca2c0..6a322c29 100644 --- a/scripts/report_render/annual_method_reference.py +++ b/scripts/report_render/annual_method_reference.py @@ -85,6 +85,26 @@ def reset_reference_cache() -> None: _CACHE.clear() +def mask_report_fingerprint_lines(markdown: str) -> str: + """Blank the report-id and result-fingerprint lines before a byte compare. + + Including this chapter changes ``result_hash``, so those two identity lines + move. Everything else, after the chapter itself is stripped, must not. + """ + output: list[str] = [] + for line in (markdown or "").splitlines(keepends=True): + body = line.rstrip("\r\n") + ending = line[len(body):] + stripped = body.strip() + if stripped.startswith("- 报告 ID: `") and stripped.endswith("`"): + output.append(f"- 报告 ID: `FINGERPRINT`{ending}") + elif stripped.startswith("- 计算结果摘要: `") and stripped.endswith("`"): + output.append(f"- 计算结果摘要: `FINGERPRINT`{ending}") + else: + output.append(line) + return "".join(output) + + def strip_annual_method_chapter(markdown: str) -> str: """Drop this chapter so older full-text checks still see the previous report.""" lines = (markdown or "").splitlines(keepends=True) diff --git a/tests/test_professional_report_reference_api.py b/tests/test_professional_report_reference_api.py index ace977fa..4410113b 100644 --- a/tests/test_professional_report_reference_api.py +++ b/tests/test_professional_report_reference_api.py @@ -56,7 +56,7 @@ class _ReferenceEngine: self.invalid_pack = invalid_pack self.calls = [] - def build_professional_report_reference_packet(self, full_reading, args, packs): # noqa: ANN001 + def build_professional_report_reference_packet(self, full_reading, args, packs, **_kwargs): # noqa: ANN001 assert isinstance(args.hour, int) assert isinstance(args.minute, int) assert isinstance(args.second, int) diff --git a/tests/test_pyjhora_annual_method_reference.py b/tests/test_pyjhora_annual_method_reference.py index 4e374139..303eb13d 100644 --- a/tests/test_pyjhora_annual_method_reference.py +++ b/tests/test_pyjhora_annual_method_reference.py @@ -17,6 +17,7 @@ from scripts.report_render.annual_method_reference import ( CHAPTER_ZH, build_pyjhora_annual_method_reference, normalize_worker_payload, + mask_report_fingerprint_lines, render_annual_method_reference, reset_reference_cache, set_reference_runner, @@ -32,8 +33,11 @@ def setup_function() -> None: set_reference_runner(None) -def test_result_hash_ignores_the_annual_method_chapter() -> None: - from scripts.jyotish_engine import _bind_result_without_annual_method_reference +def test_result_hash_includes_the_annual_method_chapter() -> None: + # 原值: 两个标记的 result_hash 相同(本章被摘出指纹)。 + # 新值: 两个标记的 result_hash 不同,且本章仍留在资料包里。 + # 原因: 交付包里有这一章时,重算指纹必须对得上(BUG-1429)。 + from scripts.calculation_profile_contract import bind_result_to_profile profile = {"input_hash": "ab" * 32} @@ -48,13 +52,76 @@ def test_result_hash_ignores_the_annual_method_chapter() -> None: }, } - installed = _bind_result_without_annual_method_reference(packet("installed"), profile) - blocked = _bind_result_without_annual_method_reference(packet("blocked"), profile) - assert installed["result_hash"] == blocked["result_hash"] + installed = bind_result_to_profile(packet("installed"), profile) + blocked = bind_result_to_profile(packet("blocked"), profile) + assert installed["result_hash"] != blocked["result_hash"] assert installed["worksheets"]["timing_and_predictive_systems"]["pyjhora_annual_method_reference"]["marker"] == "installed" assert installed["worksheets"]["timing_and_predictive_systems"]["kept"] == "same" +def test_fingerprint_lines_match_after_masking_for_the_chapter_compare() -> None: + left = "\n".join([ + "## 报告身份与来源", + "- 报告 ID: `full-report://one/aaaaaaaaaaaaaaaaaaaaaaaa`", + "- 计算结果摘要: `0123456789abcdef...`", + "## 前面的章", + "same", + f"## {CHAPTER_ZH}", + "only-in-left", + ]) + "\n" + right = "\n".join([ + "## 报告身份与来源", + "- 报告 ID: `full-report://two/bbbbbbbbbbbbbbbbbbbbbbbb`", + "- 计算结果摘要: `fedcba9876543210...`", + "## 前面的章", + "same", + f"## {CHAPTER_ZH}", + "only-in-right", + ]) + "\n" + assert mask_report_fingerprint_lines(strip_annual_method_chapter(left)) == mask_report_fingerprint_lines( + strip_annual_method_chapter(right) + ) + assert "aaaaaaaaaaaaaaaaaaaaaaaa" not in mask_report_fingerprint_lines(left) + assert "0123456789abcdef" not in mask_report_fingerprint_lines(left) + + +def test_reference_edition_does_not_request_the_annual_method_worker(monkeypatch) -> None: + from types import SimpleNamespace + + from scripts.professional_report_reference import ( + FULL_DATA_EDITION, + _assemble_professional_report_reference, + ) + + seen: list[bool] = [] + + def build(_reading, _args, _packs, include_annual_method_reference=True): + seen.append(include_annual_method_reference) + return {"report_version": "v", "worksheets": {}} + + engine = SimpleNamespace( + build_professional_report_reference_packet=build, + render_pl9_markdown=lambda _packet: "standard", + ) + handler = SimpleNamespace(_compute_full_reading_for_thematic=lambda _birth: {}) + birth = fictional_case(0) + reference = _assemble_professional_report_reference( + handler, birth, "markdown", "reference", [], [], False, engine, + ) + assert reference["markdown"] == "standard" + assert seen == [False] + + monkeypatch.setattr( + "scripts.professional_report_reference.render_full_data_markdown", + lambda _packet: "full-data", + ) + full = _assemble_professional_report_reference( + handler, birth, "markdown", FULL_DATA_EDITION, [], [], False, engine, + ) + assert full["markdown"] == "full-data" + assert seen == [False, True] + + def test_product_report_does_not_probe(monkeypatch) -> None: def _boom(*_args, **_kwargs): raise AssertionError("product diagnostics must not probe jhora") diff --git a/tests/test_report_english_edition.py b/tests/test_report_english_edition.py index 46d582b6..7bcb820d 100644 --- a/tests/test_report_english_edition.py +++ b/tests/test_report_english_edition.py @@ -152,7 +152,7 @@ def _api_response(packet: dict, body: dict) -> dict: return {"stub": True} class Engine: - def build_professional_report_reference_packet(self, full_reading, args, packs): + def build_professional_report_reference_packet(self, full_reading, args, packs, **_kwargs): return copy.deepcopy(packet) return build_professional_report_reference(Handler(), body, engine=Engine()) diff --git a/tests/test_report_reader_main.py b/tests/test_report_reader_main.py index b5f0f374..bdf6a474 100644 --- a/tests/test_report_reader_main.py +++ b/tests/test_report_reader_main.py @@ -109,7 +109,7 @@ def test_default_edition_stays_reference_and_full_data_requests_v4() -> None: return {"modules": {"chart": {}}} class Engine: - def build_professional_report_reference_packet(self, full_reading, args, packs): + def build_professional_report_reference_packet(self, full_reading, args, packs, **_kwargs): return { "schema": "pl9_style_professional_export_v1", "selected_report_pack_ids": packs, diff --git a/tests/test_report_reference_perf.py b/tests/test_report_reference_perf.py index 752b33a8..767b00b4 100644 --- a/tests/test_report_reference_perf.py +++ b/tests/test_report_reference_perf.py @@ -265,7 +265,7 @@ def cached_report(monkeypatch, tmp_path, day_views): handler = _CountingHandler(birth, packet) class _Engine: - def build_professional_report_reference_packet(self, _reading, _args, _packs): + def build_professional_report_reference_packet(self, _reading, _args, _packs, **_kwargs): return copy.deepcopy(packet) def render_pl9_markdown(self, _packet):