Files
Jyotisha/tests/test_consultation_plan_contract.py
Jesse_Chen e1db576284
Independent Staging Quality Gate / validate (push) Successful in 14m21s
Independent Staging Quality Gate / publish (push) Has been cancelled
fix(consult): let the declared domain decide the route, not the question text
A staging consultation asked one question about two domains, career and
wealth. The model planned both, and all four attempts failed identically with
calculation_failed. The server resolves the workflow route from the question
text — an explicit-timing check, then keyword domain_tokens, falling back to
the themes argument only when the text yields nothing — while the frontend
declares strict_workflow_route from the domain it chose. The plan contract then
requires the text-derived route to equal the declared one, and each
RouteContract allows exactly one, so the mismatch became
ConsultationPlanContractError, BadRequest, HTTP 400, workflow_bad_request.
Here "事业" is in the career token list and "财运" is not in the wealth one, so
both calls resolved to career and the wealth call was rejected every time.

Widening the token list would only move the contradiction to the next
phrasing. The frontend sends one Python call per domain with the same question
text, so text routing can agree with at most one domain of a multi-domain plan
and every other domain is refused by construction. Now that 1955ba8c caps the
plan at three domains and merges the per-domain packets into one top-level
contract, two- and three-domain plans are expected to work end to end and this
is what stops them.

Make the server-issued declaration authoritative. declared_workflow_route()
returns the route a complete, version-supported, allowlisted plan declares, and
resolve_route() honours it instead of reading the text; a caller that sends no
plan metadata keeps the text heuristics verbatim, so the MCP strict_workflow
tool and the research callers behave exactly as before. The route packet
records which rule decided, because routing now has two legitimate sources.

This is not a way to silence the 400: the whole packet comes from the declared
domain's RouteDefinition, so the sync steps, the consumer_context required
layers, the evidence packet and the frontend's themes[primary_theme] lookup all
land on the domain that was declared. A call declaring wealth can no longer
execute career and label career evidence as wealth. The contract stays
fail-closed — a route off the allowlist is refused before execution, and
themes, layers, boundary, domains, categories, depth, horizon and precision are
still checked one by one. The surviving resolved_routes check changes meaning
rather than going away: it now asserts the workflow executed what was declared.

The timing prefix "应期与阶段问题:" existed only to inject 应期 so the text
router would agree with the declared route for one domain out of ten. With the
declaration authoritative it fixes nothing and still rewrites the question the
model's answer derives from, so it goes. It influences no other server
behaviour: it matches none of the consumer-context domain regexes, and the
timing route already sets precise_timing_requested.

test_consultation_workflow_domains.py deliberately sent no plan metadata, which
is why this was never caught — its per-domain question happened to route to its
own domain. It now sends the real plan for all ten canonical domains behind one
question whose text routes to career; nine of them fail without this change.

Refs BUG-259.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 18:10:00 +08:00

336 lines
12 KiB
Python

from __future__ import annotations
import pytest
from scripts.consultation_domain_registry import CANONICAL_DOMAINS
from scripts.consultation_plan_contract import (
ConsultationPlanContractError,
apply_plan_precision_boundary,
declared_workflow_route,
plan_route_contract,
validate_consultation_plan_contract,
)
def plan_body(route: str, **overrides):
"""Build the metadata the product runtime sends for one declared domain."""
contract = plan_route_contract(route)
assert contract is not None
body = {
"plan_version": "consultation-plan-v2",
"strict_workflow_route": route,
"required_layers": list(contract.required_layers),
"claim_boundary": contract.claim_boundary,
"plan_depth": "standard",
"requested_domains": list(contract.requested_domains),
"timing_horizon": "next_12_months" if route in {"timing", "annual"} else None,
"precision_boundary": "server_evidence_required",
"required_evidence_categories": list(contract.required_evidence_categories),
}
body.update(overrides)
return body
def timing_body(**overrides):
body = {
"plan_version": "consultation-plan-v2",
"strict_workflow_route": "timing",
"required_layers": ["Vimshottari", "Narayana", "Transit", "Varga", "negative holdout gate"],
"claim_boundary": "candidate_day_month_window_only_until_holdout_passes",
"plan_depth": "standard",
"requested_domains": ["timing"],
"timing_horizon": "next_12_months",
"precision_boundary": "precise_timing_blocked",
"required_evidence_categories": ["natal_foundation", "timing", "validation"],
}
body.update(overrides)
return body
def test_versioned_plan_is_validated_against_server_route_allowlist():
contract = validate_consultation_plan_contract(
timing_body(),
themes=["timing"],
route_packet={"question_type": "timing"},
)
assert contract == {
"plan_version": "consultation-plan-v2",
"strict_workflow_route": "timing",
"plan_depth": "standard",
"requested_domains": ["timing"],
"timing_horizon": "next_12_months",
"required_evidence_categories": ["natal_foundation", "timing", "validation"],
"required_layers": ["Vimshottari", "Narayana", "Transit", "Varga", "negative holdout gate"],
"claim_boundary": "candidate_day_month_window_only_until_holdout_passes",
"precision_boundary": "precise_timing_blocked",
"enforcement": "server_allowlist_validated",
}
@pytest.mark.parametrize(
("override", "message"),
[
({"plan_version": "consultation-plan-v1"}, "version"),
({"strict_workflow_route": "free_script"}, "route"),
({"required_layers": ["D1"]}, "required layers"),
({"claim_boundary": "exact_date_allowed"}, "claim boundary"),
({"requested_domains": ["career"]}, "requested domains"),
({"required_evidence_categories": ["natal_foundation"]}, "evidence categories"),
({"plan_depth": "unbounded"}, "depth"),
({"timing_horizon": None}, "requires a horizon"),
({"precision_boundary": "precise_timing_allowed"}, "precision boundary"),
],
)
def test_versioned_plan_rejects_free_form_or_tampered_workflow_control(override, message):
with pytest.raises(ConsultationPlanContractError, match=message):
validate_consultation_plan_contract(
timing_body(**override),
themes=["timing"],
route_packet={"question_type": "timing"},
)
def test_versioned_plan_rejects_theme_and_resolved_route_mismatch():
with pytest.raises(ConsultationPlanContractError, match="theme mismatch"):
validate_consultation_plan_contract(
timing_body(),
themes=["marriage"],
route_packet={"question_type": "timing"},
)
with pytest.raises(ConsultationPlanContractError, match="route mismatch"):
validate_consultation_plan_contract(
timing_body(),
themes=["timing"],
route_packet={"question_type": "career"},
)
@pytest.mark.parametrize(
("route", "required_layers", "claim_boundary", "evidence_categories", "timing_horizon"),
[
(
"health",
["D1", "D6", "D8", "6th/8th houses", "Dasha", "non-medical boundary"],
"wellbeing_pressure_patterns_not_medical_diagnosis",
["natal_foundation", "domain"],
None,
),
(
"education",
["D1", "D24", "5th/9th houses", "Mercury/Jupiter", "Dasha"],
"learning_pattern_and_broad_timing_only",
["natal_foundation", "domain"],
None,
),
(
"migration",
["D1", "D4", "D12", "4th/12th houses", "Dasha", "Narayana"],
"migration_and_home_direction_broad_window_only",
["natal_foundation", "domain"],
None,
),
(
"family",
["D1", "D7", "D12", "4th/5th/9th houses", "Dasha"],
"family_pattern_not_deterministic_event_claim",
["natal_foundation", "domain"],
None,
),
(
"annual",
["D1", "Annual chart boundary", "Dasha", "Transit", "Tajika candidate"],
"annual_report_broad_periods_only",
["natal_foundation", "timing", "validation"],
"next_12_months",
),
],
)
def test_expanded_staging_domains_use_canonical_plan_contracts(
route, required_layers, claim_boundary, evidence_categories, timing_horizon,
):
body = {
"plan_version": "consultation-plan-v2",
"strict_workflow_route": route,
"required_layers": required_layers,
"claim_boundary": claim_boundary,
"plan_depth": "standard",
"requested_domains": [route],
"timing_horizon": timing_horizon,
"precision_boundary": "server_evidence_required",
"required_evidence_categories": evidence_categories,
}
contract = validate_consultation_plan_contract(
body,
themes=[route],
route_packet={"question_type": route},
)
assert contract is not None
assert contract["strict_workflow_route"] == route
assert contract["requested_domains"] == [route]
assert contract["enforcement"] == "server_allowlist_validated"
def test_legacy_research_callers_without_plan_metadata_remain_compatible():
assert validate_consultation_plan_contract(
{"question": "事业如何"},
themes=["career"],
route_packet={"question_type": "career"},
) is None
@pytest.mark.parametrize(
"partial_body",
[
{"strict_workflow_route": "timing"},
{"required_layers": ["Vimshottari"]},
{"claim_boundary": "candidate_day_month_window_only_until_holdout_passes"},
{"plan_version": "consultation-plan-v2"},
],
)
def test_partial_plan_metadata_cannot_downgrade_to_legacy(partial_body):
with pytest.raises(ConsultationPlanContractError, match="incomplete consultation plan metadata"):
validate_consultation_plan_contract(
partial_body,
themes=["career"],
route_packet={"question_type": "timing"},
)
@pytest.mark.parametrize("missing_key", list(timing_body()))
def test_versioned_plan_requires_the_complete_metadata_set(missing_key):
body = timing_body()
body.pop(missing_key)
with pytest.raises(ConsultationPlanContractError, match="incomplete consultation plan metadata"):
validate_consultation_plan_contract(
body,
themes=["timing"],
route_packet={"question_type": "timing"},
)
def test_blocked_plan_can_only_restrict_evidence_owned_precision_policy():
context = {
"answer_policy": {
"can_answer_precise_timing": True,
"should_lead_with_limitations": False,
}
}
blocked = apply_plan_precision_boundary(context, {"precision_boundary": "precise_timing_blocked"})
evidence_owned = apply_plan_precision_boundary(context, {"precision_boundary": "server_evidence_required"})
assert blocked["answer_policy"]["can_answer_precise_timing"] is False
assert blocked["answer_policy"]["should_lead_with_limitations"] is True
assert evidence_owned == context
def test_declared_workflow_route_is_only_read_from_a_complete_allowlisted_plan():
assert declared_workflow_route({"question": "事业如何"}) is None
assert declared_workflow_route(timing_body()) == "timing"
assert declared_workflow_route(plan_body("wealth")) == "wealth"
with pytest.raises(ConsultationPlanContractError, match="incomplete consultation plan metadata"):
declared_workflow_route({"strict_workflow_route": "wealth"})
with pytest.raises(ConsultationPlanContractError, match="version"):
declared_workflow_route(timing_body(plan_version="consultation-plan-v1"))
with pytest.raises(ConsultationPlanContractError, match="unsupported consultation workflow route"):
declared_workflow_route(timing_body(strict_workflow_route="free_script"))
@pytest.mark.parametrize("domain", CANONICAL_DOMAINS)
def test_plan_route_allowlist_covers_every_canonical_domain(domain: str):
contract = plan_route_contract(domain)
assert contract is not None
assert contract.themes == (domain,)
assert domain in contract.resolved_routes
assert plan_route_contract("free_script") is None
def _consultation_body(**overrides):
body = {
"dry_run": True,
"entry_mode": "direct_chart",
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"lat": 25,
"lon": 121,
"tz": 8,
}
body.update(overrides)
return body
def test_declared_route_executes_even_when_question_text_names_another_domain():
"""One question, several domains: text routing can only ever agree with one of them."""
from scripts.jyotish_api_server import JyotishAPIHandler
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
question = "我的事业和财运接下来会怎么走,两者之间该怎么取舍"
wealth = handler._compute_consultation_workflow(_consultation_body(
**plan_body("wealth"), question=question, theme=["wealth"],
))
career = handler._compute_consultation_workflow(_consultation_body(
**plan_body("career"), question=question, theme=["career"],
))
assert wealth["routing"]["question_type"] == "wealth"
assert wealth["routing"]["primary_theme"] == "wealth"
assert "D2" in wealth["routing"]["focus_techniques"]
assert wealth["consultation_plan_contract"]["strict_workflow_route"] == "wealth"
assert career["routing"]["primary_theme"] == "career"
def test_declared_route_cannot_smuggle_a_route_off_the_server_allowlist():
from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
with pytest.raises(BadRequest, match="unsupported consultation workflow route"):
handler._compute_consultation_workflow(_consultation_body(
**plan_body("wealth", strict_workflow_route="free_script"),
question="财运如何",
theme=["wealth"],
))
with pytest.raises(BadRequest, match="theme mismatch"):
handler._compute_consultation_workflow(_consultation_body(
**plan_body("wealth"), question="财运如何", theme=["career"],
))
with pytest.raises(BadRequest, match="required layers mismatch"):
handler._compute_consultation_workflow(_consultation_body(
**plan_body("wealth", required_layers=["D1"]), question="财运如何", theme=["wealth"],
))
def test_api_dry_run_exposes_validated_plan_contract_and_rejects_tampering():
from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
body = {
**timing_body(),
"dry_run": True,
"entry_mode": "direct_chart",
"question": "未来哪些阶段值得把握?",
"theme": ["timing"],
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"lat": 25,
"lon": 121,
"tz": 8,
}
result = handler._compute_consultation_workflow(body)
assert result["routing"]["question_type"] == "timing"
assert result["consultation_plan_contract"]["enforcement"] == "server_allowlist_validated"
with pytest.raises(BadRequest, match="claim boundary mismatch"):
handler._compute_consultation_workflow({**body, "claim_boundary": "exact_date_allowed"})