"""当前桌面端 API;不挂载学校填报、账号准入和审批回查接口。""" from __future__ import annotations import asyncio import os import time from typing import Any, Literal import httpx from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse from pydantic import BaseModel, Field from backend.browser import runner from backend.browser.llm_factory import get_llm_config from backend.config import MODELS_BY_PROVIDER, settings from backend.db import database as db from backend.orchestrator.progress_doc import progress_doc_path, task_doc_dir from backend.prospecting.contracts import KNOWLEDGE_ID from backend.prospecting.orchestrator import ProspectingOrchestrator, collection_is_incomplete from backend.prospecting import workflows router = APIRouter() orchestrator = ProspectingOrchestrator() STARTED_AT = time.time() class StartRequest(BaseModel): knowledge_id: Literal["xiaohongshu_prospecting"] = KNOWLEDGE_ID message: str = Field(default="", max_length=2000) inputs: dict[str, Any] = Field(default_factory=dict) class InputsRequest(BaseModel): inputs: dict[str, Any] | None = None message: str = Field(default="", max_length=2000) class AnswerRequest(BaseModel): answer: str = Field(default="继续", max_length=2000) class MessageRequest(BaseModel): message: str = Field(min_length=1, max_length=2000) class ClarificationRequest(BaseModel): card_id: str = Field(min_length=1, max_length=200) goal_version: int = Field(ge=1) answers: dict[str, str] class AlternativeRequest(BaseModel): goal_version: int = Field(ge=1) result_version: str = Field(pattern=r"^[a-f0-9]{16}$") force: bool = False class FeedbackRequest(BaseModel): rating: Literal["up", "down"] reason: str = Field(default="", max_length=1000) class CorrectionRequest(BaseModel): text: str = Field(min_length=1, max_length=2000) class BusinessOutcomeRequest(BaseModel): source_id: str = Field(min_length=1, max_length=128) outcome: Literal["replied", "need_confirmed", "cooperated", "no_reply", "not_relevant"] note: str = Field(default="", max_length=1000) class MethodFeedbackRequest(BaseModel): task_id: str | None = Field(default=None, max_length=128) text: str = Field(min_length=1, max_length=1000) class OutreachRequest(BaseModel): action: Literal["follow", "message"] source_ids: list[str] = Field(min_length=1) content: str = Field(default="", max_length=2000) account_profile_url: str = Field(min_length=1, max_length=8192) class AcknowledgeOutreachRequest(BaseModel): account_profile_url: str = Field(min_length=1, max_length=8192) confirmation: Literal[True] class SettingsRequest(BaseModel): llm_provider: Literal["deepseek", "qwen"] | None = None llm_api_key: str | None = Field(default=None, max_length=1000) dashscope_api_key: str | None = Field(default=None, max_length=1000) llm_model: str | None = Field(default=None, max_length=100) llm_base_url: str | None = Field(default=None, max_length=300) def task_for(task_id: str) -> dict: try: return orchestrator.task(task_id) except ValueError as error: raise HTTPException(404, str(error)) from error def observed_task(task: dict) -> dict: """Use the same saved evidence for UI versions and their later actions.""" artifacts = orchestrator._recover_evidence(task["id"], task.get("artifacts") or {}) observed = {**task, "artifacts": artifacts} # Recover coverage only from recorded, actually observed URLs. Older tasks # predate search_result_ai recognition. This projection never writes the DB # or the evidence file and never equates search arrival with reading. from backend.browser.tools import observed_search_keyword history = artifacts.get("browser_history") or {} coverage = dict(artifacts.get("coverage") or {}) keywords = list(coverage.get("search_keywords_observed") or []) newly_observed = False for url in history.get("urls") or []: keyword = observed_search_keyword(url) if keyword and keyword not in keywords: keywords.append(keyword) newly_observed = True if newly_observed: pending = [word for word in task.get("inputs", {}).get("keywords") or [] if word not in keywords] coverage.update(search_keywords_observed=keywords, search_keywords_not_verified=pending) artifacts["coverage"] = coverage artifacts["coverage_note"] = "仅代表已保存的实际文字及回复;未展开、未加载内容与媒体信息仍未知。实际观察到搜索页的关键词:" + "、".join(keywords) + "。" if pending: artifacts["coverage_note"] += "尚未核对检索页访问:" + "、".join(pending) + "。" reason = artifacts.get("stop_reason") if isinstance(reason, dict): reason = reason.get("code") if task.get("status") == "completed" and reason == "review_completed" and collection_is_incomplete(observed): detail = "已保存资料的复核已完成;原网页采集仍有未完成范围,尚未完成整个任务。" artifacts.update(stop_reason="agent_reported_failure", stop_detail=detail, summary="阶段结果:" + str(artifacts.get("summary") or detail)) observed.update(status="blocked", current_step="stage_results", pause_reason=detail) return observed def decorate(task: dict) -> dict: from backend.prospecting import cards # A running browser checkpoints observations before its final return. This # read-only projection shows saved evidence without modifying the task row. task = observed_task(task) artifacts = task["artifacts"] ui = cards.project(task) doc = progress_doc_path(task["id"]) steps = task_doc_dir(task["id"]) / "steps" shots = sorted(steps.glob("step_*.png")) if steps.exists() else [] artifacts = dict(artifacts) corrections = {row["source_id"]: row["text"] for row in artifacts.get("corrections") or []} artifacts["leads"] = [{**lead, **({"correction": corrections[lead.get("source_id", lead.get("id"))]} if lead.get("source_id", lead.get("id")) in corrections else {})} for lead in artifacts.get("leads") or []] artifacts["alternatives"] = workflows.alternatives(task) artifacts["exports"] = ui["delivery"]["exports"] return {**task, "ui": ui, "artifacts": artifacts, "business_name": "小红书拓客", "goal": task["inputs"].get("goal", ""), "reply": (task.get("artifacts") or {}).get("summary"), "needs_input": (task.get("artifacts") or {}).get("plan", {}).get("missing", []), "alternatives": workflows.alternatives(task), "progress_doc_path": str(doc) if doc.exists() else None, "latest_screenshot": str(shots[-1]) if shots else None, "live_session": runner.has_live_session(task["id"]), "takeover": runner.is_taken_over(task["id"])} @router.get("/health") async def health(): return {"status": "ok", "pid": os.getpid(), "product": "橘子拓客"} @router.get("/meta") async def meta(): cfg = get_llm_config() from backend.browser import llm_keys return {"pid": os.getpid(), "version": "0.4.0", "product": "橘子拓客", "platform": "xiaohongshu", "operator_mode": True, "embedded": runner.embedded_mode(), "school_id": settings.school_id, "workspace_id": settings.school_id, "tracking": {"enabled": False, "poll_seconds": 0}, "live_sessions": runner.live_session_ids(), "uptime_s": round(time.time() - STARTED_AT), "llm": {"provider": cfg["provider"], "model": cfg["model"], "api_key_set": bool(cfg["api_key"]), **llm_keys.status(cfg["provider"], cfg["primary_api_key"])}, "features": {"prospecting": True, "public_research": True, "conversation": True, "threaded_replies": True, "csv_export": True, "feedback": True, "outreach": True, "outreach_requires_authorization": True, "outreach_live_verified": False}} @router.get("/knowledge") async def knowledge(): item = db.get_knowledge(KNOWLEDGE_ID) return [item] if item else [] @router.get("/knowledge/methods") async def knowledge_methods(task_id: str | None = None): from backend.prospecting import knowledge as ledger from backend.prospecting.verification import context_for task = task_for(task_id) if task_id else None context = context_for(task) if task else {"platform": "xiaohongshu"} available = ledger.list_methods(context, include_legacy=False) context["available_method_ids"] = [row["id"] for row in available if row.get("kind") == "operation" and row.get("applicability") and row.get("status") != "paused"] return {"methods": ledger.list_methods(context), "selected_ids": (task.get("artifacts") or {}).get("knowledge_selected_ids", []) if task else [], "policy": {"version": ledger.RULE_VERSION, "minimum_tasks": 3, "minimum_objects": 3, "ranking_is_probability": False}} @router.post("/knowledge/methods/{method_id}/feedback") async def method_feedback(method_id: str, body: MethodFeedbackRequest): from backend.prospecting import knowledge as ledger import uuid if not any(row["id"] == method_id for row in ledger.list_methods()): raise HTTPException(404, "方法不属于当前工作区") if not body.text.strip(): raise HTTPException(400, "请输入需要纠正或不适用的情况") task = task_for(body.task_id) if body.task_id else None event = ledger.record_business_feedback(body.task_id or "knowledge_feedback", workflows.result_version(task) if task else "method:" + method_id, {"kind": "method_feedback", "method_id": method_id, "text": body.text.strip(), "technical_verdict": "unknown"}, event_key=uuid.uuid4().hex, goal_version=task["inputs"].get("_goal_version", 1) if task else None) return {"ok": True, "id": event["id"], "message": "已记录方法反馈,系统将结合后续证据判断;本次目标与技术成功计数保持不变。"} def save_business_feedback(task: dict, feedback: dict, event_key: str) -> None: from backend.prospecting.knowledge import record_business_feedback record_business_feedback(task["id"], workflows.result_version(task), feedback, goal_version=task["inputs"].get("_goal_version", 1), event_key=event_key, source="user") def record_export(task_id: str, entry: dict) -> None: from backend.prospecting.verification import export_fact current = task_for(task_id) artifacts = export_fact(current, entry) db.update_task(task_id, artifacts=artifacts) # A learning storage issue must not turn an already generated file into # an export failure or cause its creation to be repeated. try: orchestrator._learn(task_id, artifacts) except Exception: db.update_task(task_id, artifacts={**artifacts, "learning_error": "文件已交付,方法验证记录暂未保存,可稍后重试记录。"}) @router.get("/tasks") async def tasks(limit: int = Query(default=30, ge=1, le=100)): return [decorate(task) for task in db.list_tasks(limit) if task["knowledge_id"] == KNOWLEDGE_ID] @router.post("/tasks/start") async def start(body: StartRequest): try: return decorate(await orchestrator.start(body.message, body.inputs)) except ValueError as error: raise HTTPException(400, str(error)) from error @router.get("/tasks/{task_id}") async def get_task(task_id: str): return decorate(task_for(task_id)) @router.get("/tasks/{task_id}/progress-doc") async def get_progress(task_id: str): task_for(task_id) doc = progress_doc_path(task_id) return {"content": doc.read_text(encoding="utf-8") if doc.exists() else ""} @router.get("/tasks/{task_id}/validation-proof") async def validation_proof(task_id: str, reference: str = Query(min_length=1, max_length=512), event_id: str | None = Query(default=None, min_length=1, max_length=80)): task = observed_task(task_for(task_id)) facts = task.get("artifacts", {}).get("operation_evidence") or [] if event_id: from backend.prospecting import knowledge as ledger summary = ledger.task_summary(task_id) event = next((row for row in summary["events"] if row.get("id") == event_id and row.get("task_id") == task_id), None) refs = (event or {}).get("evidence_refs") or {} pre_refs = refs.get("pre") pre_refs = pre_refs if isinstance(pre_refs, list) else [pre_refs] post_refs = refs.get("post") post_refs = post_refs if isinstance(post_refs, list) else [post_refs] method = next((row for row in summary["methods"] if event and row.get("id") == event.get("method_id")), None) if not event or not method or reference not in pre_refs or len(post_refs) != 1 or not post_refs[0]: raise HTTPException(404, "该任务没有对应的验证事件及完整证据引用") def snapshot(side: str, ref: str) -> dict | None: for fact in facts: if not isinstance(fact, dict) or (fact.get("evidence_refs") or {}).get(side) != ref: continue value = (fact.get("details") or {}).get(side + "_snapshot") if isinstance(value, dict) and value: return value return None pre, post = snapshot("pre", reference), snapshot("post", post_refs[0]) if pre is None or post is None: raise HTTPException(404, "该验证事件缺少已持久化的动作前或动作后快照") return {"id": event["id"], "event_id": event["id"], "capability": method["capability"], "outcome": event["outcome"], "verifier": event.get("metadata", {}).get("verifier"), "observed_at": event.get("created_at"), "pre": pre, "post": post} for fact in facts: if reference not in (fact.get("evidence_refs") or {}).values(): continue details = fact.get("details") or {} return {"id": fact["id"], "capability": fact["capability"], "outcome": fact["outcome"], "verifier": fact["verifier"], "observed_at": fact.get("observed_at"), "pre": details.get("pre_snapshot", {}), "post": details.get("post_snapshot", {})} raise HTTPException(404, "该任务没有此项持久化动作依据,不能把引用当作验收成功") @router.post("/tasks/{task_id}/message") async def message(task_id: str, body: MessageRequest): task_for(task_id) try: return decorate(await orchestrator.message(task_id, body.message)) except ValueError as error: raise HTTPException(409, str(error)) from error @router.post("/tasks/{task_id}/clarification") async def clarification(task_id: str, body: ClarificationRequest): task_for(task_id) try: return decorate(await orchestrator.clarify(task_id, body.card_id, body.goal_version, body.answers)) except ValueError as error: raise HTTPException(409, str(error)) from error @router.post("/tasks/{task_id}/feedback") async def feedback(task_id: str, body: FeedbackRequest): event = workflows.record_feedback(task_for(task_id), body.rating, body.reason) save_business_feedback(task_for(task_id), {"kind": "satisfaction", "rating": body.rating, "reason": body.reason}, event["id"]) return decorate(task_for(task_id)) @router.post("/tasks/{task_id}/leads/{source_id}/correction") async def correction(task_id: str, source_id: str, body: CorrectionRequest): try: workflows.record_correction(task_for(task_id), source_id, body.text) return decorate(task_for(task_id)) except ValueError as error: raise HTTPException(400, str(error)) from error @router.post("/tasks/{task_id}/export") async def export(task_id: str): task = observed_task(task_for(task_id)) if task_id in orchestrator.jobs or task.get("status") == "running": raise HTTPException(409, "请先等待当前阶段结束或停止,再导出当前名单") try: entry = workflows.export_leads(task) record_export(task_id, entry) return entry except ValueError as error: raise HTTPException(409, str(error)) from error @router.get("/tasks/{task_id}/exports/{export_id}") async def exported_file(task_id: str, export_id: str): task = task_for(task_id) try: path, entry = workflows.export_path(task, export_id) return FileResponse(path, media_type="text/csv", filename=entry["filename"]) except ValueError as error: raise HTTPException(404, str(error)) from error @router.post("/tasks/{task_id}/alternatives/{route_id}") async def alternative(task_id: str, route_id: Literal["review", "export"], body: AlternativeRequest): from backend.prospecting.cards import goal_version task = observed_task(task_for(task_id)) if task_id in orchestrator.jobs or task.get("status") == "running": raise HTTPException(409, "请先等待当前阶段结束或停止,再选择后续路线") if body.goal_version != goal_version(task) or body.result_version != workflows.result_version(task): raise HTTPException(409, "任务或结果已更新,请查看最新路线") route = next((row for row in workflows.alternatives(task) if row["id"] == route_id), None) if not route or not route["enabled"]: raise HTTPException(409, "这条路线当前不可用,请查看已有结果") try: if route_id == "export": entry = workflows.export_leads(task) record_export(task_id, entry) return decorate(task_for(task_id)) return decorate(await orchestrator.review_saved(task_id, force=body.force)) except ValueError as error: raise HTTPException(409, str(error)) from error @router.post("/tasks/{task_id}/business-outcome") async def business_outcome(task_id: str, body: BusinessOutcomeRequest): task = task_for(task_id) if body.source_id not in {row.get("source_id") for row in (task.get("artifacts") or {}).get("sources") or []}: raise HTTPException(404, "反馈来源不属于本任务") from backend.prospecting.workflows import now import uuid event = {"id": uuid.uuid4().hex, "kind": "business_outcome", "source_id": body.source_id, "outcome": body.outcome, "note": body.note, "at": now(), "source": "user", "attribution": "unproven"} artifacts = dict(task.get("artifacts") or {}) artifacts["business_outcomes"] = [*(artifacts.get("business_outcomes") or []), event] db.update_task(task_id, artifacts=artifacts) save_business_feedback(task, event, event["id"]) return decorate(task_for(task_id)) @router.post("/tasks/{task_id}/outreach/authorize") async def authorize_outreach(task_id: str, body: OutreachRequest): from backend.prospecting.outreach import authorize_outreach as authorize task = task_for(task_id) if task_id in orchestrator.jobs or task["status"] == "running": raise HTTPException(409, "请先等待当前核对阶段结束或停止,再执行具体跟进计划") try: authorize(task, body.action, body.source_ids, body.content, body.account_profile_url) return decorate(await orchestrator.resume(task_id)) except ValueError as error: raise HTTPException(409, str(error)) from error @router.post("/tasks/{task_id}/outreach/recheck") async def recheck_outreach(task_id: str): task = task_for(task_id) if task_id in orchestrator.jobs or task["status"] == "running": raise HTTPException(409, "当前页面仍在执行,请先等待或停止") if task["inputs"].get("intent") not in {"follow", "message"}: raise HTTPException(409, "当前任务尚无跟进计划") artifacts = dict(task.get("artifacts") or {}) artifacts["outreach_grants"] = [{**row, "status": "recheck_requested"} if row.get("status") == "authorized" else row for row in artifacts.get("outreach_grants") or []] artifacts.pop("outreach_account", None) db.update_task(task_id, artifacts=artifacts) return decorate(await orchestrator.resume(task_id)) @router.post("/tasks/{task_id}/outreach/acknowledge") async def acknowledge_outreach(task_id: str, body: AcknowledgeOutreachRequest): from backend.prospecting.outreach import acknowledge_account_pause task = task_for(task_id) if task_id in orchestrator.jobs or task["status"] == "running": raise HTTPException(409, "请先等待当前页面核对结束") try: acknowledge_account_pause(task, body.account_profile_url, body.confirmation) return decorate(task_for(task_id)) except ValueError as error: raise HTTPException(409, str(error)) from error @router.get("/workflows") async def list_workflows(): return workflows.STAGE_TEMPLATES @router.get("/tasks/{task_id}/screenshot") async def screenshot(task_id: str): task = decorate(task_for(task_id)) if not task["latest_screenshot"]: raise HTTPException(404, "尚无截图") return FileResponse(task["latest_screenshot"], media_type="image/png") @router.post("/tasks/{task_id}/inputs") async def submit_inputs(task_id: str, body: InputsRequest): task_for(task_id) try: return decorate(await orchestrator.inputs(task_id, body.inputs, body.message)) except ValueError as error: raise HTTPException(400, str(error)) from error @router.post("/tasks/{task_id}/human") async def human(task_id: str, body: AnswerRequest): task_for(task_id) try: return decorate(await orchestrator.resume(task_id, body.answer)) except ValueError as error: raise HTTPException(409, str(error)) from error @router.post("/tasks/{task_id}/resume") async def resume(task_id: str): return await human(task_id, AnswerRequest()) @router.post("/tasks/{task_id}/cancel") async def cancel(task_id: str): task_for(task_id) return decorate(await orchestrator.cancel(task_id)) @router.post("/tasks/{task_id}/takeover") async def takeover(task_id: str): task_for(task_id) try: await runner.takeover(task_id) await runner.set_browser_window(task_id, "show") except RuntimeError as error: raise HTTPException(409, str(error)) from error return {"ok": True, "takeover": True} @router.post("/tasks/{task_id}/takeover-done") async def takeover_done(task_id: str): task_for(task_id) await runner.takeover_done(task_id) return {"ok": True, "takeover": False} @router.post("/tasks/{task_id}/browser/{action}") async def browser_window(task_id: str, action: Literal["show", "hide"]): task_for(task_id) try: return await runner.set_browser_window(task_id, action) except RuntimeError as error: raise HTTPException(409, str(error)) from error @router.get("/learned") async def learned(source_task_id: str | None = None, status: str | None = None): if source_task_id: task_for(source_task_id) if status is not None and status not in db.LEARNED_STATUSES: raise HTTPException(400, "无效经验状态") return db.list_learned(KNOWLEDGE_ID, "prospecting", status, source_task_id) @router.post("/learned/{entry_id}/{action}") async def review_learned(entry_id: int, action: Literal["verify", "reject"]): entry = db.get_learned(entry_id) if not entry or entry["knowledge_id"] != KNOWLEDGE_ID: raise HTTPException(404, "经验不存在") return db.set_learned_status(entry_id, "verified" if action == "verify" else "rejected") @router.get("/settings") async def get_settings(): cfg = get_llm_config() return {"llm_provider": cfg["provider"], "llm_api_key_set": bool(cfg["api_key"]), "fallback_key_set": bool(cfg["fallback_api_key"]), "using_fallback": cfg["using_fallback"], "llm_model": cfg["model"], "llm_base_url": cfg["base_url"], "providers": list(MODELS_BY_PROVIDER), "models_by_provider": MODELS_BY_PROVIDER} @router.put("/settings") async def save_settings(body: SettingsRequest): changes = body.model_dump(exclude_none=True) if body.llm_base_url and not body.llm_base_url.startswith("https://"): raise HTTPException(400, "模型接口须为 HTTPS 地址") provider = body.llm_provider or get_llm_config()["provider"] if body.llm_model and body.llm_model not in MODELS_BY_PROVIDER[provider]: raise HTTPException(400, "请从当前提供商支持的模型中选择") for key, value in changes.items(): if value.strip(): db.set_setting(key, value.strip()) return await get_settings() @router.post("/settings/test") async def test_model(): cfg = get_llm_config() if not cfg["api_key"]: raise HTTPException(400, "尚未配置 DeepSeek Key,请填写并保存") payload = {"model": cfg["model"], "messages": [{"role": "user", "content": "只回复:连接成功"}], "max_tokens": 32} if cfg["provider"] == "deepseek": payload["thinking"] = {"type": "disabled"} try: async with httpx.AsyncClient(timeout=30) as client: response = await client.post(cfg["base_url"].rstrip("/") + "/chat/completions", json=payload, headers={"Authorization": "Bearer " + cfg["api_key"]}) if response.status_code >= 400: raise HTTPException(400, {401: "Key 无效", 402: "DeepSeek 余额不足", 429: "模型接口限流"}.get(response.status_code, f"模型接口 HTTP {response.status_code}")) body = response.json() if not (body.get("choices") or [{}])[0].get("message", {}).get("content"): raise HTTPException(502, "模型接口未返回有效回复,连接尚未验证") return {"ok": True, "model": body.get("model", cfg["model"]), "message": "连接测试通过"} except httpx.HTTPError as error: raise HTTPException(502, f"无法连接模型服务({type(error).__name__})") from error class SearchRequest(BaseModel): query: str = Field(min_length=1, max_length=128) count: int = Field(default=3, ge=1, le=10) class FetchRequest(BaseModel): url: str = Field(min_length=1, max_length=8192) @router.post("/research/search") async def research_search(body: SearchRequest): from backend.research import ResearchError, search_public try: return await search_public(body.query, body.count) except ResearchError as error: raise HTTPException(error.status_code, str(error)) from error except ValueError as error: raise HTTPException(400, str(error)) from error @router.post("/research/fetch") async def research_fetch(body: FetchRequest): from backend.research import ResearchError, fetch_public try: return await fetch_public(body.url) except ResearchError as error: raise HTTPException(error.status_code, str(error)) from error except ValueError as error: raise HTTPException(400, str(error)) from error @router.post("/shutdown") async def shutdown(): async def stop(): await asyncio.sleep(0.3) await runner.close_all_sessions() os._exit(0) asyncio.create_task(stop()) return {"ok": True, "pid": os.getpid()}