"""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"