510 lines
28 KiB
Python
510 lines
28 KiB
Python
"""UI contracts keep task versions, evidence and independent routes intact."""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import copy
|
||
import json
|
||
from pathlib import Path
|
||
from unittest.mock import AsyncMock
|
||
|
||
import httpx
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
|
||
from backend.prospecting import cards, orchestrator as module, routes, workflows
|
||
from backend.prospecting.contracts import IntentPlan, KNOWLEDGE_ID, ProspectingBrief
|
||
|
||
|
||
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 workspace(monkeypatch, tmp_path):
|
||
from backend.prospecting import usage
|
||
row = {"id": "ui-task", "knowledge_id": KNOWLEDGE_ID, "status": "collecting", "current_step": "brief",
|
||
"inputs": {**ProspectingBrief(user_notes="先看一轮找客户", discovery_scope="route_pass").model_dump(), "goal": "先看一轮找客户", "_goal_version": 1,
|
||
"_execution_policy": "conversation-v1", "_needs_interpretation": False},
|
||
"artifacts": {"plan": {"missing": ["你经营什么或希望寻找哪类人?"]}, "messages": []}, "pause_reason": "请补充业务背景"}
|
||
def update(task_id, **fields):
|
||
assert task_id == row["id"]
|
||
row.update(copy.deepcopy(fields))
|
||
return copy.deepcopy(row)
|
||
monkeypatch.setattr(module.db, "get_task", lambda task_id: copy.deepcopy(row) if task_id == row["id"] else None)
|
||
monkeypatch.setattr(module.db, "update_task", update)
|
||
monkeypatch.setattr(module, "get_llm_config", lambda: {"api_key": "fixture-placeholder"})
|
||
monkeypatch.setattr(module.runner, "close_live_session", AsyncMock())
|
||
monkeypatch.setattr(module.runner, "run_browser_step", AsyncMock(side_effect=AssertionError("UI route must not browse")))
|
||
monkeypatch.setattr(module.runner, "has_live_session", lambda tid: False)
|
||
monkeypatch.setattr(module.runner, "is_taken_over", lambda tid: False)
|
||
for target in (module, workflows, routes):
|
||
monkeypatch.setattr(target, "task_doc_dir", lambda tid: tmp_path / tid)
|
||
monkeypatch.setattr(cards, "progress_doc_path", lambda tid: tmp_path / tid / "progress.md")
|
||
monkeypatch.setattr(routes, "progress_doc_path", lambda tid: tmp_path / tid / "progress.md")
|
||
monkeypatch.setattr(module, "append_progress", lambda *args, **kwargs: None)
|
||
monkeypatch.setattr(usage, "task_doc_dir", lambda tid: tmp_path / tid)
|
||
engine = module.ProspectingOrchestrator()
|
||
monkeypatch.setattr(engine, "_learn", lambda *args: None)
|
||
monkeypatch.setattr(engine, "_review", lambda tid, artifacts: artifacts)
|
||
monkeypatch.setattr(routes, "orchestrator", engine)
|
||
app = FastAPI()
|
||
app.include_router(routes.router)
|
||
return row, engine, app, tmp_path
|
||
|
||
|
||
def candidate_sources(row, target=2):
|
||
row["inputs"].update(target_candidates=target, discovery_scope="candidate_count", user_notes="寻找装修客户")
|
||
row["artifacts"].update(plan={"missing": []}, sources=[
|
||
{"source_id": sid, "source_type": "comment", "content": "想找装修队", "author_profile_url": f"https://www.xiaohongshu.com/user/profile/{person * 24}"}
|
||
for sid, person in [("a1", "a"), ("a2", "a"), ("b", "b")]
|
||
])
|
||
|
||
|
||
async def keep_analysis(brief, sources, *, context_sources=None):
|
||
return {"decisions": [{**source, "decision": "keep", "quote": source["content"], "reason": "本人需求"} for source in sources], "analysis_model": "fixture"}
|
||
|
||
|
||
async def test_number_only_reply_uses_same_goal_without_another_interpretation(workspace, monkeypatch):
|
||
row, engine, _, _ = workspace
|
||
row["inputs"].update(discovery_scope="candidate_count", target_customer="想找装修队的人", user_notes="寻找装修客户")
|
||
row["artifacts"]["plan"] = {"missing": []}
|
||
nlu = AsyncMock(side_effect=AssertionError("number confirmation must not invoke model"))
|
||
monkeypatch.setattr(module, "interpret_message", nlu)
|
||
monkeypatch.setattr(engine, "_run", AsyncMock())
|
||
result = await engine.message(row["id"], "20")
|
||
await engine.jobs[row["id"]]
|
||
await asyncio.sleep(0)
|
||
assert result["inputs"]["target_candidates"] == 20
|
||
assert result["inputs"]["max_notes"] is None
|
||
assert result["id"] == "ui-task" and result["inputs"]["_goal_version"] == 2
|
||
assert cards.clarification(result) is None
|
||
nlu.assert_not_awaited()
|
||
|
||
|
||
async def test_checkpoint_groups_people_and_reuses_analyzed_evidence(workspace, monkeypatch):
|
||
row, engine, _, _ = workspace
|
||
candidate_sources(row)
|
||
row.update(status="running", current_step="search_read")
|
||
analyze = AsyncMock(side_effect=keep_analysis)
|
||
monkeypatch.setattr(module, "analyze_sources", analyze)
|
||
first = await engine._candidate_checkpoint(row["id"], engine._brief(row), copy.deepcopy(row["artifacts"]))
|
||
assert first["code"] == "target_reached" and first["current"] == 2
|
||
assert cards.project(row)["goal_progress"]["counts"]["signals"] == 3
|
||
await engine._candidate_checkpoint(row["id"], engine._brief(row), copy.deepcopy(row["artifacts"]))
|
||
analyze.assert_awaited_once()
|
||
|
||
|
||
async def test_checkpoint_does_not_apply_analysis_after_goal_version_changed(workspace, monkeypatch):
|
||
row, engine, _, _ = workspace
|
||
candidate_sources(row)
|
||
row.update(status="running", current_step="search_read")
|
||
async def revise(brief, sources, *, context_sources=None):
|
||
row["inputs"]["_goal_version"] = 2
|
||
row["status"] = "collecting"
|
||
return await keep_analysis(brief, sources, context_sources=context_sources)
|
||
monkeypatch.setattr(module, "analyze_sources", revise)
|
||
assert await engine._candidate_checkpoint(row["id"], engine._brief(row), copy.deepcopy(row["artifacts"])) is None
|
||
assert not row["artifacts"].get("leads") and row["status"] == "collecting"
|
||
|
||
|
||
async def test_saved_review_reuses_identical_input_and_force_reanalyzes(workspace, monkeypatch):
|
||
row, engine, _, _ = workspace
|
||
candidate_sources(row)
|
||
row.update(status="blocked", current_step="stage_results")
|
||
analyze = AsyncMock(side_effect=keep_analysis)
|
||
monkeypatch.setattr(module, "analyze_sources", analyze)
|
||
for force in [False, False, True]:
|
||
await engine.review_saved(row["id"], force=force)
|
||
await engine.jobs[row["id"]]
|
||
await asyncio.sleep(0)
|
||
assert analyze.await_count == 2
|
||
assert row["status"] == "completed"
|
||
module.runner.run_browser_step.assert_not_awaited()
|
||
|
||
|
||
async def test_successful_browser_cannot_complete_with_two_signals_from_one_person(workspace, monkeypatch):
|
||
row, engine, _, _ = workspace
|
||
candidate_sources(row)
|
||
row["artifacts"]["sources"].pop()
|
||
row.update(status="running", current_step="search_read", browser_profile_path="fixture")
|
||
monkeypatch.setattr(module.db, "list_learned", lambda *args: [])
|
||
monkeypatch.setattr(module.db, "record_trials", lambda *args: None)
|
||
monkeypatch.setattr(module, "write_summary", lambda *args: None)
|
||
monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=keep_analysis))
|
||
monkeypatch.setattr(module.runner, "run_browser_step", AsyncMock(return_value={"verdict": "success", "stop_reason": "agent_done", "history": {"steps_this_run": 1}, "artifacts": copy.deepcopy(row["artifacts"])}))
|
||
await engine._run(row["id"], engine._brief(row), "")
|
||
assert row["status"] == "blocked" and row["artifacts"]["stop_reason"] == "target_shortfall"
|
||
assert cards.project(row)["goal_progress"]["current"] == 1
|
||
|
||
|
||
def test_revised_goal_preserves_old_evidence_without_counting_toward_new_target(workspace):
|
||
row, _, _, _ = workspace
|
||
candidate_sources(row, target=1)
|
||
row["inputs"]["_goal_version"] = 2
|
||
row["artifacts"].update(analysis_goal_version=1, leads=[{**row["artifacts"]["sources"][0], "decision": "keep"}])
|
||
progress = cards.project(row)["goal_progress"]
|
||
assert not progress["assessment_current"] and not progress["reached"]
|
||
assert progress["current"] == 0 and progress["remaining"] == 1
|
||
assert progress["counts"]["candidates"] == 1 and progress["groups"]
|
||
|
||
|
||
async def test_achieved_target_resume_does_not_open_browser_or_reanalyze(workspace, monkeypatch):
|
||
from backend.prospecting.execution import analyze_evidence
|
||
row, engine, _, _ = workspace
|
||
candidate_sources(row)
|
||
row["artifacts"].update(await analyze_evidence(row, engine._brief(row), keep_analysis))
|
||
run = AsyncMock(side_effect=AssertionError("achieved target must not restart"))
|
||
monkeypatch.setattr(engine, "_run", run)
|
||
result = await engine.resume(row["id"])
|
||
assert result["status"] == "completed" and result["artifacts"]["stop_reason"] == "target_reached"
|
||
run.assert_not_awaited()
|
||
module.runner.run_browser_step.assert_not_awaited()
|
||
|
||
|
||
async def test_csv_groups_people_and_preserves_all_source_quotes_links_and_corrections(workspace):
|
||
import csv
|
||
from backend.prospecting.execution import analyze_evidence
|
||
row, engine, _, _ = workspace
|
||
candidate_sources(row)
|
||
for index, source in enumerate(row["artifacts"]["sources"]):
|
||
source.update(content=f"本人需求{index}", source_url=f"https://www.xiaohongshu.com/explore/fixture{index}")
|
||
row["artifacts"]["corrections"] = [{"source_id": "a2", "text": "还需确认预算"}]
|
||
row["artifacts"].update(await analyze_evidence(row, engine._brief(row), keep_analysis))
|
||
exported = workflows.export_leads(row)
|
||
with Path(exported["path"]).open(encoding="utf-8-sig", newline="") as handle:
|
||
rows = list(csv.DictReader(handle))
|
||
assert exported["count"] == exported["candidate_count"] == len(rows) == 2
|
||
assert "本人需求0" in rows[0]["需求原话"] and "本人需求1" in rows[0]["需求原话"]
|
||
assert "fixture0" in rows[0]["来源链接"] and "fixture1" in rows[0]["来源链接"]
|
||
assert rows[0]["用户纠正"] == "还需确认预算" and rows[0]["小红书号"] == ""
|
||
|
||
|
||
def add_sources(row):
|
||
row["artifacts"].update({
|
||
"sources": [{"source_id": "n", "source_type": "note", "content": "装修准备", "source_url": "https://www.xiaohongshu.com/explore/a"},
|
||
{"source_id": "c", "source_type": "comment", "content": "想找装修队", "author": "本人", "source_url": "https://www.xiaohongshu.com/explore/a"},
|
||
{"source_id": "r", "source_type": "reply", "content": "求设计师", "author": "回复者", "source_url": "https://www.xiaohongshu.com/explore/a"}],
|
||
"leads": [{"source_id": "c", "quote": "想找装修队", "reason": "本人询问", "status": "pending"}],
|
||
"profiles": [], "decisions": [{"source_id": "c", "decision": "keep"}]})
|
||
|
||
|
||
def test_unknown_question_is_context_not_mandatory_product(workspace):
|
||
row, _, _, _ = workspace
|
||
card = cards.clarification(row)
|
||
assert len(card["fields"]) == 1
|
||
assert card["fields"][0]["key"] == "context"
|
||
assert card["fields"][0]["prefill"] == ""
|
||
assert card == cards.clarification(copy.deepcopy(row))
|
||
assert row["inputs"]["max_notes"] is None and row["inputs"]["max_comments_per_note"] is None
|
||
|
||
|
||
async def test_clarification_continues_same_task_through_chat_and_rejects_replay(workspace, monkeypatch):
|
||
row, engine, _, _ = workspace
|
||
card = cards.clarification(row)
|
||
field_id = card["fields"][0]["id"]
|
||
nlu = AsyncMock(return_value=IntentPlan(brief=ProspectingBrief(intent="export"), summary="整理已有名单"))
|
||
monkeypatch.setattr(module, "interpret_message", nlu)
|
||
result = await engine.clarify(row["id"], card["id"], 1, {field_id: "先整理已有名单"})
|
||
assert result["id"] == "ui-task" and result["inputs"]["_goal_version"] == 2
|
||
assert result["current_step"] == "export_ready"
|
||
assert "先整理已有名单" in nlu.await_args.args[0]
|
||
assert result["artifacts"]["clarification_history"][0]["status"] == "answered"
|
||
with pytest.raises(ValueError, match="已更新或已回答"):
|
||
await engine.clarify(row["id"], card["id"], 1, {field_id: "重复"})
|
||
nlu.assert_awaited_once()
|
||
module.runner.run_browser_step.assert_not_awaited()
|
||
|
||
|
||
async def test_clarification_rejects_stale_or_extra_fields_without_consuming(workspace):
|
||
row, engine, _, _ = workspace
|
||
card = cards.clarification(row)
|
||
answers = {card["fields"][0]["id"]: "装修"}
|
||
for version, values in ((2, answers), (1, {**answers, "_steps_used": "0"}), (1, {next(iter(answers)): " "})):
|
||
with pytest.raises(ValueError):
|
||
await engine.clarify(row["id"], card["id"], version, values)
|
||
assert not row["artifacts"].get("clarification_submissions")
|
||
assert row["inputs"]["_goal_version"] == 1
|
||
|
||
|
||
async def test_saved_form_reply_can_retry_interpretation_after_failure(workspace, monkeypatch):
|
||
row, engine, _, _ = workspace
|
||
card = cards.clarification(row)
|
||
monkeypatch.setattr(module, "interpret_message", AsyncMock(side_effect=ValueError("意图服务暂时不可用")))
|
||
await engine.clarify(row["id"], card["id"], 1, {card["fields"][0]["id"]: "只导出已有名单"})
|
||
assert row["status"] == "blocked" and row["inputs"]["_needs_interpretation"] is True
|
||
assert "只导出已有名单" in row["inputs"]["goal"]
|
||
monkeypatch.setattr(module, "interpret_message", AsyncMock(return_value=IntentPlan(brief=ProspectingBrief(intent="export"), summary="导出已有名单")))
|
||
result = await engine.resume(row["id"])
|
||
assert result["status"] == "completed" and result["id"] == "ui-task"
|
||
assert len(result["artifacts"]["clarification_submissions"]) == 1
|
||
|
||
|
||
async def test_chat_reply_preserves_old_card_in_history(workspace, monkeypatch):
|
||
row, engine, _, _ = workspace
|
||
old = cards.clarification(row)
|
||
monkeypatch.setattr(module, "interpret_message", AsyncMock(return_value=IntentPlan(brief=ProspectingBrief(intent="export"), summary="导出已有名单")))
|
||
await engine.message(row["id"], "整理之前已有名单")
|
||
history = row["artifacts"]["clarification_history"][0]
|
||
assert history["id"] == old["id"] and history["status"] == "responded_in_chat"
|
||
assert cards.clarification(row) is None
|
||
|
||
|
||
def test_projection_counts_actual_replies_and_real_progress_events(workspace):
|
||
row, _, _, tmp_path = workspace
|
||
add_sources(row)
|
||
row.update(status="running", current_step="search_read")
|
||
directory = tmp_path / row["id"]
|
||
directory.mkdir()
|
||
(directory / "progress.md").write_text("### ℹ️ [2026-10-29 11:30:00] 步骤 12\n\n- **状态**:running\n- **详情**:动作:scroll\n\n", encoding="utf-8")
|
||
ui = cards.project(row)
|
||
assert ui["phase"]["counts"] == {"notes": 1, "comments": 1, "replies": 1, "profiles": 0, "leads": 1}
|
||
assert ui["phase"]["events"][0]["title"] == "步骤 12"
|
||
assert "percent" not in json.dumps(ui)
|
||
assert ui["clarification"] is None
|
||
|
||
|
||
def test_export_manifest_survives_old_task_artifact_write_and_marks_versions(workspace):
|
||
row, _, _, _ = workspace
|
||
add_sources(row)
|
||
row.update(status="blocked", current_step="stage_results")
|
||
old_artifacts = copy.deepcopy(row["artifacts"])
|
||
first = workflows.export_leads(copy.deepcopy(row))
|
||
second = workflows.export_leads(copy.deepcopy(row))
|
||
assert first["id"] != second["id"] and first["path"] != second["path"]
|
||
assert len(row["artifacts"]["exports"]) == 2
|
||
row["artifacts"] = old_artifacts
|
||
assert len(cards.project(row)["delivery"]["exports"]) == 2
|
||
row["artifacts"]["profiles"] = [{"source_id": "c", "xiaohongshu_id": "newly-verified"}]
|
||
assert all(not item["is_current"] for item in workflows.list_exports(row))
|
||
path, metadata = workflows.export_path(row, first["id"])
|
||
assert path.is_file() and metadata["result_version"] == first["result_version"]
|
||
|
||
|
||
def test_correction_updates_result_version_and_missing_export_is_not_openable(workspace):
|
||
row, _, _, _ = workspace
|
||
add_sources(row)
|
||
entry = workflows.export_leads(row)
|
||
row["artifacts"]["corrections"] = [{"source_id": "c", "text": "还需要核实是否本人找服务"}]
|
||
shown = workflows.list_exports(row)[0]
|
||
assert not shown["is_current"] and shown["available"]
|
||
Path(entry["path"]).unlink()
|
||
shown = cards.project(row)["delivery"]["exports"][0]
|
||
assert not shown["available"] and shown["path"] == ""
|
||
|
||
|
||
def test_export_download_rejects_foreign_id_and_manifest_path_escape(workspace):
|
||
row, _, _, tmp_path = workspace
|
||
add_sources(row)
|
||
entry = workflows.export_leads(row)
|
||
with pytest.raises(ValueError):
|
||
workflows.export_path(row, "a" * 32)
|
||
manifest = tmp_path / row["id"] / "exports" / "manifest.json"
|
||
data = json.loads(manifest.read_text())
|
||
data[0]["filename"] = "../private.csv"
|
||
manifest.write_text(json.dumps(data))
|
||
with pytest.raises(ValueError, match="位置无法核对"):
|
||
workflows.export_path(row, entry["id"])
|
||
|
||
|
||
async def test_independent_review_never_browses_or_clears_account_pause(workspace, monkeypatch):
|
||
row, engine, _, _ = workspace
|
||
add_sources(row)
|
||
row.update(status="blocked", current_step="outreach_results")
|
||
pause = {"message": "发送结果未知,账号后续私信已暂停", "daily_limit": False}
|
||
ledger = [{"id": "action", "status": "unknown"}]
|
||
row["artifacts"].update(outreach_pause=pause, action_ledger=ledger, stop_reason={"code": "message_failed"}, stop_detail=pause["message"])
|
||
monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=empty_analysis))
|
||
await engine.review_saved(row["id"])
|
||
with pytest.raises(ValueError, match="当前阶段"):
|
||
await engine.review_saved(row["id"])
|
||
await engine.jobs[row["id"]]
|
||
await asyncio.sleep(0)
|
||
assert row["status"] == "blocked"
|
||
assert row["artifacts"]["outreach_pause"] == pause and row["artifacts"]["action_ledger"] == ledger
|
||
assert row["inputs"]["_goal_version"] == 1
|
||
module.runner.run_browser_step.assert_not_awaited()
|
||
|
||
|
||
async def test_saved_review_restores_checkpoint_and_accepts_normal_dict_reason(workspace, monkeypatch):
|
||
row, engine, _, tmp_path = workspace
|
||
row.update(status="completed", current_step="results")
|
||
add_sources(row)
|
||
saved = copy.deepcopy(row["artifacts"]["sources"])
|
||
row["artifacts"]["sources"] = []
|
||
row["artifacts"]["stop_reason"] = {"code": "goal_completed"}
|
||
directory = tmp_path / row["id"]
|
||
directory.mkdir()
|
||
(directory / "evidence.json").write_text(json.dumps({"sources": saved}), encoding="utf-8")
|
||
analyze = AsyncMock(side_effect=empty_analysis)
|
||
monkeypatch.setattr(module, "analyze_sources", analyze)
|
||
await engine.review_saved(row["id"])
|
||
await engine.jobs[row["id"]]
|
||
await asyncio.sleep(0)
|
||
assert analyze.await_args.args[1] == saved
|
||
assert row["status"] == "completed" and row["artifacts"]["sources"] == saved
|
||
module.runner.run_browser_step.assert_not_awaited()
|
||
|
||
|
||
@pytest.mark.parametrize("stop,verdict,history_success", [
|
||
("agent_done", "failed", False),
|
||
("review_completed", "failed", False),
|
||
("agent_done", "success", False),
|
||
])
|
||
async def test_review_cannot_complete_an_unsuccessful_collection(workspace, monkeypatch, stop, verdict, history_success):
|
||
row, engine, _, _ = workspace
|
||
add_sources(row)
|
||
row.update(status="completed", current_step="results")
|
||
row["artifacts"].update(stop_reason=stop, browser_verdict=verdict,
|
||
browser_history={"is_done": True, "is_successful": history_success})
|
||
monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=empty_analysis))
|
||
await engine.review_saved(row["id"])
|
||
await engine.jobs[row["id"]]
|
||
await asyncio.sleep(0)
|
||
assert row["status"] == "blocked" and row["current_step"] == "stage_results"
|
||
assert row["artifacts"]["stop_reason"] == "agent_reported_failure"
|
||
assert "原网页采集仍有未完成范围" in row["artifacts"]["stop_detail"]
|
||
module.runner.run_browser_step.assert_not_awaited()
|
||
|
||
|
||
async def test_read_projection_corrects_old_review_success_and_actual_search_coverage_without_writing(workspace):
|
||
row, _, app, _ = workspace
|
||
add_sources(row)
|
||
row.update(status="completed", current_step="results")
|
||
row["inputs"].update(intent="discover", keywords=["杭州跟妆", "杭州新娘跟妆", "求跟妆"])
|
||
row["artifacts"].update(stop_reason="review_completed", summary="已复核7条保存资料", browser_verdict="failed",
|
||
browser_history={"is_done": True, "is_successful": False, "urls": [
|
||
"https://www.xiaohongshu.com/search_result_ai?keyword=%25E6%259D%25AD%25E5%25B7%259E%25E8%25B7%259F%25E5%25A6%2586",
|
||
"https://www.xiaohongshu.com/search_result_ai?keyword=杭州新娘跟妆"]})
|
||
original = copy.deepcopy(row)
|
||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://fixture") as client:
|
||
result = (await client.get("/tasks/ui-task")).json()
|
||
assert row == original
|
||
assert result["status"] == "blocked" and result["current_step"] == "stage_results"
|
||
assert result["ui"]["phase"]["coverage"] == {"observed_keywords": ["杭州跟妆", "杭州新娘跟妆"], "pending_keywords": ["求跟妆"]}
|
||
assert result["artifacts"]["summary"].startswith("阶段结果:")
|
||
assert result["artifacts"]["stop_reason"] == "agent_reported_failure"
|
||
|
||
|
||
def test_read_projection_does_not_downgrade_explicit_export_completion(workspace):
|
||
row, _, _, _ = workspace
|
||
row.update(status="completed", current_step="export_ready")
|
||
row["inputs"]["intent"] = "export"
|
||
row["artifacts"].update(stop_reason="review_completed", browser_verdict="failed")
|
||
assert routes.observed_task(row)["status"] == "completed"
|
||
|
||
|
||
async def test_failed_browser_release_does_not_leave_unowned_running_review(workspace, monkeypatch):
|
||
row, engine, _, _ = workspace
|
||
add_sources(row)
|
||
row["status"] = "blocked"
|
||
monkeypatch.setattr(module.runner, "close_live_session", AsyncMock(side_effect=RuntimeError("fixture")))
|
||
with pytest.raises(ValueError, match="复核没有启动"):
|
||
await engine.review_saved(row["id"])
|
||
assert row["status"] == "blocked" and not engine.jobs
|
||
|
||
|
||
async def test_routes_reject_stale_actions_and_running_exports_and_serve_task_file(workspace):
|
||
row, _, app, _ = workspace
|
||
add_sources(row)
|
||
row.update(status="blocked", current_step="stage_results")
|
||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://fixture") as client:
|
||
stale = await client.post("/tasks/ui-task/alternatives/export", json={"goal_version": 2, "result_version": workflows.result_version(row)})
|
||
assert stale.status_code == 409
|
||
fresh = await client.post("/tasks/ui-task/alternatives/export", json={"goal_version": 1, "result_version": workflows.result_version(row)})
|
||
assert fresh.status_code == 200
|
||
entry = fresh.json()["ui"]["delivery"]["exports"][0]
|
||
download = await client.get(entry["download_url"])
|
||
assert download.status_code == 200 and "text/csv" in download.headers.get("content-type", "")
|
||
assert "小红书号" in download.text
|
||
assert (await client.get("/tasks/foreign/exports/" + entry["id"])).status_code == 404
|
||
row["status"] = "running"
|
||
assert (await client.post("/tasks/ui-task/export")).status_code == 409
|
||
assert (await client.post("/tasks/ui-task/alternatives/export", json={"goal_version": 1, "result_version": workflows.result_version(row)})).status_code == 409
|
||
|
||
|
||
async def test_form_http_contract_returns_task_and_duplicate_is_conflict(workspace, monkeypatch):
|
||
row, _, app, _ = workspace
|
||
nlu = AsyncMock(return_value=IntentPlan(brief=ProspectingBrief(intent="export"), summary="整理名单"))
|
||
monkeypatch.setattr(module, "interpret_message", nlu)
|
||
card = cards.clarification(row)
|
||
body = {"card_id": card["id"], "goal_version": 1, "answers": {card["fields"][0]["id"]: "导出已有名单"}}
|
||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://fixture") as client:
|
||
result = await client.post("/tasks/ui-task/clarification", json=body)
|
||
assert result.status_code == 200 and result.json()["id"] == row["id"]
|
||
assert result.json()["ui"]["clarification"] is None
|
||
assert (await client.post("/tasks/ui-task/clarification", json=body)).status_code == 409
|
||
nlu.assert_awaited_once()
|
||
|
||
|
||
@pytest.mark.parametrize("previous_reason", ["agent_done", "platform_limited"])
|
||
async def test_review_error_is_current_while_original_stop_is_preserved(workspace, monkeypatch, previous_reason):
|
||
row, engine, _, _ = workspace
|
||
add_sources(row)
|
||
row.update(status="blocked", current_step="stage_results")
|
||
row["artifacts"].update(stop_reason=previous_reason, stop_detail="之前的结束说明")
|
||
monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=ValueError("本次资料分析连接失败")))
|
||
await engine.review_saved(row["id"])
|
||
await engine.jobs[row["id"]]
|
||
await asyncio.sleep(0)
|
||
ui = cards.project(row)
|
||
assert ui["phase"]["detail"] == "本次资料分析连接失败"
|
||
assert ui["recovery"]["reason"] == "review_error"
|
||
assert ui["recovery"]["underlying_reason"] == previous_reason
|
||
assert row["artifacts"]["stop_reason"] == previous_reason
|
||
assert row["artifacts"]["stop_detail"] == "之前的结束说明"
|
||
assert len(row["artifacts"]["leads"]) == 1
|
||
monkeypatch.setattr(module, "analyze_sources", AsyncMock(side_effect=empty_analysis))
|
||
await engine.resume(row["id"])
|
||
await engine.jobs[row["id"]]
|
||
await asyncio.sleep(0)
|
||
assert "review_error" not in row["artifacts"]
|
||
module.runner.run_browser_step.assert_not_awaited()
|
||
|
||
|
||
async def test_ui_version_and_actions_use_same_richer_checkpoint(workspace, monkeypatch):
|
||
row, engine, app, tmp_path = workspace
|
||
add_sources(row)
|
||
row.update(status="blocked", current_step="stage_results")
|
||
row["artifacts"]["sources"] = []
|
||
checkpoint = [{"source_id": "c", "source_type": "comment", "content": "想找装修队", "author": "检查点昵称",
|
||
"source_url": "https://www.xiaohongshu.com/explore/a", "observed_at": "2026-10-29T10:00:00+08:00"}]
|
||
directory = tmp_path / row["id"]
|
||
directory.mkdir()
|
||
(directory / "evidence.json").write_text(json.dumps({"sources": checkpoint}), encoding="utf-8")
|
||
analyze = AsyncMock(side_effect=empty_analysis)
|
||
monkeypatch.setattr(module, "analyze_sources", analyze)
|
||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://fixture") as client:
|
||
shown = (await client.get("/tasks/ui-task")).json()
|
||
assert shown["ui"]["delivery"]["source_count"] == 1
|
||
payload = {key: shown["ui"][key] for key in ("goal_version", "result_version")}
|
||
exported = await client.post("/tasks/ui-task/alternatives/export", json=payload)
|
||
assert exported.status_code == 200
|
||
entry = exported.json()["ui"]["delivery"]["exports"][0]
|
||
assert entry["result_version"] == payload["result_version"] and entry["is_current"]
|
||
assert "检查点昵称" in Path(entry["path"]).read_text(encoding="utf-8-sig")
|
||
reviewed = await client.post("/tasks/ui-task/alternatives/review", json=payload)
|
||
assert reviewed.status_code == 200
|
||
await engine.jobs[row["id"]]
|
||
await asyncio.sleep(0)
|
||
assert analyze.await_args.args[1] == checkpoint
|
||
module.runner.run_browser_step.assert_not_awaited()
|
||
|
||
|
||
def test_older_checkpoint_does_not_replace_newer_database_evidence(workspace):
|
||
row, engine, _, tmp_path = workspace
|
||
row["artifacts"]["sources"] = [{"source_id": "same", "source_type": "comment", "author": "最新核对",
|
||
"observed_at": "2026-10-29T11:00:00+08:00"},
|
||
{"source_id": "db-only", "source_type": "reply"}]
|
||
row["artifacts"]["profiles"] = [{"source_id": "same", "xiaohongshu_id": "最新账号",
|
||
"observed_at": "2026-10-29T11:00:00+08:00"}]
|
||
directory = tmp_path / row["id"]
|
||
directory.mkdir()
|
||
(directory / "evidence.json").write_text(json.dumps({"sources": [
|
||
{"source_id": "same", "source_type": "comment", "author": "旧截图", "observed_at": "2026-10-29T10:00:00+08:00"},
|
||
{"source_id": "checkpoint-only", "source_type": "note"}], "profiles": [
|
||
{"source_id": "same", "xiaohongshu_id": "旧账号", "observed_at": "2026-10-29T10:00:00+08:00"}]}), encoding="utf-8")
|
||
restored = engine._recover_evidence(row["id"], row["artifacts"])
|
||
assert {item["source_id"] for item in restored["sources"]} == {"same", "db-only", "checkpoint-only"}
|
||
assert next(item for item in restored["sources"] if item["source_id"] == "same")["author"] == "最新核对"
|
||
assert restored["profiles"][0]["xiaohongshu_id"] == "最新账号"
|