diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index dc15e56f..e7329fad 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3818,3 +3818,17 @@ - 防复发:失败路径的诊断价值必须与成功路径持平,二者共用同一个白名单构建入口;对外 schema 是 strict 不能作为放弃全部诊断的理由,只能作为“哪些字段留在服务端”的划线依据。诊断信息的构建不得成为失败事件本身的前置条件。凡新增 settle-and-log 类入口,必须确认它覆盖到最早的失败点,否则失败越早、可观测性越差。 - 相关记录:BUG-255、BUG-214、BUG-256、BUG-257 - 修复版本:本地未提交候选 + +## BUG-259 | 两套路由各说各话:问题文本选路与声明领域不一致时,多领域计划里除一个领域外全部 400 + +- 状态:resolved(本地修复,未提交、未发布) +- 影响面:`POST /api/consultation_workflow` 携带 `plan_version` 的全部产品运行,即 `/api/consult` 个人咨询的每一次领域调用;不带计划元数据的研究 / MCP 调用方(`mcp_server.py` 的 `strict_workflow`、`scripts/consultation_workflow_service.py`)行为不变。 +- 用户现象:staging 综合类咨询(问题“我的事业和财运接下来会怎么走,两者之间该怎么取舍”,模型选择 career + wealth 两个领域)连续四次 `tool.failed code=calculation_failed`,四次完全相同,重试无一次成功;用户只看到兜底文案。同批次里问题文本直接含“事业”而只选事业单领域的运行成功(20936ms,完整回答)。 +- 触发条件:一次运行提交两个及以上领域,且问题文本的关键词命中的领域不等于其中某个领域声明的路由。因为每个领域各发一次 Python 调用却共用同一段问题文本,文本只能选出一个路由,所以多领域计划里至多一个领域能对上,其余每个都必然 400;判定完全确定,故重试逐次复现同一结果。 +- 根因:服务端存在两个互不知情的路由器。`UnifiedConsultationOrchestrator.resolve_route()` 以文本优先:先查显式应期词,再按 `domain_tokens` 关键词匹配,只有文本一无所获时才回落到 `themes` 参数。而 `validate_consultation_plan_contract()` 要求文本推出的路由必须落在 `RouteContract.resolved_routes` 内,每条契约只允许一个路由,不等即抛 `ConsultationPlanContractError`,在 `execute_consultation_workflow()` 里被包成 `BadRequest` → HTTP 400,前端映射为 `workflow_bad_request`,再被 `safeToolError()` 压成 `calculation_failed`。本次现象的落点是词表不对称:“事业”在 career 词表里,“财运”却不在 wealth 词表(`财务/财富/投资/房产/收入`)中,于是 career 与 wealth 两次调用都被文本判成 career,wealth 那次声明 wealth,必然 400。补齐词表只会把矛盾推到下一个问法上——只要一次运行发出多个领域调用,文本选路与声明路由就在结构上不可能同时满足。`frontend/src/lib/consultation-workflow-request.ts` 里 `theme === "timing"` 时给问题加前缀“应期与阶段问题:”,正是为了把“应期”这个词塞进文本让文本路由同意声明路由,是本 bug 只对一个领域打过的绕行补丁;同批 timing 运行成功恰恰因为它带着这个前缀。 +- 修复:让服务端自己签发的声明路由成为权威,两套路由不再可能互相矛盾。`consultation_plan_contract` 新增 `declared_workflow_route()`:只在计划元数据完整、版本受支持、路由在服务端白名单内时返回该路由,缺一即抛;无任何计划元数据时返回 `None`。`execute_consultation_workflow()` 先取声明路由,再以 `resolve_route(question, themes, declared_route=...)` 解析,声明存在即直接返回该路由定义,声明为 `None` 时文本启发式逐字不变——这保证遗留调用方行为不变。路由包新增 `route_source`(`declared_plan` / `question_text`),使响应能自证由哪套路由决定;`routing` 在前端是 `z.record` + passthrough,新增键不破坏契约。执行面同步正确:`question_type` / `primary_theme` / `focus_techniques` 都取声明领域的 `RouteDefinition`,因此 `runtime_planner` 的同步步骤、`consumer_context.route` 的必需层、`machine_evidence_packet`、`real_case_calibration` 以及前端据 `routing.primary_theme` 选取 `thematic_report.themes[primary_theme]` 的领域证据,全部落在声明领域上,不会出现“声明 wealth 却拿到 career 证据”。契约检查保持 fail-closed 且一处未放宽:白名单外的路由在 `declared_workflow_route()` 就被拒(`unsupported consultation workflow route`),根本进不到执行;`themes` 与契约不一致、必需层 / claim boundary / requested domains / 证据类别 / depth / horizon / precision boundary 任一不符仍逐项拒绝;原先那条 `resolved_routes` 检查保留,语义从“两套路由仲裁”变为“断言执行路由确实等于声明路由”。既然声明路由已经权威,timing 前缀所修的 bug 不再存在,故删除:它会把一句合成前缀塞进模型据以作答的问题文本,而它对路由不再有任何作用;核对过前缀不影响 `_build_consumer_context` 的任何领域正则与 `precise_timing_requested` 判定(`应期` 不在这些正则里,timing 路由本身已置该标志),也不影响 prashna 之外的 `question_text` 用途。 +- 验证:新增修复前失败的回归——(1) Python 端复现 run 4:声明 `strict_workflow_route: "wealth"`、问题文本文本路由到 career 时必须成功且执行 wealth 路由(修复前抛 `BadRequest: consultation plan route mismatch`,即线上那个 400);(2) `tests/test_consultation_workflow_domains.py` 补齐它一直缺的计划元数据——十个规范领域各带完整计划、共用同一段文本路由到 career 的问题,逐个断言 `routing.question_type` / `primary_theme` / `consumer_context.route` 等于声明领域,且 `thematic_report.themes[primary_theme]` 就是该领域证据(修复前除 career 外九个全部 400,这正是此前 bug 从未被测试发现的原因:该文件原本不带计划元数据);(3) 编排器直接断言声明路由压过关键词、无声明时文本路由逐字不变、每个规范领域都可作为声明路由执行、未知声明路由必须拒绝而不是静默回落文本;(4) `declared_workflow_route()` 的白名单与完整性断言,含 `strict_workflow_route: "free_script"` 与篡改必需层经 API 仍为 `BadRequest`,确认计划无法夹带不受支持的路由。前端回归改为断言问题文本逐字送达(前缀存在时必然失败)。测试结果:`tests/test_consultation_plan_contract.py`、`tests/test_consultation_workflow_domains.py`、`tests/test_unified_consultation_orchestrator.py`、`tests/test_consultation_consumer_context.py`、`tests/test_api_server_security.py`、`tests/test_mcp_strict_workflow_career.py`、`tests/test_runtime_import_boundaries.py`、`tests/test_historical_event_backtest.py` 共 275 项通过;`scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime` 通过,`ruff` 门禁文件全通过(改动文件相对修复前无新增告警),`py_compile`、`commercial_privacy_artifact_scan`(findings 0)、`python -m build` 均通过;前端 `npx tsc --noEmit` 0 错误、改动文件 `npx eslint` 0 错误、非数据库套件 1664 项中 1660 通过,4 项失败全部是本机并发 Postgres 容器争抢(另有工作树同时在跑数据库测试,本机同时存在 11 个测试用 Postgres 容器),逐个单独重跑后 `onboarding-route`、`rectification-v9-database`、`admin-database`、`identity-auth-integration` 均通过,`model-configuration-security` 的 `database ...` 一项单独重跑仍以 `Connection terminated unexpectedly` 失败,该文件不引用本轮任何改动模块。 +- 待跟进:run 2(问题“请综合说明我当前最值得关注的主题”的那次 `calculation_failed`)不由本机制解释——该文本不含任何领域关键词,每个领域都会回落到自己的 `themes` 并对上,本轮未能找到独立证据说明它为何失败,不认领。同一问题文本的一次失败已在 BUG-257 记为领域数乘单领域耗时超出时钟预算,但本轮没有该次运行的领域数与耗时证据可核对,因此既不视为已解释也不视为复发。另记:领域循环里任一领域抛出即让整次工具调用失败,BUG-257 已有 `omitted_domains` 这条降级披露通道,把单领域失败也接入该通道属独立改动,本轮未做——本次修复消除的是那个确定性的失败源。 +- 防复发:同一个决定必须只有一个权威来源。凡服务端自己签发的受控元数据已经声明了执行参数,就不得再由请求文本的启发式重新推导一遍并要求两者相等——这类“契约允许了服务端不接受的东西”的自伤矛盾在 fail-closed 门禁下必然表现为确定性 400。文本关键词只能作为没有声明时的兜底,且必须在返回值里标明本次由哪套规则决定。为了让文本路由同意声明路由而改写用户问题(如注入关键词前缀)不是修复而是绕行:它只覆盖被打补丁的那一个领域,还会污染模型据以作答的输入,一旦声明路由成为权威必须删除。凡是“一次运行对同一文本发出多次不同领域调用”的形态,测试必须带上产品真实发送的计划元数据并逐领域断言执行路由等于声明领域,否则测试会用一段刚好自洽的文本掩盖矛盾(本 bug 正是如此漏过)。 +- 相关记录:BUG-257、BUG-256、BUG-255 +- 修复版本:本地未提交候选 diff --git a/frontend/src/lib/consultation-workflow-request.ts b/frontend/src/lib/consultation-workflow-request.ts index 1be3cb37..f7770278 100644 --- a/frontend/src/lib/consultation-workflow-request.ts +++ b/frontend/src/lib/consultation-workflow-request.ts @@ -38,9 +38,8 @@ export function projectConsultationWorkflowRequest( plan: ConsultationWorkflowPlanMetadata, ) { const requirement = consultationDomainDefinition(theme); - const prefix = theme === "timing" ? "应期与阶段问题:" : ""; return { - question: `${prefix}${question}`, + question, themes: requirement.workflowThemes, strictWorkflowRoute: requirement.strictWorkflowRoute, requiredLayers: requirement.requiredLayers, diff --git a/frontend/tests/consultation-workflow-request.test.ts b/frontend/tests/consultation-workflow-request.test.ts index b493d348..521e3464 100644 --- a/frontend/tests/consultation-workflow-request.test.ts +++ b/frontend/tests/consultation-workflow-request.test.ts @@ -5,7 +5,7 @@ import { projectConsultationWorkflowRequest } from "../src/lib/consultation-work import { runConsultationWorkflow } from "../src/mastra/index.ts"; import type { ConsultationInput } from "../src/mastra/index.ts"; -test("timing questions use a legal report theme and preserve a timing route hint", () => { +test("timing questions declare their route instead of steering it through the question text", () => { // Given: a public timing consultation question. const question = "未来哪些阶段值得把握?"; @@ -17,8 +17,8 @@ test("timing questions use a legal report theme and preserve a timing route hint }); const request = projectConsultationWorkflowRequest(question, "timing", plan); - // Then: the public theme remains a canonical allowlisted domain with strict route metadata. - assert.equal(request.question, "应期与阶段问题:未来哪些阶段值得把握?"); + // Then: the question reaches the server verbatim and the route travels as plan metadata. + assert.equal(request.question, question); assert.deepEqual(request.themes, ["timing"]); assert.equal(request.strictWorkflowRoute, "timing"); assert.ok(request.requiredLayers.includes("Narayana")); @@ -90,8 +90,8 @@ test("timing input projects only legal private workflow fields", async () => { // Then: private workflow fields are projected without mutating public input. const body = JSON.parse(requestBody); - assert.equal(body.question, "应期与阶段问题:未来哪些阶段值得把握?"); - assert.equal(body.question_text, "应期与阶段问题:未来哪些阶段值得把握?"); + assert.equal(body.question, "未来哪些阶段值得把握?"); + assert.equal(body.question_text, "未来哪些阶段值得把握?"); assert.deepEqual(body.theme, ["timing"]); assert.match(requestBody, /"timing"/); assert.equal(body.plan_version, "consultation-plan-v2"); diff --git a/scripts/consultation_plan_contract.py b/scripts/consultation_plan_contract.py index fb7e16b9..d63a0da5 100644 --- a/scripts/consultation_plan_contract.py +++ b/scripts/consultation_plan_contract.py @@ -121,6 +121,41 @@ class ConsultationPlanContractError(ValueError): """Raised when a versioned product plan does not match the server allowlist.""" +def plan_route_contract(route: Any) -> RouteContract | None: + """Expose the server-owned contract for a route; callers never supply their own.""" + + return _ROUTE_CONTRACTS.get(route) if isinstance(route, str) else None + + +def _declared_route_and_contract(body: dict[str, Any]) -> tuple[str, RouteContract] | None: + present_plan_keys = _PLAN_METADATA_KEYS.intersection(body) + if not present_plan_keys: + return None + missing_plan_keys = _PLAN_METADATA_KEYS.difference(body) + if missing_plan_keys: + missing = ", ".join(sorted(missing_plan_keys)) + raise ConsultationPlanContractError(f"incomplete consultation plan metadata: {missing}") + if body.get("plan_version") != PLAN_VERSION: + raise ConsultationPlanContractError("unsupported consultation plan version") + strict_route = body.get("strict_workflow_route") + contract = plan_route_contract(strict_route) + if contract is None: + raise ConsultationPlanContractError("unsupported consultation workflow route") + return str(strict_route), contract + + +def declared_workflow_route(body: dict[str, Any]) -> str | None: + """Return the allowlisted route a versioned plan declares, before any text heuristic runs. + + The route the product declares is server-issued, so it decides execution instead of + competing with keyword routing. ``None`` keeps question-text routing authoritative for + legacy research/MCP callers that send no plan metadata. + """ + + resolved = _declared_route_and_contract(body) + return None if resolved is None else resolved[0] + + def _require_list(body: dict[str, Any], key: str) -> tuple[str, ...]: value = body.get(key) if not isinstance(value, list) or not value or any(not isinstance(item, str) or not item for item in value): @@ -137,23 +172,15 @@ def validate_consultation_plan_contract( """Validate the optional versioned plan without accepting free-form workflow control. Older research/MCP callers may omit ``plan_version``. Product runtime calls include it - and must match every server-owned route field exactly. + and must match every server-owned route field exactly. ``route_packet`` must already be + resolved from :func:`declared_workflow_route`, so the route check below confirms the + workflow executed the declared route rather than arbitrating between two routers. """ - present_plan_keys = _PLAN_METADATA_KEYS.intersection(body) - if not present_plan_keys: + resolved = _declared_route_and_contract(body) + if resolved is None: return None - missing_plan_keys = _PLAN_METADATA_KEYS.difference(body) - if missing_plan_keys: - missing = ", ".join(sorted(missing_plan_keys)) - raise ConsultationPlanContractError(f"incomplete consultation plan metadata: {missing}") - if body.get("plan_version") != PLAN_VERSION: - raise ConsultationPlanContractError("unsupported consultation plan version") - - strict_route = body.get("strict_workflow_route") - contract = _ROUTE_CONTRACTS.get(strict_route) - if contract is None: - raise ConsultationPlanContractError("unsupported consultation workflow route") + strict_route, contract = resolved if tuple(themes) != contract.themes: raise ConsultationPlanContractError("consultation plan theme mismatch") diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 2515f281..f129cdc5 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -851,19 +851,29 @@ def execute_consultation_workflow( external_parity_gate = validate_manifest( Path(__file__).resolve().parents[1] / 'references/oracle/three_engine_parity_replay_manifest.json' ) - route_packet = _UNIFIED_CONSULTATION_ORCHESTRATOR.resolve_route(question, themes) try: from scripts.consultation_plan_contract import ( ConsultationPlanContractError, apply_plan_precision_boundary, + declared_workflow_route, validate_consultation_plan_contract, ) except ModuleNotFoundError: # pragma: no cover - direct script execution from consultation_plan_contract import ( ConsultationPlanContractError, apply_plan_precision_boundary, + declared_workflow_route, validate_consultation_plan_contract, ) + try: + # A versioned plan declares one domain per call, so the declared route decides + # execution and question-text routing stays for callers that declare nothing. + declared_route = declared_workflow_route(body) + except ConsultationPlanContractError as exc: + raise BadRequest(str(exc)) from exc + route_packet = _UNIFIED_CONSULTATION_ORCHESTRATOR.resolve_route( + question, themes, declared_route=declared_route, + ) try: consultation_plan_contract = validate_consultation_plan_contract( body, themes=themes, route_packet=route_packet, diff --git a/scripts/unified_consultation_orchestrator.py b/scripts/unified_consultation_orchestrator.py index 6dd1957d..7bcab4b3 100644 --- a/scripts/unified_consultation_orchestrator.py +++ b/scripts/unified_consultation_orchestrator.py @@ -249,7 +249,25 @@ class UnifiedConsultationOrchestrator: def normalize_themes(self, raw: Any) -> list[str]: return normalize_consultation_themes(raw) - def resolve_route(self, question: str, themes: list[str] | None = None) -> dict[str, Any]: + def resolve_route( + self, + question: str, + themes: list[str] | None = None, + *, + declared_route: str | None = None, + ) -> dict[str, Any]: + """Resolve the workflow route, preferring an explicitly declared one over the question text. + + ``declared_route`` comes from server-issued plan metadata and is already allowlisted, so it + decides execution: one question asked for several domains would otherwise let keyword + matching answer for at most one of them. Callers that declare nothing keep text routing. + """ + if declared_route is not None: + route = self._ROUTE_DEFINITIONS.get(declared_route) + if route is None: + raise ValueError(f"unknown declared consultation route: {declared_route}") + return self._route_packet(route, source="declared_plan") + text = (question or "").lower() normalized_themes = self.normalize_themes(themes) explicit_timing_tokens = ("when", "timing", "何时", "什么时候", "应期", "几月", "哪月", "哪天", "日期") @@ -301,11 +319,16 @@ class UnifiedConsultationOrchestrator: else: route = self._ROUTE_DEFINITIONS["general"] + return self._route_packet(route, source="question_text") + + @staticmethod + def _route_packet(route: RouteDefinition, *, source: str) -> dict[str, Any]: return { "question_type": route.question_type, "primary_theme": route.primary_theme, "focus_techniques": list(route.focus_techniques), "display_label": route.display_label, + "route_source": source, } def route_profile(self, question: str, themes: list[str] | None = None) -> dict[str, Any]: diff --git a/tests/test_consultation_plan_contract.py b/tests/test_consultation_plan_contract.py index 80c7d546..bef61b67 100644 --- a/tests/test_consultation_plan_contract.py +++ b/tests/test_consultation_plan_contract.py @@ -2,13 +2,35 @@ 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", @@ -203,6 +225,88 @@ def test_blocked_plan_can_only_restrict_evidence_owned_precision_policy(): 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 @@ -211,7 +315,7 @@ def test_api_dry_run_exposes_validated_plan_contract_and_rejects_tampering(): **timing_body(), "dry_run": True, "entry_mode": "direct_chart", - "question": "应期与阶段问题:未来哪些阶段值得把握?", + "question": "未来哪些阶段值得把握?", "theme": ["timing"], "year": 1990, "month": 1, diff --git a/tests/test_consultation_workflow_domains.py b/tests/test_consultation_workflow_domains.py index a4cd719f..12cbccf5 100644 --- a/tests/test_consultation_workflow_domains.py +++ b/tests/test_consultation_workflow_domains.py @@ -6,6 +6,7 @@ from __future__ import annotations import pytest from scripts.consultation_domain_registry import CANONICAL_DOMAINS +from scripts.consultation_plan_contract import plan_route_contract from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler from scripts.report_orchestrator import ( INDEPENDENT_REPORT_THEMES, @@ -98,6 +99,51 @@ def test_complete_consultation_workflow_handles_every_canonical_domain( assert "No general-theme fallback" in domain_report["boundary"] +@pytest.mark.parametrize("domain", CANONICAL_DOMAINS) +def test_versioned_plan_domains_execute_their_declared_route_not_the_question_text_route( + monkeypatch, + domain: str, +) -> None: + """The product runtime sends one question per domain, so text routing agrees with at most one.""" + handler = _handler() + _stub_consultation_runtime(monkeypatch, handler) + contract = plan_route_contract(domain) + assert contract is not None + + result = handler._compute_consultation_workflow({ + "entry_mode": "direct_chart", + "question": "我的事业和财运接下来会怎么走,两者之间该怎么取舍", + "theme": [domain], + "plan_version": "consultation-plan-v2", + "strict_workflow_route": domain, + "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 domain in {"timing", "annual"} else None, + "precision_boundary": "server_evidence_required", + "required_evidence_categories": list(contract.required_evidence_categories), + "year": 1997, + "month": 8, + "day": 8, + "hour": 5, + "minute": 0, + "lat": 36.420487, + "lon": 114.209936, + "tz": 8, + "western_mode": False, + "defer_optional_external_evidence": True, + }) + + primary_theme = result["routing"]["primary_theme"] + assert result["success"] is True + assert result["routing"]["question_type"] == domain + assert primary_theme == domain + assert result["consumer_context"]["route"] == domain + # The consumer reads thematic_report.themes[primary_theme] as this domain's evidence. + assert result["thematic_report"]["themes"][primary_theme]["theme"] == domain + + def test_consultation_thematic_capabilities_are_registry_derived_and_exclude_spirituality() -> None: capabilities = consultation_thematic_capabilities() diff --git a/tests/test_unified_consultation_orchestrator.py b/tests/test_unified_consultation_orchestrator.py index 214dd288..b6f90bcb 100644 --- a/tests/test_unified_consultation_orchestrator.py +++ b/tests/test_unified_consultation_orchestrator.py @@ -226,6 +226,41 @@ def test_unified_consultation_orchestrator_prefers_timing_when_career_question_a assert route["question_type"] == "timing" +def test_declared_route_wins_over_question_text_keywords() -> None: + orchestrator = UnifiedConsultationOrchestrator() + question = "我的事业和财运接下来会怎么走,两者之间该怎么取舍" + + declared = orchestrator.resolve_route(question, ["wealth"], declared_route="wealth") + + assert declared["question_type"] == "wealth" + assert declared["primary_theme"] == "wealth" + assert "D2" in declared["focus_techniques"] + assert declared["route_source"] == "declared_plan" + + +def test_callers_without_a_declared_route_keep_question_text_routing() -> None: + orchestrator = UnifiedConsultationOrchestrator() + question = "我的事业和财运接下来会怎么走,两者之间该怎么取舍" + + text_routed = orchestrator.resolve_route(question, ["wealth"]) + + assert text_routed["question_type"] == "career" + assert text_routed["route_source"] == "question_text" + + +@pytest.mark.parametrize("domain", CANONICAL_DOMAINS) +def test_every_canonical_domain_is_executable_as_a_declared_route(domain: str) -> None: + route = UnifiedConsultationOrchestrator().resolve_route("综合看看", [domain], declared_route=domain) + + assert route["question_type"] == domain + assert route["primary_theme"] == domain + + +def test_unknown_declared_route_is_refused_instead_of_silently_text_routed() -> None: + with pytest.raises(ValueError, match="unknown declared consultation route"): + UnifiedConsultationOrchestrator().resolve_route("事业如何", ["career"], declared_route="free_script") + + def test_runtime_evidence_log_classifies_official_verified_local_fallback_and_blocked() -> None: orchestrator = UnifiedConsultationOrchestrator() route = {"question_type": "career", "primary_theme": "career"}