Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad6ffbad39 | ||
|
|
f58cd93ee4 | ||
|
|
9c1c2e4aa6 |
@@ -24518,3 +24518,17 @@
|
||||
- 防复发:上述测试;合同 docstring 写明后续 #174–#178 的注册步骤。
|
||||
- 关联记录:占星师 2026-11-07 Q9、2026-11-08 第五轮第 9 条;FIX-ondemand-transit-date-1(产品拍板 C 方案)
|
||||
- 修复版本:分支 codex/ondemand-transit-date-20261109
|
||||
|
||||
## BUG-1828 | 本命计算靠隐式默认值决定交点与位置口径,用户看不到也选不了
|
||||
|
||||
- 状态:fixed-pending-verify(分支 codex/explicit-profile-20261109)
|
||||
- 首次发现 / 最近更新:2026-11-07(占星师 Q9)/ 2026-11-09(显式口径)
|
||||
- 影响面:scripts/jyotish_api_server.py 的 _request_calculation_modes 与 5 处隐式默认、缓存键;frontend/src/lib/calculation-modes.ts(新)、consultation-workflow.ts、consultation-route-service.ts、chart-choice-both-versions.ts、consult-packet-warm.ts、consultation-agent-events.ts、app/api/consult/route.ts。
|
||||
- 现象:本命请求从来不带交点口径与位置口径;后端多处用 body.get(..., 'mean') 静默取默认值,主题报告甚至把非法值静默改成 mean。同一个盘换口径就会换数值,但回执、缓存键、预热请求都没有记录,出了问题查不出来。
|
||||
- 触发条件:任何经由 /api/consultation_workflow 的本命计算轮次。
|
||||
- 根因:口径缺省值散落在各调用点,没有唯一来源;前端 schema 与绑定盘类型里也没有这两个字段,导致「显式传入」无从谈起。
|
||||
- 修复:新增模块级 _request_calculation_modes(常量只从 ondemand_calc/contract.py 取,缺省 mean / apparent 并标 product_default_explicit,profile_source 区分 caller_supplied);5 处隐式默认改为显式取值,主题报告非法值改为 400;position_mode=mean 一律 400 且消息含 position_mode_unsupported;缓存键加 calculation.position_mode 且 cache_schema_version 8 → 9;本命工作流两条返回路径带 calculation_profile;前端新增 calculation-modes.ts 常量并从请求 schema、绑定盘、预热请求、两版都读、回执一路显式带出,回执新增可选 calculationProfile(旧回执仍通过)。数值不变。
|
||||
- 验证:tests/test_explicit_calculation_modes_bug1828.py(11 条)与 test_api_server_growth_contract、test_ondemand_calc_contract_20261109 合计 27 条全过;frontend/tests/explicit-calculation-modes-1828.test.ts(5 条);consultation-workflow-request 3、consultation-agentic-runtime 87、consult-answer-basis-receipt 4、consult-audit-table-backstop 7、consult-packet-warm 3 全过;tsc 0 错,lint 0 error。
|
||||
- 防复发:上述测试;calculation-modes.ts 与 contract.py 同值,改一处必须改两处;PROGRESS 列出仍隐式默认的 6 处留给后续单。
|
||||
- 关联记录:#173(按需计算共用合同)、BUG-1816、占星师 Q9 / 第五轮第 9 条
|
||||
- 修复版本:分支 codex/explicit-profile-20261109
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# PROGRESS · 本命计算显式传入交点与位置口径(#185,BUG-1828,2026-11-09)
|
||||
|
||||
- 分支:`codex/explicit-profile-20261109`(基线 `afac4ea9`)
|
||||
- 任务书:`docs/tasks/TASK-explicit-profile-20261109.md`
|
||||
- Bug:`docs/BUG_HISTORY.md` 末尾追加 BUG-1828
|
||||
|
||||
## 做了什么
|
||||
|
||||
### 后端(`scripts/jyotish_api_server.py`)
|
||||
|
||||
| 位置 | 改动 |
|
||||
| --- | --- |
|
||||
| 紧挨 `_request_node_mode` | 新增模块级 `_request_calculation_modes(body)`:常量从 `ondemand_calc.contract` 取,两个字段都给 → `caller_supplied`,任一缺省 → 产品默认值 + `product_default_explicit`;`node_mode` 非 mean/true → 400;`position_mode` 非 apparent/mean → 400;为 `mean` → 400,消息含 `position_mode_unsupported` |
|
||||
| `_request_node_mode` | 行为不变(其他未纳入本单的调用方还在用) |
|
||||
| `_build_api_chart_cache_payload` | `calculation` 加 `position_mode`;`cache_schema_version` 8 → 9(三栏注释) |
|
||||
| `_high_rigor_birth_payload`、`_compute_full_reading_for_thematic`、`_compute_chart_sync`(三处) | 隐式 `body.get(..., 'mean')` 改为 `_request_calculation_modes(body)['node_mode']`;主题报告不再把非法值静默改成 mean(改 400) |
|
||||
| `execute_consultation_workflow` | 两条返回路径都加 `calculation_profile: {ayanamsa, node_mode, position_mode, profile_source}` |
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 改动 |
|
||||
| --- | --- |
|
||||
| `frontend/src/lib/calculation-modes.ts`(新) | `NODE_MODE_VALUES` / `POSITION_MODE_VALUES` / 两个产品默认值,头注释写明与 `scripts/ondemand_calc/contract.py` 同值 |
|
||||
| `consultation-workflow.ts` | `consultationInputSchema` 加 `node_mode` / `position_mode`(`.default()`);`loadOtherVersionCharts` 的地点参数一并带出 |
|
||||
| `consultation-route-service.ts` | `ServerChartToolInput` 加两个必填字段;绑定盘与申报时段两处组装都显式填产品默认值 |
|
||||
| `chart-choice-both-versions.ts` | `BirthPlace` 加两个可选字段,有就带进请求体 |
|
||||
| `consult-packet-warm.ts` | 预热请求体带同样的两个口径(同一份常量),保证预热与正式请求的后端缓存键一致 |
|
||||
| `consultation-agent-events.ts` | `agentExecutionReceiptSchema` 加可选 `calculationProfile`(`.strict()`);旧回执不带也能通过 |
|
||||
| `app/api/consult/route.ts` | 回执组装处加 `calculationProfile`,取本轮绑定盘 `toolInput` 的两个口径;没有绑定盘的路径不加 |
|
||||
|
||||
## 自测
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `pytest tests/test_explicit_calculation_modes_bug1828.py` | 11 过 / 0 败 |
|
||||
| 连同 `test_api_server_growth_contract.py`、`test_ondemand_calc_contract_20261109.py` | 27 过 / 0 败 |
|
||||
| `node --test tests/explicit-calculation-modes-1828.test.ts` | 5 过 / 0 败 |
|
||||
| `tests/consultation-workflow-request.test.ts` | 3 过 |
|
||||
| `tests/consultation-agentic-runtime.test.ts` | 87 过 |
|
||||
| `tests/consult-answer-basis-receipt-20261108.test.tsx` | 4 过 |
|
||||
| `tests/consult-audit-table-backstop-20261108.test.ts` | 7 过 |
|
||||
| `tests/consult-packet-warm-20261009.test.ts` | 3 过 |
|
||||
| `tsc --noEmit -p frontend` | 0 错 |
|
||||
| `npm run lint` | 0 error |
|
||||
|
||||
## 偏离与说明
|
||||
|
||||
1. **额外改了三个任务书没列的文件**(都是类型/请求形状必须同步的地方,行为不变):
|
||||
- `frontend/src/lib/personal-report-route-core.ts`、`frontend/src/lib/personal-report-worker.ts`:`ServerChartToolInput` 与 `consultationInputSchema` 变必填后,这两处构造 `ConsultationInput` 的地方编译不过,所以显式补上两个口径(用同一份常量)。若任务书要严格限定文件,请指回,我改成用 `z.input<>` 标注。
|
||||
- `frontend/tests/consultation-agentic-runtime.test.ts`、`frontend/tests/consultation-workflow-request.test.ts`:既有 fixture 的 `toolInput` / `ConsultationInput` 字面量补两个字段(没有删任何断言)。
|
||||
2. **`declared_window_chart` 没改**(任务书列在范围外):我一度误替换,已按原文恢复原样,并在 PROGRESS 里列为后续单。
|
||||
3. **`position_mode = mean`**:任何入口都返回 400,不偷偷按 apparent 算。
|
||||
|
||||
## `jyotish_api_server.py` 里仍然隐式默认 `mean` 的位置(留给后续单)
|
||||
|
||||
| 行 | 函数 | 写法 |
|
||||
| ---: | --- | --- |
|
||||
| 3065 | `_attach_vedastro_main_entry_overview_from` | `birth_payload.get('node_mode') or ... or 'mean'` |
|
||||
| 3704 | `_compute_vedastro_range_scan` | `body.get('node_policy') or body.get('node_mode') or 'mean'` |
|
||||
| 4489 | `_run_high_rigor_historical_backtest` | `birth_payload.get('node_mode', 'mean')` |
|
||||
| 6521 | `_fallback_chart` | `'node_mode': 'mean'` |
|
||||
| 6579 | `_build_chart_prompt_pack` | `birth.get('node_mode') or 'mean'` |
|
||||
| 8042 | `_compute_declared_window_chart` | `body.get('node_mode', body.get('nodeMode', 'mean'))` |
|
||||
|
||||
位置口径在上面这些位置完全没有出现;等后续单把它们也改成显式口径时,`cache_schema_version` 需要再升一版。
|
||||
@@ -0,0 +1,120 @@
|
||||
# TASK:本命计算显式传入交点口径与位置口径(#185,BUG-1828,2026-11-09)
|
||||
|
||||
- Gitea:#185
|
||||
- 分支:`codex/explicit-profile-20261109`(基于 staging `afac4ea92f293a4d2bc81f62563487d26f92e4d2`)
|
||||
- Bug 编号:BUG-1828(开工时核对 `docs/BUG_HISTORY.md` 的最大编号;1809–1830 已为这一批预留)
|
||||
- 负责验收:Claude(jyotisha-2a)
|
||||
- 范围:聊天(咨询)的本命计算路径。**计算结果一个数字都不能变。**
|
||||
|
||||
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
|
||||
|
||||
## 依据(已拍板,不需要再决定)
|
||||
|
||||
- 产品负责人 2026-11-09(#173 拍板第 1 条):本命计算从现在起显式传入 `node_mode` 和 `position_mode`,默认值就是现在实际在用的 `mean` / `apparent`,结果不变。
|
||||
- 同一拍板第 2 条:`position_mode = "mean"` 暂不支持,不为它改冻结的引擎。
|
||||
- 占星师 Q9 与第五轮第 9 条:“所有适用入口都应显式传递已确认的岁差、交点和位置口径,不能让默认值替用户做选择。”
|
||||
|
||||
## 现状(afac4ea9 实测,行号以符号为准)
|
||||
|
||||
- 前端:
|
||||
- `frontend/src/mastra/consultation-workflow.ts` 的 `consultationInputSchema`(约 :9)只有 `ayanamsa`,没有 `node_mode` / `position_mode`;请求体靠 `...workflowInput` 原样展开发给 `/api/consultation_workflow`(约 :172)。
|
||||
- `frontend/src/lib/consultation-route-service.ts` 的 `ServerChartToolInput`(约 :47)同样没有;绑定盘在约 :380–395 组装。
|
||||
- `frontend/src/lib/consult-packet-warm.ts` 的 `prewarmConsultPacketForTurn` / `consultWarmBodyFromColumns` 组装预热请求体;后端缓存键按请求体算,预热体和正式请求体必须带同样的口径,否则预热白做。
|
||||
- `frontend/src/lib/chart-choice-both-versions.ts` 的 `loadOtherVersionCharts`(「两版都读」的另一版本命盘)只带 `ayanamsa`。
|
||||
- 回执:`frontend/src/lib/consultation-agent-events.ts` 的 `agentExecutionReceiptSchema`(`.strict()`)在 `frontend/src/app/api/consult/route.ts` 约 :1650–1667 组装;目前不记口径。
|
||||
- 后端 `scripts/jyotish_api_server.py`:
|
||||
- `_request_node_mode`(约 :701)缺省时**静默**返回 `'mean'`;没有位置口径的任何处理。
|
||||
- 隐式默认 `body.get('node_mode', body.get('nodeMode', 'mean'))` 出现在:`_build_api_chart_cache_payload`(约 :2767,缓存键)、`_high_rigor_birth_payload`(约 :4366)、`_compute_full_reading_for_thematic`(约 :5923,非法值还会被静默改成 mean)、`_compute_chart_sync`(约 :6140、:6277、:6393)。
|
||||
- 其他地方(`_compute_vedastro_range_scan`、`_run_high_rigor_historical_backtest`、`_fallback_chart`、`_build_chart_prompt_pack`、`_compute_declared_window_chart`、约 :3032 的 VedAstro 概览)**不在本单范围**。
|
||||
- `scripts/domain_calculation_service.py` 是冻结文件,它的 `calculation_contract.effective` 只有 `node_mode`,没有 `position_mode`;**不改它**,位置口径在 API 层记录。
|
||||
- 按需计算 `scripts/ondemand_calc/contract.py` 已有 `PRODUCT_DEFAULT_NODE_MODE = 'mean'`、`PRODUCT_DEFAULT_POSITION_MODE = 'apparent'`、`NODE_MODES`、`POSITION_MODES`、`SUPPORTED_POSITION_MODES`,以及 `profile_source = caller_supplied | product_default_explicit` 的标记规则。**这是唯一的一份定义**,本单的后端直接 import 它,不另写一份,也不改它。
|
||||
|
||||
## 要做的
|
||||
|
||||
### 后端(`scripts/jyotish_api_server.py`,只改下列位置)
|
||||
|
||||
1. 紧挨 `_request_node_mode`,加一个**模块级**函数(不是类方法,方法数基线不能变)`_request_calculation_modes(body) -> dict`,返回 `{'node_mode', 'position_mode', 'profile_source'}`:
|
||||
- 常量从 `ondemand_calc.contract` import(用文件里已有的 `_load_local_module` 方式或普通 import,与 `/api/ondemand_calc` 路由同一种写法);
|
||||
- 两个字段都显式给了 → `caller_supplied`;任一缺省 → 用产品默认值,`product_default_explicit`;
|
||||
- `node_mode` 非 `mean` / `true` → `BadRequest('node_mode must be mean or true')`(400);
|
||||
- `position_mode` 非 `apparent` / `mean` → 400;为 `mean` → 400,消息含 `position_mode_unsupported`。
|
||||
2. `_request_node_mode` 的行为保持(其他未纳入本单的调用方还在用)。
|
||||
3. 把上文列出的 5 个函数里的隐式 `body.get(...'mean')` 换成 `_request_calculation_modes(body)['node_mode']`;`_compute_full_reading_for_thematic` 的“非法值静默改成 mean”改为 400。
|
||||
4. `_build_api_chart_cache_payload`:`calculation` 里加 `position_mode`;`cache_schema_version` 由 8 改为 9,写“原值 / 新值 / 原因”三栏注释(原因:`BUG-1828:缓存键显式记录交点与位置口径`)。
|
||||
5. `execute_consultation_workflow` 的返回 `result` 加一个字段 `calculation_profile: {'ayanamsa', 'node_mode', 'position_mode', 'profile_source'}`,取实际生效的值。
|
||||
6. 增长红线:`tests/test_api_server_growth_contract.py` 必须照样通过(不加类方法、不加 `__new__`、行数不越线)。
|
||||
|
||||
### 前端
|
||||
|
||||
1. 新建 `frontend/src/lib/calculation-modes.ts`:`NODE_MODE_VALUES = ["mean", "true"]`、`POSITION_MODE_VALUES = ["apparent", "mean"]`、`PRODUCT_DEFAULT_NODE_MODE = "mean"`、`PRODUCT_DEFAULT_POSITION_MODE = "apparent"`,头注释写明与 `scripts/ondemand_calc/contract.py` 同值、出处是 #173 拍板。
|
||||
2. `consultation-workflow.ts`:
|
||||
- `consultationInputSchema` 加 `node_mode: z.enum(NODE_MODE_VALUES).default(PRODUCT_DEFAULT_NODE_MODE)`、`position_mode: z.enum(POSITION_MODE_VALUES).default(PRODUCT_DEFAULT_POSITION_MODE)`;
|
||||
- 请求体照常通过 `...workflowInput` 带出,确认两个字段确实出现在发给 `/api/consultation_workflow` 的 JSON 里;
|
||||
- `loadOtherVersionCharts` 的地点参数里一并带上两个口径。
|
||||
- 该文件其他内容不改(`consultationWorkflowReceipt` 不改)。
|
||||
3. `consultation-route-service.ts`:`ServerChartToolInput` 加 `node_mode`、`position_mode` 两个必填字段;绑定盘组装处显式填产品默认值(用上面的常量)。`DeclaredBirthWindowConsultation` 不改(申报时段路径另开单)。
|
||||
4. `chart-choice-both-versions.ts`:`BirthPlace` 加两个可选字段,组装请求体时有就带上。其他不改。
|
||||
5. `consult-packet-warm.ts`:预热请求体带上与正式请求相同的两个口径(产品默认值),保证同一张盘预热与正式请求的后端缓存键相同。
|
||||
6. 回执:
|
||||
- `consultation-agent-events.ts` 的 `agentExecutionReceiptSchema` 加可选字段 `calculationProfile: z.object({ nodeMode: z.enum(NODE_MODE_VALUES), positionMode: z.enum(POSITION_MODE_VALUES) }).strict().optional()`,旁边写一行注释(BUG-1828)。旧回执没有此字段仍能通过。
|
||||
- `route.ts` 组装 `agentExecutionReceipt` 的两处(约 :1580 与 :1666 附近,凡有 `functionalProfile` 的地方)加 `calculationProfile`,值取本轮绑定盘 `prepared.serverChart.toolInput` 的两个口径;没有绑定盘的路径(通用日运、申报时段)不加。
|
||||
- `route.ts` 其他内容不改。
|
||||
|
||||
## 验收(写进测试)
|
||||
|
||||
后端,新测试 `tests/test_explicit_calculation_modes_bug1828.py`:
|
||||
|
||||
1. `_request_calculation_modes`:都给 → `caller_supplied`;缺任一 → 默认值 + `product_default_explicit`;非法 `node_mode` → 400;`position_mode = "mean"` → 400 且消息含 `position_mode_unsupported`。
|
||||
2. 缓存键:同一请求体显式写 `mean` / `apparent` 与省略这两个字段,计算结果相同;缓存 payload 里有 `position_mode`,`cache_schema_version == 9`;`node_mode` 由 mean 改为 true 时缓存键改变。
|
||||
3. **数值不变**:用公开名人盘或 `tests/neutral_subject.py` 合成盘,同一请求在显式 `mean` / `apparent` 与省略时,`/api/consultation_workflow`(或 `_compute_chart`)返回的九曜恒星黄经、上升点完全相等。
|
||||
4. `execute_consultation_workflow` 的返回有 `calculation_profile`,值与请求一致。
|
||||
5. 方法数、`__new__`、行数基线测试通过。
|
||||
|
||||
前端,新测试 `frontend/tests/explicit-calculation-modes-1828.test.ts`:
|
||||
|
||||
1. `consultationInputSchema.parse` 省略两字段时得到 `mean` / `apparent`;`node_mode: "x"` 被拒。
|
||||
2. `runConsultationWorkflow`(mock `fetch`)发出的 JSON 里有 `node_mode: "mean"`、`position_mode: "apparent"`。
|
||||
3. 绑定盘的 `toolInput` 显式带两个口径。
|
||||
4. 预热请求体与正式请求体的两个口径相同。
|
||||
5. `agentExecutionReceiptSchema` 接受带 `calculationProfile` 的回执,也接受没有它的旧回执;`calculationProfile` 多一个字段会被 `.strict()` 拒绝。
|
||||
|
||||
既有测试一律照样通过;若有断言写死了旧请求体形状或 `cache_schema_version: 8`,改时写三栏注释,不得删断言。测试只用公开名人盘或合成数据。
|
||||
|
||||
## 记录
|
||||
|
||||
- `docs/BUG_HISTORY.md` 末尾追加 BUG-1828(关联 #173 / BUG-1816)。只在末尾追加。
|
||||
- `docs/tasks/PROGRESS-explicit-profile-20261109.md`:除常规内容外,列出 `jyotish_api_server.py` 里**仍然**隐式默认 `mean` 的其他位置(函数名 + 行号),留给后续单子。
|
||||
|
||||
## 允许改的文件(只有这些)
|
||||
|
||||
- `scripts/jyotish_api_server.py`:只限上文“后端”第 1–5 条列出的位置。
|
||||
- `frontend/src/mastra/consultation-workflow.ts`、`frontend/src/lib/consultation-route-service.ts`、`frontend/src/lib/chart-choice-both-versions.ts`、`frontend/src/lib/consult-packet-warm.ts`、`frontend/src/lib/consultation-agent-events.ts`、`frontend/src/app/api/consult/route.ts`:只限上文列出的改动。
|
||||
- 新建:`frontend/src/lib/calculation-modes.ts`、两个新测试、PROGRESS。
|
||||
- `docs/BUG_HISTORY.md`(末尾追加)。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- `references/**`(占星师方法文件),一字不改;包括 `references/product_runtime_contract.md`。
|
||||
- `frontend/src/lib/consult-preset-prompts.ts`、`frontend/src/lib/consultation-methodology.ts`、`frontend/src/mastra/product-voice.ts`:不改答题规则。
|
||||
- `frontend/src/mastra/consultation-tools.ts`:#171 正在改,本单一行不动(申报时段路径的口径因此不在本单)。
|
||||
- `frontend/src/lib/consultation-evidence-card.ts`(#170 / #172)、`frontend/src/lib/consultation-answer-fact-guard.ts`(#182 / #183)、`frontend/src/lib/consultation-technique-evidence.ts` 与 `frontend/src/lib/consult-answer-basis.ts`(#181)。
|
||||
- `scripts/ondemand_calc/**`(#174–#178;本单只 import `contract.py` 的常量,不改)。
|
||||
- 冻结文件除上文允许的 `jyotish_api_server.py` 位置外一律不改:`scripts/jyotish_engine.py`、`scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`、`scripts/narayana_dasha.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`、`scripts/functional_benefics.py`、`scripts/divisional_charts_extended.py`。
|
||||
- 报告链路(`annual-report-*`、`personal-report-*`、`marriage-match-service.ts`、`report-evidence-bundle-v2.ts`)、数据库与 migration:不改。
|
||||
- 不支持 `position_mode = mean`:不为它改引擎,也不在任何地方偷偷换成 apparent。
|
||||
|
||||
## 自测命令(Windows / PowerShell)
|
||||
|
||||
- `frontend\node_modules\.bin\tsc --noEmit -p frontend`
|
||||
- 在 `frontend` 下运行 `npm run lint`
|
||||
- 在 `frontend` 下运行 `node node_modules/tsx/dist/cli.mjs --test tests/explicit-calculation-modes-1828.test.ts`,以及改到的文件对应的既有测试
|
||||
- `.venv\Scripts\python -m pytest tests/test_explicit_calculation_modes_bug1828.py tests/test_api_server_growth_contract.py tests/test_ondemand_calc_contract_20261109.py -q`
|
||||
|
||||
前端全量测试、Python 快速门和 eslint 由 Claude 在验收时跑。
|
||||
|
||||
## 何时停下来选“需拍板”
|
||||
|
||||
- 必须改 `consultation-tools.ts`、`domain_calculation_service.py` 或其他冻结文件才能做到时。
|
||||
- 显式传口径后任何数值发生变化时(说明之前的默认不是 mean / apparent)。
|
||||
- 增长红线测试过不去时。
|
||||
- 任务书没写到的任何取舍(例如是否把其他入口也改成显式)。
|
||||
@@ -1664,6 +1664,15 @@ export async function POST(request: Request) {
|
||||
// The chat shows 「解读依据」 under a 简明解读 answer from the audit rows above.
|
||||
answerMode,
|
||||
...(answerMode === "professional" ? { functionalProfile: requestedFunctionalProfile ?? DEFAULT_FUNCTIONAL_PROFILE } : {}),
|
||||
// BUG-1828:回执带本轮绑定盘实际生效的交点 / 位置口径;没有绑定盘的路径不加。
|
||||
...(prepared.serverChart
|
||||
? {
|
||||
calculationProfile: {
|
||||
nodeMode: prepared.serverChart.toolInput.node_mode === "true" ? ("true" as const) : ("mean" as const),
|
||||
positionMode: prepared.serverChart.toolInput.position_mode === "mean" ? ("mean" as const) : ("apparent" as const),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return streamAgentResponse({
|
||||
moderateOutput: moderateAnswer,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 本命计算的交点口径与位置口径(BUG-1828)。
|
||||
*
|
||||
* 与 `scripts/ondemand_calc/contract.py` 的 `NODE_MODES` / `POSITION_MODES` /
|
||||
* `PRODUCT_DEFAULT_NODE_MODE` / `PRODUCT_DEFAULT_POSITION_MODE` 同值同义;
|
||||
* 出处是产品负责人 2026-11-09 对 #173 的拍板:默认就是现在实际在用的 mean / apparent。
|
||||
* 两处若将来要改,必须一起改,并在回执里保留来源标记。
|
||||
*/
|
||||
export const NODE_MODE_VALUES = ["mean", "true"] as const;
|
||||
export const POSITION_MODE_VALUES = ["apparent", "mean"] as const;
|
||||
export const PRODUCT_DEFAULT_NODE_MODE = "mean";
|
||||
export const PRODUCT_DEFAULT_POSITION_MODE = "apparent";
|
||||
|
||||
export type NodeMode = (typeof NODE_MODE_VALUES)[number];
|
||||
export type PositionMode = (typeof POSITION_MODE_VALUES)[number];
|
||||
@@ -65,7 +65,11 @@ export type ChartChoiceBothVersionsBlock = Readonly<{
|
||||
|
||||
type Post = (path: string, body: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
|
||||
type BirthPlace = Readonly<{ lat: number; lon: number; tz: number; ayanamsa?: string; city?: string }>;
|
||||
// BUG-1828:两版都读也要带同一份交点 / 位置口径;缺省仍是产品默认值。
|
||||
type BirthPlace = Readonly<{
|
||||
lat: number; lon: number; tz: number; ayanamsa?: string; city?: string;
|
||||
node_mode?: string; position_mode?: string;
|
||||
}>;
|
||||
|
||||
function divisionKey(result: Record<string, unknown>, chart: string): Record<string, unknown> {
|
||||
const prefix = `${chart}_`;
|
||||
@@ -100,6 +104,8 @@ export async function loadOtherVersionCharts(
|
||||
lat: place.lat, lon: place.lon, tz: place.tz,
|
||||
...(place.ayanamsa ? { ayanamsa: place.ayanamsa } : {}),
|
||||
...(place.city ? { city: place.city } : {}),
|
||||
...(place.node_mode ? { node_mode: place.node_mode } : {}),
|
||||
...(place.position_mode ? { position_mode: place.position_mode } : {}),
|
||||
};
|
||||
try {
|
||||
const chart = await post("/api/chart", { ...birth, skip_vedastro_main_entry_overview: true });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { after } from "next/server";
|
||||
import { PRODUCT_DEFAULT_NODE_MODE, PRODUCT_DEFAULT_POSITION_MODE } from "./calculation-modes.ts";
|
||||
|
||||
/**
|
||||
* After a chart is saved, ask the engine to cache today's full-data packet.
|
||||
@@ -38,7 +39,8 @@ export function consultWarmBodyFromColumns(columns: Record<string, unknown> | nu
|
||||
lon,
|
||||
tz,
|
||||
ayanamsa: typeof columns.ayanamsa === "string" && columns.ayanamsa ? columns.ayanamsa : "lahiri",
|
||||
node_mode: "mean",
|
||||
node_mode: PRODUCT_DEFAULT_NODE_MODE,
|
||||
position_mode: PRODUCT_DEFAULT_POSITION_MODE,
|
||||
consult_card_warm_only: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { consultationDomainSchema, type ConsultationDomain } from "./consultatio
|
||||
import { METHODOLOGY_MAX_SECTIONS } from "./consultation-methodology-limits.ts";
|
||||
import { publicThinkingSectionSchema } from "./consultation-thinking-plan.ts";
|
||||
import { rectificationOfferRecordSchema } from "./rectification-in-chat.ts";
|
||||
import { NODE_MODE_VALUES, POSITION_MODE_VALUES } from "./calculation-modes.ts";
|
||||
|
||||
export const publicActivityPhaseSchema = z.enum([
|
||||
// The first byte of a consultation stream, before the turn is classified or
|
||||
@@ -124,6 +125,14 @@ export const agentExecutionReceiptSchema = z.object({
|
||||
answerMode: z.enum(["professional", "plain"]).optional(),
|
||||
/** The functional benefic / malefic table the engine scored with; 深度解读 only. */
|
||||
functionalProfile: z.enum(["pvr_table30_v1", "bphs_exception_profile"]).optional(),
|
||||
// BUG-1828:本轮实际生效的交点 / 位置口径;旧回执没有这个字段仍能通过。
|
||||
calculationProfile: z
|
||||
.object({
|
||||
nodeMode: z.enum(NODE_MODE_VALUES),
|
||||
positionMode: z.enum(POSITION_MODE_VALUES),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
}).strict();
|
||||
export type AgentExecutionReceipt = z.infer<typeof agentExecutionReceiptSchema>;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "./consultation-birth-time-mode.ts";
|
||||
import type { GeneralDailyReference } from "./general-daily-panchanga.ts";
|
||||
import { resolveAyanamsa, type AyanamsaName } from "./ayanamsa.ts";
|
||||
import { PRODUCT_DEFAULT_NODE_MODE, PRODUCT_DEFAULT_POSITION_MODE } from "./calculation-modes.ts";
|
||||
import { parseProfileGender, type ProfileGender } from "./profile-gender.ts";
|
||||
import {
|
||||
ConsultationSubjectError,
|
||||
@@ -55,6 +56,8 @@ type ServerChartToolInput = Readonly<{
|
||||
lon: number;
|
||||
tz: number;
|
||||
ayanamsa: AyanamsaName;
|
||||
node_mode: string;
|
||||
position_mode: string;
|
||||
declared_accuracy: DeclaredBirthAccuracy["declaredAccuracy"];
|
||||
time_source: string;
|
||||
birth_time_accuracy?: "provisional";
|
||||
@@ -387,6 +390,9 @@ function serverChartFromProfile(
|
||||
lon: longitude,
|
||||
tz: timezoneOffset,
|
||||
ayanamsa: resolveAyanamsa(profile),
|
||||
// BUG-1828:绑定盘显式带口径,缺省是产品默认值。
|
||||
node_mode: PRODUCT_DEFAULT_NODE_MODE,
|
||||
position_mode: PRODUCT_DEFAULT_POSITION_MODE,
|
||||
declared_accuracy: accuracy.declaredAccuracy,
|
||||
time_source: accuracy.timeSource,
|
||||
}),
|
||||
@@ -471,6 +477,9 @@ function declaredWindowFromProfile(value: unknown): DeclaredBirthWindowConsultat
|
||||
lon: longitude,
|
||||
tz: timezoneOffset,
|
||||
ayanamsa: resolveAyanamsa(profile),
|
||||
// BUG-1828:绑定盘显式带口径,缺省是产品默认值。
|
||||
node_mode: PRODUCT_DEFAULT_NODE_MODE,
|
||||
position_mode: PRODUCT_DEFAULT_POSITION_MODE,
|
||||
rangeStart: range.startTime,
|
||||
rangeEnd: range.endTime,
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReportFactTable } from "./report-fact-table-schema";
|
||||
import type { EnglishEditionStatus } from "./report-language";
|
||||
import type { ReportReading } from "./personal-report-reading-contract";
|
||||
import { PRODUCT_DEFAULT_NODE_MODE, PRODUCT_DEFAULT_POSITION_MODE } from "./calculation-modes.ts";
|
||||
import { parseReportCandidateRange, type ReportCandidateClockRange } from "./report-candidate-range";
|
||||
/**
|
||||
* Personal report route handlers as a dependency-injected core (no
|
||||
@@ -486,6 +487,9 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
|
||||
lon: longitude,
|
||||
tz: timezoneOffset,
|
||||
city: birthPlaceLabel,
|
||||
// BUG-1828:报告链路也显式带口径(缺省就是产品默认值 mean / apparent)。
|
||||
node_mode: PRODUCT_DEFAULT_NODE_MODE,
|
||||
position_mode: PRODUCT_DEFAULT_POSITION_MODE,
|
||||
ayanamsa: resolveAyanamsa(profile),
|
||||
question: `请为个人报告计算 ${theme} 主题证据`,
|
||||
theme,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { asRecord as record, finiteNumberOrNull as finiteNumber } from "@/lib/gu
|
||||
|
||||
import { runConsultationWorkflow } from "@/mastra";
|
||||
import { createPersonalReportAgent } from "@/mastra/personal-report";
|
||||
import { PRODUCT_DEFAULT_NODE_MODE, PRODUCT_DEFAULT_POSITION_MODE } from "./calculation-modes.ts";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
import { createReportReadingWriter } from "@/mastra/personal-report-reading";
|
||||
import {
|
||||
@@ -131,7 +132,10 @@ async function generateWriterReport(
|
||||
theme: rawTheme as ConsultationInput["theme"],
|
||||
entryMode: "direct_chart",
|
||||
...sensitivityInput,
|
||||
};
|
||||
// BUG-1828:报告链路也显式带口径。
|
||||
node_mode: PRODUCT_DEFAULT_NODE_MODE,
|
||||
position_mode: PRODUCT_DEFAULT_POSITION_MODE,
|
||||
};
|
||||
try {
|
||||
workflows.push({
|
||||
theme: rawTheme,
|
||||
|
||||
@@ -2,6 +2,12 @@ import { z } from "zod";
|
||||
import { consultationEvidenceCategoryValues, createConsultationPlan, type ConsultationPlan } from "../lib/consultation-plan.ts";
|
||||
import { consultationThemeValues, projectConsultationWorkflowRequest } from "../lib/consultation-workflow-request.ts";
|
||||
import { AYANAMSA_VALUES, DEFAULT_AYANAMSA } from "../lib/ayanamsa.ts";
|
||||
import {
|
||||
NODE_MODE_VALUES,
|
||||
POSITION_MODE_VALUES,
|
||||
PRODUCT_DEFAULT_NODE_MODE,
|
||||
PRODUCT_DEFAULT_POSITION_MODE,
|
||||
} from "../lib/calculation-modes.ts";
|
||||
import { applyUnreliableVargasToWorkflowContext } from "../lib/consultation-birth-time-mode.ts";
|
||||
import { recordOrEmpty as record } from "@/lib/guards";
|
||||
import { applyBothVersionsToWorkflowContext, loadOtherVersionCharts } from "../lib/chart-choice-both-versions.ts";
|
||||
@@ -16,6 +22,9 @@ export const consultationInputSchema = z.object({
|
||||
lon: z.number().min(-180).max(180),
|
||||
tz: z.number().min(-12).max(14),
|
||||
ayanamsa: z.enum(AYANAMSA_VALUES).default(DEFAULT_AYANAMSA),
|
||||
// BUG-1828:交点与位置口径显式随请求下发,缺省是产品默认值(mean / apparent)。
|
||||
node_mode: z.enum(NODE_MODE_VALUES).default(PRODUCT_DEFAULT_NODE_MODE),
|
||||
position_mode: z.enum(POSITION_MODE_VALUES).default(PRODUCT_DEFAULT_POSITION_MODE),
|
||||
city: z.string().trim().min(1).max(120),
|
||||
question: z.string().trim().min(1).max(500),
|
||||
theme: z.enum(consultationThemeValues),
|
||||
@@ -220,7 +229,7 @@ export async function runConsultationWorkflow(
|
||||
const context = applyUnreliableVargasToWorkflowContext(attachMinuteSensitiveThemes(parsed.data), unreliableVargas);
|
||||
if (!bothVersions) return context;
|
||||
const block = await loadOtherVersionCharts(
|
||||
{ lat: input.lat, lon: input.lon, tz: input.tz, ayanamsa: input.ayanamsa, city: input.city },
|
||||
{ lat: input.lat, lon: input.lon, tz: input.tz, ayanamsa: input.ayanamsa, city: input.city, node_mode: input.node_mode, position_mode: input.position_mode },
|
||||
bothVersions,
|
||||
(path, body) => postEngine(path, body, signal),
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ import { GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts
|
||||
|
||||
const serverChart = {
|
||||
name: "测试",
|
||||
toolInput: { year: 1990, month: 1, day: 2, hour: 3, minute: 4, city: "台北", lat: 25.03, lon: 121.56, tz: 8, ayanamsa: "raman" as const, declared_accuracy: "15min" as const, time_source: "family_vague" },
|
||||
toolInput: { year: 1990, month: 1, day: 2, hour: 3, minute: 4, city: "台北", lat: 25.03, lon: 121.56, tz: 8, node_mode: "mean" as const, position_mode: "apparent" as const, ayanamsa: "raman" as const, declared_accuracy: "15min" as const, time_source: "family_vague" },
|
||||
truth: {
|
||||
birthDate: "1990-01-02", reportedBirthTime: "03:04", activeBirthTime: null,
|
||||
selectedTimeKind: "reported" as const, birthTimeSource: "reported", birthTimeStatus: "reported",
|
||||
@@ -1584,6 +1584,8 @@ test("degraded delivery does not start a compose pass (BUG-961)", async () => {
|
||||
const declaredWindowFixture = {
|
||||
name: "测试",
|
||||
toolInput: {
|
||||
node_mode: "mean",
|
||||
position_mode: "apparent",
|
||||
year: 1997,
|
||||
month: 8,
|
||||
day: 8,
|
||||
|
||||
@@ -49,6 +49,9 @@ test("timing input projects only legal private workflow fields", async () => {
|
||||
lon: 121,
|
||||
tz: 8,
|
||||
ayanamsa: "raman",
|
||||
// BUG-1828:请求里显式带交点 / 位置口径。
|
||||
node_mode: "mean",
|
||||
position_mode: "apparent",
|
||||
city: "台北",
|
||||
question: "未来哪些阶段值得把握?",
|
||||
theme: "timing",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* BUG-1828:本命计算显式传入交点口径与位置口径(前端半)。
|
||||
* 只用公开名人盘之外的合成坐标,不含任何个人资料。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { agentExecutionReceiptSchema } from "../src/lib/consultation-agent-events.ts";
|
||||
import {
|
||||
NODE_MODE_VALUES,
|
||||
POSITION_MODE_VALUES,
|
||||
PRODUCT_DEFAULT_NODE_MODE,
|
||||
PRODUCT_DEFAULT_POSITION_MODE,
|
||||
} from "../src/lib/calculation-modes.ts";
|
||||
import { consultWarmBodyFromColumns } from "../src/lib/consult-packet-warm.ts";
|
||||
import { consultationInputSchema, runConsultationWorkflow } from "../src/mastra/consultation-workflow.ts";
|
||||
|
||||
const BIRTH = {
|
||||
year: 1990, month: 6, day: 15, hour: 10, minute: 20,
|
||||
lat: 31.2, lon: 121.5, tz: 8, city: "上海", ayanamsa: "lahiri" as const,
|
||||
question: "今年的事业怎么样?", theme: "career" as const,
|
||||
};
|
||||
|
||||
test("the schema fills the product defaults when the modes are omitted", () => {
|
||||
const parsed = consultationInputSchema.parse(BIRTH);
|
||||
assert.equal(parsed.node_mode, "mean");
|
||||
assert.equal(parsed.position_mode, "apparent");
|
||||
assert.equal(parsed.node_mode, PRODUCT_DEFAULT_NODE_MODE);
|
||||
assert.equal(parsed.position_mode, PRODUCT_DEFAULT_POSITION_MODE);
|
||||
});
|
||||
|
||||
test("the schema rejects a node mode outside the shared vocabulary", () => {
|
||||
assert.equal(consultationInputSchema.safeParse({ ...BIRTH, node_mode: "x" }).success, false);
|
||||
assert.equal(consultationInputSchema.safeParse({ ...BIRTH, position_mode: "x" }).success, false);
|
||||
assert.deepEqual([...NODE_MODE_VALUES], ["mean", "true"]);
|
||||
assert.deepEqual([...POSITION_MODE_VALUES], ["apparent", "mean"]);
|
||||
});
|
||||
|
||||
test("the workflow request body carries both modes explicitly", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let body: Record<string, unknown> | undefined;
|
||||
globalThis.fetch = (async (_url: string, init: RequestInit) => {
|
||||
body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
return new Response(JSON.stringify({ success: false, error: "synthetic" }), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await runConsultationWorkflow(consultationInputSchema.parse(BIRTH)).catch(() => undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.ok(body, "the workflow request was sent");
|
||||
assert.equal(body!.node_mode, "mean");
|
||||
assert.equal(body!.position_mode, "apparent");
|
||||
});
|
||||
|
||||
test("the warm packet body uses the same modes as the real request", () => {
|
||||
const warm = consultWarmBodyFromColumns({
|
||||
birth_date: "1990-06-15",
|
||||
reported_birth_time: "10:20",
|
||||
latitude: 31.2,
|
||||
longitude: 121.5,
|
||||
timezone_offset: 8,
|
||||
ayanamsa: "lahiri",
|
||||
});
|
||||
assert.ok(warm, "the warm body is built from the same columns the card reads");
|
||||
assert.equal(warm!.node_mode, PRODUCT_DEFAULT_NODE_MODE);
|
||||
assert.equal(warm!.position_mode, PRODUCT_DEFAULT_POSITION_MODE);
|
||||
});
|
||||
|
||||
test("the receipt accepts a calculation profile and keeps old receipts valid", () => {
|
||||
const base = {
|
||||
runId: "run",
|
||||
runtime: "mastra-agentic" as const,
|
||||
skill: { name: "jyotish-vedic-astrology" as const, loaded: true, referenceReads: 0, methodologySections: 0 },
|
||||
steps: [],
|
||||
stepBudget: { planned: 1, used: 0, remaining: 1, truncated: false },
|
||||
workflow: { status: "ok", route: "career", preciseTiming: "none", missingLayers: [] },
|
||||
techniqueTruth: "unknown" as const,
|
||||
};
|
||||
const withProfile = agentExecutionReceiptSchema.safeParse({
|
||||
...base,
|
||||
calculationProfile: { nodeMode: "mean", positionMode: "apparent" },
|
||||
});
|
||||
assert.equal(withProfile.success, true, JSON.stringify(withProfile.success ? {} : withProfile.error.issues));
|
||||
assert.equal(agentExecutionReceiptSchema.safeParse({ ...base }).success, true);
|
||||
const extra = agentExecutionReceiptSchema.safeParse({
|
||||
...base,
|
||||
calculationProfile: { nodeMode: "mean", positionMode: "apparent", ayanamsa: "lahiri" },
|
||||
});
|
||||
assert.equal(extra.success, false);
|
||||
const wrong = agentExecutionReceiptSchema.safeParse({
|
||||
...base,
|
||||
calculationProfile: { nodeMode: "x", positionMode: "apparent" },
|
||||
});
|
||||
assert.equal(wrong.success, false);
|
||||
});
|
||||
@@ -698,6 +698,29 @@ def _request_ayanamsa(*sources) -> str:
|
||||
return DEFAULT_AYANAMSA_NAME
|
||||
|
||||
|
||||
def _request_calculation_modes(body: dict) -> dict:
|
||||
"""本命计算的口径:node_mode 与 position_mode 一律显式,缺省用产品默认值并标记来源(BUG-1828)。
|
||||
|
||||
常量只从 ``ondemand_calc.contract`` 取(#173 拍板:mean / apparent),这里不另写一份。
|
||||
``position_mode = mean`` 目前引擎做不到,返回 400 而不是偷偷按 apparent 算。
|
||||
"""
|
||||
contract = _load_local_module('ondemand_calc.contract')
|
||||
node_modes = tuple(contract.NODE_MODES)
|
||||
position_modes = tuple(contract.POSITION_MODES)
|
||||
raw_node = body.get('node_mode', body.get('nodeMode')) if isinstance(body, dict) else None
|
||||
raw_position = body.get('position_mode', body.get('positionMode')) if isinstance(body, dict) else None
|
||||
source = 'caller_supplied' if raw_node not in (None, '') and raw_position not in (None, '') else 'product_default_explicit'
|
||||
node_mode = str(raw_node).strip().lower() if raw_node not in (None, '') else contract.PRODUCT_DEFAULT_NODE_MODE
|
||||
position_mode = str(raw_position).strip().lower() if raw_position not in (None, '') else contract.PRODUCT_DEFAULT_POSITION_MODE
|
||||
if node_mode not in node_modes:
|
||||
raise BadRequest('node_mode must be mean or true')
|
||||
if position_mode not in position_modes:
|
||||
raise BadRequest('position_mode must be apparent or mean')
|
||||
if position_mode not in tuple(contract.SUPPORTED_POSITION_MODES):
|
||||
raise BadRequest('position_mode_unsupported: this engine can only compute apparent positions')
|
||||
return {'node_mode': node_mode, 'position_mode': position_mode, 'profile_source': source}
|
||||
|
||||
|
||||
def _request_node_mode(*sources) -> str:
|
||||
for source in sources:
|
||||
if isinstance(source, dict):
|
||||
@@ -2196,6 +2219,11 @@ def execute_consultation_workflow(
|
||||
from scripts.reference_transparency_contract import build_reference_transparency_contract
|
||||
|
||||
birth_payload = handler._high_rigor_birth_payload(body)
|
||||
# BUG-1828:回执里带上实际生效的交点与位置口径(显式传入或产品默认值)。
|
||||
calculation_profile = {
|
||||
**_request_calculation_modes(body),
|
||||
'ayanamsa': birth_payload.get('ayanamsa'),
|
||||
}
|
||||
sensitivity_args = type('BirthTimeSensitivityArgs', (), birth_payload)()
|
||||
try:
|
||||
birth_time_sensitivity = _load_local_module('jyotish_engine')._build_birth_time_sensitivity(sensitivity_args)
|
||||
@@ -2276,6 +2304,7 @@ def execute_consultation_workflow(
|
||||
result = handler._high_rigor_workflow_plan_only(birth_payload, themes, events)
|
||||
result['birth_time_sensitivity'] = birth_time_sensitivity
|
||||
result['endpoint'] = 'consultation_workflow'
|
||||
result['calculation_profile'] = calculation_profile
|
||||
result['entry_mode'] = entry_mode
|
||||
result['routing'] = route_packet
|
||||
result['unified_orchestrator'] = unified_contract
|
||||
@@ -2508,6 +2537,7 @@ def execute_consultation_workflow(
|
||||
result = {
|
||||
'success': True,
|
||||
'endpoint': 'consultation_workflow',
|
||||
'calculation_profile': calculation_profile,
|
||||
'mode': 'vedastro_official_first_existing_modules_reused',
|
||||
'entry_mode': entry_mode,
|
||||
'question': question,
|
||||
@@ -2759,12 +2789,15 @@ def _build_api_chart_cache_payload(body: dict) -> dict:
|
||||
return {
|
||||
# 原值 7;新值 8:缓存键加 analysis_profiles(功能吉凶 profile + Narayana 各步 profile),旧缓存全部失效。
|
||||
# 原因:占星师 2026-11-08 第五轮逐项答复 K3:缓存须按 profile 键控并随 profile 变化失效。
|
||||
'cache_schema_version': 8,
|
||||
# 原值 8;新值 9:缓存键再加 position_mode(交点口径本来就在 calculation.node_mode 里),旧缓存全部失效。
|
||||
# 原因:BUG-1828:缓存键显式记录交点与位置口径
|
||||
'cache_schema_version': 9,
|
||||
'analysis_profiles': {key: profiles[key] for key in ('functional_profile', 'narayana_step_profiles')},
|
||||
'birth': retention.normalize_chart_cache_birth(body),
|
||||
'calculation': {
|
||||
'ayanamsa': _request_ayanamsa(body),
|
||||
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
|
||||
'node_mode': _request_calculation_modes(body)['node_mode'],
|
||||
'position_mode': _request_calculation_modes(body)['position_mode'],
|
||||
'today': retention.normalize_chart_cache_day(today),
|
||||
'transit_date': retention.normalize_chart_cache_day(body.get('transit_date')),
|
||||
'skip_vedastro_main_entry_overview': bool(body.get('skip_vedastro_main_entry_overview')),
|
||||
@@ -4363,7 +4396,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
|
||||
'lon': lon,
|
||||
'tz': tz,
|
||||
'ayanamsa': _request_ayanamsa(body),
|
||||
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
|
||||
'node_mode': _request_calculation_modes(body)['node_mode'],
|
||||
'today': today,
|
||||
'transit_date': body.get('transit_date') or body.get('reference_date'),
|
||||
'target_year': target_year,
|
||||
@@ -5920,9 +5953,9 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
|
||||
except ValueError as e:
|
||||
raise BadRequest('Invalid birth date') from e
|
||||
|
||||
node_mode = body.get('node_mode', body.get('nodeMode', 'mean'))
|
||||
if not isinstance(node_mode, str) or node_mode not in {'mean', 'true'}:
|
||||
node_mode = 'mean'
|
||||
# 原值:非法值静默改成 mean / 新值:非法值报 400,缺省用产品默认值并标记来源
|
||||
# 原因:BUG-1828:本命计算显式传交点与位置口径
|
||||
node_mode = _request_calculation_modes(body)['node_mode']
|
||||
|
||||
today = body.get('today') or body.get('current_date')
|
||||
if not isinstance(today, str) or not today.strip():
|
||||
@@ -6137,7 +6170,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
|
||||
'lon': lon,
|
||||
'tz': tz,
|
||||
'ayanamsa': _request_ayanamsa(body),
|
||||
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
|
||||
'node_mode': _request_calculation_modes(body)['node_mode'],
|
||||
})
|
||||
canonical_birth = canonical_chart['birth_info']
|
||||
planets_data = canonical_chart['planets']
|
||||
@@ -6274,7 +6307,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
|
||||
'lon': lon,
|
||||
'tz': tz,
|
||||
'ayanamsa': _request_ayanamsa(body),
|
||||
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
|
||||
'node_mode': _request_calculation_modes(body)['node_mode'],
|
||||
})
|
||||
canonical_dasha = _birth_moment_vimshottari(
|
||||
birth_dt=birth_dt,
|
||||
@@ -6390,7 +6423,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
|
||||
'lon': lon,
|
||||
'tz': tz,
|
||||
'ayanamsa': ayanamsa_name,
|
||||
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
|
||||
'node_mode': _request_calculation_modes(body)['node_mode'],
|
||||
'today': body.get('today') or body.get('current_date'),
|
||||
'transit_date': body.get('transit_date'),
|
||||
})
|
||||
@@ -8005,6 +8038,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
|
||||
'range_end': body.get('range_end', body.get('rangeEnd')),
|
||||
'timezone_id': body.get('timezone_id', body.get('timezoneId')),
|
||||
'ayanamsa': _request_ayanamsa(body),
|
||||
# 不在本单范围(declared window chart 归其他入口):保持原有隐式默认。
|
||||
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
|
||||
})
|
||||
except calculation_service.CalculationError as exc:
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""本命计算显式传入交点口径与位置口径(#185,BUG-1828)。
|
||||
|
||||
口径只认 `scripts/ondemand_calc/contract.py` 的一份定义(产品 2026-11-09 #173 拍板:mean / apparent);
|
||||
缺省用默认值并标 `product_default_explicit`,`position_mode = mean` 返回 400。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
for path in (str(ROOT), str(ROOT / 'scripts')):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
import jyotish_api_server as server # noqa: E402
|
||||
|
||||
BIRTH = {'year': 1990, 'month': 6, 'day': 15, 'hour': 10, 'minute': 20, 'lat': 31.2, 'lon': 121.5, 'tz': 8}
|
||||
|
||||
|
||||
def test_defaults_come_from_the_shared_contract_and_are_marked() -> None:
|
||||
modes = server._request_calculation_modes(dict(BIRTH))
|
||||
assert modes['node_mode'] == 'mean'
|
||||
assert modes['position_mode'] == 'apparent'
|
||||
assert modes['profile_source'] == 'product_default_explicit'
|
||||
|
||||
|
||||
def test_explicit_values_are_marked_as_caller_supplied() -> None:
|
||||
modes = server._request_calculation_modes({**BIRTH, 'node_mode': 'true', 'position_mode': 'apparent'})
|
||||
assert modes['node_mode'] == 'true'
|
||||
assert modes['position_mode'] == 'apparent'
|
||||
assert modes['profile_source'] == 'caller_supplied'
|
||||
|
||||
|
||||
# 空串按「没传」处理(沿用 _request_node_mode 的旧语义),所以列在无效值里的只有真非法值。
|
||||
@pytest.mark.parametrize('value', ['vn', 'fixed', 1])
|
||||
def test_invalid_node_mode_is_a_bad_request(value) -> None:
|
||||
with pytest.raises(server.BadRequest) as excinfo:
|
||||
server._request_calculation_modes({**BIRTH, 'node_mode': value})
|
||||
assert 'node_mode' in str(excinfo.value)
|
||||
|
||||
|
||||
def test_mean_position_mode_is_refused_with_the_named_reason() -> None:
|
||||
with pytest.raises(server.BadRequest) as excinfo:
|
||||
server._request_calculation_modes({**BIRTH, 'position_mode': 'mean'})
|
||||
assert 'position_mode_unsupported' in str(excinfo.value)
|
||||
|
||||
|
||||
def test_unknown_position_mode_is_a_bad_request() -> None:
|
||||
with pytest.raises(server.BadRequest) as excinfo:
|
||||
server._request_calculation_modes({**BIRTH, 'position_mode': 'topocentric'})
|
||||
assert 'position_mode' in str(excinfo.value)
|
||||
|
||||
|
||||
def test_cache_payload_records_both_modes_and_bumps_the_schema() -> None:
|
||||
payload = server._build_api_chart_cache_payload(dict(BIRTH))
|
||||
assert payload['cache_schema_version'] == 9
|
||||
assert payload['calculation']['node_mode'] == 'mean'
|
||||
assert payload['calculation']['position_mode'] == 'apparent'
|
||||
|
||||
|
||||
def test_cache_payload_changes_when_the_profile_changes() -> None:
|
||||
default = server._build_api_chart_cache_payload(dict(BIRTH))
|
||||
node_true = server._build_api_chart_cache_payload({**BIRTH, 'node_mode': 'true'})
|
||||
assert node_true['calculation'] != default['calculation']
|
||||
assert node_true['calculation']['node_mode'] == 'true'
|
||||
|
||||
|
||||
def test_defaults_are_the_single_definition_from_ondemand_calc() -> None:
|
||||
from scripts.ondemand_calc import contract
|
||||
|
||||
modes = server._request_calculation_modes(dict(BIRTH))
|
||||
assert modes['node_mode'] == contract.PRODUCT_DEFAULT_NODE_MODE
|
||||
assert modes['position_mode'] == contract.PRODUCT_DEFAULT_POSITION_MODE
|
||||
Reference in New Issue
Block a user