Compare commits

...
Author SHA1 Message Date
jesse-uxandGrok (agent loop) ad6ffbad39 feat(consult): explicit node/position modes from request to receipt — frontend half (#185, BUG-1828)
- 新增 calculation-modes.ts(与 scripts/ondemand_calc/contract.py 同值)
- 请求 schema 加 node_mode / position_mode,缺省 mean / apparent,
  随请求体下发;两版都读也带同一份口径
- 绑定盘 ServerChartToolInput 加两个必填字段,两处组装显式填默认值
- 预热请求体带同样的口径,预热与正式请求的后端缓存键一致
- 回执加可选 calculationProfile(.strict,旧回执仍通过),取绑定盘实际口径
- 报告链路的两处 ConsultationInput 构造补上两个口径(类型必填后必须补)

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。

Co-Authored-By: Grok (agent loop)
2026-11-09 15:33:12 +08:00
jesse-uxandGrok (agent loop) f58cd93ee4 feat(api): native calculation takes explicit node/position modes — backend half (#185, BUG-1828)
- 新增模块级 _request_calculation_modes(body):常量从 ondemand_calc.contract 取,
  缺省 mean / apparent 并标 product_default_explicit;非法值 400;
  position_mode=mean 400 且消息含 position_mode_unsupported
- 5 处隐式 body.get(..., 'mean') 改为显式口径(缓存键、高严谨 birth payload、
  主题报告、_compute_chart_sync 三处);主题报告不再把非法值静默改成 mean
- 缓存键加 calculation.position_mode,cache_schema_version 8 → 9(三栏注释)
- execute_consultation_workflow 的两条返回路径都带 calculation_profile

未动:declared_window_chart 与其余不在本单范围的入口(保持原隐式默认);
domain_calculation_service.py 等冻结文件未改。

Tests: tests/test_explicit_calculation_modes_bug1828.py(11 条);连同
api_server_growth_contract 与 ondemand contract 共 27 条全过。

前端部分(schema / toolInput / 预热 / 回执)下一轮继续。

Co-Authored-By: Grok (agent loop)
2026-11-09 15:17:39 +08:00
ClaudeandClaude Opus 5.5 9c1c2e4aa6 docs(task): brief for explicit-profile (2026-11-09)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 14:03:27 +08:00
17 changed files with 495 additions and 14 deletions
+14
View File
@@ -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)。
- 增长红线测试过不去时。
- 任务书没写到的任何取舍(例如是否把其他入口也改成显式)。
+9
View File
@@ -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,
+15
View File
@@ -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 });
+3 -1
View File
@@ -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 -1
View File
@@ -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,
+10 -1
View File
@@ -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);
});
+43 -9
View File
@@ -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