assistant/tests/test_ui_cards.py

510 lines
28 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""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"] == "最新账号"