Compare commits

..
9 changed files with 295 additions and 711 deletions
-14
View File
@@ -24832,17 +24832,3 @@
- 防复发:`tests/test_report_wording_sanitizer_20261111.py` 覆盖独立状态词、字段名、代码、工程标签和真实完整导出。
- 关联记录:#188。
- 修复版本:`codex/report-wording-sanitizer-20261111`。
## BUG-1821 | 聊天按需计算缺少问卜盘入口
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-11-07(占星师 Q9)/ 2026-11-11(后端实现)
- 影响面:`scripts/ondemand_calc/prashna.py`、`scripts/ondemand_calc/registry.py`
- 现象:按需计算的调用方无法提交提问时刻、地点和问题文本来取得问卜盘与计算口径。
- 触发条件:通过 `/api/ondemand_calc` 请求问卜盘。
- 根因:问卜盘计算已有独立接口,按需工具注册表缺少对应处理函数和请求校验。
- 修复:注册 `prashna` 并校验显式提问时刻、地点、问题文本及计算口径。调用现有 Swiss Ephemeris 问卜上下文,返回盘面、口径、输入指纹和阻塞判词,问题文本只在计算输入内使用。
- 验证:`tests/test_ondemand_prashna_20261111.py` 验证缺项零计算、三入口盘面与结果哈希相等、问句保密、默认与显式交点、非法时区和引擎异常阻塞。
- 防复发:由定向契约测试锁定原生计算等价性和返回信封的隐私边界。
- 关联记录:#178、BUG-1816、BUG-1820、BUG-513
- 修复版本:`codex/ondemand-prashna-20261111`
+36
View File
@@ -0,0 +1,36 @@
# FIX-1:#189 两处状态口径的答复(2026-11-11)
Claude 答复。两条都按占星师已有规则和任务书“单一来源”要求处理,不是新的产品裁定。
## 1. 瑜伽状态:报告与聊天用同一行数据、同一个函数
- 不允许报告和聊天对同一条瑜伽给出不同状态。原因不在映射表,而在报告这边喂给 `yogaStatus` 的是自己投影出来的字段,聊天喂的是引擎 `yogas_detail` 的原始行。
- 做法:报告取每条瑜伽时,直接用引擎 `yogas_detail` 的原始行(`status`、`claim_status`、`formation` 原样),按 `yogaStatus()` 的同一优先级判定:
1. `formation.status` 为 `formed_under_source` / `conditions_not_met` → 这两种;
2. 否则看 `status`(candidate → computed → blocked);
3. 再看 `claim_status` / `formation == candidate`;
4. 都没有 → 当前无法确认,列缺项。
- Python 的 `yoga_status_words.py` 实现这同一个函数(或同一张优先级表),并加一条对照测试:两张公开盘的 24 条规则,Python 结果与 `frontend/src/lib/yoga-status-words.ts::yogaStatus` 对同一原始行的结果逐条相等。
- 报告自己的形成评估只有在它产出 `formed_under_source` / `conditions_not_met` 时才算数(就是第 1 步);其余不另立口径。
- 章节标题与表前声明仍按任务书写「候选瑜伽」——这是她 r40 的叫法;每行状态词可以是「检测器检出,待核实」,两者不矛盾。
## 2. 只有 `parameter_sensitive` 的旧状态:计算轴不是“阻塞”
占星师 2026-11-11 Q2:`parameter_sensitive` 是解读条件轴的证据标签,不是计算受阻;“相关但未运行”才写未完成。
- 两轴换算规则(Python 与 TS 同步改,保持逐字相同):
- 单一旧状态是 `parameter_sensitive`、`profile_sensitive`、`single_system_inference`、`unclosed_divisional_chart`、`user_history_verification_required` 之一时:
- 计算轴:该层在 packet / 卡里有实际计算结果 → `executed`(已执行);没有结果 → `partial` 并在 gap 写缺什么。**不得写 blocked。**
- 解读轴:`conditional`(有条件),显示词后接原始标签,例如「有条件(parameter_sensitive)」。
- 其他未知旧状态仍按现规则落到 blocked,并在 gap 写原值。
- TS 侧只改 `frontend/src/lib/consultation-technique-audit.ts` 的 `parseTechniqueAuditStatus` / `parseTechniqueCalculationStatus` 这一处换算,以及它的单元测试;聊天侧对这些标签原来显示「计算阻塞,解读不作判断」,属于同一个错误,一起修。不碰该文件其他函数。
- 测试:
- 两张公开盘报告里 `Narayana / Patyayini / Tajika Parity` 行显示「计算已执行,解读有条件(parameter_sensitive)」(若该层确有结果);
- TS 单测:`parameter_sensitive` → `{executed, conditional}`;`blocked` → 仍是 blocked;`not_applicable` → 仍是 not_applicable;
- 现有引用 `parseTechniqueAuditStatus` 的前端测试全部通过;若有断言因此变化,写原值/新值/原因(原因:`占星师 2026-11-11 Q2:parameter_sensitive 属解读条件轴`)。
- 任务书验收标准 5 的正则相应允许「有条件(原始标签)」。
`frontend/src/lib/consultation-technique-audit.ts` 由此加入本单允许改的文件;其余“不要碰”照旧。
不得自行写“产品决定/裁定”。
不得修改或删除既有断言来让测试通过。
@@ -1,221 +0,0 @@
# #178 按需问卜盘
- 任务书 `docs/tasks/TASK-ondemand-prashna-20261111.md`
- 基线 `70e3fda184cfa5a2f52e6d68dcde6c8a9dfa8da2`
- 分支 `codex/ondemand-prashna-20261111`
`/api/ondemand_calc` 支持 `tool=prashna`。必填 `question_text`、带偏移的 `question_time`、包含 `lat`、`lon`、`timezone` 的 `location` 和 `ayanamsa`。缺少提问三要素时一次点名缺项,出生资料或客户端盘面出现即阻塞。校验结束后用明确的岁差、交点和提问所在地调用原生引擎。返回的问题信息为修剪空白后的长度和 SHA-256。盘面已计算时,Kunda 和判词继续标为 `blocked`。
使用两个合成请求逐字段核对 `ondemand_calc_api`、`build_prashna_context` 和 `/api/prashna` 的处理函数,覆盖半小时时区及 UTC 零偏移。
| 字段 | 对照结果 |
| --- | --- |
| `ascendant`、`planets`、`result_hash` | 三条路径逐字段相等 |
| `calculation_contract`、`supporting_indicators`、`blocked_layers`、`boundary` | 三条路径逐字段相等 |
| `location_convention` | 三条路径均为 `wgs84` |
| `verdict` | 与原接口的阻塞状态和原因逐字相等 |
| `question_text_sha256`、`question_text_length` | 与修剪空白后的问题一致,整份信封逐段检查隐私 |
定向套件 170 项通过,覆盖任务书指定测试和现有问卜上下文、入口、注册测试。直接从 `scripts/` 导入注册表并分派 UTC 零偏移请求通过。隐私检查 62 项通过。
开工预检通过远端可见性和引擎诊断。镜像路径断言在分支和 staging 的扩展测试中均失败。
## Windows 扩展测试与基线
在同一个 Python 环境使用 `PYTHONUTF8=1`,对任务书基线 `70e3fda184cfa5a2f52e6d68dcde6c8a9dfa8da2` 和代码提交 `a044c04e1782805fd6984ec0e97a6db7594afda8` 运行同一命令。
```text
python -m pytest tests --ignore=tests/test_report_english_sweep.py -n 4 --dist loadfile --timeout 600 -o addopts= -p no:cacheprovider -q --tb=line --color=no
```
此次扩展测试排除 200 张虚构盘的英文报告长测。该用例在 #188 的 Windows 对照中单独运行超过 80 分钟,本轮选用任务书允许的扩展测试范围。英文报告的常规测试及 12 盘 smoke 包含在命令内。
| 版本 | 通过 | 失败 | 跳过 | 报错 |
| --- | ---: | ---: | ---: | ---: |
| 任务书基线 | 5,816 | 164 | 33 | 7 |
| #178 代码提交 | 5,847 | 164 | 33 | 7 |
171 个失败或报错节点按完整 pytest ID 对照,节点和结果类型逐一相同。新增 31 项问卜用例全部通过。名单中的参数组用哈希标识。
<details>
<summary>两边共有的 171 个失败或报错节点</summary>
- `FAILED` `tests/test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow`
- `FAILED` `tests/test_answer_quality_audit.py::test_answer_quality_audit_passes_when_boundaries_are_present`
- `FAILED` `tests/test_api_scratch_retention.py::test_numeric_birth_spellings_share_one_chart_cache_key`
- `FAILED` `tests/test_arudha_lord_selection_pvr.py::test_jaimini_and_scoring_paths_do_not_import_the_selector`
- `FAILED` `tests/test_ashtakavarga_compare_compat.py::test_ashtakavarga_compare_uses_repository_scripts_and_public_baselines`
- `FAILED` `tests/test_astrologer_rulings_20261107.py::test_report_carries_the_gates_and_the_comparison_wording`
- `FAILED` `tests/test_ayanamsa_mode_ownership.py::test_only_ayanamsa_utils_sets_process_sidereal_mode`
- `FAILED` `tests/test_blocked_domain_resolution_queue.py::test_blocked_domain_resolution_queue_covers_all_current_blocked_domains`
- `FAILED` `tests/test_character_level_inventory_manifest.py::test_external_manifest_indexes_high_relevance_machine_fragments_without_copying_text`
- `FAILED` `tests/test_character_level_inventory_manifest.py::test_extraction_queue_report_combines_project_and_external_binary_work`
- `FAILED` `tests/test_character_level_inventory_manifest.py::test_extraction_results_extract_pdf_and_docx_without_storing_text`
- `FAILED` `tests/test_character_level_inventory_manifest.py::test_extraction_review_grades_extracted_sources_without_promoting_runtime_truth`
- `FAILED` `tests/test_character_level_inventory_manifest.py::test_manifest_indexes_project_source_layers_without_heavy_ocr`
- `FAILED` `tests/test_character_level_inventory_manifest.py::test_source_grading_batch1_classifies_promote_review_candidates_without_runtime_promotion`
- `FAILED` `tests/test_claim_audit_runtime_gate_report.py::test_claim_gate_report_covers_all_index_domains_for_high_claim`
- `FAILED` `tests/test_claim_audit_runtime_gate_report.py::test_claim_gate_report_keeps_known_blockers_and_partials_visible`
- `FAILED` `tests/test_commercial_domain_calculation_contract.py::test_api_dasha_boundary_comes_from_domain_service`
- `FAILED` `tests/test_compatibility_skill_readiness_dashboard.py::test_compatibility_skill_dashboard_generator_creates_bounded_layers`
- `FAILED` `tests/test_compute_one_chart_pyjhora_time_basis.py::test_pyjhora_one_chart_uses_local_birth_time_for_ascendant`
- `FAILED` `tests/test_consult_card_cache_memory_20261028.py::test_new_cache_files_are_private`
- `FAILED` `tests/test_consult_evidence_card_research.py::test_source_parsers_keep_family_split_gap`
- `FAILED` `tests/test_consultation_domain_registry.py::test_typescript_registry_matches_python_canonical_domains_and_aliases`
- `FAILED` `tests/test_consultation_native_layers.py::test_the_new_layer_leaves_every_existing_output_unchanged`
- `FAILED` `tests/test_cross_project_contract.py::test_local_calculation_matches_public_compatibility_hash`
- `FAILED` `tests/test_cross_repo_advantage_sync_plan.py::test_commercial_e2e_acceptance_has_eight_real_user_questions`
- `FAILED` `tests/test_d1_d60_source_use_readiness.py::test_d1_d60_source_readiness_script_keeps_scope_narrow`
- `FAILED` `tests/test_d1_d60_varga_mapping_registry.py::test_generator_outputs_all_d1_to_d60_rows`
- `FAILED` `tests/test_d60_two_way_research.py::test_g1_agrees_with_pyjhora_on_every_v5_case`
- `FAILED` `tests/test_d60_varga_mapping.py::test_d60_matches_pyjhora_shashtyamsa_for_all_signs_and_parts`
- `FAILED` `tests/test_dasha_api_engine_parity.py::test_ashtottari_birth_balance_matches_pyjhora_within_one_day`
- `FAILED` `tests/test_dasha_api_engine_parity.py::test_conditional_tables_match_pyjhora_start_days`
- `FAILED` `tests/test_dasha_api_engine_parity.py::test_unimplemented_dasha_keys_are_rejected`
- `FAILED` `tests/test_dasha_api_engine_parity.py::test_yogini_api_majors_match_pyjhora_and_antardashas_are_proportional`
- `FAILED` `tests/test_day_level_holdout_readiness_ledger.py::test_holdout_readiness_script_keeps_unlabeled_pilot_blocked`
- `FAILED` `tests/test_evidence_packet_index_integrity.py::test_evidence_packet_index_integrity_reports_bad_index`
- `FAILED` `tests/test_evidence_packet_index_integrity.py::test_evidence_packet_index_integrity_validator_passes_current_index`
- `FAILED` `tests/test_external_evidence_reuse_matrix_2026_07_23.py::test_external_evidence_reuse_matrix_separates_code_reuse_from_truth_upgrade`
- `FAILED` `tests/test_external_source_use_tier_registry.py::test_external_source_use_tier_registry_blocks_truth_upgrade`
- `FAILED` `tests/test_external_validation_release_gate.py::test_evaluate_manifest_accepts_current_public_release`
- `FAILED` `tests/test_first_oracle_packet_assistant.py::test_first_oracle_packet_assistant_reports_tajika_front`
- `FAILED` `tests/test_five_track_blocked_progress_dashboard.py::test_five_track_dashboard_summarizes_requested_tracks`
- `FAILED` `tests/test_flexible_birth_time_engine.py::test_api_turns_invalid_birth_time_accuracy_into_bad_request`
- `FAILED` `tests/test_flexible_birth_time_engine.py::test_api_turns_invalid_report_window_into_bad_request`
- `FAILED` `tests/test_frontend_theme_contract.py::test_lightweight_warm_palette_contract`
- `FAILED` `tests/test_full_synastry_dossier.py::test_same_named_special_aspect_uses_directed_angle_not_planet_label`
- `FAILED` `tests/test_generate_blank_oracle.py::test_generate_blank_oracle_writes_first_packet_kit`
- `FAILED` `tests/test_hard_gap_source_hunt_2026_07_23.py::test_hard_gap_source_hunt_does_not_upgrade_oracles`
- `FAILED` `tests/test_interpretation_source_inventory_gate.py::test_interpretation_source_inventory_gate_classifies_full_candidate_pool`
- `FAILED` `tests/test_interpretation_source_priority1_batch1_audit.py::test_priority1_batch1_audit_has_required_shape_and_dispositions`
- `FAILED` `tests/test_jyotish_web_auth.py::test_admin_pages_are_guarded_server_side`
- `FAILED` `tests/test_jyotish_web_auth.py::test_email_login_sends_and_verifies_otp`
- `FAILED` `tests/test_jyotishganit_and_vedicastro_probes.py::test_jyotishganit_field_probe_outputs_raw_hash_and_required_sections`
- `FAILED` `tests/test_jyotishganit_and_vedicastro_probes.py::test_vedicastro_kp_api_probe_is_observation_only_even_when_import_blocks`
- `FAILED` `tests/test_jyotishganit_mismatch_attribution_queue.py::test_comparison_d4_alias_is_not_counted_as_missing_local_schema`
- `FAILED` `tests/test_jyotishganit_vs_local_field_comparison.py::test_jyotishganit_vs_local_comparison_outputs_sign_rows_and_hash`
- `FAILED` `tests/test_jyotishyamitra_adapter_probe.py::test_jyotishyamitra_probe_contract_uses_pinned_identity_fixture`
- `FAILED` `tests/test_kalanala_chakra.py::test_base_rotation_changes_planet_coordinates[参数组 ef35f7391f]`
- `FAILED` `tests/test_kalanala_chakra.py::test_base_rotation_changes_planet_coordinates[参数组 ecbbd66cdc]`
- `FAILED` `tests/test_kota_chakra_external.py::test_all_planets_placed_once_and_birth_basis_changes_anchor`
- `FAILED` `tests/test_kp_api_pinned_raw_capture_readiness.py::test_kp_api_capture_readiness_requires_key_terms_version`
- `FAILED` `tests/test_kp_exact_cusp_invocation_closure.py::test_kp_invocation_closes_exact_cusp_runtime_not_oracle`
- `FAILED` `tests/test_kp_exact_cusp_runtime_oracle_boundary_2026_07_23.py::test_kp_runtime_oracle_boundary_builder_keeps_truth_blocked`
- `FAILED` `tests/test_kp_significator_workflow_gate.py::test_kp_significator_workflow_gate_stays_blocked`
- `FAILED` `tests/test_local_accuracy_report.py::test_local_accuracy_report_markdown_names_skill_gaps_and_command`
- `FAILED` `tests/test_local_accuracy_report.py::test_local_accuracy_report_outputs_machine_readable_baseline`
- `FAILED` `tests/test_local_env.py::test_load_local_env_reads_repo_env_file_without_overriding_explicit_env`
- `FAILED` `tests/test_marriage_match_api.py::test_snapshot_golden_matches_the_engine`
- `FAILED` `tests/test_muhurta_factor_only_scoring_packet.py::test_muhurta_factor_only_packet_generator_captures_ten_factors`
- `FAILED` `tests/test_muhurta_factor_probe.py::test_muhurta_factor_probe_keeps_full_scoring_blocked`
- `FAILED` `tests/test_muhurta_factor_scoring_readiness.py::test_muhurta_readiness_script_outputs_supporting_only_rows`
- `FAILED` `tests/test_muhurta_numeric_candidate_capture_packet.py::test_muhurta_numeric_candidate_capture_packet_selects_only_numeric_muhurta_sources`
- `FAILED` `tests/test_muhurta_oss_worked_example_readiness.py::test_muhurta_oss_readiness_keeps_candidates_below_verdict_threshold`
- `FAILED` `tests/test_narayana_branches_d_20261109.py::test_fixtures_are_fresh`
- `FAILED` `tests/test_narayana_rath_book_tables.py::test_rath_profile_is_the_product_default_and_stays_out_of_rectification_scoring`
- `FAILED` `tests/test_numeric_oracle_gap_queue_v3.py::test_numeric_oracle_gap_queue_v3_blocks_truth_upgrade`
- `FAILED` `tests/test_oracle_benchmark_inventory.py::test_oracle_benchmark_inventory_maps_high_value_assets`
- `FAILED` `tests/test_oracle_closure_master_dashboard.py::test_checked_in_dashboard_matches_current_render_except_timestamp`
- `FAILED` `tests/test_oracle_closure_master_dashboard.py::test_oracle_closure_master_dashboard_aggregates_all_hard_fronts`
- `FAILED` `tests/test_oracle_closure_master_dashboard.py::test_oracle_closure_master_dashboard_markdown_can_be_written`
- `FAILED` `tests/test_oracle_packet_assistant_index.py::test_oracle_packet_assistant_index_aggregates_three_fronts`
- `FAILED` `tests/test_owner_case_purge_contract.py::test_invocation_matrix_paths_counts_hash_and_boundaries`
- `FAILED` `tests/test_prashna_input_contract_and_oracle_queue.py::test_prashna_contract_requires_time_place_timezone_ayanamsa_node`
- `FAILED` `tests/test_prashna_marga_excerpt_locator.py::test_prashna_marga_excerpt_locator_finds_keyword_windows_without_vendoring_text`
- `FAILED` `tests/test_prashna_marga_raw_capture_packet.py::test_prashna_marga_raw_capture_packet_pins_internet_archive_files`
- `FAILED` `tests/test_prashna_sphuta_candidate_replay_readiness.py::test_prashna_sphuta_candidate_replay_parses_expected_values_without_truth_upgrade`
- `FAILED` `tests/test_prashna_sphuta_line_review_queue.py::test_prashna_sphuta_line_review_queue_creates_human_review_tasks`
- `FAILED` `tests/test_prashna_sphuta_mismatch_arbitration.py::test_prashna_sphuta_mismatch_arbitration_records_formula_and_transcription_candidates`
- `FAILED` `tests/test_prashna_sphuta_oss_case_probe.py::test_prashna_sphuta_oss_case_probe_runs_pyjhora_case_in_isolation`
- `FAILED` `tests/test_prashna_sphuta_review_result_template.py::test_prashna_sphuta_review_result_template_is_blank_and_schema_complete`
- `FAILED` `tests/test_prashna_sphuta_review_result_validator.py::test_prashna_sphuta_review_result_validator_blocks_blank_templates`
- `FAILED` `tests/test_prashna_sphuta_source_comparison_matrix.py::test_prashna_sphuta_source_comparison_matrix_classifies_each_field`
- `ERROR` `tests/test_preflight_fragment_scan.py::test_preflight_fragment_scan_emits_repo_cleanup_priorities`
- `ERROR` `tests/test_preflight_fragment_scan.py::test_preflight_fragment_scan_links_pre_work_governance`
- `ERROR` `tests/test_preflight_fragment_scan.py::test_preflight_fragment_scan_preserves_audit_capability_and_oracle_boundaries`
- `ERROR` `tests/test_preflight_fragment_scan.py::test_preflight_fragment_scan_reports_authority_layers_and_risk_buckets`
- `FAILED` `tests/test_prepare_oracle_capture_packets.py::test_prepare_oracle_capture_packets_writes_packets_manifest_and_next_steps`
- `FAILED` `tests/test_public_benchmark_dashboard.py::test_public_benchmark_dashboard_outputs_markdown_and_can_write_file`
- `FAILED` `tests/test_public_benchmark_dashboard.py::test_public_benchmark_dashboard_outputs_stable_json_summary`
- `FAILED` `tests/test_public_worked_example_source_triage.py::test_public_worked_example_source_triage_classifies_web_sources`
- `FAILED` `tests/test_pyjhora_annual_method_reference.py::test_parent_moon_and_ayanamsa_stay_put`
- `FAILED` `tests/test_pyjhora_annual_method_reference.py::test_three_fictional_charts_match_a_second_annual_chart_call`
- `FAILED` `tests/test_pyjhora_multi_case_panchanga_gulika_replay.py::test_pyjhora_multi_case_replay_is_observation_only`
- `FAILED` `tests/test_pyjhora_oracle_artifact_manifest.py::test_pyjhora_oracle_artifact_manifest_reports_current_blackbox_assets`
- `FAILED` `tests/test_pyjhora_shadbala_stdout_parser.py::test_parser_extracts_42_same_unit_component_rows`
- `FAILED` `tests/test_pyjhora_traditional_branch_oracle_boundary.py::test_pyjhora_traditional_boundary_is_matrix_only_not_runtime_copy`
- `FAILED` `tests/test_raman_shadbala_raw_replay_and_input_drift.py::test_raman_shadbala_replay_records_raw_hash_and_blocks_promotion`
- `FAILED` `tests/test_real_case_website_e2e_eval.py::test_real_case_website_e2e_eval_builds_twenty_case_contract`
- `FAILED` `tests/test_reference_only_line_audit_v2.py::test_reference_only_line_audit_v2_blocks_runtime_copy`
- `FAILED` `tests/test_report_chart_blank_columns.py::test_zh_hygiene_is_clean_on_night_and_a_public_chart`
- `FAILED` `tests/test_report_chart_blank_columns.py::test_zh_hygiene_rejects_glued_english`
- `FAILED` `tests/test_report_english_dictionary.py::test_every_han_string_has_an_english_sibling`
- `FAILED` `tests/test_report_motion_and_dasha_start_1674.py::test_vimshottari_balance_row_starts_at_birth`
- `FAILED` `tests/test_report_reader_main.py::test_reader_annual_cells_use_real_distinct_years_and_keep_return_dates`
- `FAILED` `tests/test_report_reader_main.py::test_reader_annual_conflict_keeps_both_real_producer_values`
- `FAILED` `tests/test_report_reader_main.py::test_reader_headings_drop_field_jargon_and_dignity_is_one_name`
- `FAILED` `tests/test_report_reader_main.py::test_reader_main_hides_audit_vocabulary`
- `FAILED` `tests/test_report_upstream_coverage_contract.py::test_every_upstream_topic_is_carried_with_real_content`
- `FAILED` `tests/test_research_governance_docs.py::test_local_drafts_disposition_table_covers_all_june_drafts`
- `FAILED` `tests/test_shadbala_component_arbitration_queue_v2.py::test_shadbala_component_arbitration_queue_v2_is_stable_and_guarded`
- `FAILED` `tests/test_shadbala_component_closure_batch1.py::test_batch1_generator_outputs_row_level_closure_for_priority_components`
- `FAILED` `tests/test_shadbala_component_closure_matrix.py::test_matrix_generator_outputs_42_component_rows`
- `FAILED` `tests/test_shadbala_d3_branch_audit.py::test_shadbala_d3_branch_audit_after_d3_exaltation_cap_retargets_to_own_branch`
- `FAILED` `tests/test_shadbala_d3_branch_audit.py::test_shadbala_d3_branch_audit_points_to_calc_sthana_bala_d3_branch`
- `FAILED` `tests/test_shadbala_d3_branch_audit.py::test_shadbala_d3_exaltation_cap_reduces_sun_sthana_gap_for_synthetic_north_china_case`
- `FAILED` `tests/test_shadbala_d3_branch_audit.py::test_shadbala_d3_own_sign_cap_reduces_venus_sthana_gap_for_synthetic_north_china_case`
- `FAILED` `tests/test_shadbala_d3_mapping_audit.py::test_shadbala_d3_mapping_audit_distinguishes_mapping_vs_dignity_path`
- `FAILED` `tests/test_shadbala_dig_source_of_truth_audit.py::test_checked_in_dig_audit_snapshot_matches_generator_and_is_indexed`
- `FAILED` `tests/test_shadbala_minimum_first.py::test_consultation_chart_summary_carries_the_fields`
- `FAILED` `tests/test_shadbala_oracle_batch_summary.py::test_shadbala_oracle_batch_summary_reuses_all_external_verified_cases`
- `FAILED` `tests/test_shadbala_oracle_component_cluster_summary.py::test_shadbala_component_cluster_summary_reuses_external_verified_cases`
- `FAILED` `tests/test_shadbala_priority_component_arbitration.py::test_priority_arbitration_covers_naisargika_dig_drik`
- `FAILED` `tests/test_shadbala_same_unit_normalizer.py::test_normalizer_outputs_42_same_unit_rows`
- `FAILED` `tests/test_shadbala_sapta_dignity_whitelist.py::test_shadbala_sapta_dignity_whitelist_surfaces_d7_d12_d3_d4_exalted_own_flags`
- `FAILED` `tests/test_shadbala_sapta_layer_hotspots.py::test_shadbala_sapta_layer_hotspots_surfaces_layer_ranking_and_driver_mix`
- `FAILED` `tests/test_shadbala_sthana_targeted_audit.py::test_shadbala_sthana_targeted_audit_identifies_sapta_and_dignity_signals`
- `FAILED` `tests/test_sidereal_nutation_frame.py::test_frozen_pyjhora_difference_stays_inside_the_recorded_bound`
- `FAILED` `tests/test_sidereal_nutation_frame.py::test_legacy_matches_apparent_and_mean_still_drops_nutation`
- `FAILED` `tests/test_skill_truth_overlay_application.py::test_commercial_agent_must_not_promote_reference_only_or_blocked_techniques`
- `FAILED` `tests/test_skill_truth_overlay_application.py::test_skill_truth_overlay_view_preserves_registry_status_as_raw_field`
- `FAILED` `tests/test_supabase_migration_versions.py::test_supabase_migration_versions_are_unique`
- `FAILED` `tests/test_tajika.py::TestSahams::test_tajika_module_exposes_saham_rules_reference_path`
- `FAILED` `tests/test_three_engine_mismatch_progress_ledger.py::test_three_engine_progress_ledger_reports_attributed_node_rows`
- `FAILED` `tests/test_three_engine_owner_track_batch_plan.py::test_three_engine_owner_track_batch_plan_groups_remaining_open_rows`
- `FAILED` `tests/test_three_engine_parity_runner.py::test_public_same_chart_replay_imports_verified_vedastro_artifact`
- `FAILED` `tests/test_three_engine_parity_runner.py::test_public_same_chart_replay_never_promotes_missing_vedastro_raw`
- `FAILED` `tests/test_three_engine_worked_example_bridge.py::test_three_engine_worked_example_bridge_links_owner_tracks_to_intake_domains`
- `FAILED` `tests/test_timeline_boundaries_r3.py::test_annual_package_checks_every_period_table`
- `FAILED` `tests/test_timeline_boundaries_r3.py::test_solar_return_report_separates_real_interval_dasha_year_and_window`
- `FAILED` `tests/test_ul_a7_a10_kp_cusp_same_input_parity_probe.py::test_same_input_parity_probe_generates_local_fields_and_blocks_parity`
- `FAILED` `tests/test_upstream_technique_layers.py::test_dasha_registry_serves_or_fails_closed`
- `FAILED` `tests/test_upstream_technique_layers.py::test_public_chart_values_are_deterministic_and_pinned`
- `FAILED` `tests/test_varga_vedastro_mode.py::test_varga_vedastro_mode_matches_official_golden_case[参数组 8b26911923]`
- `FAILED` `tests/test_varga_vedastro_mode.py::test_varga_vedastro_mode_matches_official_golden_case[参数组 5f65f53ed9]`
- `FAILED` `tests/test_varga_vedastro_mode.py::test_varga_vedastro_mode_matches_official_golden_case[参数组 efa29c6343]`
- `FAILED` `tests/test_varga_vedastro_mode.py::test_varga_vedastro_mode_matches_official_golden_case[参数组 a97e0b78a9]`
- `FAILED` `tests/test_varga_vedastro_mode.py::test_varga_vedastro_mode_matches_official_golden_case[参数组 c104bb2cc9]`
- `FAILED` `tests/test_varga_vedastro_mode.py::test_varga_vedastro_mode_matches_official_golden_case[参数组 909bfc5fae]`
- `FAILED` `tests/test_varga_vedastro_mode.py::test_varga_vedastro_mode_matches_official_golden_case[参数组 a73b69df15]`
- `FAILED` `tests/test_varga_vedastro_mode.py::test_varga_vedastro_mode_matches_official_golden_case[参数组 edb6def5d5]`
- `FAILED` `tests/test_varga_vedastro_mode.py::test_varga_vedastro_mode_matches_official_golden_case[参数组 3e539469ef]`
- `FAILED` `tests/test_vedastro_divisional_longitude_bridge.py::test_vedastro_bridge_divisional_longitudes_match_local_degree_mapping`
- `FAILED` `tests/test_vedastro_gateway.py::test_gateway_completion_archives_official_raw_response`
- `FAILED` `tests/test_vedastro_gateway.py::test_gateway_status_defaults_to_official_first_cn_safe`
- `FAILED` `tests/test_vedastro_reproducible_build_probe.py::test_build_identity_binds_source_and_container_inputs`
- `ERROR` `tests/test_vedastro_service_adapter_executor.py::test_vedastro_official_snapshot_budget_does_not_mask_mock_rest_endpoint`
- `ERROR` `tests/test_vedastro_service_adapter_executor.py::test_vedastro_official_snapshot_skips_bridge_after_runner_consumes_budget`
- `ERROR` `tests/test_vedastro_service_adapter_executor.py::test_vedastro_official_snapshot_stops_when_foreground_budget_is_exhausted`
- `FAILED` `tests/test_vedicastro_kp_tmp_env_preparer.py::test_preparer_supports_report_only_mode_without_project_install`
- `FAILED` `tests/test_vibe_coding_setup.py::test_print_cline_mcp_config_points_at_current_repo_mcp_server`
- `FAILED` `tests/test_worked_example_numeric_packet_eligibility.py::test_worked_example_numeric_packet_eligibility_summarizes_candidate_rows`
- `FAILED` `tests/test_worked_example_packet_intake_plan.py::test_worked_example_packet_intake_plan_groups_candidates_by_domain`
</details>
## 最新 staging 上的验证
工单分支合入 staging `0fd69a1d1a804cfd1c3bbe9860c5b85bed83aa3e`,解决 Bug 历史的文末追加冲突,保留 BUG-1821 和 BUG-1836 至 BUG-1838。合并提交 `6c2027d337f62b7203c959a9a233169075e16f77` 的问卜模块、注册表和测试与原代码提交逐字节相等。该合并提交的 170 项定向测试复跑通过。
本机运行 `scripts/run_quality_gate.py --profile quick`,Python 套件 1,463 项通过、3 项失败、2 项跳过,另有 29 个子测试通过。三项失败均在 `tests/test_upstream_technique_layers.py`,在最新 staging 单独复跑该文件,复现同三个失败。Gitea 的 Python 和前端门禁均通过。
[Gitea 门禁 7188](https://git.copse.top/root/Jyotisha/actions/runs/7188)
@@ -0,0 +1,33 @@
# #189 个人报告状态词执行进度
- 状态 `blocked`,等待指定报告的瑜伽状态数据源
- 任务书 `docs/tasks/TASK-report-status-words-20261111.md`
- 基线 `0fd69a1d1a804cfd1c3bbe9860c5b85bed83aa3e`
## R2 瑜伽状态来源(BUG-1839)
用任务书指定的两张公开盘构建原生报告 packet。第一张盘的 51 条报告瑜伽均为 `formation_assessment.status=candidate`,行上没有 `status` 或 `claim_status`。第二张盘有 51 条同类记录,另外 2 条缺少这三个状态字段。
取第一张盘的同一批报告行,用 `sieve_yogas_for_routes` 的默认领域筛选,再走聊天使用的 `build_yoga_conditions`。将报告行的 `formation_assessment.status` 接到 `yogaStatus()` 的 `formation.status` 后,与聊天侧行分别交给仓库原有的 TypeScript `yogaStatus()`。24 条可按规则对照的聊天计算行全部出现差异,报告侧为 `candidate`,聊天侧为 `computed`。例如 `amala_yoga` 与 `moon_jupiter_kendra_relation`。
需要确认按报告的形成评估显示候选,还是在缺少完整形成结论时采用聊天的检出分组。建议保留完整形成结论的优先级,并明确其余报告行是否采用聊天分组,避免同一检测结果在两个入口产生不同措辞。
## R5 技法审计状态(BUG-1840)
两张公开盘的 `Narayana / Patyayini / Tajika Parity` 行都只有旧状态 `parameter_sensitive`,未提供 `calculation_status` 或 `interpretation_status`。直接调用仓库的 TypeScript `parseTechniqueAuditStatus('parameter_sensitive')` 得到 `blocked / blocked`,显示「计算阻塞,解读不作判断」。任务书要求保留证据标签并以「有条件(parameter_sensitive)」显示该类证据。
需要确认这条旧状态的两轴映射。建议保留原始证据标签,解读轴采用「有条件」,计算轴由验收方指定为已执行、部分执行,或从指定的执行字段读取。严格沿用当前聊天解析会显示两轴均阻塞。
## 本轮范围
两张公开盘只读取状态字段及条数。使用 Node 22 直接调用仓库已有的两套 TypeScript 状态函数。R2 与 R5 命中任务书的停工条件,R9 和 R4b 待恢复后实施。英文状态词翻译继续按任务书留待确认。既有测试断言和业务代码均未改动。
## 补充修复单的数据源核验
补充修复单已确定 R5 的两轴换算。本轮阻塞在 R2 的输入数据。
按公开盘夹具调用 `cmd_full_reading` 与 `build_professional_report_reference_packet`,递归检查两张盘的原生输出与最终报告 packet。两者均没有 `yogas_detail`、`yoga_conditions` 或 `raman_support`。报告可读取的是 `worksheets.advanced_systems.yoga.yogas`。第一张盘 51 条,第二张盘 53 条。这些行没有聊天所用的 `status`、`claim_status`、`formation` 字段,第二张盘另有 2 条连 `formation_assessment` 也没有。
`base.natal.yogas_detail` 在 `frontend/src/lib/consultation-evidence-card.ts` 中由 `local_layers.native_layers.yoga_conditions` 投影生成。该咨询层由 `build_yoga_conditions` 读取 `raman_support.yogas`,筛选分组来自咨询请求的领域。现有报告入口提供独立的完整盘计算,任务书未指定报告应使用哪个咨询领域或怎样取得相同的原始行。
请在修复单中指定报告可直接读取的来源路径,或明确允许复用哪条咨询投影及其领域口径。本轮只记录两张公开盘的字段与条数。
@@ -1,102 +0,0 @@
# TASK:按需计算——问卜盘(必须有提问时刻、地点和问题文本)(#178,BUG-1821,2026-11-11)
- Gitea:#178
- 分支:`codex/ondemand-prashna-20261111`(基于 staging `70e3fda184cfa5a2f52e6d68dcde6c8a9dfa8da2`)
- Bug 编号:BUG-1821(1809–1830 已为这一批预留;开工时核对 `docs/BUG_HISTORY.md` 末尾没有被占用)
- 负责验收:Claude(jyotisha-2a)
- 范围:**只做后端**,写法与 #177(`dasha_date`)相同。
- 新增一个工具文件 `scripts/ondemand_calc/prashna.py`。
- 注册表加两行:import 一行,`TOOLS` 一行。
- 不做前端接入,不建 `frontend/src/mastra/ondemand-tools/`。2026-11-09 旧草稿里的前端部分、“缺口径就 400”和“服务器当前时刻”的写法全部作废。
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因并在 PROGRESS 列出。
## 背景(占星师原话)
- 2026-11-07 Q9:“问卜:`prashna --datetime --question-text --lat --lon --timezone`。”
- 2026-11-08 第五轮第 9 条:“Prashna:必须有提问时刻、地点和问题文本。”
- 第五轮第 5 条:所有日期与盘面必须由原生引擎生成。
## 开工前先读
- `scripts/ondemand_calc/contract.py` 顶部 docstring:信封字段、`blocked` 规则、加工具的步骤。
- `scripts/ondemand_calc/registry.py`、`scripts/ondemand_calc/dasha_date.py`(最新的工具写法)、`varga.py`。
- `tests/test_ondemand_dasha_date_20261111.py`。
## 现状(staging 实测,行号以符号为准)
- 调用入口:`/api/ondemand_calc` → `ondemand_calc.registry.ondemand_calc_api(body)` → `TOOLS[body["tool"]](body)`。本单不改 API 服务器。
- 缺参数时返回 `calculation_status = "blocked"` 的信封(HTTP 200),`gaps` 点名缺项,不是 400。
- `contract.resolve_profile`:岁差必填;`node_mode` / `position_mode` 缺省时用产品默认并写 `profile_source = "product_default_explicit"`;`position_mode = "mean"` 返回 `blocked` + `position_mode_unsupported`。照用,不改。
- `scripts/prashna_context.py` 的 `build_prashna_context(payload)`:
- 必填 `question_text`、`question_timestamp`、`lat`、`lon`、`timezone`;缺项、时区与时间戳偏移冲突、非 wgs84 都抛 `PrashnaContextError`;
- **`ayanamsa` 缺省会落到 `DEFAULT_AYANAMSA_NAME`,`node_mode` 缺省会落到 `mean`**——本工具必须把 `resolve_profile` 得到的值显式传进去,不得依赖这两个缺省;
- 返回 `ascendant`、`planets`、`calculation_contract`、`result_hash`、`supporting_indicators`(Gulika / Sphuta,仅辅助)、`blocked_layers`(Kunda、Prashna verdict)、`boundary`。
- `/api/prashna`(`jyotish_api_server.py::_compute_prashna`)调用同一函数,判词固定为 `blocked`。
## 要做的
工具名 `prashna`(`TOOLS['prashna']`)。
**输入**(全部显式):
- `question_text`:必填,去掉首尾空白后 1–500 字;
- `question_time`:必填,ISO-8601,必须带 UTC 偏移(如 `2026-11-11T20:15:00+08:00`)。不带偏移、格式错、早于 1900 或晚于 2150 都是 `blocked`。**不得用服务器当前时刻补,不得用出生时刻补。**
- `location`:必填,`{lat, lon, timezone}`,`timezone` 为数值 UTC 偏移,且必须与 `question_time` 的偏移一致(不一致 → `blocked`,gap 点名 `timezone_conflict`);
- `ayanamsa`(必填);`node_mode` / `position_mode` 可选,走 `resolve_profile`。
- 请求里出现 `birth`、`planets`、`asc_degree` 任一键:`blocked`,gap `client_chart_or_birth_not_accepted`。**问卜不回退出生地、不接受客户端盘面。**
**缺项**:`question_text_missing` / `question_time_missing` / `location_missing` 逐项列出(同时缺几项就列几项),引擎调用次数为 0。
**输出**(#173 信封):
- `requested`:回显 `question_time`、`location`、`question_text_sha256`、`question_text_length`。**`requested` 和 `raw` 里都不得出现问题原文**,以免进日志。
- `raw`:`ascendant`、`planets`、`calculation_contract`、`result_hash`、`supporting_indicators`、`blocked_layers`、`boundary`、`location_convention`、`question_text_sha256`;外加 `verdict: {"status": "blocked", ...}`,原因照抄 `_compute_prashna` 的那句。不另造判词,不加任何解读字段。
- `input_fingerprint`:包含问题文本的哈希、时刻、地点和口径;任一改变,fingerprint 随之改变。
## 本单专有验收(写进 `tests/test_ondemand_prashna_20261111.py`)
1. 三项各缺一、以及三项全缺:都是 `blocked`,gap 逐项点名;monkeypatch `build_prashna_context` 证明调用次数为 0。
2. 用一个公开时刻和公开地点(合成,不涉及任何人的出生资料)起盘:`ascendant`、`planets`、`result_hash` 与同输入直接调用 `build_prashna_context`(显式传同样的 ayanamsa、node_mode)逐字段相等,也与 `_compute_prashna` 同输入返回的 `prashna_context` 相等。
3. 把整个返回信封 `json.dumps` 后,不含问题原文的任何连续 8 个字符以上的片段;含 `question_text_sha256`。
4. 请求带 `birth` 时为 `blocked`,`client_chart_or_birth_not_accepted`;不会用出生地起盘。
5. `question_time` 不带偏移、或偏移与 `location.timezone` 不一致:`blocked`,gap 点名。
6. 省略 `node_mode` 时,传给 `build_prashna_context` 的 `node_mode` 等于 `resolve_profile` 的产品默认值,且 `profile_source == "product_default_explicit"`;显式传 `true` 交点时,结果与 mean 不同(证明不是被缺省吞掉)。
7. `raw.verdict.status == "blocked"`;`raw.blocked_layers` 原样保留 Kunda 和 Prashna verdict。
## 通用验收(写进同一测试)
1. 缺岁差 → `blocked`,`gaps[0].field` 点名;`position_mode = "mean"` → `blocked` + `position_mode_unsupported`。
2. 同一输入的 `input_fingerprint` 稳定;问题文本、时刻、地点、岁差、交点任一改变,fingerprint 随之改变。
3. 引擎抛错(monkeypatch `build_prashna_context` 抛 `PrashnaContextError` 和普通异常各一次):`blocked`、`raw == {}`,除 `requested` 外没有日期字符串。
4. `ondemand_calc_api({"tool": "prashna", ...})` 能分派到本工具;`transit_date`、`transit_window`、`solar_return`、`varga`、`dasha_date` 原有测试不受影响。
5. 测试只用合成时刻与地点,不写任何真人出生资料或真实提问。
## 不要碰
- `references/**`(占星师方法文件),一字不改,包括 `references/product_runtime_contract.md`。
- `frontend/**` 全部。
- `scripts/prashna_context.py`、`scripts/prashna.py`、`scripts/jyotish_api_server.py`、`scripts/jyotish_engine.py`、`scripts/gulika.py`:只许 import 调用。
- `scripts/ondemand_calc/contract.py`、`__init__.py` 和其他工具文件;`registry.py` 只许做上文两处改动。合同不够用就停下,选“需拍板”。
- 冻结文件只许 import 调用:`scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`、`scripts/narayana_dasha.py`、`scripts/narayana_rath.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`、`scripts/functional_benefics.py`、`scripts/divisional_charts_extended.py`、`scripts/consultation_native_layers.py`。
- 其他未完成单子占用的文件:`scripts/pl9_reader_export.py`、`scripts/report_render/**`(#188–#191)。
## 自测命令(Windows / PowerShell)
- `.venv\Scripts\python -m pytest tests/test_ondemand_prashna_20261111.py tests/test_ondemand_dasha_date_20261111.py tests/test_ondemand_varga_20261109.py tests/test_ondemand_solar_return_20261109.py tests/test_ondemand_transit_window_20261109.py tests/test_ondemand_transit_date_20261109.py tests/test_ondemand_calc_contract_20261109.py tests/test_api_server_growth_contract.py tests/test_python_hygiene_budget.py -q`
- 再跑一次完整 `pytest tests -q --timeout 600`(或能跑的最大范围),把失败名单写进 PROGRESS,并逐条对照 staging 基线。
Python 快速门、前端全量测试和 eslint 由 Claude 在验收时跑。
## 进度记录
写在 `docs/tasks/PROGRESS-ondemand-prashna-20261111.md`:做了什么;跑了哪些命令和通过/失败数;偏离任务书的地方与原因;验收第 2 条的逐字段对照。
`docs/BUG_HISTORY.md` 只在末尾追加 BUG-1821(不得用 sed 改整个文件)。
## 何时停下来选“需拍板”
- `contract.py` 的信封或校验表达不了本工具的输入输出,必须改合同时。
- 要做到显式口径,就必须改 `prashna_context.py` 或其他冻结文件时。
- 任何要输出问卜判词、吉凶或解读的冲动——判词层保持 `blocked`。
- 任务书没写到的任何取舍。
@@ -0,0 +1,225 @@
# TASK:个人报告的状态词——候选瑜伽、两轴状态、功能吉凶口径名(R2 + R5 + R9,2026-11-11)
- Gitea:#189
- 分支:`codex/report-status-words-20261111`(基于 staging `0fd69a1d1a804cfd1c3bbe9860c5b85bed83aa3e`)
- Bug 编号:
- BUG-1839(R2:瑜伽状态)
- BUG-1840(R5:两轴状态)
- BUG-1841(R9:功能吉凶口径名)
- 1836–1845 由 jyotisha-7e 预留;开工时核对 `docs/BUG_HISTORY.md` 末尾。
- 负责验收:Claude(jyotisha-2a)
- 前置:本批第 1 单(措辞与清洗,BUG-1836..1838)已合入 staging。本单要用它新建的 `tests/public_chart_reports.py` 真实报告夹具,以及 `audit_chapters.STATUS` 的状态词。
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因,并在 PROGRESS 里逐条列出。
## 背景(占星师原话与已定规则)
- 第七轮回复 §5 P0.2(2026-11-09)给了 Yoga 状态的唯一映射表,并说“模型和页面必须用同一张表;用 hits 列表不能推断‘成立’”:
| 状态 | 文字 |
|---|---|
| candidate / support_only_candidate | 命中候选规则,尚未完整确认 |
| computed | 检测器检出,待核实 |
| formed_under_source | 按所选来源的形成条件成立;强度和结果另评 |
| conditions_not_met | 所选来源的必要条件未满足 |
| blocked / not_available_yet | 当前无法确认,列明缺项 |
前端已有这张表:`frontend/src/lib/yoga-status-words.ts` 的 `YOGA_STATUS_MAPPING`、`YOGA_BLOCKED_PREFIX`、`yogaStatus()`。Python 报告侧还没有。
- 她的上游报告原文(`yinduzhanxing` 的 `scripts/jyotish_engine.py`,约 :13283):
- 标题是 `## Detected Yoga Candidates`;
- 表头第一列 `Yoga candidate`;
- 表前一句:`These are rule-screened candidates. Strength, cancellation, house context and divisional repetition must be assessed before treating a candidate as operative.`
- `references/interpretation_status_contract.md`:两轴
- `calculation_status`:executed / partial / blocked / not_applicable
- `interpretation_status`:primary / supporting / conditional / observation_only / blocked
- 聊天侧的显示词在 `frontend/src/lib/consultation-technique-audit.ts`:`CALCULATION_LABELS`、`INTERPRETATION_LABELS`、`parseTechniqueAuditStatus`、`techniqueAuditStatusText`(写成“计算X,解读Y”)。
- 第五轮裁定 3:功能吉凶默认口径 `pvr_table30_v1`。显示名见 `frontend/src/lib/consult-answer-preference.ts` 的 `FUNCTIONAL_PROFILE_LABELS`:
- `pvr_table30_v1` → `PVR 表 30`
- `bphs_exception_profile` → `BPHS 例外表`
### 占星师 2026-11-11 回复(yinduzhanxing `1b33aff6`,`handoff/2026-11-11/report_delivery/README.md` Q2)
- 只对瑜伽的形成状态用五行词;其他层用两轴。瑜伽本身也保留两轴,五行是额外的形成状态,不替代执行记录。
- 计算轴:已执行 / 阻塞(写缺什么)/ 不适用(写为何不适用)。**相关但没有运行的,写“未完成”,不得写成“不适用”。**
- 解读轴:显示词之后保留实际证据标签,例如 `有条件(parameter_sensitive)`、`辅助依据(single_system_inference)`。不得只留粗分类词把标签丢掉。同源包装的多次结果不算多体系共识。
- 本单验收标准 5 的正则相应放宽为允许显示词后接 `(原始标签)`;另加一条测试:一个“相关未运行”的技法行显示“未完成”而不是“不适用”。
## 现状(staging 实测,行号以符号为准)
**R2**
- 中文报告把她的“候选”改成了“可用”:
- `## 可用瑜伽`、`### 可用瑜伽总表`、`#### 可用吉性/混合型/凶性瑜伽`;
- 表前没有候选声明;
- 作用说明一栏直接写“长寿;健康良好”“婚姻美满”。
- 改名的位置:
- `scripts/pl9_reader_export.py` 约 :157–158、:164,表在约 :3370–3420;
- `scripts/pl9_full_data_export.py` 约 :477、:989、:995,表在约 :2235–2261;
- `scripts/report_render/appendix_language.py` 约 :75、:146,`yoga_cells` 约 :673;
- `scripts/report_render/upstream_coverage.py` 约 :92。
- `scripts/report_render/reading_facts.py` 的 `_yogas`(约 :474–490)对每一行都写 `"state": "candidate"`,不看行本身的状态。
**R5**:技法审计表(`scripts/report_render/audit_chapters.py` 的 `_audit_rows`)只有一个“状态”格,没有分开写计算与解读两轴。`reading_facts` 也没有两轴字段。
**R9**
- 正文写“这一层用本仓功能吉凶 v2”(`scripts/pl9_interpretation_packs.py` 约 :885),另一处写“功能吉凶用本仓功能吉凶层”(约 :601)。两处都不说用的是哪个口径。
- 审计章的「功能吉凶与自然吉凶」表(`audit_chapters._functional_table`)不写口径名。
- `reading_facts._functional`(约 :268)不带 `functional_profile`。
- 口径值在 packet 的 `worksheets.strengths_and_scores.functional_benefic_malefic.functional_profile`(真实报告附录里能看到 `pvr_table30_v1`)。
## 要改的文件
- 新建 `scripts/report_render/yoga_status_words.py`:Python 侧唯一映射。
- 新建 `scripts/report_render/technique_status_axes.py`:两轴映射与显示词。
- `scripts/report_render/reading_facts.py`:`_yogas`、`_functional`,以及技法状态处加两轴。
- `scripts/report_render/audit_chapters.py`:技法审计表状态格、`_functional_table` 标题或说明。
- `scripts/report_render/appendix_language.py`、`scripts/report_render/upstream_coverage.py`
- `scripts/pl9_reader_export.py`:只改瑜伽标题映射与瑜伽总表那一段,以及 `render_pl9_parity_markdown` 里的内部函数 `_public_report_text`(见“要做的”R4b)。
- `scripts/pl9_full_data_export.py`:只改瑜伽标题映射与 `Applicable Yogas` 那一段。
- `scripts/pl9_interpretation_packs.py`:只改约 :601、:885 两句。
- 测试:
- 新建 `tests/test_report_status_words_20261111.py`;
- 既有测试只按“原值/新值/原因”改。
- `docs/BUG_HISTORY.md`:只在末尾追加三条。
- `docs/tasks/PROGRESS-report-status-words-20261111.md`
## 要做的
### 一、R2:Python 侧唯一映射 + 候选瑜伽
1. `yoga_status_words.py` 内容:
- `YOGA_STATUS_MAPPING`:键、`evidence` 列表、`text`,逐字照抄 TS;
- `YOGA_BLOCKED_PREFIX`、`YOGA_GAP_NO_ROW`、`YOGA_GAP_NOT_EVALUATED`;
- `yoga_status(row, not_evaluated=False) -> (key, text)`:逐条移植 TS 的 `yogaStatus()`。
- 只有 `formation.status`(报告 packet 里是行上的 `formation_assessment.status`,先核对字段名)能给出 `formed_under_source` 或 `conditions_not_met`;
- 其次看 `status` / `claim_status`;
- 找不到状态时一律 blocked,并写明缺什么;
- 不得从“出现在 hits 里”推出“成立”。
2. 新测试读 `frontend/src/lib/yoga-status-words.ts` 源码,解析出 `YOGA_STATUS_MAPPING` 与三个常量,断言和 Python 版逐字相同。TS 文件只读不改。
3. 报告里的瑜伽行要拿到状态:
- 先在 packet 里找到每行的状态来源:`scripts/yoga_engine.py` 约 :1169 写 `formation_assessment`;聊天卡的做法在 `scripts/consultation_native_layers.py` 约 :2131,`hits` → candidate,`ungoverned_detected` → computed。只读参考,不改那个文件。
- 报告 packet 里没有这些字段、要改冻结文件才能拿到时,停下选需拍板。
4. 中文报告:
- 标题“可用瑜伽”一律改成“候选瑜伽”:`## 候选瑜伽`、`### 候选瑜伽总表`、`#### 候选吉性/混合型/凶性瑜伽`。
- 两张瑜伽表都在表前加一句候选声明,译自她的英文原句:`以下是按规则筛出的候选。强度、取消条件、宫位背景与分盘重复都要评估之后,才能把某个候选当作起作用。`
- 两张瑜伽表都加一列“状态”,放在第一列名称之后,文字取 `yoga_status()`。
- “作用说明”一栏照她上游的写法保留,不删、不改写。
5. 英文报告:
- 标题回到她的原文:`Detected Yoga Candidates`,分组标题 `Benefic / Mixed / Malefic Yoga Candidates`;
- 表头第一列 `Yoga candidate`;
- 声明句逐字用她的英文原句;
- 状态列英文不自己译:写原始键(`candidate` 等),并在 PROGRESS 列入待拍板。
6. `reading_facts._yogas`:`state` 改成 `yoga_status()` 的键,另加 `state_text`(映射文字)。不再一律写 candidate。
### 二、R5:技法审计表两轴
1. `technique_status_axes.py` 照搬 `consultation-technique-audit.ts` 的逻辑:
- `CALCULATION_LABELS` / `INTERPRETATION_LABELS`,文字逐字相同;
- `parse_technique_audit_status`:把旧的单一 `status` 换算成两轴,规则与 TS 的 `parseTechniqueAuditStatus` 相同;
- 行上已有 `calculation_status` / `interpretation_status` 时直接用。
新测试读该 TS 文件源码,断言两张显示词表逐字相同,并用同一组输入(至少 used、executed、partial、partial_verified、parameter_sensitive、blocked、not_applicable、`used(候选)`)断言两边换算结果相同。
2. 中文技法审计表的“状态”格改为“计算X,解读Y”,与 `techniqueAuditStatusText` 同一格式。
- 本批第 1 单加的固定说明行(前三格 `—`)保持原样,不加两轴。
- 英文版写 `calculation: …; interpretation: …`,用原始键,不自己译。
3. `reading_facts` 里每个技法状态条目加 `calculation_status` 与 `interpretation_status` 两个字段。原有字段不删。
### 三、R9:功能吉凶写明口径
1. 口径名从 packet 的 `functional_profile` 读,不手写。显示名按 `FUNCTIONAL_PROFILE_LABELS`:
- 在 `technique_status_axes.py` 或单独的小常量里放 Python 版;
- 测试读 `frontend/src/lib/consult-answer-preference.ts` 源码,断言逐字相同。
2. `pl9_interpretation_packs.py` 两句:“本仓功能吉凶 v2”“本仓功能吉凶层”改为写出口径显示名。例如 `功能吉凶按 PVR 表 30(pvr_table30_v1)判定,与自然吉凶分开读。`
3. 审计章「功能吉凶与自然吉凶」表的说明句、技法审计表 `Functional Benefic/Malefic` 一行的说明,都写出口径显示名和原始值。
4. `reading_facts._functional` 加 `functional_profile` 与 `functional_profile_label`。
5. packet 里没有 `functional_profile` 时写“口径未注明”,不要默认填 PVR。
### R4b:`_public_report_text` 里的旧状态映射(#188 验收时发现)
- `scripts/pl9_reader_export.py::render_pl9_parity_markdown` 的内部函数 `_public_report_text`(约 :941)仍是旧映射:`blocked` → `未列`、`parameter_sensitive` → `参考`,用子串替换。两张公开盘目前没走到这条路径,但占星师 2026-11-11 明确要求 blocked 不得降为“未列”、parameter_sensitive 不得降为无条件“参考”。
- 改成引用 #188 建的同一张 `STATUS` 表,并只在独立词上替换(同 #188 的正则边界)。不另建第三份表。
- 测试:给这个函数一段含 `blocked`、`parameter_sensitive`、`functional_profile`、`blocked_step` 的文本,断言前两个按 STATUS 表换、后两个原样。
- 同时:`_strip_pl9_user_engineering_markers` 表里的 `profile_sensitive → 参考` 是 #188 遗留的死条目;本单不改该函数,在 PROGRESS 的待拍板项里列出即可。
## 不要碰
- `references/**` 一字不改。
- `frontend/src/lib/consult-preset-prompts.ts`。
- 以下前端文件只读,用来比对,不改:
- `frontend/src/lib/yoga-status-words.ts`(#183 也在用)
- `frontend/src/lib/consultation-technique-audit.ts`
- `frontend/src/lib/consult-answer-preference.ts`
- 聊天文件 `frontend/src/lib/consultation-*.ts`、`frontend/src/mastra/**`。
- 个人报告解读层(Claude 下一阶段自己做):
- `frontend/src/lib/personal-report-reading.ts`
- `frontend/src/lib/personal-report-reading-contract.ts`
- `frontend/src/mastra/personal-report-reading.ts`
- 冻结文件只许 import:
- `scripts/jyotish_engine.py`、`scripts/jyotish_api_server.py`
- `scripts/yoga_engine.py`、`scripts/consultation_native_layers.py`
- `scripts/narayana_rath.py`、`scripts/narayana_dasha.py`、`scripts/chart_dasha/**`
- `scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`
- `scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`
- `scripts/functional_benefics.py`、`scripts/functional_layer.py`、`scripts/divisional_charts_extended.py`
- 进行中单子的文件:
- #172:`frontend/src/app/api/consult/route.ts`、`frontend/src/mastra/consultation-tools.ts`、`frontend/src/lib/consult/turn-card-cache.ts`
- #177、#178:`scripts/ondemand_calc/**`
- #183:`frontend/src/lib/consultation-answer-fact-guard.ts`
- 本批后两单的地盘,本单不预改:
- `scripts/report_render/opening_guide.py`、`scripts/report_render/parity_chapters.py`、`scripts/narayana_legacy_label.py`、`scripts/shadbala_display.py`
- `reading_facts._narayana`、`reading_facts` 的强度部分
- `audit_chapters` 的 `Strength Layers` 一行
- Mudda 年盘段落
- `frontend/src/lib/report-export-blocks.ts`、`frontend/src/components/personal-report/**`
- 本批第 1 单的清洗函数 `_sanitize_pl9_public_markdown`、`_strip_pl9_user_engineering_markers`:本单不改。
## 验收标准(写进 `tests/test_report_status_words_20261111.py`;真实报告用 `tests/public_chart_reports.py` 的 Jobs 和 Obama)
1. **单一来源**:Python 的 `YOGA_STATUS_MAPPING` 与三个常量、两轴显示词、口径显示名,都和对应 TS 源码逐字相同。
2. **映射行为**:用合成行覆盖五种状态、`formation_assessment.status` 优先、未知状态值、没有状态行,断言 `yoga_status()` 的键和文字。其中“只出现在 hits 里、没有 formation 结果”的行不得给出 `formed_under_source`。
3. **真实报告 R2**:两张盘的中文报告里:
- 没有“可用瑜伽”四个字;
- 有 `## 候选瑜伽` 和 `### 候选瑜伽总表`,两张表前都有候选声明句;
- 每个瑜伽行的状态格都是映射表五种文字之一,blocked 行写明缺项;
- Jobs 报告里“长寿格局”“婚姻格局(7宫主有力)”这两行的状态格不是“按所选来源的形成条件成立…”,除非该行 packet 里 `formation_assessment.status` 真的是 `formed_under_source`,此时 PROGRESS 列出依据。
英文报告有 `Detected Yoga Candidates` 和她的声明原句。
4. **真实报告 R2 reading_facts**:Jobs 的 `reading_facts.yogas` 每行的 `state` 与该行 packet 状态经 `yoga_status()` 算出的键相同。staging 原来是全为 candidate;PROGRESS 列出新的各状态计数。
5. **真实报告 R5**:两张盘中文技法审计表里,除固定说明行外,每行状态格都匹配 `^计算(已执行|部分执行|阻塞|不适用),解读(主要依据|辅助依据|有条件|仅观察|不作判断|不参与)$`。`reading_facts` 的技法状态条目都带两轴字段。
6. **真实报告 R9**:两张盘的中文报告里:
- 没有“本仓功能吉凶”;
- 功能吉凶表说明和 `Functional Benefic/Malefic` 一行说明都含 `PVR 表 30` 和 `pvr_table30_v1`;
- `reading_facts.functional.functional_profile == "pvr_table30_v1"`。
再用合成 packet 删掉 `functional_profile`,断言写的是“口径未注明”。
7. 本批第 1 单的测试 `tests/test_report_wording_sanitizer_20261111.py`、`tests/test_report_no_web_wording_20261109.py` 照样全过。
8. `docs/BUG_HISTORY.md` 末尾追加 BUG-1839、BUG-1840、BUG-1841,格式照最近条目,不写任何出生资料或姓名。BUG-1839 的“关联记录”写 BUG-1824 / BUG-1825(前端同一张表)。
## 自测命令(Windows / PowerShell,在仓库根目录)
- `.venv\Scripts\python -m pytest tests/test_report_status_words_20261111.py tests/test_report_wording_sanitizer_20261111.py tests/test_report_no_web_wording_20261109.py tests/test_report_audit_chapters_20261107.py tests/test_report_reading_facts.py tests/test_neecha_bhanga_conditions.py -q`
- `.venv\Scripts\python -m pytest tests/test_report_full_data_fix.py tests/test_full_data_parity_chapters.py tests/test_full_data_parity_appendix.py tests/test_report_english_edition.py tests/test_full_report_quality_gate.py -q`
- 再跑一次 `.venv\Scripts\python -m pytest tests -q --timeout 600`(或你能跑的最大范围),把失败名单写进 PROGRESS,并注明哪些是 staging 上原本就红的。
前端全量测试、Python 快速门和 eslint 由 Claude 在验收时跑。
## 进度记录
写在 `docs/tasks/PROGRESS-report-status-words-20261111.md`,内容包括:
- 做了什么;
- 跑了哪些命令,通过 / 失败数;
- 改过的既有断言(原值/新值/原因);
- 瑜伽状态来源字段的核对结果;
- 验收第 4 条的计数;
- 偏离任务书的地方;
- 待拍板项(至少含“英文状态词怎么译”)。
`docs/BUG_HISTORY.md` 只在末尾追加,不得用 sed 改整个文件。
## 何时停下来选“需拍板”
- 报告 packet 里的瑜伽行没有可用的状态字段,要改 `yoga_engine.py` 或 `jyotish_engine.py` 才能拿到时。
- 报告的状态字段与聊天卡的字段对不上,按映射表会得出不同的结论时。
- 某个技法的旧状态值在 TS 的换算里没有对应,你需要自己定一个时。
- 加“状态”列以后,前端投影或导出把表格弄坏了时。
- 任务书没写到的任何取舍。
-158
View File
@@ -1,158 +0,0 @@
"""Compute a Prashna chart from an explicit question moment (#178, BUG-1821)."""
from __future__ import annotations
import hashlib
import math
import re
from datetime import datetime
from importlib import import_module
from typing import Any
from . import contract
from .contract import OnDemandInputError
TOOL = 'prashna'
_CONTEXT_FIELDS = (
'ascendant', 'planets', 'calculation_contract', 'result_hash',
'supporting_indicators', 'blocked_layers', 'boundary',
)
_VERDICT_REASON = 'Prashna adjudication is disabled until Tajika/Saham/Sphuta kernels pass classic golden cases.'
_QUESTION_TIME = re.compile(
r'[0-9W-]+[Tt ][0-9]{2}(?::?[0-9]{2}(?::?[0-9]{2})?)?(?:[.,][0-9]+)?'
r'(?:Z|[+-](?:[01][0-9]|2[0-3])(?::?[0-5][0-9](?::?[0-5][0-9](?:[.,][0-9]+)?)?)?)?'
)
def build_prashna_context(payload: dict[str, Any]) -> dict[str, Any]:
# Loading the registry must not load Swiss Ephemeris for other tools.
module = import_module('prashna_context' if __package__ == 'ondemand_calc' else 'scripts.prashna_context')
return module.build_prashna_context(payload)
def _question(body: dict[str, Any], requested: dict, gaps: list[dict]) -> str:
value = body.get('question_text')
text = value.strip() if isinstance(value, str) else ''
if value is None or (isinstance(value, str) and not text):
gaps.append({'field': 'question_text', 'reason': 'question_text_missing'})
return ''
if not text or len(text) > 500:
gaps.append({'field': 'question_text', 'reason': 'question_text_invalid'})
return ''
try:
requested['question_text_sha256'] = hashlib.sha256(text.encode('utf-8')).hexdigest()
except UnicodeEncodeError:
gaps.append({'field': 'question_text', 'reason': 'question_text_invalid'})
return ''
requested['question_text_length'] = len(text)
return text
def _question_moment(value: Any, requested: dict, gaps: list[dict]) -> datetime | None:
if value is None or (isinstance(value, str) and not value.strip()):
gaps.append({'field': 'question_time', 'reason': 'question_time_missing'})
return None
# Require a date and a time, and do not echo arbitrary text on parse failure.
if isinstance(value, str) and _QUESTION_TIME.fullmatch(value):
try:
moment = datetime.fromisoformat(value.replace('Z', '+00:00'))
except ValueError:
pass
else:
requested['question_time'] = value
if moment.utcoffset() is not None and 1900 <= moment.year <= 2150:
return moment
gaps.append({'field': 'question_time', 'reason': 'question_time_invalid'})
return None
def _location(value: Any, requested: dict, gaps: list[dict]) -> dict[str, float]:
if value is None or value == {}:
gaps.append({'field': 'location', 'reason': 'location_missing'})
return {}
if not isinstance(value, dict):
gaps.append({'field': 'location', 'reason': 'location_invalid'})
return {}
location: dict[str, float] = {}
for key in ('lat', 'lon', 'timezone'):
item = value.get(key)
if item is None:
gaps.append({'field': f'location.{key}', 'reason': f'location_{key}_missing'})
continue
try:
if isinstance(item, bool) or (key == 'timezone' and not isinstance(item, (int, float))):
raise ValueError
number = float(item)
if not math.isfinite(number):
raise ValueError
except (TypeError, ValueError, OverflowError):
gaps.append({'field': f'location.{key}', 'reason': f'location_{key}_invalid'})
else:
location[key] = number
requested['location'] = location
return location
def run(body: dict[str, Any]) -> dict[str, Any]:
requested: dict[str, Any] = {}
gaps: list[dict[str, str]] = []
if not isinstance(body, dict):
return contract.blocked_envelope(
tool=TOOL, body=requested, error=OnDemandInputError('body must be an object'),
)
question = _question(body, requested, gaps)
moment = _question_moment(body.get('question_time'), requested, gaps)
location = _location(body.get('location'), requested, gaps)
if any(key in body for key in ('birth', 'planets', 'asc_degree')):
gaps.append({'field': 'input', 'reason': 'client_chart_or_birth_not_accepted'})
if gaps:
return contract.build_envelope(tool=TOOL, requested=requested, profile=None, status='blocked', gaps=gaps)
assert moment is not None
offset = moment.utcoffset()
assert offset is not None
if offset.total_seconds() / 3600 != location['timezone']:
return contract.build_envelope(
tool=TOOL, requested=requested, profile=None, status='blocked',
gaps=[{'field': 'location.timezone', 'reason': 'timezone_conflict'}],
)
try:
common = contract.validate_common({
'date': moment.date().isoformat(), 'lat': location['lat'],
'lon': location['lon'], 'tz': location['timezone'],
})
profile = contract.resolve_profile(body, common)
except OnDemandInputError as error:
field = error.field or 'input'
if field in ('lat', 'lon', 'tz'):
field = 'location.' + ('timezone' if field == 'tz' else field)
missing_ayanamsa = field == 'ayanamsa' and str(error) == 'missing required field: ayanamsa'
return contract.build_envelope(
tool=TOOL, requested=requested, profile=None, status='blocked',
gaps=[{'field': field, 'reason': 'ayanamsa_missing' if missing_ayanamsa else 'invalid_input'}],
)
if profile['position_mode'] not in contract.SUPPORTED_POSITION_MODES:
return contract.build_envelope(
tool=TOOL, requested=requested, profile=profile, status='blocked',
gaps=[{'field': 'position_mode', 'reason': 'position_mode_unsupported'}],
)
try:
context = build_prashna_context({
'question_text': question, 'question_timestamp': requested['question_time'],
**location, 'location_convention': 'wgs84',
'ayanamsa': profile['ayanamsa'], 'node_mode': profile['node_mode'],
})
raw = {key: context[key] for key in _CONTEXT_FIELDS}
raw.update(
location_convention=context['location']['location_convention'],
question_text_sha256=requested['question_text_sha256'],
verdict={'status': 'blocked', 'reason': _VERDICT_REASON},
)
except Exception as error: # Engine errors must not echo the submitted question.
return contract.build_envelope(
tool=TOOL, requested=requested, profile=profile, status='blocked',
gaps=[{'field': 'calculation', 'reason': f'engine_failed:{type(error).__name__}'}],
)
return contract.build_envelope(tool=TOOL, requested=requested, profile=profile, status='executed', raw=raw)
+1 -2
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
from typing import Any, Callable, Dict
from . import dasha_date, prashna, solar_return, transit_date, transit_window, varga
from . import dasha_date, solar_return, transit_date, transit_window, varga
from .contract import OnDemandInputError, blocked_envelope
# 工具名 → run(body) -> envelope
@@ -25,7 +25,6 @@ TOOLS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
# variants / node_mode / position_mode 选填。
'varga': varga.run,
'dasha_date': dasha_date.run,
'prashna': prashna.run, # question_text, question_time, location, ayanamsa
}
__all__ = ['TOOLS', 'OnDemandInputError', 'ondemand_calc_api']
-214
View File
@@ -1,214 +0,0 @@
"""Contract tests for #178 using synthetic questions and a public location."""
from __future__ import annotations
import hashlib
import json
import re
import pytest
from scripts.jyotish_api_server import JyotishAPIHandler
from scripts.ondemand_calc import SCHEMA, ondemand_calc_api, prashna
from scripts.ondemand_calc.contract import PRODUCT_DEFAULT_NODE_MODE
from scripts.prashna_context import PrashnaContextError, build_prashna_context
QUESTION = '虚构问题用于验证输入边界与隐私保护,仅供自动化测试'
MOMENT = '2026-11-11T20:15:00+05:30'
LOCATION = {'lat': 28.6139, 'lon': 77.2090, 'timezone': 5.5}
def _body(**changes):
return {
'tool': 'prashna', 'question_text': f' {QUESTION} ',
'question_time': MOMENT, 'location': dict(LOCATION), 'ayanamsa': 'raman',
**changes,
}
def _assert_blocked(result, *reasons):
assert result['schema'] == SCHEMA
assert result['tool'] == 'prashna'
assert result['calculation_status'] == 'blocked'
assert result['raw'] == {}
assert {gap['reason'] for gap in result['gaps']} == set(reasons)
def _assert_private(result):
serialized = json.dumps(result, ensure_ascii=False, allow_nan=False)
assert all(QUESTION[i:i + 8] not in serialized for i in range(len(QUESTION) - 7))
@pytest.fixture
def no_engine(monkeypatch):
calls = []
def unexpected(payload):
calls.append(payload)
raise AssertionError('validation must complete before calling the engine')
monkeypatch.setattr(prashna, 'build_prashna_context', unexpected)
yield
assert calls == []
@pytest.mark.parametrize('missing', [
('question_text',), ('question_time',), ('location',),
('question_text', 'question_time', 'location'),
])
def test_required_fields_are_reported_together(no_engine, missing):
query = _body()
for key in missing:
del query[key]
result = ondemand_calc_api(query)
_assert_blocked(result, *(f'{key}_missing' for key in missing))
assert {gap['field'] for gap in result['gaps']} == set(missing)
_assert_private(result)
@pytest.mark.parametrize(('moment', 'location'), [
(MOMENT, LOCATION), (MOMENT.replace('+05:30', 'Z'), {**LOCATION, 'timezone': 0}),
])
def test_native_chart_matches_both_existing_entrypoints(moment, location):
result = ondemand_calc_api(_body(
question_time=moment, location={**location, 'note': QUESTION}, note=QUESTION,
node_mode='true', position_mode='apparent',
))
payload = {
'question_text': QUESTION, 'question_timestamp': moment, **location,
'ayanamsa': 'raman', 'node_mode': 'true', 'location_convention': 'wgs84',
}
direct = build_prashna_context(payload)
api = JyotishAPIHandler._compute_prashna(None, payload)
assert result['schema'] == SCHEMA
assert result['calculation_status'] == 'executed' and result['gaps'] == []
assert result['profile']['profile_source'] == 'caller_supplied'
assert result['profile']['time_source'] == 'caller_supplied'
assert result['profile']['ayanamsa'] == 'raman'
assert result['profile']['node_mode'] == 'true'
raw = result['raw']
for key in ('ascendant', 'planets', 'calculation_contract', 'result_hash',
'supporting_indicators', 'blocked_layers', 'boundary'):
assert raw[key] == direct[key] == api['prashna_context'][key], key
assert raw['location_convention'] == direct['location']['location_convention'] == 'wgs84'
assert raw['blocked_layers'] == ['Kunda', 'Prashna verdict']
assert raw['verdict'] == api['verdict']
assert raw['verdict']['status'] == 'blocked'
digest = hashlib.sha256(QUESTION.encode('utf-8')).hexdigest()
assert result['requested'] == {
'question_time': moment, 'location': location, 'question_text_sha256': digest,
'question_text_length': len(QUESTION),
}
assert raw['question_text_sha256'] == digest
_assert_private(result)
@pytest.mark.parametrize('field', ['birth', 'planets', 'asc_degree'])
def test_client_chart_or_birth_is_rejected_by_presence(no_engine, field):
result = ondemand_calc_api(_body(**{field: {'note': QUESTION}}))
_assert_blocked(result, 'client_chart_or_birth_not_accepted')
assert field not in result['requested']
_assert_private(result)
@pytest.mark.parametrize('moment', [
'2026-11-11T20:15:00', '2026-11-11', '2026-11-31T20:15:00+05:30',
'1899-12-31T20:15:00+05:30', '2151-01-01T20:15:00+05:30',
'2026-11-11T20:15:00+04:90', QUESTION,
])
def test_invalid_timestamp_never_reaches_engine(no_engine, moment):
result = ondemand_calc_api(_body(question_time=moment))
_assert_blocked(result, 'question_time_invalid')
assert result['gaps'][0]['field'] == 'question_time'
_assert_private(result)
def test_explicit_timezone_must_match_timestamp(no_engine):
result = ondemand_calc_api(_body(location={**LOCATION, 'timezone': 0}))
_assert_blocked(result, 'timezone_conflict')
assert result['gaps'][0]['field'] == 'location.timezone'
@pytest.mark.parametrize(('location', 'reason'), [
({'lat': 10, 'lon': 20}, 'location_timezone_missing'),
({**LOCATION, 'timezone': '5.5'}, 'location_timezone_invalid'),
({**LOCATION, 'lat': float('nan')}, 'location_lat_invalid'),
({**LOCATION, 'lon': 181}, 'invalid_input'),
])
def test_location_has_only_explicit_valid_coordinates(no_engine, location, reason):
result = ondemand_calc_api(_body(location=location))
_assert_blocked(result, reason)
_assert_private(result)
@pytest.mark.parametrize(('text', 'reason'), [
(' ', 'question_text_missing'), (QUESTION * 50, 'question_text_invalid'),
({'text': QUESTION}, 'question_text_invalid'),
])
def test_question_length_and_type_are_checked_without_echo(no_engine, text, reason):
result = ondemand_calc_api(_body(question_text=text))
_assert_blocked(result, reason)
assert 'question_text_sha256' not in result['requested']
_assert_private(result)
@pytest.mark.parametrize(('updates', 'field', 'reason'), [
({'ayanamsa': None}, 'ayanamsa', 'ayanamsa_missing'),
({'ayanamsa': QUESTION}, 'ayanamsa', 'invalid_input'),
({'position_mode': 'mean'}, 'position_mode', 'position_mode_unsupported'),
])
def test_profile_validation_blocks_without_echo(no_engine, updates, field, reason):
result = ondemand_calc_api(_body(**updates))
_assert_blocked(result, reason)
assert result['gaps'][0]['field'] == field
_assert_private(result)
def test_node_mode_reaches_the_native_engine(monkeypatch):
supplied = []
def record(payload):
supplied.append(payload['node_mode'])
return build_prashna_context(payload)
monkeypatch.setattr(prashna, 'build_prashna_context', record)
default = ondemand_calc_api(_body(position_mode='apparent'))
true = ondemand_calc_api(_body(node_mode='true', position_mode='apparent'))
assert supplied == [PRODUCT_DEFAULT_NODE_MODE, 'true']
assert default['profile']['profile_source'] == 'product_default_explicit'
assert true['profile']['profile_source'] == 'caller_supplied'
assert default['raw']['planets']['Rahu'] != true['raw']['planets']['Rahu']
def test_fingerprint_covers_moment_location_question_and_profile():
def fingerprint(**changes):
result = ondemand_calc_api(_body(**changes))
assert result['calculation_status'] == 'executed'
return result['input_fingerprint']
baseline = fingerprint()
assert baseline == fingerprint(question_text=QUESTION)
for changes in (
{'question_text': QUESTION + '?'}, {'question_time': MOMENT.replace('20:15', '20:16')},
{'location': {**LOCATION, 'lon': LOCATION['lon'] + 0.0000001}},
{'ayanamsa': 'lahiri'}, {'node_mode': 'true'},
):
assert baseline != fingerprint(**changes)
@pytest.mark.parametrize('exception_type', [PrashnaContextError, RuntimeError])
def test_engine_error_discards_partial_results_and_message(monkeypatch, exception_type):
def fail(payload):
raise exception_type(f"{payload['question_text']} {payload['question_timestamp']}")
monkeypatch.setattr(prashna, 'build_prashna_context', fail)
result = ondemand_calc_api(_body())
_assert_blocked(result, f'engine_failed:{exception_type.__name__}')
_assert_private(result)
calculation_fields = {
key: value for key, value in result.items() if key not in {'requested', 'computed_at_utc'}
}
assert not re.search(r'\d{4}-\d{2}-\d{2}', json.dumps(calculation_fields))