Compare commits

...
Author SHA1 Message Date
jesse-ux 6c0c58fb93 docs(task): record #178 staging test comparison
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m57s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m8s
Independent Staging Quality Gate / publish (pull_request) Skipped
Independent Staging Quality Gate / validate-python (push) Successful in 7m22s
Independent Staging Quality Gate / validate-frontend (push) Successful in 4m58s
Independent Staging Quality Gate / publish (push) Successful in 2m38s
2026-11-11 23:07:56 +08:00
jesse-ux 6c2027d337 merge: update #178 to current staging
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m19s
Independent Staging Quality Gate / validate-python (pull_request) Successful in 7m30s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-11 22:37:16 +08:00
jesse-ux a044c04e17 feat(ondemand): add explicit Prashna chart calculation 2026-11-11 22:35:47 +08:00
ClaudeandClaude Opus 5.5 b734992076 docs(task): brief for ondemand-prashna (#178, BUG-1821, 2026-11-11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-11 22:21:01 +08:00
6 changed files with 711 additions and 1 deletions
+14
View File
@@ -24832,3 +24832,17 @@
- 防复发:`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`
@@ -0,0 +1,221 @@
# #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,102 @@
# 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`。
- 任务书没写到的任何取舍。
+158
View File
@@ -0,0 +1,158 @@
"""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)
+2 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
from typing import Any, Callable, Dict
from . import dasha_date, solar_return, transit_date, transit_window, varga
from . import dasha_date, prashna, solar_return, transit_date, transit_window, varga
from .contract import OnDemandInputError, blocked_envelope
# 工具名 → run(body) -> envelope
@@ -25,6 +25,7 @@ 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
@@ -0,0 +1,214 @@
"""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))