assistant/tests/test_v03_runtime_wiring.py

130 lines
9.1 KiB
Python

"""Real isolated DB + local tool observations, without model or platform work."""
from copy import deepcopy
import httpx
import pytest
from fastapi import FastAPI
from backend.browser import tools
from backend.db import database as db
from backend.prospecting import knowledge, orchestrator, routes, workflows
from backend.prospecting.contracts import KNOWLEDGE_ID, ProspectingBrief
from backend.prospecting.prompts import browser_prompt
@pytest.mark.db
async def test_save_learn_recall_proof_export_and_feedback_are_wired(test_school, monkeypatch, tmp_path):
monkeypatch.setattr(db, "PROFILES_DIR", tmp_path / "profiles")
def task_dir(tid):
path = tmp_path / tid
path.mkdir(parents=True, exist_ok=True)
return path
for module in (tools, orchestrator, workflows, routes):
monkeypatch.setattr(module, "task_doc_dir", task_dir)
brief = ProspectingBrief(user_notes="找准备装修的人", target_candidates=1)
task = db.create_task(KNOWLEDGE_ID, "fixture", {**brief.model_dump(), "goal": "找1位准备装修的人", "_goal_version": 1, "_execution_policy": "conversation-v1", "_needs_interpretation": False})
tid = task["id"]
async def observe(session, selector):
return {"url": "https://www.xiaohongshu.com/explore/" + "a" * 24,
"text": "我准备装修,想找设计师", "source_type": "note" if selector == "#note" else "comment",
"author": "本地夹具作者", "author_profile_url": "https://www.xiaohongshu.com/user/profile/" + "b" * 24}
monkeypatch.setattr(tools, "_observe_source", observe)
tools.set_prospecting_context(tid)
try:
for kind in ("note", "comment"):
await tools._save_source(tid, object(), source_type=kind, content="我准备装修,想找设计师", source_selector="#" + kind)
artifacts = deepcopy(tools.get_task_artifacts(tid))
artifacts.update(leads=[{**row, "decision": "keep", "reason": "本人需求", "seeking_service": "yes"} for row in artifacts["sources"]],
analysis_goal_version=1, browser_verdict="success", browser_history={"is_successful": True}, stop_reason="target_reached")
db.update_task(tid, artifacts=artifacts, status="completed", current_step="results")
orchestrator.ProspectingOrchestrator._learn(tid, artifacts)
saved = db.get_task(tid)
assert saved["artifacts"]["knowledge_summary"]["outcomes"]["success"] == 3
assert all(row["stats"]["success"] == 1 for row in knowledge.task_summary(tid)["methods"])
orchestrator.ProspectingOrchestrator._learn(tid, saved["artifacts"])
assert len(knowledge.task_summary(tid)["events"]) == 3
next_task = db.create_task(KNOWLEDGE_ID, "fixture", {**brief.model_dump(), "_goal_version": 1})
methods = orchestrator.ranked_experience(next_task)
assert len(methods) == 3 and "保存" in browser_prompt(brief, methods)
db.update_task(next_task["id"], artifacts={"knowledge_methods": methods}) # JSONB must accept all public projections.
app = FastAPI()
app.include_router(routes.router)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://fixture") as client:
fact = artifacts["operation_evidence"][0]
proof = await client.get(f"/tasks/{tid}/validation-proof", params={"reference": fact["evidence_refs"]["pre"]})
assert proof.status_code == 200 and proof.json()["pre"]["quote_matched"] is True
assert proof.json()["post"]["persisted"] is True
assert (await client.get(f"/tasks/{tid}/validation-proof", params={"reference": "foreign:proof"})).status_code == 404
summary = knowledge.task_summary(tid)
stage_method = next(row for row in summary["methods"] if row["kind"] == "pipeline")
stage_event = next(row for row in summary["events"] if row["method_id"] == stage_method["id"])
note_fact, discussion_fact = artifacts["operation_evidence"]
params = {"reference": stage_event["evidence_refs"]["pre"], "event_id": stage_event["id"]}
stage_proof = await client.get(f"/tasks/{tid}/validation-proof", params=params)
assert stage_proof.status_code == 200
stage_body = stage_proof.json()
assert stage_body["id"] == stage_event["id"] and stage_body["capability"] == "read_discussion"
assert stage_body["verifier"] == "same_note_saved_sources" and stage_body["outcome"] == "success"
assert stage_body["observed_at"] == stage_event["created_at"]
assert stage_body["pre"] == note_fact["details"]["pre_snapshot"]
assert stage_body["post"] == discussion_fact["details"]["post_snapshot"]
assert stage_body["post"]["source_type"] == "comment"
assert (await client.get(f"/tasks/{tid}/validation-proof", params={**params, "event_id": "validation_foreign"})).status_code == 404
assert (await client.get(f"/tasks/{next_task['id']}/validation-proof", params=params)).status_code == 404
assert (await client.get(f"/tasks/{tid}/validation-proof", params={**params, "reference": discussion_fact["evidence_refs"]["pre"]})).status_code == 404
assert (await client.get(f"/tasks/{tid}/validation-proof", params={**params, "reference": stage_event["evidence_refs"]["post"]})).status_code == 404
# Missing either persisted half fails closed, rather than returning
# the note operation's own post-state as the whole stage result.
original_summary = knowledge.task_summary
missing = deepcopy(summary)
missing_event = next(row for row in missing["events"] if row["id"] == stage_event["id"])
missing_event["evidence_refs"]["post"] = f"{tid}:missing:post"
with monkeypatch.context() as scoped:
scoped.setattr(knowledge, "task_summary", lambda _tid: missing)
assert (await client.get(f"/tasks/{tid}/validation-proof", params=params)).status_code == 404
missing_event["evidence_refs"]["post"] = stage_event["evidence_refs"]["post"]
missing_event["evidence_refs"]["pre"] = f"{tid}:missing:pre"
assert (await client.get(f"/tasks/{tid}/validation-proof", params={**params, "reference": missing_event["evidence_refs"]["pre"]})).status_code == 404
assert knowledge.task_summary is original_summary
before = knowledge.list_methods(include_legacy=False)
feedback = await client.post(f"/tasks/{tid}/feedback", json={"rating": "up", "reason": "本地夹具反馈"})
assert feedback.status_code == 200
outcome = await client.post(f"/tasks/{tid}/business-outcome", json={"source_id": artifacts["sources"][0]["source_id"], "outcome": "replied"})
assert outcome.status_code == 200
old_inputs = db.get_task(tid)["inputs"]
correction = await client.post(f"/knowledge/methods/{before[0]['id']}/feedback", json={"task_id": tid, "text": "这次入口和预期不同,请后续核对"})
assert correction.status_code == 200 and db.get_task(tid)["inputs"] == old_inputs
assert knowledge.list_methods(include_legacy=False) == before
for _ in range(2):
assert (await client.post(f"/tasks/{tid}/export")).status_code == 200
export_methods = [row for row in knowledge.list_methods(include_legacy=False) if row["capability"] == "export"]
assert len(export_methods) == 1 and export_methods[0]["stats"]["success"] == 1
assert len(knowledge.task_summary(tid)["business_feedback"]) == 3
finally:
tools.clear_task_context(tid)
@pytest.mark.db
async def test_export_only_creates_real_current_file_without_key_and_failure_is_not_complete(test_school, monkeypatch, tmp_path):
monkeypatch.setattr(db, "PROFILES_DIR", tmp_path / "profiles")
def task_dir(tid):
path = tmp_path / tid
path.mkdir(exist_ok=True)
return path
for module in (orchestrator, workflows, routes):
monkeypatch.setattr(module, "task_doc_dir", task_dir)
brief = ProspectingBrief(intent="export", user_notes="导出已有名单")
row = db.create_task(KNOWLEDGE_ID, "fixture", {**brief.model_dump(), "goal": "导出已有名单", "_goal_version": 1,
"_execution_policy": "conversation-v1", "_needs_interpretation": False})
monkeypatch.setattr(orchestrator, "get_llm_config", lambda: {"api_key": ""})
engine = orchestrator.ProspectingOrchestrator()
done = await engine._prepare(row)
assert done["status"] == "completed"
files = workflows.list_exports(done)
assert len(files) == 1 and files[0]["available"] and files[0]["is_current"]
assert knowledge.task_summary(row["id"])["outcomes"]["success"] == 1
second = db.create_task(KNOWLEDGE_ID, "fixture", row["inputs"])
monkeypatch.setattr(workflows, "export_leads", lambda task: (_ for _ in ()).throw(OSError("fixture unavailable storage")))
failed = await engine._prepare(second)
assert failed["status"] == "blocked" and failed["artifacts"]["stop_reason"] == "file_error"