102 lines
5.4 KiB
Python
102 lines
5.4 KiB
Python
"""真实解析合同经HTTP夹具测试,不调用外部模型。"""
|
||
|
||
import json
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from backend.prospecting import dialogue
|
||
from backend.prospecting.analysis import analyze_sources
|
||
from backend.prospecting import analysis
|
||
from backend.prospecting.contracts import ProspectingBrief, TARGET_QUESTION
|
||
|
||
CONFIG = {"provider": "deepseek", "model": "test-model", "api_key": "test-placeholder", "base_url": "https://model.invalid/v1"}
|
||
|
||
|
||
def install_http(monkeypatch, target, handler):
|
||
client_class = httpx.AsyncClient
|
||
monkeypatch.setattr(target, "get_llm_config", lambda: CONFIG)
|
||
monkeypatch.setattr(target.httpx, "AsyncClient", lambda **kwargs: client_class(transport=httpx.MockTransport(handler), **kwargs))
|
||
|
||
|
||
def plan_response(**updates):
|
||
plan = {"brief": ProspectingBrief(target_customer="杭州准备装修的人", keywords=["杭州 装修准备"]).model_dump(),
|
||
"summary": "寻找杭州准备装修的人", "stages": ["搜索", "阅读"], "missing": [], "assumptions": ["关键词由目标生成"]}
|
||
plan.update(updates)
|
||
return {"choices": [{"message": {"content": json.dumps(plan, ensure_ascii=False)}}]}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_interpretation_uses_json_contract_without_default_quotas(monkeypatch):
|
||
sent = []
|
||
def handler(request):
|
||
sent.append(json.loads(request.content))
|
||
return httpx.Response(200, json=plan_response())
|
||
install_http(monkeypatch, dialogue, handler)
|
||
plan = await dialogue.interpret_message("找杭州准备装修的人", ProspectingBrief(), [])
|
||
assert plan.brief.product == ""
|
||
assert plan.brief.max_notes is None
|
||
assert plan.missing == [TARGET_QUESTION]
|
||
assert sent[0]["response_format"] == {"type": "json_object"}
|
||
assert "地区、产品" not in plan.summary
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_interpretation_rejects_model_invented_destination(monkeypatch):
|
||
brief = ProspectingBrief(intent="inspect", subject_urls=["https://www.xiaohongshu.com/explore/not-provided"])
|
||
install_http(monkeypatch, dialogue, lambda request: httpx.Response(200, json=plan_response(brief=brief.model_dump())))
|
||
with pytest.raises(ValueError, match="不在用户原话"):
|
||
await dialogue.interpret_message("看这篇笔记", ProspectingBrief(), [])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_interpretation_accepts_actual_user_share_link(monkeypatch):
|
||
url = "https://xhslink.com/a/real"
|
||
brief = ProspectingBrief(intent="inspect", subject_urls=[url])
|
||
install_http(monkeypatch, dialogue, lambda request: httpx.Response(200, json=plan_response(brief=brief.model_dump())))
|
||
plan = await dialogue.interpret_message("看这个 " + url, ProspectingBrief(), [])
|
||
assert plan.brief.subject_urls == [url]
|
||
assert plan.missing == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("body", [{"choices": []}, {"choices": [{"message": {"content": "bad json"}}]}, plan_response(summary="")])
|
||
async def test_invalid_interpretation_is_retryable_and_human_readable(monkeypatch, body):
|
||
install_http(monkeypatch, dialogue, lambda request: httpx.Response(200, json=body))
|
||
with pytest.raises(ValueError, match="意图识别未返回可用计划"):
|
||
await dialogue.interpret_message("找装修客户", ProspectingBrief(), [])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_follow_without_message_text_has_no_irrelevant_question(monkeypatch):
|
||
brief = ProspectingBrief(intent="follow", action_scope="已核对的现有候选")
|
||
install_http(monkeypatch, dialogue, lambda request: httpx.Response(200, json=plan_response(brief=brief.model_dump())))
|
||
plan = await dialogue.interpret_message("关注这些人", ProspectingBrief(), [])
|
||
assert plan.missing == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_analysis_batches_cover_every_source_and_keep_reply_context(monkeypatch):
|
||
note = {"source_id": "n", "source_type": "note", "source_url": "https://www.xiaohongshu.com/explore/n", "content": "父正文"}
|
||
root = {"source_id": "c", "source_type": "comment", "source_url": note["source_url"], "content": "父评论"}
|
||
sources = [note, root, *[{"source_id": f"r{i}", "source_type": "reply", "source_url": note["source_url"],
|
||
"content": f"本人需求{i}", "parent_source_id": "c", "root_source_id": "c", "author": f"回复者{i}"} for i in range(18)]]
|
||
requests = []
|
||
def handler(request):
|
||
text = json.loads(request.content)["messages"][0]["content"]
|
||
batch = json.loads(text.split("待分析sources:", 1)[1])
|
||
context = json.loads(text.split("context:", 1)[1].split("\n待分析sources:", 1)[0])
|
||
requests.append((batch, context))
|
||
output = {"results": [{"source_id": row["source_id"], "decision": "keep", "quote": row["content"], "reason": "待核需求"} for row in batch], "summary": "已读摘录"}
|
||
return httpx.Response(200, json={"model": "test-model", "choices": [{"message": {"content": json.dumps(output, ensure_ascii=False)}}], "usage": {"total_tokens": 10}})
|
||
install_http(monkeypatch, analysis, handler)
|
||
result = await analyze_sources(ProspectingBrief(target_customer="有需求的人"), sources)
|
||
assert len(result["decisions"]) == 20
|
||
assert len(result["leads"]) == 20
|
||
assert result["analysis_batches"] == 2
|
||
assert result["analysis_usage"]["total_tokens"] == 20
|
||
assert {row["source_id"] for row in requests[1][1]} == {"n", "c"}
|
||
reply = next(row for row in result["leads"] if row["id"] == "r17")
|
||
assert reply["author"] == "回复者17"
|
||
assert reply["parent_source_id"] == "c"
|