PMFlow/backend/tests/test_external_query_planner.py

1611 lines
67 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
from types import SimpleNamespace
from datetime import datetime, timezone
import pytest
from app.services.external_query_service.gateway import ExternalQueryGateway
from app.services.external_query_service.research_runtime import ExternalResearchRuntime
from app.services.external_query_service.registry import ExternalProviderRegistry
from app.services.external_query_service.adapters import DashScopeResponsesSearchAdapter
from app.services.external_query_service.adapters.live_search import LiveSearchFallbackAdapter
from app.services.external_query_service.planner import ExternalQueryPlanner
from app.services.external_query_service.presenter import ExternalAnswerPresenter
from app.services.external_query_service.schemas import ExternalAdapterResult, ExternalEvidenceItem, ExternalQueryPlan
from app.services.mcp_config.runtime import DuckDuckGoSearchRuntime
def test_fallback_plan_stays_generic_for_fresh_weather_semantics() -> None:
planner = ExternalQueryPlanner(
llm_service=object(),
db=object(),
tenant_id=1,
)
plan = planner._build_fallback_plan(
question="帮我看看未来5天的广州天气如何",
query_semantics={
"query_type": "status",
"object_mentions": ["广州"],
"query_profile": {
"needs_freshness": True,
"referenced_objects": ["weather"],
},
},
)
assert plan.query_kind == "generic_web"
assert plan.answer_contract == "summary_with_sources"
assert plan.location_hint is None
assert plan.result_limit == 3
def test_fallback_plan_uses_compare_contract_without_object_kind_mapping() -> None:
planner = ExternalQueryPlanner(
llm_service=object(),
db=object(),
tenant_id=1,
)
plan = planner._build_fallback_plan(
question="对比 OpenAI 和 Anthropic 最新模型能力差异",
query_semantics={
"query_type": "compare",
"object_mentions": ["OpenAI", "Anthropic"],
"query_profile": {
"needs_freshness": True,
"referenced_objects": ["public_model"],
},
},
)
assert plan.query_kind == "generic_web"
assert plan.answer_contract == "compare_with_sources"
assert plan.requires_multi_source is True
assert plan.result_limit == 3
def test_duckduckgo_backend_direct_news_search_uses_progressive_time_windows() -> None:
attempts = DuckDuckGoSearchRuntime._search_attempts(search_kind="news")
assert attempts[:3] == [("news", "m"), ("news", "w"), ("news", "d")]
assert ("text", None) in attempts
def test_external_gateway_blocks_off_topic_results_without_subject_entities() -> None:
plan = ExternalQueryPlan(
query_kind="generic_web",
topic="国家医保局 近半年 政策动态",
search_queries=["国家医保局 2026 政策动态"],
freshness_required=True,
)
verifier = ExternalQueryGateway._verify_evidence_bundle(
plan=plan,
evidence=[
ExternalEvidenceItem(
source_type="url",
title="2026 NFL Draft is underway",
target_url="https://example.com/2026-nfl-draft",
snippet="The 2026 NFL Draft started in Pittsburgh.",
)
],
)
assert verifier["ok"] is False
assert verifier["reason_code"] == "external_evidence_off_topic"
def test_external_gateway_accepts_query_relevant_results_without_subject_entities() -> None:
plan = ExternalQueryPlan(
query_kind="generic_web",
topic="国家医保局 近半年 政策动态",
search_queries=["国家医保局 2026 政策动态"],
freshness_required=True,
)
verifier = ExternalQueryGateway._verify_evidence_bundle(
plan=plan,
evidence=[
ExternalEvidenceItem(
source_type="url",
title="国家医保局发布 2026 年医保政策动态",
target_url="https://example.gov.cn/2026-medical-security-policy",
snippet="2026 年国家医保局持续推进医保支付方式改革和医保目录管理。",
)
],
)
assert verifier["ok"] is True
@pytest.mark.asyncio
async def test_live_search_multi_source_executes_and_diversifies_planner_queries() -> None:
called_queries: list[str] = []
async def search_results_callable(*, query: str, max_results: int) -> list[dict[str, str]]:
called_queries.append(query)
prefix = query.split()[0].lower()
return [
{"title": f"{prefix} exact result", "url": f"https://example.com/{prefix}-1"},
{"title": f"{prefix} secondary result", "url": f"https://example.com/{prefix}-2"},
][:max_results]
async def fetch_text_callable(url: str) -> str:
return f"{url} official product update with adoption signal and user feedback."
def chunk_text_callable(text: str, **kwargs) -> list[str]:
_ = kwargs
return [text]
def score_search_result_callable(**kwargs) -> float:
title = str(kwargs.get("title") or "")
return 0.9 if "exact" in title else 0.4
def score_chunk_callable(**kwargs) -> float:
content = str(kwargs.get("content") or "")
return 0.95 if "adoption signal" in content else 0.1
plan = ExternalQueryPlan(
query_kind="generic_web",
topic="AI meeting assistant market",
search_queries=[
"vendor updates",
"analyst summaries",
"app store reviews",
],
result_limit=3,
requires_multi_source=True,
)
result = await LiveSearchFallbackAdapter(
search_results_callable=search_results_callable,
fetch_text_callable=fetch_text_callable,
chunk_text_callable=chunk_text_callable,
score_search_result_callable=score_search_result_callable,
score_chunk_callable=score_chunk_callable,
max_pages=3,
).execute(plan)
assert called_queries == ["vendor updates", "analyst summaries", "app store reviews"]
assert [item.raw_metadata["query"] for item in result.evidence] == [
"vendor updates",
"analyst summaries",
"app store reviews",
]
@pytest.mark.asyncio
async def test_external_gateway_times_out_one_adapter_and_continues_to_next() -> None:
plan = ExternalQueryPlan(
query_kind="generic_web",
topic="国家医保局 近半年 政策动态",
search_queries=["国家医保局 近半年 政策动态"],
freshness_required=True,
result_limit=3,
requires_multi_source=True,
)
class _Planner:
async def build_plan(self, *, question: str, query_semantics: dict | None = None):
_ = (question, query_semantics)
return plan
class _SlowAdapter:
adapter_name = "slow_external_provider"
supported_query_kinds = {"generic_web"}
async def execute(self, current_plan: ExternalQueryPlan) -> ExternalAdapterResult:
_ = current_plan
await asyncio.sleep(1.1)
return ExternalAdapterResult(adapter_name=self.adapter_name, status="empty", evidence=[])
class _FallbackAdapter:
adapter_name = "bounded_live_search"
supported_query_kinds = {"generic_web"}
async def execute(self, current_plan: ExternalQueryPlan) -> ExternalAdapterResult:
_ = current_plan
return ExternalAdapterResult(
adapter_name=self.adapter_name,
status="success",
evidence=[
ExternalEvidenceItem(
source_type="url",
source_label="国家医保局",
title="2026 年国家医保局政策动态",
target_url="https://example.gov.cn/2026-medical-security-policy",
snippet="2026 年国家医保局发布近半年政策动态。",
retrieved_at="2026-05-17T00:00:00+00:00",
freshness_score=0.9,
authority_score=0.9,
grounded_fields=["snippet"],
)
],
)
execution = await ExternalQueryGateway(
planner=_Planner(),
registry=ExternalProviderRegistry(adapters=[_SlowAdapter(), _FallbackAdapter()]),
adapter_timeout_seconds=1.0,
).execute(question="国家医保局 近半年 政策动态")
assert execution.adapter_name == "bounded_live_search"
assert execution.meta["adapter_attempts"][0]["status"] == "timeout"
assert execution.meta["fallback_used"] is True
assert execution.verifier["ok"] is True
@pytest.mark.asyncio
async def test_external_research_runtime_fans_out_and_returns_before_slow_provider() -> None:
plan = ExternalQueryPlan(
query_kind="generic_web",
topic="AI briefing assistant public evidence",
search_queries=["AI briefing assistant public evidence"],
result_limit=2,
requires_multi_source=False,
)
class _Planner:
async def build_plan(self, *, question: str, query_semantics: dict | None = None, fit_brief: dict | None = None):
_ = (question, query_semantics, fit_brief)
return plan, {"planner": "test"}
class _SlowAdapter:
adapter_name = "slow_dashscope_like"
supported_query_kinds = {"generic_web"}
async def execute(self, current_plan: ExternalQueryPlan) -> ExternalAdapterResult:
_ = current_plan
await asyncio.sleep(0.5)
return ExternalAdapterResult(adapter_name=self.adapter_name, status="empty", evidence=[])
class _FastAdapter:
adapter_name = "fast_search"
supported_query_kinds = {"generic_web"}
async def execute(self, current_plan: ExternalQueryPlan) -> ExternalAdapterResult:
_ = current_plan
return ExternalAdapterResult(
adapter_name=self.adapter_name,
status="success",
evidence=[
ExternalEvidenceItem(
source_type="url",
source_label="Example",
title="AI briefing assistant public evidence",
target_url="https://example.com/ai-briefing-assistant",
snippet="AI briefing assistant public evidence and product workflow notes.",
freshness_score=0.8,
authority_score=0.7,
)
],
)
started = asyncio.get_running_loop().time()
execution = await ExternalResearchRuntime(
planner=_Planner(),
registry=ExternalProviderRegistry(adapters=[_SlowAdapter(), _FastAdapter()]),
adapter_timeout_seconds=1.0,
min_verified_sources=1,
).execute(question="AI briefing assistant public evidence")
elapsed = asyncio.get_running_loop().time() - started
assert elapsed < 0.45
assert execution.adapter_name == "fast_search"
assert execution.verifier["ok"] is True
assert execution.meta["research_runtime"] == "parallel_external_research.v1"
@pytest.mark.asyncio
async def test_dashscope_responses_adapter_maps_web_search_results_to_external_evidence(monkeypatch) -> None:
calls: list[dict[str, object]] = []
class FakeResponse:
status_code = 200
text = "{}"
def json(self):
return {
"id": "resp-test",
"model": "qwen3.6-plus",
"output_text": "2026 年 AI Agent 市场仍在增长。",
"search_info": {
"search_results": [
{
"title": "2026 AI Agent market report",
"url": "https://example.com/2026-agent-market",
"snippet": "2026 report says agent adoption increased.",
"date": "2026-05-01",
}
]
},
}
def raise_for_status(self):
return None
class FakeClient:
def __init__(self, *args, **kwargs):
_ = (args, kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def post(self, *args, **kwargs):
_ = args
calls.append(dict(kwargs.get("json") or {}))
return FakeResponse()
async def fake_log(*args, **kwargs):
_ = (args, kwargs)
adapter = DashScopeResponsesSearchAdapter(
api_key="sk-test",
model="qwen3.6-plus",
client_factory=lambda: FakeClient(),
)
monkeypatch.setattr(adapter, "_log_ai_call", fake_log)
plan = ExternalQueryPlan(
query_kind="market",
topic="AI Agent market 2026",
search_queries=["AI Agent market 2026 report"],
freshness_required=True,
result_limit=3,
)
result = await adapter.execute(plan)
assert result.status == "success"
assert calls[0]["model"] == "qwen3.6-plus"
assert calls[0]["tools"] == [{"type": "web_search"}]
assert calls[0]["search_options"] == {"forced_search": True, "freshness": 30}
assert calls[0]["enable_thinking"] is True
assert result.evidence[0].target_url == "https://example.com/2026-agent-market"
assert result.evidence[0].raw_metadata["provider"] == "dashscope_responses"
@pytest.mark.asyncio
async def test_dashscope_responses_adapter_maps_web_search_call_sources(monkeypatch) -> None:
class FakeResponse:
status_code = 200
text = "{}"
def json(self):
return {
"id": "resp-test",
"model": "qwen3.6-plus",
"output": [
{
"type": "web_search_call",
"action": {
"query": "Web search",
"sources": [
{"type": "url", "url": "https://www.elysee.fr/en/"},
],
},
},
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "法国现任总统是 Emmanuel Macron。",
}
],
},
],
"usage": {"x_tools": {"web_search": {"count": 1}}},
}
def raise_for_status(self):
return None
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def post(self, *args, **kwargs):
_ = (args, kwargs)
return FakeResponse()
async def fake_log(*args, **kwargs):
_ = (args, kwargs)
adapter = DashScopeResponsesSearchAdapter(
api_key="sk-test",
model="qwen3.6-plus",
client_factory=lambda: FakeClient(),
)
monkeypatch.setattr(adapter, "_log_ai_call", fake_log)
result = await adapter.execute(
ExternalQueryPlan(
query_kind="generic_web",
topic="法国现任总统是谁?",
freshness_required=True,
result_limit=2,
)
)
assert result.status == "success"
assert result.meta["tool_usage"] == {"web_search": {"count": 1}}
assert result.evidence[0].target_url == "https://www.elysee.fr/en/"
assert "Emmanuel Macron" in result.evidence[0].snippet
@pytest.mark.asyncio
async def test_dashscope_responses_adapter_does_not_copy_synthesized_answer_to_multiple_sources(monkeypatch) -> None:
class FakeResponse:
status_code = 200
text = "{}"
def json(self):
return {
"id": "resp-test",
"model": "qwen3.6-plus",
"output": [
{
"type": "web_search_call",
"action": {
"query": "Web search",
"sources": [
{"type": "url", "url": "https://example.com/a"},
{"type": "url", "url": "https://example.com/b"},
],
},
},
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "这是模型综合答案,不是任一单独 URL 的网页摘要。",
}
],
},
],
"usage": {"x_tools": {"web_search": {"count": 1}}},
}
def raise_for_status(self):
return None
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def post(self, *args, **kwargs):
_ = (args, kwargs)
return FakeResponse()
adapter = DashScopeResponsesSearchAdapter(
api_key="sk-test",
model="qwen3.6-plus",
client_factory=lambda: FakeClient(),
)
async def fake_log(*args, **kwargs):
_ = (args, kwargs)
monkeypatch.setattr(adapter, "_log_ai_call", fake_log)
result = await adapter.execute(
ExternalQueryPlan(
query_kind="generic_web",
topic="行业趋势",
freshness_required=True,
result_limit=2,
)
)
assert result.status == "success"
assert len(result.evidence) == 2
assert all("模型综合答案" not in item.snippet for item in result.evidence)
@pytest.mark.asyncio
async def test_build_plan_prepends_freshness_queries_for_market_research() -> None:
class _FakeLLM:
async def generate_structured_object(self, **kwargs):
_ = kwargs
return SimpleNamespace(
payload={
"query_kind": "market",
"freshness_required": True,
"topic": "短剧行业趋势",
"subject_entities": ["短剧"],
"search_queries": ["中国短剧行业2024年市场规模与增长趋势"],
}
)
planner = ExternalQueryPlanner(
llm_service=_FakeLLM(),
db=object(),
tenant_id=1,
)
current_year = datetime.now(timezone.utc).year
previous_year = current_year - 1
plan, meta = await planner.build_plan(
question="你帮我分析下短剧行业的行业趋势",
query_semantics={
"query_profile": {"needs_freshness": True},
"object_mentions": ["短剧"],
},
)
assert meta["planner"] == "llm_structured"
assert plan.freshness_required is True
assert str(current_year) in plan.search_queries[0]
assert str(previous_year) in plan.search_queries[0]
assert "中国短剧行业2024年市场规模与增长趋势" in plan.search_queries
def test_external_query_gateway_blocks_stale_market_evidence_when_freshness_required() -> None:
plan = ExternalQueryPlan(
query_kind="market",
answer_contract="summary_with_sources",
freshness_required=True,
topic="短剧行业趋势",
subject_entities=["短剧"],
search_queries=["短剧行业趋势 2026 2025 最新 数据 报告"],
)
verifier = ExternalQueryGateway._verify_evidence_bundle(
plan=plan,
evidence=[
ExternalEvidenceItem(
source_type="url",
source_label="2024年微短剧行业白皮书",
title="2024年微短剧行业白皮书",
target_url="https://example.com/short-drama-2024",
snippet="2024年微短剧行业白皮书显示,短剧用户规模增长。",
retrieved_at=f"{datetime.now(timezone.utc).year}-05-09T00:00:00+00:00",
)
],
)
assert verifier["ok"] is False
assert verifier["reason_code"] == "external_evidence_stale"
assert verifier["missing_fields"] == ["fresh_external_evidence"]
def test_external_query_gateway_accepts_recent_market_evidence_when_freshness_required() -> None:
current_year = datetime.now(timezone.utc).year
plan = ExternalQueryPlan(
query_kind="market",
answer_contract="summary_with_sources",
freshness_required=True,
topic="短剧行业趋势",
subject_entities=["短剧"],
search_queries=[f"短剧行业趋势 {current_year} 最新 数据 报告"],
)
verifier = ExternalQueryGateway._verify_evidence_bundle(
plan=plan,
evidence=[
ExternalEvidenceItem(
source_type="url",
source_label=f"{current_year}年微短剧行业数据报告",
title=f"{current_year}年微短剧行业数据报告",
target_url=f"https://example.com/short-drama-{current_year}",
snippet=f"{current_year}年短剧行业数据报告显示,行业进入精细化运营阶段。",
)
],
)
assert verifier["ok"] is True
assert verifier["fresh_years"] == [current_year]
@pytest.mark.asyncio
async def test_build_plan_threads_fit_brief_guidance_into_prompt_and_plan() -> None:
class _FakeLLM:
def __init__(self) -> None:
self.prompt = ""
async def generate_structured_object(self, **kwargs):
self.prompt = str(kwargs.get("prompt") or "")
return SimpleNamespace(
payload={
"topic": "Responses API 适配 PMFlow",
"search_queries": ["OpenAI Responses API project management workflow"],
}
)
fake_llm = _FakeLLM()
planner = ExternalQueryPlanner(
llm_service=fake_llm,
db=object(),
tenant_id=1,
)
fit_brief = {
"project_summary": "当前项目:PMFlow AI 管家,聚焦项目内问答与需求流转。",
"repo_summary": "当前仓库:FastAPI 后端 + React 前端。",
"current_capabilities": ["AI 管家", "智能配置"],
"constraints": ["只允许基于外部公开证据给出推荐。"],
"recommendation_focus": ["判断 Responses API 是否适合当前项目接入"],
}
plan, meta = await planner.build_plan(
question="这个项目适不适合接 OpenAI Responses API?",
query_semantics={
"query_profile": {"needs_freshness": True},
"object_mentions": ["OpenAI Responses API"],
},
fit_brief=fit_brief,
)
assert meta["planner"] == "llm_structured"
assert "PMFlow AI 管家" in fake_llm.prompt
assert "智能配置" in fake_llm.prompt
assert plan.fit_brief_summary
assert plan.project_scope_hint == "current_project_and_repo"
assert "适合当前项目接入" in str(plan.recommendation_goal)
assert "AI 管家" in plan.project_object_anchors
def test_fallback_plan_builds_concrete_signal_source_recommendation_queries() -> None:
planner = ExternalQueryPlanner(
llm_service=object(),
db=object(),
tenant_id=1,
)
plan = planner._build_fallback_plan(
question="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。",
query_semantics={
"query_profile": {"needs_freshness": True},
},
fit_brief={
"project_summary": "当前项目:#81 测试产能警告 (status=active)",
"recommendation_focus": ["给测试产能警告推荐最值得先看的外部信号源。"],
"signal_source_objectives": [
"设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据",
"实现产能警告触发服务:实时比对数据与阈值",
],
},
)
joined_queries = " ".join(plan.search_queries[:2])
assert plan.search_queries[0].startswith("测试产能警告 设计产能监控逻辑")
assert "测试队列与资源使用数据" in joined_queries
assert "实时比对数据与阈值" in joined_queries
assert "外部信号源" not in joined_queries
assert "发布变更" not in joined_queries
assert "工单" not in joined_queries
def test_fallback_plan_preserves_structured_interaction_level() -> None:
planner = ExternalQueryPlanner(
llm_service=object(),
db=object(),
tenant_id=1,
)
plan = planner._build_fallback_plan(
question="这个项目我想先补外部东西,你先帮我列几个最值得看的。",
query_semantics={
"query_profile": {
"needs_freshness": True,
"interaction_level": "recommendation",
},
"interaction_level": "recommendation",
},
fit_brief={
"project_summary": "当前项目:自动开票系统,目标是在收到开票请求后自动开票并发送发票图片。",
"recommendation_focus": ["给自动开票系统推荐最值得先看的外部信号源。"],
"signal_source_objectives": [
"处理开票请求:收到接口请求后自动开票",
"发送发票图片:把结果发给客户",
],
},
)
assert plan.interaction_level == "recommendation"
def test_fallback_plan_extracts_explicit_project_binding_from_fit_brief() -> None:
planner = ExternalQueryPlanner(
llm_service=object(),
db=object(),
tenant_id=1,
)
plan = planner._build_fallback_plan(
question="把我们的项目产品和宝宝树对比下",
query_semantics={
"query_type": "compare",
"object_mentions": ["童析—学龄前儿童行为特质AI评估助手", "宝宝树"],
},
fit_brief={
"project_summary": "当前项目:#141 童析—学龄前儿童行为特质AI评估助手 (status=active)",
},
)
assert plan.project_binding == 141
assert plan.project_title == "童析—学龄前儿童行为特质AI评估助手"
def test_fallback_plan_builds_store_inventory_recommendation_queries_from_project_objectives() -> None:
planner = ExternalQueryPlanner(
llm_service=object(),
db=object(),
tenant_id=1,
)
plan = planner._build_fallback_plan(
question="门店库存异常日报助手如果想更早发现异常,先看哪几类外部信号最值?",
query_semantics={
"query_profile": {
"needs_freshness": True,
"interaction_level": "recommendation",
},
"interaction_level": "recommendation",
},
fit_brief={
"project_summary": "当前项目:门店库存异常日报助手,目标是每日自动汇总门店库存异常并发给区域经理。",
"recommendation_focus": ["给门店库存异常日报助手推荐最值得先看的外部信号源。"],
"signal_source_objectives": [
"汇总门店库存异常:识别缺货、库存跳变和异常门店分布",
"推送区域经理日报:把高风险异常按区域维度发给负责人",
],
},
)
joined_queries = " ".join(plan.search_queries[:3])
assert plan.interaction_level == "recommendation"
assert plan.search_queries[0].startswith("门店库存异常日报助手 汇总门店库存异常")
assert "缺货" in joined_queries
assert "库存跳变" in joined_queries
assert "区域经理" in joined_queries
assert "外部信号源" not in joined_queries
@pytest.mark.asyncio
async def test_build_plan_signal_source_recommendation_keeps_concrete_seed_queries_ahead_of_generic_model_query() -> None:
class _FakeLLM:
async def generate_structured_object(self, **kwargs):
_ = kwargs
return SimpleNamespace(
payload={
"topic": "测试产能警告外部信号源推荐",
"search_queries": ["production capacity warning system external data sources industry best practices"],
}
)
planner = ExternalQueryPlanner(
llm_service=_FakeLLM(),
db=object(),
tenant_id=1,
)
plan, meta = await planner.build_plan(
question="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。",
query_semantics={
"query_profile": {"needs_freshness": True},
},
fit_brief={
"project_summary": "当前项目:#81 测试产能警告 (status=active)",
"recommendation_focus": ["给测试产能警告推荐最值得先看的外部信号源。"],
"signal_source_objectives": [
"设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据",
"实现产能警告触发服务:实时比对数据与阈值",
],
},
)
assert meta["planner"] == "llm_structured"
joined_queries = " ".join(plan.search_queries[:3])
assert plan.search_queries[0].startswith("测试产能警告 设计产能监控逻辑")
assert "测试队列与资源使用数据" in joined_queries
assert "实时比对数据与阈值" in joined_queries
assert "production capacity warning system external data sources industry best practices" in plan.search_queries
@pytest.mark.asyncio
@pytest.mark.parametrize(
("retrieval_status", "expected_variant", "expected_phrase"),
[
("verifier_blocked", "external_verifier_blocked", "没有通过核验"),
("no_relevant_results", "external_no_relevant_results", "没有找到足够相关的外部公开证据"),
("retrieval_failed", "external_retrieval_failed", "外部检索这次执行失败"),
],
)
async def test_presenter_distinguishes_external_failure_states(
retrieval_status: str,
expected_variant: str,
expected_phrase: str,
) -> None:
class _FakeLLM:
def __init__(self) -> None:
self.calls = 0
async def generate(self, *args, **kwargs):
_ = (args, kwargs)
self.calls += 1
return "should not be used"
fake_llm = _FakeLLM()
presenter = ExternalAnswerPresenter(
llm_service=fake_llm,
db=object(),
tenant_id=1,
)
result = await presenter.present(
question="这个项目适不适合接某个外部能力?",
context_packet={
"question": "这个项目适不适合接某个外部能力?",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "外部能力适配判断",
},
"adapter_attempts": [{"adapter": "live_search", "status": "empty"}],
"verified_evidence": [],
"verifier": {"ok": False, "reason_code": "external_evidence_off_topic"},
"uncertainties": ["external_evidence_off_topic"],
"retrieval_status": retrieval_status,
"impact_status": "no_direct_impact",
},
thinking_mode="normal",
)
assert result["prompt_variant_id"] == expected_variant
assert expected_phrase in result["answer_text"]
assert fake_llm.calls == 0
@pytest.mark.asyncio
async def test_presenter_distinguishes_indirect_only_without_overclaiming_direct_match() -> None:
class _FakeLLM:
def __init__(self) -> None:
self.calls = 0
async def generate(self, *args, **kwargs):
_ = (args, kwargs)
self.calls += 1
return "should not be used"
fake_llm = _FakeLLM()
presenter = ExternalAnswerPresenter(
llm_service=fake_llm,
db=object(),
tenant_id=1,
)
result = await presenter.present(
question="这个项目要不要跟进 OpenAI Responses API?",
context_packet={
"question": "这个项目要不要跟进 OpenAI Responses API?",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "Responses API 适配性",
"fit_brief_summary": "当前项目:#141 PMFlow AI 管家 (status=active)",
"subject_entities": ["PMFlow AI 管家", "OpenAI Responses API", "Anthropic MCP"],
},
"adapter_attempts": [{"adapter": "live_search", "status": "success"}],
"verified_evidence": [
{
"source_type": "url",
"source_label": "URL[OpenAI Docs]",
"title": "Responses API guide",
"target_url": "https://platform.openai.com/docs/guides/responses",
"snippet": "Responses API can orchestrate tool calls and structured outputs.",
}
],
"verifier": {"ok": True},
"uncertainties": [],
"retrieval_status": "evidence_found",
"impact_status": "indirect_only",
},
thinking_mode="normal",
)
assert result["prompt_variant_id"] == "external_indirect_only"
assert "只能说明它和当前项目有背景相关性" in result["answer_text"]
assert "不能直接说明它已经命中当前项目对象" in result["answer_text"]
assert "当前可以继续围绕这些候选对象往下查" in result["answer_text"]
assert "URL[" not in result["answer_text"]
assert "OpenAI Docs" in result["answer_text"]
assert result["research_workspace"]["primary_object_ref"] == "project:141"
assert result["research_workspace"]["candidate_set_binding"].startswith("cset_")
assert len(result["research_workspace"]["candidate_items"]) >= 1
assert fake_llm.calls == 0
@pytest.mark.asyncio
async def test_presenter_compare_with_sources_keeps_candidate_level_compare_when_indirect_only() -> None:
class _FakeLLM:
def __init__(self) -> None:
self.calls = 0
async def generate(self, prompt: str, **kwargs):
_ = kwargs
self.calls += 1
assert "当前仍是候选级比较" in prompt
assert "当前项目背景" in prompt
return "我先按候选级比较展开,明确哪些是高相关,哪些还需要进一步映射验证。"
fake_llm = _FakeLLM()
presenter = ExternalAnswerPresenter(
llm_service=fake_llm,
db=object(),
tenant_id=1,
)
result = await presenter.present(
question="把这个项目和上面那些竞品详细对比下",
context_packet={
"question": "把这个项目和上面那些竞品详细对比下",
"plan": {
"answer_contract": "compare_with_sources",
"topic": "竞品详细对比",
"fit_brief_summary": "当前项目:#141 童析-学龄前儿童行为特质AI评估助手 (status=active)",
"project_binding": 141,
"project_title": "童析—学龄前儿童行为特质AI评估助手",
"subject_entities": ["童析—学龄前儿童行为特质AI评估助手", "Goally", "ABCmouse", "Cognoa"],
},
"adapter_attempts": [{"adapter": "live_search", "status": "success"}],
"verified_evidence": [
{
"source_type": "url",
"source_label": "Goally",
"title": "The Best Tablet for Kids | Goally",
"target_url": "https://example.com/goally",
"snippet": "Goally provides tools for kids routines and behavior support.",
},
{
"source_type": "url",
"source_label": "ABCmouse",
"title": "About the Assessment Center in ABCmouse",
"target_url": "https://example.com/abcmouse",
"snippet": "ABCmouse includes assessment-oriented learning flows.",
},
],
"verifier": {"ok": True},
"uncertainties": [],
"retrieval_status": "evidence_found",
"impact_status": "indirect_only",
},
thinking_mode="normal",
)
assert result["prompt_variant_id"] == "external_compare_with_sources"
assert "候选级比较" in result["answer_text"]
assert result["research_workspace"]["primary_object_ref"] == "project:141"
assert result["research_workspace"]["candidate_set_binding"].startswith("cset_")
assert len(result["research_workspace"]["candidate_items"]) >= 2
candidate_names = [item["display_name"] for item in result["research_workspace"]["candidate_items"]]
assert "童析—学龄前儿童行为特质AI评估助手" not in candidate_names
assert fake_llm.calls == 1
@pytest.mark.asyncio
async def test_presenter_prefers_recommendation_answer_for_signal_source_recommendation_question() -> None:
class _FakeLLM:
def __init__(self) -> None:
self.calls = 0
self.prompt = ""
self.structured_calls = 0
async def generate_structured_object(self, **kwargs):
self.structured_calls += 1
self.prompt = str(kwargs.get("prompt") or "")
return SimpleNamespace(
payload={
"candidates": [
{
"name": "测试执行失败和阻塞记录",
"reason": "它最早反映测试队列是不是已经开始堆积,适合先盯住产能吃紧的信号。",
},
{
"name": "构建或发布变更记录",
"reason": "它能帮你判断产能波动是不是由最近的版本改动带出来的。",
},
{
"name": "工单或异常反馈",
"reason": "它能看出问题有没有开始影响到实际交付,不会只停在内部指标波动。",
},
],
"caveat": "这些先算建议,不代表已经接入或验证完成。",
}
)
async def generate(self, prompt: str, **kwargs):
_ = kwargs
self.calls += 1
self.prompt = prompt
return (
"如果只按当前项目任务来排优先级,我会先补齐“设计产能监控逻辑与数据采集”需要的外部输入,"
"再看“实现产能警告触发服务”依赖的实时变化信号。"
)
fake_llm = _FakeLLM()
presenter = ExternalAnswerPresenter(
llm_service=fake_llm,
db=object(),
tenant_id=1,
)
result = await presenter.present(
question="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。",
context_packet={
"question": "测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "测试产能警告外部信号源推荐",
"interaction_level": "recommendation",
"fit_brief_summary": "当前项目:测试产能警告,聚焦识别产能异常和告警触发。",
"project_scope_hint": "current_project_and_repo",
"recommendation_goal": "给测试产能警告推荐最值得先看的外部信号源。",
"project_object_anchors": ["实现产能警告触发服务", "告警接收链路"],
"signal_source_objectives": [
"设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据",
"实现产能警告触发服务:实时比对数据与阈值",
],
},
"adapter_attempts": [{"adapter": "live_search", "status": "empty"}],
"verified_evidence": [],
"verifier": {"ok": False, "reason_code": "external_evidence_off_topic"},
"uncertainties": ["external_evidence_off_topic"],
"retrieval_status": "verifier_blocked",
"impact_status": "no_direct_impact",
},
thinking_mode="normal",
)
assert result["prompt_variant_id"] == "external_signal_source_recommendation"
assert "1. 测试执行失败和阻塞记录" in result["answer_text"]
assert "2. 构建或发布变更记录" in result["answer_text"]
assert "3. 工单或异常反馈" in result["answer_text"]
assert "最早反映测试队列是不是已经开始堆积" in result["answer_text"]
assert "这些先算建议,不代表已经接入或验证完成。" in result["answer_text"]
assert "主链" not in result["answer_text"]
assert "这一层" not in result["answer_text"]
assert result["recommendation_packet"]["resource_role"] == "signal_source"
assert result["recommendation_packet"]["source_kind"] == "external"
assert result["recommendation_packet"]["evidence_mode"] == "advisory"
assert result["recommendation_packet"]["candidate_set_id"]
assert result["recommendation_packet"]["candidates"][0]["candidate_ref"]
assert result["recommendation_packet"]["candidates"][0]["display_name"] == "测试执行失败和阻塞记录"
assert (
result["recommendation_packet"]["candidates"][0]["materialization_payload"]["display_name"]
== "测试执行失败和阻塞记录"
)
assert fake_llm.structured_calls == 1
assert "给测试产能警告推荐最值得先看的外部信号源" in fake_llm.prompt
assert "测试队列与资源使用数据" in fake_llm.prompt
assert "实时比对数据与阈值" in fake_llm.prompt
@pytest.mark.asyncio
async def test_presenter_signal_source_recommendation_varies_by_project_context() -> None:
class _FakeLLM:
def __init__(self) -> None:
self.structured_calls = 0
async def generate_structured_object(self, **kwargs):
self.structured_calls += 1
prompt = str(kwargs.get("prompt") or "")
if "自动开票系统" in prompt:
return SimpleNamespace(
payload={
"candidates": [
{
"name": "开票失败或重试回执",
"reason": "它最早告诉你票是不是卡在开票动作本身。",
},
{
"name": "税控或发票平台状态变化",
"reason": "它能解释失败到底是业务问题还是外部平台波动。",
},
{
"name": "发票发送失败反馈",
"reason": "它能看出问题有没有真正影响到客户收票。",
},
],
"caveat": "这些先算建议,不代表已经接入或验证完成。",
}
)
return SimpleNamespace(
payload={
"candidates": [
{
"name": "用户投诉和差评变化",
"reason": "它最容易最先暴露客服回复质量是不是已经开始出问题。",
},
{
"name": "社媒互动异常波动",
"reason": "它能帮你看出问题是不是已经从单点反馈扩到公开场域。",
},
{
"name": "竞品或行业FAQ变化",
"reason": "它能提示当前话术和知识点是不是已经落后。",
},
],
"caveat": "这些先算建议,不代表已经接入或验证完成。",
}
)
async def generate(self, prompt: str, **kwargs):
_ = (prompt, kwargs)
return "如果只看外部信号源,我会先看最关键的外部输入。"
presenter = ExternalAnswerPresenter(
llm_service=_FakeLLM(),
db=object(),
tenant_id=1,
)
invoice_result = await presenter.present(
question="自动开票系统这个项目,除了系统里现有的数据,你觉得还应该盯哪些外部信号?直接说几个最有用的,不要给我出方案。",
context_packet={
"question": "自动开票系统这个项目,除了系统里现有的数据,你觉得还应该盯哪些外部信号?直接说几个最有用的,不要给我出方案。",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "自动开票系统外部信号源推荐",
"interaction_level": "recommendation",
"fit_brief_summary": "当前项目:自动开票系统,目标是在收到开票请求后自动开票并发送发票图片。",
"project_scope_hint": "current_project_and_repo",
"recommendation_goal": "给自动开票系统推荐最值得先看的外部信号源。",
"project_object_anchors": ["开票请求处理", "发票发送结果"],
"signal_source_objectives": [
"处理开票请求:收到接口请求后自动开票",
"发送发票图片:把结果发给客户",
],
},
"adapter_attempts": [{"adapter": "live_search", "status": "empty"}],
"verified_evidence": [],
"verifier": {"ok": False, "reason_code": "external_evidence_off_topic"},
"uncertainties": ["external_evidence_off_topic"],
"retrieval_status": "verifier_blocked",
"impact_status": "no_direct_impact",
},
thinking_mode="normal",
)
ai_customer_result = await presenter.present(
question="AI客服这个项目,除了现在这些数据,你觉得最值得再补哪几类外部线索?直接推荐几个。",
context_packet={
"question": "AI客服这个项目,除了现在这些数据,你觉得最值得再补哪几类外部线索?直接推荐几个。",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "AI客服外部信号源推荐",
"interaction_level": "recommendation",
"fit_brief_summary": "当前项目:AI客服,核心痛点是内容本地化,当前能力围绕社媒运营和用户洞察。",
"project_scope_hint": "current_project_and_repo",
"recommendation_goal": "给 AI 客服推荐最值得先看的外部线索。",
"project_object_anchors": ["用户洞察数据源", "社媒运营数据源"],
"signal_source_objectives": [
"用户洞察:更快发现用户反馈与投诉变化",
"社媒运营:跟踪公开互动和话题波动",
],
},
"adapter_attempts": [{"adapter": "live_search", "status": "empty"}],
"verified_evidence": [],
"verifier": {"ok": False, "reason_code": "external_evidence_off_topic"},
"uncertainties": ["external_evidence_off_topic"],
"retrieval_status": "verifier_blocked",
"impact_status": "no_direct_impact",
},
thinking_mode="normal",
)
assert "开票失败或重试回执" in invoice_result["answer_text"]
assert "发票发送失败反馈" in invoice_result["answer_text"]
assert "用户投诉和差评变化" not in invoice_result["answer_text"]
assert "用户投诉和差评变化" in ai_customer_result["answer_text"]
assert "社媒互动异常波动" in ai_customer_result["answer_text"]
assert "开票失败或重试回执" not in ai_customer_result["answer_text"]
@pytest.mark.asyncio
async def test_presenter_signal_source_recommendation_varies_for_cashflow_and_knowledge_base_projects() -> None:
class _FakeLLM:
async def generate_structured_object(self, *, prompt: str, **kwargs):
_ = kwargs
if "智能现金流管家" in prompt:
return SimpleNamespace(
payload={
"candidates": [
{
"name": "银行回单或到账异常反馈",
"reason": "它最早告诉你资金是不是已经在到账环节卡住了。",
},
{
"name": "税费或社保扣缴提醒变化",
"reason": "它能帮助区分现金流压力是不是已经开始影响刚性支出。",
},
{
"name": "上游平台结算延迟通知",
"reason": "它能提前暴露回款节奏是不是被外部平台拖慢了。",
},
],
"caveat": "这些先算建议,不代表已经接入或验证完成。",
}
)
return SimpleNamespace(
payload={
"candidates": [
{
"name": "外部知识源更新通知",
"reason": "它能让知识库问答先知道哪些权威资料已经变化。",
},
{
"name": "第三方模型服务可用性波动",
"reason": "它最容易直接影响问答响应质量和稳定性。",
},
{
"name": "企业 SSO 或目录服务异常公告",
"reason": "它能帮助判断问答不可用是不是卡在登录或权限链路。",
},
],
"caveat": "这些先算建议,不代表已经接入或验证完成。",
}
)
async def generate(self, prompt: str, **kwargs):
_ = (prompt, kwargs)
return "关于外部信号,我现在还不能直接推荐。"
presenter = ExternalAnswerPresenter(
llm_service=_FakeLLM(),
db=object(),
tenant_id=1,
)
cashflow_result = await presenter.present(
question="智能现金流管家除了银行流水,还值得补哪些外部线索?",
context_packet={
"question": "智能现金流管家除了银行流水,还值得补哪些外部线索?",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "智能现金流管家外部信号源推荐",
"interaction_level": "recommendation",
"fit_brief_summary": "当前项目:智能现金流管家,目标是更早识别回款异常和资金压力。",
"project_scope_hint": "current_project_and_repo",
"recommendation_goal": "给智能现金流管家推荐最值得先看的外部信号源。",
"project_object_anchors": ["回款节奏监控", "资金压力预警"],
"signal_source_objectives": [
"识别到账异常:更早发现回款延迟和结算波动",
"识别刚性支出压力:尽快发现税费或社保扣缴变化",
],
},
"adapter_attempts": [{"adapter": "live_search", "status": "empty"}],
"verified_evidence": [],
"verifier": {"ok": False, "reason_code": "external_evidence_off_topic"},
"uncertainties": ["external_evidence_off_topic"],
"retrieval_status": "verifier_blocked",
"impact_status": "no_direct_impact",
},
thinking_mode="normal",
)
knowledge_base_result = await presenter.present(
question="企业知识库问答AI项目如果只补一个外部信号,你觉得先补哪个最值?",
context_packet={
"question": "企业知识库问答AI项目如果只补一个外部信号,你觉得先补哪个最值?",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "企业知识库问答AI项目外部信号源推荐",
"interaction_level": "recommendation",
"fit_brief_summary": "当前项目:企业知识库问答AI项目,目标是提升企业知识问答的准确性和可用性。",
"project_scope_hint": "current_project_and_repo",
"recommendation_goal": "给企业知识库问答AI项目推荐最值得先看的外部信号源。",
"project_object_anchors": ["知识更新同步", "问答服务稳定性"],
"signal_source_objectives": [
"识别知识更新:及时发现外部知识源变化",
"识别服务波动:尽快定位问答质量和可用性异常",
],
},
"adapter_attempts": [{"adapter": "live_search", "status": "empty"}],
"verified_evidence": [],
"verifier": {"ok": False, "reason_code": "external_evidence_off_topic"},
"uncertainties": ["external_evidence_off_topic"],
"retrieval_status": "verifier_blocked",
"impact_status": "no_direct_impact",
},
thinking_mode="normal",
)
assert "银行回单或到账异常反馈" in cashflow_result["answer_text"]
assert "税费或社保扣缴提醒变化" in cashflow_result["answer_text"]
assert "外部知识源更新通知" not in cashflow_result["answer_text"]
assert "外部知识源更新通知" in knowledge_base_result["answer_text"]
assert "第三方模型服务可用性波动" in knowledge_base_result["answer_text"]
assert "银行回单或到账异常反馈" not in knowledge_base_result["answer_text"]
@pytest.mark.asyncio
async def test_presenter_signal_source_recommendation_varies_for_medical_industry_research_project() -> None:
class _FakeLLM:
async def generate_structured_object(self, **kwargs):
_ = kwargs
return SimpleNamespace(
payload={
"candidates": [
{
"name": "医药政策与监管口径更新",
"reason": "它最容易先改变研究假设和布局判断。",
},
{
"name": "重点药企投融资和并购动态",
"reason": "它能更快暴露产业链方向是不是正在变化。",
},
{
"name": "临床试验与注册进展公告",
"reason": "它能帮助判断哪些赛道是真的在加速,哪些还停在预期里。",
},
],
"caveat": "这些先算建议,不代表已经接入或验证完成。",
}
)
async def generate(self, prompt: str, **kwargs):
_ = (prompt, kwargs)
return "我现在还不能直接推荐。"
presenter = ExternalAnswerPresenter(
llm_service=_FakeLLM(),
db=object(),
tenant_id=1,
)
result = await presenter.present(
question="医药行业十五五规划布局研究现在最值得优先盯的外部变化有哪些?",
context_packet={
"question": "医药行业十五五规划布局研究现在最值得优先盯的外部变化有哪些?",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "医药行业十五五规划布局研究外部信号源推荐",
"interaction_level": "recommendation",
"fit_brief_summary": "当前项目:医药行业十五五规划布局研究,目标是研判政策、产业和重点赛道变化。",
"project_scope_hint": "current_project_and_repo",
"recommendation_goal": "给医药行业十五五规划布局研究推荐最值得优先看的外部变化。",
"project_object_anchors": ["政策口径变化", "重点赛道布局判断"],
"signal_source_objectives": [
"识别政策变化:尽快发现监管和支持方向的变化",
"识别产业变化:尽快发现资本和临床进展带来的赛道变化",
],
},
"adapter_attempts": [{"adapter": "live_search", "status": "empty"}],
"verified_evidence": [],
"verifier": {"ok": False, "reason_code": "external_evidence_off_topic"},
"uncertainties": ["external_evidence_off_topic"],
"retrieval_status": "verifier_blocked",
"impact_status": "no_direct_impact",
},
thinking_mode="normal",
)
assert "1. 医药政策与监管口径更新" in result["answer_text"]
assert "2. 重点药企投融资和并购动态" in result["answer_text"]
assert "3. 临床试验与注册进展公告" in result["answer_text"]
@pytest.mark.asyncio
async def test_presenter_signal_source_recommendation_accepts_natural_business_phrasing() -> None:
class _FakeLLM:
async def generate_structured_object(self, **kwargs):
_ = kwargs
return SimpleNamespace(
payload={
"candidates": [
{
"name": "门店缺货和补货异常反馈",
"reason": "它最早反映异常是不是已经在门店端出现。",
},
{
"name": "供应商到货延迟变化",
"reason": "它能帮助区分库存异常到底是销售波动还是供给变慢。",
},
{
"name": "竞品价格或促销波动",
"reason": "它能解释销量突然变化是不是被外部活动带出来的。",
},
],
"caveat": "这些先算建议,不代表已经接入或验证完成。",
}
)
async def generate(self, prompt: str, **kwargs):
_ = (prompt, kwargs)
return "关于外部信号,我现在还不能直接推荐。"
presenter = ExternalAnswerPresenter(
llm_service=_FakeLLM(),
db=object(),
tenant_id=1,
)
result = await presenter.present(
question="门店库存异常日报助手这个项目,如果想更早发现异常,你会先多看哪几类外部信号?",
context_packet={
"question": "门店库存异常日报助手这个项目,如果想更早发现异常,你会先多看哪几类外部信号?",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "门店库存异常日报助手外部信号源推荐",
"interaction_level": "recommendation",
"fit_brief_summary": "当前项目:门店库存异常日报助手,目标是更早发现缺货、滞销和补货异常。",
"project_scope_hint": "current_project_and_repo",
"recommendation_goal": "给门店库存异常日报助手推荐最值得先看的外部信号源。",
"project_object_anchors": ["门店异常日报", "缺货异常识别"],
"signal_source_objectives": [
"识别门店异常:更早发现缺货和补货异常",
"日报推送:让运营尽快看到高风险门店",
],
},
"adapter_attempts": [{"adapter": "live_search", "status": "empty"}],
"verified_evidence": [],
"verifier": {"ok": False, "reason_code": "external_evidence_off_topic"},
"uncertainties": ["external_evidence_off_topic"],
"retrieval_status": "verifier_blocked",
"impact_status": "no_direct_impact",
},
thinking_mode="normal",
)
assert "1. 门店缺货和补货异常反馈" in result["answer_text"]
assert "2. 供应商到货延迟变化" in result["answer_text"]
assert "3. 竞品价格或促销波动" in result["answer_text"]
@pytest.mark.asyncio
async def test_presenter_signal_source_recommendation_uses_plan_interaction_level_not_question_cues() -> None:
class _FakeLLM:
async def generate_structured_object(self, **kwargs):
_ = kwargs
return SimpleNamespace(
payload={
"candidates": [
{
"name": "开票失败或重试回执",
"reason": "它最早告诉你票是不是卡在开票动作本身。",
},
{
"name": "税控或发票平台状态变化",
"reason": "它能解释失败到底是业务问题还是外部平台波动。",
},
{
"name": "发票发送失败反馈",
"reason": "它能看出问题有没有真正影响到客户收票。",
},
],
"caveat": "这些先算建议,不代表已经接入或验证完成。",
}
)
async def generate(self, prompt: str, **kwargs):
_ = (prompt, kwargs)
return "不该走到这里。"
presenter = ExternalAnswerPresenter(
llm_service=_FakeLLM(),
db=object(),
tenant_id=1,
)
result = await presenter.present(
question="自动开票系统这个项目,我想先把票开不出去这块外部情况摸清楚。",
context_packet={
"question": "自动开票系统这个项目,我想先把票开不出去这块外部情况摸清楚。",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "自动开票系统外部信号源推荐",
"interaction_level": "recommendation",
"fit_brief_summary": "当前项目:自动开票系统,目标是在收到开票请求后自动开票并发送发票图片。",
"project_scope_hint": "current_project_and_repo",
"recommendation_goal": "给自动开票系统推荐最值得先看的外部信号源。",
"project_object_anchors": ["开票请求处理", "发票发送结果"],
"signal_source_objectives": [
"处理开票请求:收到接口请求后自动开票",
"发送发票图片:把结果发给客户",
],
},
"adapter_attempts": [{"adapter": "live_search", "status": "empty"}],
"verified_evidence": [],
"verifier": {"ok": False, "reason_code": "external_evidence_off_topic"},
"uncertainties": ["external_evidence_off_topic"],
"retrieval_status": "verifier_blocked",
"impact_status": "no_direct_impact",
},
thinking_mode="normal",
)
assert result["prompt_variant_id"] == "external_signal_source_recommendation"
assert "1. 开票失败或重试回执" in result["answer_text"]
assert "2. 税控或发票平台状态变化" in result["answer_text"]
assert "3. 发票发送失败反馈" in result["answer_text"]
@pytest.mark.asyncio
async def test_presenter_signal_source_recommendation_falls_back_when_llm_overclaims_unverified_details() -> None:
class _FakeLLM:
def __init__(self) -> None:
self.structured_calls = 0
async def generate_structured_object(self, **kwargs):
_ = kwargs
self.structured_calls += 1
return SimpleNamespace(
payload={
"candidates": [
{
"name": "工厂级设备运行日志",
"reason": "近一年案例显示其与突发性产能下滑相关性达73%。",
},
{
"name": "测试任务调度系统记录",
"reason": "当前已确认无需额外对接验证。",
},
],
"caveat": "",
}
)
async def generate(self, prompt: str, **kwargs):
_ = (prompt, kwargs)
return (
"如果只看外部信号源,我会先看工厂级设备运行日志、测试任务调度系统记录、产线温湿度与电力波动数据。"
)
fake_llm = _FakeLLM()
presenter = ExternalAnswerPresenter(
llm_service=fake_llm,
db=object(),
tenant_id=1,
)
result = await presenter.present(
question="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。",
context_packet={
"question": "测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?直接给推荐结果和理由。",
"plan": {
"answer_contract": "summary_with_sources",
"topic": "测试产能警告外部信号源推荐",
"interaction_level": "recommendation",
"fit_brief_summary": "当前项目:测试产能警告,聚焦识别产能异常和告警触发。",
"project_scope_hint": "current_project_and_repo",
"recommendation_goal": "给测试产能警告推荐最值得先看的外部信号源。",
"project_object_anchors": ["实现产能警告触发服务", "告警接收链路"],
"signal_source_objectives": [
"设计产能监控逻辑与数据采集:编写脚本收集测试队列与资源使用数据",
"实现产能警告触发服务:实时比对数据与阈值",
],
},
"adapter_attempts": [{"adapter": "live_search", "status": "success"}],
"verified_evidence": [
{
"source_type": "url",
"source_label": "FineReport",
"title": "MES 实时监控",
"target_url": "https://example.com/mes",
"snippet": "MES 可用于实时监控设备与产线状态。",
}
],
"verifier": {"ok": True},
"uncertainties": [],
"retrieval_status": "evidence_found",
"impact_status": "indirect_only",
},
thinking_mode="normal",
)
assert result["prompt_variant_id"] == "external_signal_source_recommendation"
assert "设计产能监控逻辑与数据采集" in result["answer_text"]
assert "实现产能警告触发服务" in result["answer_text"]
assert "73%" not in result["answer_text"]
assert "无需额外对接验证" not in result["answer_text"]
assert "测试执行结果或失败告警" not in result["answer_text"]
assert fake_llm.structured_calls == 1