assistant/tests/test_prospecting.py

671 lines
34 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.

"""新拓客链路的合同、暂停/续跑与来源保护;不访问模型或平台。"""
from __future__ import annotations
import asyncio
import json
from copy import deepcopy
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
from backend.prospecting.analysis import validate_analysis
from backend.prospecting.contracts import KNOWLEDGE_ID, IntentPlan, ProspectingBrief
from backend.prospecting import orchestrator as module
from backend.prospecting.orchestrator import ProspectingOrchestrator
from backend.prospecting import routes
BRIEF = {
"product": "装修设计服务",
"target_customer": "近期正在寻找设计师的业主",
"keywords": ["小户型装修"],
"region": None,
"max_notes": 3,
"max_comments_per_note": 3,
"use_web_search": False,
}
SOURCES = [
{"source_id": "note_1", "source_type": "note", "source_url": "https://www.xiaohongshu.com/explore/one", "content": "装修已经完成,分享一下我的经验。", "author": "作者", "observed_at": "2026-10-28T10:00:00+08:00"},
{"source_id": "comment_1", "source_type": "comment", "source_url": "https://www.xiaohongshu.com/explore/one", "content": "我正准备装修,想找设计师。", "author": "评论者", "observed_at": "2026-10-28T10:00:00+08:00"},
]
def analysis_rows():
return {
"results": [
{"source_id": "note_1", "decision": "exclude", "quote": "装修已经完成", "reason": "已经完成,未表达新服务缺口"},
{"source_id": "comment_1", "decision": "keep", "quote": "我正准备装修,想找设计师。", "reason": "本人寻找服务,身份与地区待核"},
],
"summary": "作者与评论者分别判断",
}
def test_brief_normalizes_user_values_without_inventing_region():
brief = ProspectingBrief.model_validate({**BRIEF, "product": " 装修设计服务 ", "keywords": [" 小户型装修 ", "小户型装修", "找设计师"], "region": " "})
assert brief.product == "装修设计服务"
assert brief.keywords == ["小户型装修", "找设计师"]
assert brief.region is None
assert brief.missing() == []
@pytest.mark.parametrize("changes", [{"max_notes": 0}, {"max_comments_per_note": -1}, {"keywords": ["词" * 81]}, {"_steps_used": 0}])
def test_brief_rejects_invalid_or_internal_inputs(changes):
with pytest.raises(ValidationError):
ProspectingBrief.model_validate({**BRIEF, **changes})
def test_sources_are_analyzed_independently_and_only_kept_as_pending():
decisions, leads = validate_analysis(analysis_rows(), deepcopy(SOURCES))
assert [row["decision"] for row in decisions] == ["exclude", "keep"]
assert len(leads) == 1
assert leads[0]["author"] == "评论者"
assert leads[0]["status"] == "pending"
assert leads[0]["quote"] == SOURCES[1]["content"]
assert leads[0]["observed_at"] == SOURCES[1]["observed_at"]
@pytest.mark.parametrize("change", ["cross_source_quote", "empty_keep_quote", "empty_exclude_quote", "missing_source", "duplicate_source", "new_source"])
def test_unmatched_or_fabricated_analysis_is_not_saved(change):
payload = analysis_rows()
if change == "cross_source_quote":
payload["results"][1]["quote"] = "装修已经完成"
elif change == "empty_keep_quote":
payload["results"][1]["quote"] = " "
elif change == "empty_exclude_quote":
payload["results"][0]["quote"] = ""
elif change == "missing_source":
payload["results"].pop()
elif change == "duplicate_source":
payload["results"].append(deepcopy(payload["results"][0]))
elif change == "new_source":
payload["results"][1]["source_id"] = "invented"
with pytest.raises(ValueError):
validate_analysis(payload, deepcopy(SOURCES))
def test_unclear_can_preserve_missing_evidence_without_creating_a_lead():
payload = analysis_rows()
payload["results"][1].update(decision="unclear", quote="", reason="短文不足以确认目标服务需求")
decisions, leads = validate_analysis(payload, deepcopy(SOURCES))
assert decisions[1]["decision"] == "unclear"
assert leads == []
async def empty_analysis(brief, sources, *, context_sources=None):
return {"decisions": [{"source_id": row["source_id"], "source_type": row["source_type"], "decision": "exclude", "quote": row["content"], "reason": "本地夹具未符合目标"} for row in sources], "leads": [], "analysis_model": "fixture"}
@pytest.fixture
def memory(monkeypatch, tmp_path):
"""与DB一样返回副本,防止测试依赖共享字典的隐式写入。"""
store = {}
counter = 0
def create(knowledge_id, student_id, inputs):
nonlocal counter
counter += 1
task_id = f"task_test_{counter}"
store[task_id] = {
"id": task_id, "knowledge_id": knowledge_id, "student_id": student_id,
"status": "collecting", "current_step": None, "inputs": deepcopy(inputs),
"artifacts": {}, "browser_profile_path": str(tmp_path / "profile"),
"pause_reason": None, "next_check_at": None,
}
return deepcopy(store[task_id])
def update(task_id, **fields):
store[task_id].update(deepcopy(fields))
return deepcopy(store[task_id])
monkeypatch.setattr(module.db, "create_task", create)
monkeypatch.setattr(module.db, "get_task", lambda task_id: deepcopy(store.get(task_id)))
monkeypatch.setattr(module.db, "update_task", update)
monkeypatch.setattr(module.db, "list_tasks", lambda limit=20: deepcopy(list(store.values())[:limit]))
monkeypatch.setattr(module.db, "list_learned", lambda *args, **kwargs: [])
monkeypatch.setattr(module.db, "record_trials", lambda *args: 0)
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": ""})
for function in ["init_progress_doc", "append_progress", "write_summary"]:
monkeypatch.setattr(module, function, lambda *args, **kwargs: None)
monkeypatch.setattr(module, "task_doc_dir", lambda task_id: tmp_path / task_id)
monkeypatch.setattr(module.runner, "close_live_session", AsyncMock())
monkeypatch.setattr(module.runner, "has_live_session", lambda task_id: False)
monkeypatch.setattr(module.runner, "run_browser_step", AsyncMock())
engine = ProspectingOrchestrator()
monkeypatch.setattr(engine, "_learn", lambda *args: None)
monkeypatch.setattr(engine, "_review", lambda task_id, artifacts: artifacts)
return engine, store
@pytest.mark.asyncio
async def test_natural_goal_is_saved_when_model_is_not_configured(memory):
engine, store = memory
task = await engine.start("找客户", {"product": "装修设计"})
assert task["status"] == "blocked"
assert task["current_step"] == "model_setup"
assert task["inputs"]["_needs_interpretation"] is True
assert store[task["id"]]["inputs"]["product"] == "装修设计"
module.runner.run_browser_step.assert_not_awaited()
@pytest.mark.asyncio
async def test_no_key_blocks_execution_after_preserving_confirmed_brief(memory):
engine, store = memory
task = await engine.start("找客户", BRIEF)
assert task["status"] == "blocked"
assert task["current_step"] == "model_setup"
assert "DeepSeek Key" in task["pause_reason"]
assert store[task["id"]]["inputs"]["region"] is None
assert store[task["id"]]["inputs"]["keywords"] == BRIEF["keywords"]
assert not engine.jobs
module.runner.run_browser_step.assert_not_awaited()
@pytest.mark.asyncio
async def test_running_conditions_can_be_revised_without_losing_attempt_history(memory):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]]["status"] = "running"
store[task["id"]]["inputs"]["_steps_used"] = 12
updated = await engine.inputs(task["id"], {"keywords": ["新词"]})
assert updated["inputs"]["_steps_used"] == 12
assert updated["inputs"]["keywords"] == ["新词"]
assert updated["artifacts"]["goal_versions"][0]["brief"]["keywords"] == BRIEF["keywords"]
module.runner.close_live_session.assert_awaited()
@pytest.mark.asyncio
async def test_paused_revision_preserves_sources_and_cumulative_steps(memory):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]].update(status="paused", current_step="login_or_question")
store[task["id"]]["inputs"]["_steps_used"] = 12
store[task["id"]]["artifacts"]["sources"] = deepcopy(SOURCES)
updated = await engine.inputs(task["id"], {"keywords": ["新词"]})
assert updated["inputs"]["_steps_used"] == 12
assert updated["artifacts"]["sources"] == SOURCES
assert updated["artifacts"]["analysis_pending"] is True
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["completed", "cancelled"])
async def test_task_can_continue_after_user_revises_goal(memory, status):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]].update(status=status, current_step="stopped")
updated = await engine.inputs(task["id"], {"keywords": ["新词"]})
assert updated["inputs"]["keywords"] == ["新词"]
assert updated["status"] == "blocked" # Only the missing model key blocks.
async def finish_job(engine, task_id):
job = engine.jobs[task_id]
await job
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_pause_resume_keeps_brief_and_accumulates_only_used_steps(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
browser = AsyncMock(side_effect=[
{"verdict": "paused", "question": "请登录", "artifacts": {"sources": []}, "history": {"steps": 12}},
{"verdict": "paused", "question": "请处理验证", "artifacts": {"sources": []}, "history": {"steps": 6}},
])
monkeypatch.setattr(module.runner, "run_browser_step", browser)
monkeypatch.setattr(module.runner, "has_live_session", lambda task_id: True)
await engine.resume(task["id"])
await finish_job(engine, task["id"])
assert store[task["id"]]["status"] == "paused"
assert store[task["id"]]["inputs"]["_steps_used"] == 12
await engine.resume(task["id"], "已登录,继续")
await finish_job(engine, task["id"])
assert store[task["id"]]["inputs"]["_steps_used"] == 18
assert browser.await_args_list[0].kwargs["max_steps"] is None
assert browser.await_args_list[1].kwargs["max_steps"] is None
assert browser.await_args_list[1].kwargs["continuation"] == "已登录,继续"
assert store[task["id"]]["inputs"]["keywords"] == BRIEF["keywords"]
assert store[task["id"]]["inputs"]["max_notes"] == BRIEF["max_notes"]
@pytest.mark.asyncio
async def test_analysis_retry_does_not_reenter_browser(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]]["artifacts"].update(sources=deepcopy(SOURCES), analysis_pending=True, browser_verdict="success")
store[task["id"]]["inputs"]["_steps_used"] = 20
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
analysis = AsyncMock(side_effect=empty_analysis)
monkeypatch.setattr(module, "analyze_sources", analysis)
await engine.resume(task["id"])
await finish_job(engine, task["id"])
module.runner.run_browser_step.assert_not_awaited()
analysis.assert_awaited_once()
assert store[task["id"]]["artifacts"]["sources"] == SOURCES
assert store[task["id"]]["inputs"]["_steps_used"] == 20
assert store[task["id"]]["status"] == "completed"
@pytest.mark.asyncio
async def test_analysis_failure_preserves_sources_for_manual_retry(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]]["artifacts"].update(sources=deepcopy(SOURCES), analysis_pending=True)
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=ValueError("原话核验失败")))
await engine.resume(task["id"])
await finish_job(engine, task["id"])
assert store[task["id"]]["status"] == "blocked"
assert store[task["id"]]["artifacts"]["sources"] == SOURCES
assert store[task["id"]]["artifacts"]["analysis_pending"] is True
assert not store[task["id"]]["artifacts"].get("leads")
module.runner.run_browser_step.assert_not_awaited()
@pytest.mark.asyncio
async def test_partial_results_can_continue_in_same_task(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
browser = AsyncMock(return_value={"verdict": "failed", "summary": "部分对象未读", "artifacts": {"sources": deepcopy(SOURCES)}, "history": {"steps": 15}})
monkeypatch.setattr(module.runner, "run_browser_step", browser)
monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=empty_analysis))
await engine.resume(task["id"])
await finish_job(engine, task["id"])
assert store[task["id"]]["status"] == "blocked"
assert store[task["id"]]["current_step"] == "stage_results"
assert store[task["id"]]["artifacts"]["sources"] == SOURCES
await engine.resume(task["id"])
await finish_job(engine, task["id"])
assert browser.await_count == 2
assert store[task["id"]]["inputs"]["_steps_used"] == 30
@pytest.mark.asyncio
@pytest.mark.parametrize("sources", [[], SOURCES])
async def test_old_sixty_step_history_does_not_block_new_execution_policy(memory, monkeypatch, sources):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]]["inputs"]["_steps_used"] = 60
store[task["id"]]["artifacts"].update(sources=deepcopy(sources), analysis_pending=False)
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
browser = AsyncMock(return_value={"verdict": "paused", "question": "请登录", "artifacts": {}, "history": {"steps_this_run": 2}})
monkeypatch.setattr(module.runner, "run_browser_step", browser)
resumed = await engine.resume(task["id"])
assert resumed["status"] == "running"
await finish_job(engine, task["id"])
assert store[task["id"]]["inputs"]["_steps_used"] == 62
assert browser.await_args.kwargs["max_steps"] is None
@pytest.mark.asyncio
async def test_saved_sources_can_be_analyzed_after_browser_budget_is_spent(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]]["inputs"]["_steps_used"] = 60
store[task["id"]]["artifacts"].update(sources=deepcopy(SOURCES), analysis_pending=True, browser_verdict="success")
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
analysis = AsyncMock(side_effect=empty_analysis)
monkeypatch.setattr(module, "analyze_sources", analysis)
await engine.resume(task["id"])
await finish_job(engine, task["id"])
module.runner.run_browser_step.assert_not_awaited()
analysis.assert_awaited_once()
assert store[task["id"]]["inputs"]["_steps_used"] == 60
@pytest.mark.asyncio
async def test_restart_recovers_saved_page_sources_without_restarting_browser(memory):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]].update(status="paused", current_step="login_or_question")
store[task["id"]]["inputs"]["_steps_used"] = 8
store[task["id"]]["artifacts"] = {"sources": deepcopy(SOURCES[:1]), "experience_ids": [1]}
directory = module.task_doc_dir(task["id"])
directory.mkdir()
(directory / "evidence.json").write_text(json.dumps({"sources": SOURCES}), encoding="utf-8")
engine.recover_interrupted()
recovered = store[task["id"]]
assert recovered["status"] == "blocked"
assert recovered["current_step"] == "interrupted"
assert recovered["artifacts"]["sources"] == SOURCES
assert recovered["artifacts"]["analysis_pending"] is True
assert recovered["artifacts"]["experience_ids"] == [1]
assert recovered["inputs"]["_steps_used"] == 8
module.runner.run_browser_step.assert_not_awaited()
@pytest.mark.asyncio
async def test_invalid_snapshot_preserves_last_database_sources(memory):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]].update(status="running")
store[task["id"]]["artifacts"]["sources"] = deepcopy(SOURCES)
directory = module.task_doc_dir(task["id"])
directory.mkdir()
(directory / "evidence.json").write_text('{"sources":', encoding="utf-8")
engine.recover_interrupted()
assert store[task["id"]]["artifacts"]["sources"] == SOURCES
assert store[task["id"]]["status"] == "blocked"
@pytest.mark.asyncio
async def test_cumulative_runner_history_uses_explicit_attempt_delta(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]]["inputs"]["_steps_used"] = 12
directory = module.task_doc_dir(task["id"])
directory.mkdir()
(directory / "browser-budget.json").write_text(json.dumps({"total_steps_used": 25}), encoding="utf-8")
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
browser = AsyncMock(return_value={"verdict": "paused", "question": "请处理验证", "artifacts": {}, "history": {"steps": 99, "steps_this_run": 3}})
monkeypatch.setattr(module.runner, "run_browser_step", browser)
await engine.resume(task["id"])
await finish_job(engine, task["id"])
assert browser.await_args.kwargs["max_steps"] is None
assert browser.await_args.kwargs["budget_steps_used"] == 25
assert store[task["id"]]["inputs"]["_steps_used"] == 28
@pytest.mark.asyncio
async def test_invalid_budget_checkpoint_does_not_reset_attempts_to_zero(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
directory = module.task_doc_dir(task["id"])
directory.mkdir()
(directory / "browser-budget.json").write_text('{"total_steps_used":', encoding="utf-8")
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
result = await engine.resume(task["id"])
assert result["status"] == "blocked"
assert result["current_step"] == "checkpoint"
module.runner.run_browser_step.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_exception_keeps_checkpointed_sources_and_used_steps(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
directory = module.task_doc_dir(task["id"])
async def fail_after_saving(**kwargs):
directory.mkdir()
(directory / "evidence.json").write_text(json.dumps({"sources": SOURCES}), encoding="utf-8")
(directory / "browser-budget.json").write_text(json.dumps({"total_steps_used": 7}), encoding="utf-8")
raise RuntimeError("模拟页面中断")
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
monkeypatch.setattr(module.runner, "run_browser_step", AsyncMock(side_effect=fail_after_saving))
await engine.resume(task["id"])
await finish_job(engine, task["id"])
current = store[task["id"]]
assert current["status"] == "blocked"
assert current["inputs"]["_browser_started"] is True
assert current["inputs"]["_steps_used"] == 7
assert current["artifacts"]["sources"] == SOURCES
assert current["artifacts"]["analysis_pending"] is True
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": ""})
revised = await engine.inputs(task["id"], {"keywords": ["改词"]})
assert revised["inputs"]["_steps_used"] == 7
assert revised["artifacts"]["sources"] == SOURCES
@pytest.mark.asyncio
async def test_cancel_recovers_sources_saved_before_runner_return(memory):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]]["status"] = "running"
directory = module.task_doc_dir(task["id"])
directory.mkdir()
(directory / "evidence.json").write_text(json.dumps({"sources": SOURCES}), encoding="utf-8")
cancelled = await engine.cancel(task["id"])
assert cancelled["status"] == "cancelled"
assert cancelled["artifacts"]["sources"] == SOURCES
@pytest.mark.asyncio
async def test_cancel_preserves_saved_sources_and_releases_browser(memory):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]].update(status="paused")
store[task["id"]]["artifacts"]["sources"] = deepcopy(SOURCES)
engine.active_task = task["id"]
cancelled = await engine.cancel(task["id"])
assert cancelled["status"] == "cancelled"
assert cancelled["artifacts"]["sources"] == SOURCES
assert engine.active_task is None
module.runner.close_live_session.assert_awaited()
@pytest.mark.asyncio
async def test_school_task_cannot_be_resumed_in_new_orchestrator(memory):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]]["knowledge_id"] = "xuegong_demo"
with pytest.raises(ValueError, match="拓客任务不存在"):
await engine.resume(task["id"])
def test_new_router_does_not_expose_school_workflows():
app = FastAPI()
app.include_router(routes.router, prefix="/api")
client = TestClient(app)
assert client.get("/api/accounts").status_code == 404
assert client.post("/api/debug/browser", json={}).status_code == 404
assert client.post("/api/tasks/old/track-now", json={}).status_code == 404
assert client.post("/api/tasks/start", json={"knowledge_id": "xuegong_demo", "message": "查课"}).status_code == 422
@pytest.mark.db
@pytest.mark.asyncio
async def test_confirmed_task_persists_in_its_own_workspace_without_key(test_school, monkeypatch, tmp_path):
from backend.db import database as db
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": ""})
monkeypatch.setattr(db, "PROFILES_DIR", tmp_path / "profiles")
monkeypatch.setattr(module, "init_progress_doc", lambda *args: None)
monkeypatch.setattr(module, "append_progress", lambda *args, **kwargs: None)
engine = ProspectingOrchestrator()
task = await engine.start("寻找真实需求", BRIEF)
saved = db.get_task(task["id"])
assert saved["school_id"] == test_school
assert saved["knowledge_id"] == KNOWLEDGE_ID
assert saved["status"] == "blocked"
assert saved["current_step"] == "model_setup"
assert saved["inputs"]["region"] is None
assert not engine.jobs
def test_no_default_business_quotas_and_no_forced_business_profile():
brief = ProspectingBrief(intent="inspect", user_notes="整理这篇笔记讨论中的需求")
assert brief.max_notes is None
assert brief.max_comments_per_note is None
assert brief.missing() == []
assert ProspectingBrief(max_notes=50, max_comments_per_note=500).max_notes == 50
assert ProspectingBrief(intent="export").missing() == []
assert ProspectingBrief(intent="follow", action_scope="现有候选").missing() == []
assert ProspectingBrief(intent="message", action_scope="现有候选").missing() == ["私信内容"]
@pytest.mark.asyncio
async def test_conversation_generates_plan_without_forced_product_or_keywords(memory, monkeypatch):
engine, store = memory
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
interpreted = IntentPlan(brief=ProspectingBrief(target_customer="杭州准备装修的人", target_candidates=10, keywords=["杭州 装修准备"]),
summary="寻找杭州准备装修的人并整理来源", stages=["搜索", "阅读讨论", "整理"],
assumptions=["搜索词由目标生成"])
nlu = AsyncMock(return_value=interpreted)
browser = AsyncMock(return_value={"verdict": "paused", "question": "请登录", "artifacts": {}, "history": {"steps_this_run": 1}})
monkeypatch.setattr(module, "interpret_message", nlu)
monkeypatch.setattr(module.runner, "run_browser_step", browser)
task = await engine.start("帮我找杭州最近准备装修的人,先整理10位候选名单")
await finish_job(engine, task["id"])
saved = store[task["id"]]
assert saved["inputs"]["product"] == ""
assert saved["inputs"]["keywords"] == ["杭州 装修准备"]
assert saved["inputs"]["max_notes"] is None
assert saved["artifacts"]["plan"]["assumptions"] == ["搜索词由目标生成"]
assert saved["artifacts"]["messages"][0]["role"] == "user"
nlu.assert_awaited_once()
assert browser.await_args.kwargs["max_steps"] is None
@pytest.mark.asyncio
async def test_only_necessary_question_stops_before_browser(memory, monkeypatch):
engine, store = memory
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
monkeypatch.setattr(module, "interpret_message", AsyncMock(return_value=IntentPlan(
brief=ProspectingBrief(user_notes="找客户"), summary="想找到合适的潜在客户", missing=["你经营什么或希望寻找哪类人?"])))
task = await engine.start("找客户")
assert task["status"] == "collecting"
assert task["pause_reason"] == "请补充:你经营什么或希望寻找哪类人?;这次先整理多少位候选?"
module.runner.run_browser_step.assert_not_awaited()
@pytest.mark.asyncio
async def test_message_revision_keeps_sources_and_reanalyzes_without_browser(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]]["artifacts"].update(sources=deepcopy(SOURCES), action_ledger=[{"status": "success"}])
store[task["id"]]["inputs"]["_steps_used"] = 61
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
monkeypatch.setattr(module, "interpret_message", AsyncMock(return_value=IntentPlan(
brief=ProspectingBrief(intent="research", target_customer="准备装修的人"), summary="按准备装修人群复核已有资料", reuse_existing_sources=True)))
monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=empty_analysis))
await engine.message(task["id"], "把刚才的资料按准备装修的人再判断一次")
await finish_job(engine, task["id"])
saved = store[task["id"]]
assert saved["inputs"]["_steps_used"] == 61
assert saved["artifacts"]["sources"] == SOURCES
assert saved["artifacts"]["action_ledger"] == [{"status": "success"}]
assert saved["inputs"]["_goal_version"] == 2
module.runner.run_browser_step.assert_not_awaited()
@pytest.mark.asyncio
async def test_nlu_cannot_enable_public_search_without_opt_in(memory, monkeypatch):
engine, store = memory
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
monkeypatch.setattr(module, "interpret_message", AsyncMock(return_value=IntentPlan(
brief=ProspectingBrief(intent="export", use_web_search=True), summary="整理导出已有名单")))
task = await engine.start("整理导出已有名单")
assert task["inputs"]["use_web_search"] is False
assert task["current_step"] == "export_ready"
module.runner.run_browser_step.assert_not_awaited()
@pytest.mark.asyncio
async def test_follow_does_not_require_message_or_automatically_start_browser(memory, monkeypatch):
engine, store = memory
task = await engine.start("关注这些候选", {"intent": "follow", "action_scope": "现有已核对候选"})
store[task["id"]]["artifacts"]["outreach_account"] = {"goal_version": 1, "account_id": "fixture"}
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
ready = await engine.resume(task["id"])
assert ready["current_step"] == "outreach_authorization"
assert "私信内容" not in ready["pause_reason"]
module.runner.run_browser_step.assert_not_awaited()
@pytest.mark.asyncio
async def test_learning_wrapup_failure_preserves_results_and_releases_browser(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
store[task["id"]]["artifacts"].update(sources=deepcopy(SOURCES), analysis_pending=True, browser_verdict="success")
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=empty_analysis))
monkeypatch.setattr(engine, "_review", lambda *args: (_ for _ in ()).throw(RuntimeError("不可用")))
await engine.resume(task["id"])
await finish_job(engine, task["id"])
assert store[task["id"]]["status"] == "completed"
assert store[task["id"]]["artifacts"]["sources"] == SOURCES
assert "learning_error" in store[task["id"]]["artifacts"]
assert engine.active_task is None
@pytest.mark.asyncio
async def test_legacy_scope_migration_preserves_history_and_reextracts_explicit_numbers(memory, monkeypatch):
engine, store = memory
task = await engine.start("只看12篇,每篇最多15条评论", BRIEF)
del store[task["id"]]["inputs"]["_execution_policy"]
store[task["id"]]["inputs"]["_steps_used"] = 61
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
nlu = AsyncMock(return_value=IntentPlan(brief=ProspectingBrief(keywords=["装修"], max_notes=12, max_comments_per_note=15), summary="按用户要求看12篇,每篇最多15条"))
monkeypatch.setattr(module, "interpret_message", nlu)
browser = AsyncMock(return_value={"verdict": "paused", "question": "请登录", "artifacts": {}, "history": {"steps_this_run": 1}})
monkeypatch.setattr(module.runner, "run_browser_step", browser)
await engine.resume(task["id"])
await finish_job(engine, task["id"])
saved = store[task["id"]]
assert saved["artifacts"]["policy_history"][0]["max_notes"] == 3
assert nlu.await_args.args[1].max_notes is None
assert saved["inputs"]["max_notes"] == 12
assert saved["inputs"]["max_comments_per_note"] == 15
assert saved["inputs"]["_steps_used"] == 62
@pytest.mark.asyncio
async def test_unverified_model_budget_claim_is_not_the_displayed_stop_reason(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
monkeypatch.setattr(module.runner, "run_browser_step", AsyncMock(return_value={
"verdict": "failed", "summary": "预算已经耗尽", "stop_reason": "agent_done", "artifacts": {}, "history": {"steps_this_run": 51}}))
await engine.resume(task["id"])
await finish_job(engine, task["id"])
saved = store[task["id"]]["artifacts"]
assert saved["browser_summary"] == "预算已经耗尽"
assert saved["stop_detail"] == "助手主动结束本阶段,实际覆盖见结果。"
assert saved["stop_reason"] == "agent_done"
@pytest.mark.asyncio
async def test_new_browser_session_is_hydrated_from_evidence_snapshot(memory, monkeypatch):
engine, store = memory
task = await engine.start("找客户", BRIEF)
directory = module.task_doc_dir(task["id"])
directory.mkdir()
(directory / "evidence.json").write_text(json.dumps({"sources": SOURCES, "source_observations": [{"operation": "inspect_source_candidates"}]}), encoding="utf-8")
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
browser = AsyncMock(return_value={"verdict": "paused", "question": "请登录", "artifacts": {}, "history": {"steps_this_run": 1}})
monkeypatch.setattr(module.runner, "run_browser_step", browser)
await engine.resume(task["id"])
await finish_job(engine, task["id"])
hydrated = browser.await_args.kwargs["prospecting_artifacts"]
assert hydrated["sources"] == SOURCES
assert hydrated["source_observations"] == [{"operation": "inspect_source_candidates"}]
@pytest.mark.asyncio
async def test_authorized_follow_uses_guarded_tool_profile_and_does_not_reanalyze_sources(memory, monkeypatch):
engine, store = memory
task = await engine.start("关注这些人", {**BRIEF, "intent": "follow", "action_scope": "已核实候选"})
store[task["id"]]["artifacts"]["outreach_grants"] = [{"id": "g", "action": "follow", "status": "authorized", "goal_version": 1,
"source_ids": ["s"], "targets": {"s": "a" * 24}, "content": "", "account_id": "b" * 24}]
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
browser = AsyncMock(return_value={"verdict": "success", "stop_reason": "agent_done", "artifacts": {}, "history": {"steps_this_run": 1}})
monkeypatch.setattr(module.runner, "run_browser_step", browser)
analyze = AsyncMock()
monkeypatch.setattr(module, "analyze_sources", analyze)
await engine.resume(task["id"])
await finish_job(engine, task["id"])
assert browser.await_args.kwargs["tool_profile"] == "outreach"
assert store[task["id"]]["current_step"] == "outreach_results"
analyze.assert_not_awaited()
@pytest.mark.asyncio
async def test_read_only_outreach_preflight_uses_guarded_tool_profile(memory, monkeypatch):
engine, store = memory
task = await engine.start("关注现有候选", {**BRIEF, "intent": "follow", "action_scope": "现有候选"})
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "test-placeholder"})
browser = AsyncMock(return_value={"verdict": "paused", "question": "请打开来源页", "artifacts": {}, "history": {"steps_this_run": 1}})
monkeypatch.setattr(module.runner, "run_browser_step", browser)
await engine.resume(task["id"])
await finish_job(engine, task["id"])
assert browser.await_args.kwargs["tool_profile"] == "outreach"
assert "open_outreach_target" in browser.await_args.kwargs["agent_task"]
assert "只读" in browser.await_args.kwargs["agent_task"]
assert "send_authorized_message" not in browser.await_args.kwargs["agent_task"]