Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c0c58fb93 | ||
|
|
6c2027d337 | ||
|
|
a044c04e17 | ||
|
|
b734992076 |
@@ -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`。
|
||||
- 任务书没写到的任何取舍。
|
||||
@@ -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)
|
||||
@@ -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']
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user