671 lines
34 KiB
Python
671 lines
34 KiB
Python
"""新拓客链路的合同、暂停/续跑与来源保护;不访问模型或平台。"""
|
||
|
||
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"]
|