"""真实解析合同经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"