"""对话目标 → 分阶段页面执行 → 来源分析 → 独立复盘。""" from __future__ import annotations import asyncio import json import re import uuid from copy import deepcopy from typing import Any from backend.browser import runner from backend.browser.llm_factory import get_llm_config from backend.config import settings from backend.db import database as db from backend.orchestrator.progress_doc import append_progress, init_progress_doc, task_doc_dir, write_summary from backend.prospecting.analysis import analyze_sources from backend.prospecting.contracts import ALLOWED_DOMAINS, BUSINESS_ID, KNOWLEDGE_ID, ProspectingBrief from backend.prospecting.dialogue import interpret_message from backend.prospecting.prompts import browser_prompt from backend.prospecting.request_routing import browser_recheck_requested, browser_recheck_brief, ground_execution_plan, recheck_without_analysis from backend.browser.preflight_policy import PreflightPolicy, compile_preflight_policy BRIEF_FIELDS = set(ProspectingBrief.model_fields) EXECUTION_POLICY = "conversation-v1" REQUIREMENTS_GROUNDING_VERSION = "request-fields-v2" CONTROL_CONTINUE = {"继续", "继续执行", "已处理,请继续", "已登录,继续", "用户已处理,请继续"} CONTROL_STOP = {"停止", "暂停", "停一下", "暂停一下", "停止任务", "停止执行", "先停下来", "先停一下"} CONTROL_STATUS = {"进度怎么样", "进度怎么样了", "进展怎么样", "现在进度怎么样", "当前进度", "查看进度", "任务进度", "现在什么情况", "做到哪了", "你现在在干嘛"} def ranked_experience(task: dict) -> list[dict]: from backend.prospecting import knowledge from backend.prospecting.verification import context_for context = context_for(task) available = knowledge.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 knowledge.select_methods(context) def persist_knowledge(task: dict, artifacts: dict) -> dict: from backend.prospecting.verification import synchronize return synchronize(task, artifacts) def requested_file(task: dict, artifacts: dict) -> dict: if task.get("inputs", {}).get("intent") != "export" and "csv" not in (task.get("inputs", {}).get("required_result_fields") or []): return artifacts from backend.prospecting import workflows from backend.prospecting.verification import export_fact task = db.update_task(task["id"], artifacts=artifacts) if any(row.get("available") and row.get("is_current") for row in workflows.list_exports(task)): return task["artifacts"] entry = workflows.export_leads(task) task = db.get_task(task["id"]) artifacts = export_fact(task, entry) try: artifacts = persist_knowledge(task, artifacts) except Exception: artifacts["learning_error"] = "文件已生成,方法验证记录暂未保存。" db.update_task(task["id"], artifacts=artifacts) return artifacts def _merge_learning_evidence(field: str, checkpoint: list, persisted: list) -> tuple[list[dict], list[dict]]: """Union saved learning records; DB wins ambiguous conflicts, both stay auditable.""" indexed: dict[str, dict] = {} origins: dict[str, str] = {} conflicts = [] for origin, rows in (("checkpoint", checkpoint), ("database", persisted)): for row in rows: if not isinstance(row, dict): continue raw_identity = row.get("key") if field == "learning_candidates" else row.get("entry_id", row.get("id")) identity = str(raw_identity) if raw_identity is not None else json.dumps(row, sort_keys=True, ensure_ascii=False) previous = indexed.get(identity) conflicting = sorted(key for key in row if previous is not None and key in previous and row[key] != previous[key]) if conflicting: conflicts.append({"field": field, "identity": identity, "conflicting_fields": conflicting, "kept_origin": origin, "kept": dict(row), "other_origin": origins[identity], "other": dict(previous)}) indexed[identity] = {**(previous or {}), **row} origins[identity] = origin return list(indexed.values()), conflicts def collection_is_incomplete(task: dict) -> bool: """A saved-source review cannot complete an unfinished collection stage. Consult recorded outcomes as well as stop strings, including old tasks whose unsuccessful done was incorrectly labelled agent_done. """ if task.get("inputs", {}).get("intent", "discover") not in {"discover", "research", "inspect"}: return False artifacts = task.get("artifacts") or {} from backend.prospecting.people import target_reached if artifacts.get("stop_reason") == "target_reached" and target_reached(task): return False history = artifacts.get("browser_history") or {} return artifacts.get("browser_verdict") in {"failed", "interrupted"} or history.get("is_successful") is False class ProspectingOrchestrator: def __init__(self) -> None: self.jobs: dict[str, asyncio.Task] = {} self.active_task: str | None = None @staticmethod def task(task_id: str) -> dict: task = db.get_task(task_id) if not task or task["knowledge_id"] != KNOWLEDGE_ID: raise ValueError("拓客任务不存在") return task @staticmethod def _brief(task: dict) -> ProspectingBrief: return ProspectingBrief.model_validate({key: value for key, value in task["inputs"].items() if key in BRIEF_FIELDS}) @staticmethod def _message(artifacts: dict, role: str, text: str) -> None: if not text: return messages = artifacts.setdefault("messages", []) if messages and messages[-1].get("role") == role and messages[-1].get("text") == text: return messages.append({"id": uuid.uuid4().hex, "role": role, "text": text}) @staticmethod def _default_plan(brief: ProspectingBrief) -> dict: stages = {"discover": ["搜索发现", "阅读讨论及回复", "需求判断", "整理结果"], "research": ["搜索发现", "阅读讨论及回复", "需求研究"], "inspect": ["检查指定页面", "阅读讨论及回复", "整理结果"], "export": ["整理现有名单", "导出"], "follow": ["核对对象", "关注"], "message": ["核对对象及话术", "私信", "核对发送结果"]}[brief.intent] return {"summary": brief.user_notes or brief.target_customer or "按当前条件推进任务", "stages": stages, "missing": brief.missing(), "assumptions": [], "reuse_existing_sources": False} @staticmethod def _review(task_id: str, artifacts: dict) -> dict: from backend.prospecting.workflows import finalize_review return finalize_review(task_id, artifacts) def recover_interrupted(self) -> None: for task in db.list_tasks(1000): if task["knowledge_id"] == KNOWLEDGE_ID and task["status"] in ("running", "paused"): artifacts = self._recover_evidence(task["id"], task.get("artifacts") or {}) artifacts["analysis_pending"] = bool(artifacts.get("sources")) artifacts["browser_verdict"] = "interrupted" artifacts["stop_reason"] = "app_interrupted" artifacts["stop_detail"] = "应用重启中断了执行;已有来源及动作记录保留,可以继续同一任务" db.update_task(task["id"], status="blocked", artifacts=artifacts, pause_reason=artifacts["stop_detail"], current_step="interrupted") @staticmethod def _recover_evidence(task_id: str, artifacts: dict) -> dict: recovered = dict(artifacts) snapshot = task_doc_dir(task_id) / "evidence.json" if snapshot.exists(): try: saved = json.loads(snapshot.read_text(encoding="utf-8")) for key in ("sources", "learning_candidates", "learning_outcomes", "profiles", "source_observations", "reply_expansions", "operation_evidence", "navigation_attempts", "preflight_observations", "preflight_navigation"): if isinstance(saved.get(key), list): if key in {"sources", "profiles"}: # Checkpoints may be newer than the DB during a # browser run, or older after analysis/profile edits. # Preserve the union; same-source conflicts use the # latest observation, with DB winning unknown/ties. indexed = {} for row in saved[key]: if isinstance(row, dict): identity = row.get("source_id") or json.dumps(row, sort_keys=True, ensure_ascii=False) indexed[identity] = row for row in recovered.get(key) or []: if not isinstance(row, dict): continue identity = row.get("source_id") or json.dumps(row, sort_keys=True, ensure_ascii=False) previous = indexed.get(identity) old_time, new_time = (previous or {}).get("observed_at"), row.get("observed_at") if not previous or not old_time or not new_time or str(new_time) >= str(old_time): indexed[identity] = row recovered[key] = list(indexed.values()) elif key in {"operation_evidence", "navigation_attempts", "preflight_observations", "preflight_navigation"}: recovered[key] = list({row["id"]: row for row in [*saved[key], *(recovered.get(key) or [])] if isinstance(row, dict) and row.get("id")}.values()) elif key in {"learning_candidates", "learning_outcomes"}: merged, conflicts = _merge_learning_evidence(key, saved[key], recovered.get(key) or []) recovered[key] = merged if conflicts: audit = [*(recovered.get("learning_recovery_conflicts") or []), *conflicts] unique = {} for row in audit: fields = row.get("conflicting_fields") or [] signature = json.dumps([row.get("field"), row.get("identity"), row.get("kept_origin"), {key: row.get("kept", {}).get(key) for key in fields}, {key: row.get("other", {}).get(key) for key in fields}], sort_keys=True, ensure_ascii=False) unique.setdefault(signature, row) recovered["learning_recovery_conflicts"] = list(unique.values()) else: recovered[key] = saved[key] marker = saved.get("preflight_run") if (not recovered.get("preflight_run") and isinstance(marker, dict) and set(marker) == {"run_id", "goal_version"} and isinstance(marker.get("run_id"), str) and re.fullmatch(r"[a-f0-9]{32}", marker["run_id"]) and type(marker.get("goal_version")) is int and marker["goal_version"] > 0): recovered["preflight_run"] = marker if isinstance(saved.get("coverage"), dict): recovered["coverage"] = saved["coverage"] if isinstance(saved.get("action_ledger"), list): # An in-flight browser snapshot can be older than a native # outreach action committed to the task row. DB entries win. actions = {row["id"]: row for row in saved["action_ledger"] if isinstance(row, dict) and row.get("id")} actions.update({row["id"]: row for row in recovered.get("action_ledger") or [] if row.get("id")}) recovered["action_ledger"] = list(actions.values()) except (ValueError, OSError, AttributeError): pass return recovered @staticmethod def _steps_used(task: dict) -> int: used = max(0, int(task["inputs"].get("_steps_used", 0))) checkpoint = task_doc_dir(task["id"]) / "browser-budget.json" if checkpoint.exists(): try: saved = json.loads(checkpoint.read_text(encoding="utf-8")) total = saved["total_steps_used"] if type(total) is not int or total < 0: raise ValueError("invalid step total") used = max(used, total) except (ValueError, OSError, AttributeError, TypeError, KeyError) as error: raise ValueError("浏览器步骤台账无法核对,保留现有记录,请检查任务证据") from error return used async def start(self, message: str, inputs: dict | None = None) -> dict: brief = ProspectingBrief.model_validate(inputs or {}) text = message.strip() if text and not brief.user_notes: brief = brief.model_copy(update={"user_notes": text[:2000]}) # Explicit structured input is already an editable user plan. Plain # conversation must be interpreted; no regex guesses replace the model. structured = bool(inputs and any(inputs.get(key) for key in ("keywords", "target_customer", "subject_urls", "action_scope"))) values = {**brief.model_dump(), "goal": text or brief.target_customer or "", "business_id": BUSINESS_ID, "_needs_interpretation": bool(text and not structured), "_goal_version": 1, "_execution_policy": EXECUTION_POLICY, "_requirements_grounding_version": REQUIREMENTS_GROUNDING_VERSION if structured else None} task = db.create_task(KNOWLEDGE_ID, "prospector", values) from backend.prospecting.usage import initialize_task initialize_task(task["id"]) artifacts = {"plan": self._default_plan(brief), "messages": []} self._message(artifacts, "user", text) task = db.update_task(task["id"], artifacts=artifacts) init_progress_doc(task["id"], "小红书 AI 拓客", values["goal"]) append_progress(task["id"], "理解任务目标", "collecting", text or "等待用户描述任务") return await self._prepare(task) async def _interrupt_for_revision(self, task_id: str, *, preserve_browser: bool = False) -> dict: with runner.preserve_managed_view_for_revision(task_id, enabled=preserve_browser) as preserved: job = self.jobs.get(task_id) if job and not job.done(): job.cancel() try: await job except asyncio.CancelledError: pass await asyncio.sleep(0) if not preserved: await runner.close_live_session(task_id) if self.active_task == task_id: self.active_task = None current = self.task(task_id) artifacts = self._recover_evidence(task_id, current.get("artifacts") or {}) return db.update_task(task_id, artifacts=artifacts, status="collecting", pause_reason=None) async def message(self, task_id: str, message: str) -> dict: text = message.strip() if not text: raise ValueError("请输入要补充或调整的内容") task = self.task(task_id) if text.rstrip("??。!! ") in CONTROL_STOP: task = await self.cancel(task_id) artifacts = dict(task.get("artifacts") or {}) self._message(artifacts, "user", text) self._message(artifacts, "assistant", "已停止后续执行,已有结果和进度保留,可以在本对话继续。") return db.update_task(task_id, artifacts=artifacts) if text.rstrip("??。!! ") in CONTROL_STATUS: artifacts = self._recover_evidence(task_id, task.get("artifacts") or {}) self._message(artifacts, "user", text) sources = artifacts.get("sources") or [] notes = sum(row.get("source_type") == "note" for row in sources) comments = sum(row.get("source_type") in {"comment", "reply"} for row in sources) phase = {"search_read": "搜索与阅读", "analyze": "需求分析", "outreach_preflight": "跟进前置核对", "outreach_results": "跟进结果", "results": "结果整理", "stage_results": "阶段结果"}.get(task.get("current_step"), "任务处理") self._message(artifacts, "assistant", f"当前阶段:{phase}。已保存{notes}篇笔记、{comments}条评论或回复。" + (task.get("pause_reason") or artifacts.get("stop_detail") or "仍在执行,未保存的范围不计入已读。")) return db.update_task(task_id, artifacts=artifacts) if text in CONTROL_CONTINUE: artifacts = self._recover_evidence(task_id, task.get("artifacts") or {}) self._message(artifacts, "user", text) task = db.update_task(task_id, artifacts=artifacts) return await self.resume(task_id, text) from backend.prospecting.cards import clarification previous_card = clarification(task) if (previous_card and len(previous_card["fields"]) == 1 and previous_card["fields"][0]["key"] == "target_candidates" and re.fullmatch(r"[1-9][0-9]*", text) and not task["inputs"].get("_needs_interpretation")): return await self.clarify(task_id, previous_card["id"], previous_card["goal_version"], {previous_card["fields"][0]["id"]: text}) task = await self._interrupt_for_revision(task_id, preserve_browser=browser_recheck_requested(text)) artifacts = dict(task.get("artifacts") or {}) if previous_card: from backend.prospecting.workflows import now artifacts.setdefault("clarification_history", []).append({**previous_card, "status": "responded_in_chat", "response_text": text, "submitted_at": now()}) artifacts.pop("review_error", None) self._message(artifacts, "user", text) versions = artifacts.setdefault("goal_versions", []) if artifacts.get("leads"): artifacts.setdefault("analysis_goal_version", task["inputs"].get("_goal_version", 1)) versions.append({"version": task["inputs"].get("_goal_version", 1), "goal": task["inputs"].get("goal", ""), "brief": self._brief(task).model_dump()}) values = {**task["inputs"], "goal": text, "_needs_interpretation": True, "_goal_version": int(task["inputs"].get("_goal_version", 1)) + 1, "_execution_policy": EXECUTION_POLICY} artifacts["analysis_pending"] = False updated = db.update_task(task_id, inputs=values, artifacts=artifacts) return await self._prepare(updated) async def clarify(self, task_id: str, card_id: str, version: int, answers: dict[str, str]) -> dict: from backend.prospecting.cards import clarification, validate_answers from backend.prospecting.workflows import now task = self.task(task_id) if task_id in self.jobs or task["status"] == "running": raise ValueError("当前阶段仍在执行,请查看最新进展") text = validate_answers(task, card_id, version, answers) card = clarification(task) artifacts = dict(task.get("artifacts") or {}) event = {"card_id": card_id, "goal_version": version, "answers": answers, "submitted_at": now()} artifacts.setdefault("clarification_submissions", []).append(event) artifacts.setdefault("clarification_history", []).append({**card, **event, "status": "answered"}) db.update_task(task_id, artifacts=artifacts) if (card and len(card["fields"]) == 1 and card["fields"][0]["key"] == "target_candidates" and not task["inputs"].get("_needs_interpretation")): self._message(artifacts, "user", text) db.update_task(task_id, artifacts=artifacts) return await self.inputs(task_id, {"target_candidates": int(answers[card["fields"][0]["id"]])}) # Exactly the same interpretation and same-task revision path as a chat # response. Form values cannot patch internal fields or grant outreach. return await self.message(task_id, text) async def review_saved(self, task_id: str, *, force: bool = False) -> dict: task = self.task(task_id) if task_id in self.jobs or task["status"] == "running": raise ValueError("请先等待当前阶段结束或停止,再复核资料") artifacts = self._recover_evidence(task_id, task.get("artifacts") or {}) if not artifacts.get("sources"): raise ValueError("当前任务没有可复核的已保存来源") from backend.prospecting.execution import analysis_is_current needs_call = force or not analysis_is_current({**task, "artifacts": artifacts}, self._brief(task)) if needs_call and not get_llm_config()["api_key"]: raise ValueError("尚未配置 DeepSeek Key,请先在设置中填写") artifacts.pop("review_error", None) artifacts.pop("analysis_cached", None) # Reserve the task before the first await, preventing duplicate route # selection while the parked browser is being released. db.update_task(task_id, status="running", current_step="analyze", pause_reason=None, artifacts=artifacts) # Stop a parked, paused session before independent analysis. No resume, # no profile navigation, no outreach grant or account pause changes. try: await runner.close_live_session(task_id) except Exception as error: note = "执行页面尚未能释放,资料复核没有启动,请检查当前页面后继续" from backend.prospecting.workflows import now latest = self.task(task_id) failed = dict(latest.get("artifacts") or {}) failed["review_error"] = {"message": note, "at": now()} db.update_task(task_id, status="blocked", current_step="analyze", pause_reason=note, artifacts=failed) raise ValueError(note) from error if self.active_task == task_id: self.active_task = None task = self.task(task_id) if task.get("status") != "running" or task.get("current_step") != "analyze": raise ValueError("任务状态已变化,请查看最新进展") artifacts = self._recover_evidence(task_id, task.get("artifacts") or {}) artifacts["analysis_pending"] = True artifacts["reviewing_saved_only"] = True artifacts.pop("review_error", None) self._message(artifacts, "user", "按当前标准复核已保存资料,不新增网页操作。") updated = db.update_task(task_id, status="running", current_step="analyze", pause_reason=None, artifacts=artifacts) job = asyncio.create_task(self._run_saved_review(task_id, self._brief(updated), force=force)) self.jobs[task_id] = job def release(completed: asyncio.Task, tid: str = task_id) -> None: if self.jobs.get(tid) is completed: self.jobs.pop(tid, None) job.add_done_callback(release) return updated async def _run_saved_review(self, task_id: str, brief: ProspectingBrief, *, force: bool = False) -> None: try: task = self.task(task_id) sources = (task.get("artifacts") or {}).get("sources") or [] append_progress(task_id, "复核已保存资料", "running", f"仅分析 {len(sources)} 条已保存来源,不操作网页") from backend.prospecting.execution import analyze_evidence from backend.prospecting.usage import usage_scope with usage_scope(task_id, "review"): analysis = await analyze_evidence(task, brief, analyze_sources, force=force) artifacts = {**self.task(task_id).get("artifacts", {}), **analysis} artifacts = requested_file(self.task(task_id), artifacts) artifacts.update(analysis_pending=False, reviewing_saved_only=False) artifacts.pop("review_error", None) note = ("已有资料、判断标准和模型未变化,沿用已核验结果,本次未新增模型调用。" if analysis.get("analysis_cached") else f"已复核 {analysis.get('analysis_new_count', len(sources))} 条新增或变化资料,复用 {analysis.get('analysis_reused_count', 0)} 条已有判断。") note += f"保留 {len(analysis.get('leads') or [])} 条待核来源;未新增网页取证,原暂停与动作记录保留。" artifacts["summary"] = note self._message(artifacts, "assistant", note) # A successful independent review does not clear a browser/platform # failure. Keep that cause visible alongside the delivered result. stop = artifacts.get("stop_reason") if isinstance(stop, dict): stop = stop.get("code") or stop.get("message") incomplete_collection = collection_is_incomplete({**task, "artifacts": artifacts}) from backend.prospecting.people import target_progress progress = target_progress({**task, "artifacts": artifacts}) shortfall = brief.intent == "discover" and progress.get("target") is not None and not progress["reached"] blocked = shortfall or incomplete_collection or bool(artifacts.get("outreach_pause")) or stop not in {None, "agent_done", "model_done", "goal_completed", "export_ready", "review_completed", "target_reached"} if incomplete_collection and stop in {None, "agent_done", "model_done", "goal_completed", "review_completed"}: artifacts.update(stop_reason="agent_reported_failure", stop_detail="已保存资料的复核已完成;原网页采集仍有未完成范围,尚未完成整个任务。") elif shortfall and stop in {None, "agent_done", "model_done", "goal_completed", "review_completed", "target_reached"}: artifacts.update(stop_reason="target_shortfall", stop_detail=f"已保存资料复核结束,当前{progress['current']} / {progress['target']}位候选,尚未达到人数目标。") if not blocked: artifacts.update(stop_reason="review_completed", stop_detail=note) db.update_task(task_id, status="blocked" if blocked else "completed", current_step="stage_results" if blocked else "results", pause_reason=artifacts.get("stop_detail") if blocked else None, artifacts=artifacts) append_progress(task_id, "已保存资料复核完成", "completed", note, level="success") except asyncio.CancelledError: raise except Exception as error: task = self.task(task_id) artifacts = dict(task.get("artifacts") or {}) note = str(error)[:300] if isinstance(error, ValueError) else f"资料复核未完成:{type(error).__name__},原结果保留" artifacts.update(analysis_pending=True, reviewing_saved_only=False) from backend.prospecting.workflows import now artifacts["review_error"] = {"message": note, "at": now()} self._message(artifacts, "assistant", note) db.update_task(task_id, status="blocked", current_step="analyze", pause_reason=note, artifacts=artifacts) finally: task = self.task(task_id) try: self._learn(task_id, self._review(task_id, task.get("artifacts") or {})) except Exception as error: # A failed learning wrap-up is visible, without replacing the # independently delivered result or the original account pause. latest = self.task(task_id) artifacts = dict(latest.get("artifacts") or {}) artifacts["learning_error"] = f"独立复盘未完成:{type(error).__name__},现有结果保留" db.update_task(task_id, artifacts=artifacts) async def inputs(self, task_id: str, inputs: dict | None, message: str = "") -> dict: task = self.task(task_id) updates = inputs or {} if set(updates) - BRIEF_FIELDS: raise ValueError("只能更新目标条件,不能修改内部执行记录") if updates: from backend.prospecting.cards import clarification from backend.prospecting.workflows import now previous_card = clarification(task) task = await self._interrupt_for_revision(task_id) brief = ProspectingBrief.model_validate({**self._brief(task).model_dump(), **updates}) artifacts = dict(task.get("artifacts") or {}) if previous_card: artifacts.setdefault("clarification_history", []).append({**previous_card, "status": "superseded", "submitted_at": now()}) artifacts.setdefault("goal_versions", []).append({"version": task["inputs"].get("_goal_version", 1), "goal": task["inputs"].get("goal", ""), "brief": self._brief(task).model_dump()}) artifacts["plan"] = self._default_plan(brief) if artifacts.get("leads"): artifacts.setdefault("analysis_goal_version", task["inputs"].get("_goal_version", 1)) if set(updates) <= {"target_candidates", "discovery_scope", "max_notes", "max_comments_per_note"} and artifacts.get("analysis_cache"): artifacts["analysis_goal_version"] = int(task["inputs"].get("_goal_version", 1)) + 1 artifacts["analysis_pending"] = bool(artifacts.get("sources")) task = db.update_task(task_id, inputs={**task["inputs"], **brief.model_dump(), "_needs_interpretation": False, "_goal_version": int(task["inputs"].get("_goal_version", 1)) + 1}, artifacts=artifacts) if message.strip(): return await self.message(task_id, message) return await self._prepare(task) async def resume(self, task_id: str, answer: str = "继续") -> dict: task = self.task(task_id) if task["status"] == "running" or task_id in self.jobs: raise ValueError("任务正在运行") if (task.get("artifacts") or {}).get("review_error"): self._reground_requirements_on_resume(task) return await self.review_saved(task_id) return await self._prepare(task, answer.strip()[:2000] or "用户已处理,请继续") def _reground_requirements_on_resume(self, task: dict) -> dict: """Repair old compiled fields only on explicit continuation, never GET. The later confirmed quantity remains authoritative. Previous goals, source facts, files and validation events retain their original version. """ from backend.prospecting.dialogue import reground_requirements values = dict(task["inputs"]) if (values.get("_requirements_grounding_version") == REQUIREMENTS_GROUNDING_VERSION or values.get("_needs_interpretation")): return task artifacts = deepcopy(task.get("artifacts") or {}) messages = artifacts.get("messages") or [] if not any(row.get("role") == "user" and row.get("text", "").strip() for row in messages): return task before = self._brief(task) after = reground_requirements(before, messages) changes = {key: {"before": getattr(before, key), "after": getattr(after, key)} for key in ("required_result_fields", "region") if getattr(before, key) != getattr(after, key)} values["_requirements_grounding_version"] = REQUIREMENTS_GROUNDING_VERSION if changes: old_version = values.get("_goal_version", 1) artifacts.setdefault("goal_versions", []).append({"version": old_version, "goal": values.get("goal", ""), "brief": before.model_dump(), "reason": "request_fields_revalidated"}) values.update(after.model_dump()) values["_goal_version"] = int(old_version) + 1 artifacts.setdefault("requirement_corrections", []).append({"from_version": old_version, "to_version": values["_goal_version"], "rule_version": REQUIREMENTS_GROUNDING_VERSION, "changes": changes, "source": "original_user_instructions"}) note = "已按你的原话重新核对必需字段,保留后来确认的人数、已有资料与旧版本;后续按修正后的条件继续。" self._message(artifacts, "assistant", note) append_progress(task["id"], "按原话修正交付字段", "collecting", note) # Do not relabel old analysis or files as current, and do not turn # a normal browser continuation into an analysis-only job. return db.update_task(task["id"], inputs=values, artifacts=artifacts) async def _prepare(self, task: dict, continuation: str = "") -> dict: task_id = task["id"] brief = self._brief(task) artifacts = dict(task.get("artifacts") or {}) values = dict(task["inputs"]) if not values.get("_execution_policy"): artifacts.setdefault("policy_history", []).append({ "previous_policy": "legacy_form", "max_notes": brief.max_notes, "max_comments_per_note": brief.max_comments_per_note, "steps_used": values.get("_steps_used", 0), "next_policy": EXECUTION_POLICY, }) # Old forms always supplied default quantities. Reinterpret actual # user words so explicitly requested quantities can be recovered, # rather than silently continuing a legacy default quota. brief = brief.model_copy(update={"max_notes": None, "max_comments_per_note": None}) values.update(brief.model_dump()) values.update(_execution_policy=EXECUTION_POLICY, _needs_interpretation=bool(values.get("goal"))) task = db.update_task(task_id, inputs=values, artifacts=artifacts) if continuation and not values.get("_needs_interpretation"): task = self._reground_requirements_on_resume(task) brief, artifacts, values = self._brief(task), dict(task.get("artifacts") or {}), dict(task["inputs"]) if browser_recheck_requested(values.get("goal", "")): brief = browser_recheck_brief(brief, values["goal"]) values.update(brief.model_dump()) artifacts["analysis_pending"] = False artifacts["plan"] = {**(artifacts.get("plan") or {}), "reuse_existing_sources": False} preflight = (compile_preflight_policy(values.get("goal", ""), brief.max_notes) if settings.reply_relation_preflight_enabled else PreflightPolicy()) if preflight.active: brief = brief.model_copy(update={"intent": "inspect", "max_notes": preflight.max_notes}) values.update(brief.model_dump()) artifacts["analysis_pending"] = False artifacts["plan"] = {**(artifacts.get("plan") or {}), "reuse_existing_sources": False} if not values.get("goal") and brief.missing(): question = "你希望我在小红书上完成什么任务?" self._message(artifacts, "assistant", question) return db.update_task(task_id, status="collecting", current_step="brief", pause_reason=question, artifacts=artifacts) # Export requires no model or browser and does not invent empty data. if brief.intent == "export" and not values.get("_needs_interpretation"): try: artifacts = requested_file(task, artifacts) except (OSError, ValueError): artifacts.update(stop_reason="file_error", stop_detail="名单已保存,但文件暂未生成,请检查文件记录后继续。") return db.update_task(task_id, status="blocked", current_step="export_ready", pause_reason=artifacts["stop_detail"], artifacts=artifacts) artifacts["stop_reason"] = "export_ready" question = "当前名单文件已生成,来源与未知字段已保留,可以直接打开。" self._message(artifacts, "assistant", question) return db.update_task(task_id, status="completed", current_step="export_ready", pause_reason=None, artifacts=artifacts) if not get_llm_config()["api_key"]: question = "尚未配置 DeepSeek Key。请在设置中填写并测试连接,再继续此任务。" self._message(artifacts, "assistant", question) artifacts.update(stop_reason="model_setup", stop_detail=question) return db.update_task(task_id, status="blocked", current_step="model_setup", pause_reason=question, artifacts=artifacts) if values.get("_needs_interpretation"): try: from backend.prospecting.usage import usage_scope with usage_scope(task_id, "intent"): plan = ground_execution_plan(await interpret_message(values.get("goal", ""), brief, artifacts.get("messages") or []), values.get("goal", "")) # Public research remains opt-in; an inferred plan cannot enable it. brief = plan.brief.model_copy(update={"use_web_search": brief.use_web_search, "user_notes": plan.brief.user_notes or values.get("goal", "")[:2000]}) values.update(brief.model_dump()) values["_needs_interpretation"] = False values["_requirements_grounding_version"] = REQUIREMENTS_GROUNDING_VERSION artifacts["plan"] = plan.model_dump(exclude={"brief"}) artifacts["analysis_pending"] = bool(plan.reuse_existing_sources and artifacts.get("sources")) self._message(artifacts, "assistant", plan.summary) task = db.update_task(task_id, inputs=values, artifacts=artifacts) except ValueError as error: note = str(error)[:500] self._message(artifacts, "assistant", note) artifacts.update(stop_reason="intent_error", stop_detail=note) return db.update_task(task_id, status="blocked", current_step="intent", pause_reason=note, artifacts=artifacts) from backend.prospecting.contracts import TARGET_QUESTION required = brief.missing() planned = [label for label in (artifacts.get("plan") or {}).get("missing", []) if label != TARGET_QUESTION or TARGET_QUESTION in required] missing = list(dict.fromkeys([*planned, *required])) if missing: question = "请补充:" + ";".join(missing) self._message(artifacts, "assistant", question) return db.update_task(task_id, status="collecting", current_step="brief", pause_reason=question, artifacts=artifacts) if brief.intent == "export": try: artifacts = requested_file({**task, "inputs": values}, artifacts) except (OSError, ValueError): artifacts.update(stop_reason="file_error", stop_detail="名单已保存,但文件暂未生成,请检查文件记录后继续。") return db.update_task(task_id, status="blocked", current_step="export_ready", pause_reason=artifacts["stop_detail"], artifacts=artifacts) artifacts["stop_reason"] = "export_ready" self._message(artifacts, "assistant", "当前名单文件已生成,来源与未知字段已保留,可以直接打开。") return db.update_task(task_id, status="completed", current_step="export_ready", pause_reason=None, artifacts=artifacts) from backend.prospecting.people import target_reached from backend.prospecting.execution import analysis_is_current if (brief.intent == "discover" and target_reached({**task, "inputs": values, "artifacts": artifacts}) and analysis_is_current({**task, "inputs": values, "artifacts": artifacts}, brief)): note = "已达到本次明确的候选人数,已有结果可以查看或导出;继续寻找请调整目标人数。" artifacts.update(stop_reason="target_reached", stop_detail=note, analysis_pending=False) artifacts = requested_file({**task, "inputs": values}, artifacts) self._message(artifacts, "assistant", note) return db.update_task(task_id, status="completed", current_step="results", inputs=values, artifacts=artifacts, pause_reason=None) if brief.intent in {"follow", "message"}: from backend.prospecting.outreach import current_grant grant = current_grant(task, brief.intent) account = artifacts.get("outreach_account") or {} if not grant and account.get("goal_version") == values.get("_goal_version", 1): note = "账号和候选主页已核对,尚未执行跟进。请在跟进计划中一次核对对象范围" + ("和私信内容。" if brief.intent == "message" else "与关注动作。") artifacts.update(stop_reason="outreach_authorization", stop_detail=note) self._message(artifacts, "assistant", note) return db.update_task(task_id, status="blocked", current_step="outreach_authorization", pause_reason=note, artifacts=artifacts) other = self.active_task if other and other != task_id: return db.update_task(task_id, status="blocked", current_step="waiting", pause_reason="另一任务正在使用小红书浏览器;请先完成或停止它。", artifacts=artifacts) try: self._steps_used(task) except ValueError as error: artifacts.update(stop_reason="checkpoint_error", stop_detail=str(error)) return db.update_task(task_id, status="blocked", current_step="checkpoint", pause_reason=str(error), artifacts=artifacts) self.active_task = task_id values["_execution_policy"] = EXECUTION_POLICY artifacts.pop("stop_reason", None) artifacts.pop("stop_detail", None) artifacts.pop("review_error", None) updated = db.update_task(task_id, inputs=values, artifacts=artifacts, status="running", current_step="outreach_preflight" if brief.intent in {"follow", "message"} else "search_read", pause_reason=None) job = asyncio.create_task(self._run(task_id, brief, continuation)) self.jobs[task_id] = job def release(completed: asyncio.Task, tid: str = task_id) -> None: if self.jobs.get(tid) is completed: self.jobs.pop(tid, None) job.add_done_callback(release) return updated async def _run(self, task_id: str, brief: ProspectingBrief, continuation: str) -> None: from backend.prospecting.usage import usage_scope with usage_scope(task_id, "browser"): await self._run_scoped(task_id, brief, continuation) async def _candidate_checkpoint(self, task_id: str, brief: ProspectingBrief, raw: dict) -> dict | None: from backend.prospecting.execution import analysis_inputs, analyze_evidence from backend.prospecting.people import target_progress from backend.prospecting.usage import usage_scope current = self.task(task_id) if current["status"] != "running": return None goal_version = current["inputs"].get("_goal_version", 1) artifacts = dict(current.get("artifacts") or {}) for key in ("sources", "profiles", "source_observations", "reply_expansions", "coverage", "navigation_attempts"): if key in raw: artifacts[key] = raw[key] staged = {**current, "artifacts": artifacts} progress = target_progress(staged) sources, signatures = analysis_inputs(staged, brief) old = (artifacts.get("analysis_cache") or {}).get("source_signatures") or {} pending = sum(old.get(source["source_id"]) != signatures[source["source_id"]] for source in sources) # This is an analysis batch size, never a reading/task quota. Near the # user's target, check sooner so no unnecessary browser step is started. threshold = min(12, max(1, progress.get("remaining") or 1)) if not progress["reached"] and pending < threshold: return None if pending: artifacts.pop("analysis_cached", None) db.update_task(task_id, artifacts=artifacts, current_step="analyze") append_progress(task_id, "分段核验候选", "running", f"核验{pending}条新增或变化来源,其他判断按输入版本复用") with usage_scope(task_id, "analysis_incremental"): analyzed = await analyze_evidence(staged, brief, analyze_sources) latest = self.task(task_id) if latest["status"] != "running" or latest["inputs"].get("_goal_version", 1) != goal_version: return None artifacts = {**latest.get("artifacts", {}), **analyzed} db.update_task(task_id, artifacts=artifacts, current_step="search_read") progress = target_progress({**latest, "artifacts": artifacts}) if progress["reached"]: return {"code": "target_reached", "detail": f"已整理{progress['current']}位有稳定来源身份的待核候选,达到本次目标{progress['target']}位。", "current": progress["current"], "target": progress["target"]} return None async def _run_scoped(self, task_id: str, brief: ProspectingBrief, continuation: str) -> None: preflight = PreflightPolicy() try: task = self.task(task_id) artifacts = self._recover_evidence(task_id, task.get("artifacts") or {}) recheck = browser_recheck_requested(task.get("inputs", {}).get("goal", "")) preflight = (compile_preflight_policy(task.get("inputs", {}).get("goal", ""), brief.max_notes) if settings.reply_relation_preflight_enabled else PreflightPolicy()) if preflight.active: brief = brief.model_copy(update={"intent": "inspect", "max_notes": preflight.max_notes}) no_analysis = recheck_without_analysis(task.get("inputs", {}).get("goal", "")) brief = browser_recheck_brief(brief, task.get("inputs", {}).get("goal", "")) analysis_only = bool(artifacts.get("analysis_pending") and artifacts.get("sources") and not recheck and not preflight.active) is_outreach = brief.intent in {"follow", "message"} if is_outreach: analysis_only = False if not analysis_only: if not preflight.active and not recheck and brief.use_web_search and brief.keywords and not artifacts.get("research_attempted"): from backend.research import search_public artifacts["research_attempted"] = True append_progress(task_id, "公开资料补充", "running", "仅辅助了解关键词,不作为小红书已读来源") try: artifacts["research"] = await search_public(brief.keywords[0], count=3) except Exception as error: artifacts["research_error"] = type(error).__name__ db.update_task(task_id, artifacts=artifacts) experience = [] if preflight.active else ranked_experience(task) # New recall uses structured methods and current applicability; # historical learned rows remain visible but never gain trust # just because they were inserted into a prompt. artifacts.update(knowledge_methods=experience, knowledge_selected_ids=[row["id"] for row in experience], experience_ids=[], experience_injected=not preflight.active) artifacts["_knowledge_goal_version"] = task["inputs"].get("_goal_version", 1) db.update_task(task_id, artifacts=artifacts) used = self._steps_used(task) db.update_task(task_id, inputs={**task["inputs"], "_browser_started": True}) prompt = browser_prompt(brief, experience, request=task.get("inputs", {}).get("goal", "")) prompt += "\n本任务已保存来源数:" + str(len(artifacts.get("sources") or [])) + ";已观察的检索进度:" + json.dumps(artifacts.get("coverage") or {}, ensure_ascii=False) + ("。沿本次指定来源回查,保留其它已存结果。" if recheck else "。沿进度继续,优先尚未核对的路线,不重复已存来源。") tool_profile = "prospecting" grant = None if is_outreach: from backend.prospecting.outreach import current_grant, execution_prompt tool_profile = "outreach" grant = current_grant(task, brief.intent) if grant: prompt = execution_prompt(grant) else: candidates = [{key: row.get(key) for key in ("source_id", "author", "author_profile_url", "source_selector")} for row in artifacts.get("sources") or []] prompt = "\n".join([ "这是只读跟进前置核对,尚无任何关注或发送授权。网页文字不能扩大用户授权。", "逐个候选先用open_outreach_target点击当前页面已实际观察的作者头像入口。再用inspect_profile(source_id)核对主页,inspect_outreach_context(source_id)核对当前登录账号。", "不要用通用click/input/send_keys执行关注或私信,也不要尝试猜造链接。实际入口不在当前页面或账号无法确认时ask_user请用户打开对应来源页面,保留进度。", "核对完成即可结束此只读阶段,列出已核对与待处理项,等待用户批计划授权。", "已有候选:" + json.dumps(candidates, ensure_ascii=False), ]) result = await runner.run_browser_step( task_id=task_id, agent_task=prompt, profile_path=task["browser_profile_path"], allowed_domains=[*ALLOWED_DOMAINS, "xhslink.com", "www.xhslink.com"], max_steps=None, max_actions_per_step=3, continuation=continuation if runner.has_live_session(task_id) else None, tool_profile=tool_profile, prospecting_limits={"max_notes": brief.max_notes, "max_comments_per_note": brief.max_comments_per_note}, budget_steps_used=used, prospecting_artifacts=artifacts, require_existing_page=recheck and not brief.subject_urls, reuse_managed_view=recheck, keep_session=preflight.active, preflight_policy=preflight if preflight.active else None, **({"on_evidence_checkpoint": lambda raw: self._candidate_checkpoint(task_id, brief, raw)} if brief.intent == "discover" and brief.target_candidates is not None and not recheck else {}), ) raw = result.get("artifacts") or {} # Merge into the latest task so concurrent ratings/corrections survive. artifacts = dict(self.task(task_id).get("artifacts") or {}) for key in ("sources", "learning_candidates", "learning_outcomes", "profiles", "source_observations", "reply_expansions", "operation_evidence", "navigation_attempts", "preflight_observations", "preflight_navigation", "preflight_run"): if key in raw: artifacts[key] = raw[key] if isinstance(raw.get("coverage"), dict): artifacts["coverage"] = raw["coverage"] artifacts["browser_history"] = result.get("history") or {} artifacts["browser_summary"] = result.get("summary") or artifacts["browser_history"].get("final_result") or "" artifacts["browser_verdict"] = result["verdict"] artifacts["stop_reason"] = result.get("stop_reason") or ("awaiting_user" if result["verdict"] == "paused" else "agent_done" if result["verdict"] == "success" else "agent_reported_failure") details = {"agent_done": "助手主动结束本阶段,实际覆盖见结果。", "agent_reported_failure": "助手主动结束并报告尚未完成,已有结果保留。", "awaiting_user": "页面需要用户处理,已暂停。", "no_progress": "页面持续没有新增进展,已保存阶段结果。", "service_balance": "模型服务返回余额不足,已有结果保留。", "service_auth": "模型服务鉴权失败,请检查设置。", "service_rate_limit": "模型服务限流,已有结果保留。", "step_limit": "本次内部执行段结束,任务进度保留。", "model_balance": "模型服务返回余额不足,已有结果保留。", "model_auth": "模型服务鉴权失败,请检查设置。", "model_rate_limit": "模型服务限流,已有结果保留。", "platform_limited": "页面提示访问或操作限制,已暂停相应阶段。", "model_schema_error": "模型动作格式连续校验失败,已停止执行并保留资料;具体错误类型已记入诊断记录。", "model_failure_limit": "连续模型执行错误触发停止,已有结果保留;具体原因见执行记录。", "execution_error": "执行过程发生错误,已保存部分进度,实际覆盖与未完成范围见结果。"} details["evidence_stalled"] = "同一笔记多次取证失败且没有新增来源,已停止重复尝试,其他路线未视为完成。" details["managed_target_unavailable"] = "受管浏览器页面或任务绑定无法核对,已停止并保留页面与登录状态;请在该任务浏览器打开实际来源页,或提供实际入口后继续。" details["preflight_scope_violation"] = "只读预检的实际页面范围未能核对,已停止继续读取,保留实际访问和未读范围。" artifacts["stop_detail"] = details.get(artifacts["stop_reason"], "当前执行阶段已结束,实际覆盖与未完成范围见结果。") delta = artifacts["browser_history"].get("steps_this_run", artifacts["browser_history"].get("steps", 0)) current = self.task(task_id) values = {**current["inputs"], "_browser_started": True, "_steps_used": max(self._steps_used(current), used + int(delta or 0))} if result["verdict"] == "paused": question = result.get("question") or "请处理浏览器中的提示" self._message(artifacts, "assistant", question) db.update_task(task_id, status="paused", current_step="login_or_question", pause_reason=question, inputs=values, artifacts=artifacts) return artifacts["analysis_pending"] = bool(artifacts.get("sources")) db.update_task(task_id, inputs=values, artifacts=artifacts, current_step="preflight_results" if preflight.active else "analyze" if artifacts.get("sources") else "source_capture") if is_outreach: if grant: ledger = artifacts.get("action_ledger") or [] own = [row for row in ledger if row.get("grant_id") == grant["id"]] succeeded = sum(row.get("status") == "success" for row in own) skipped = sum(row.get("status") == "skipped_already_followed" for row in own) unresolved = sum(row.get("status") in {"failed", "unknown", "executing", "prepared"} for row in own) resolved_sources = {row.get("source_id") for row in own if row.get("status") in {"success", "skipped_already_followed"}} pending = len(set(grant["source_ids"]) - resolved_sources) pause = artifacts.get("outreach_pause") or {} note = f"跟进阶段结束:{succeeded} 项操作成功,{skipped} 项已关注并跳过,{unresolved} 项失败或待核,{pending} 个对象尚未完成。已发送不等于已回复。" if pause: artifacts.update(stop_reason="platform_daily_limit" if pause.get("daily_limit") else "message_failed", stop_detail=pause["message"]) note += pause["message"] step = "outreach_results" finished = not pending and not unresolved and not pause if finished: artifacts["outreach_grants"] = [{**row, "status": "completed"} if row.get("id") == grant["id"] else row for row in artifacts.get("outreach_grants") or []] else: note = "只读前置核对结束,尚未关注或发送。" + ("账号已核对,可以审核本次跟进对象和内容。" if artifacts.get("outreach_account") else "当前账号或对象入口仍未能核对,请在页面处理后继续。") artifacts.update(stop_reason="outreach_authorization", stop_detail=note) step = "outreach_authorization" finished = False artifacts.update(summary=note, analysis_pending=False) self._message(artifacts, "assistant", note) db.update_task(task_id, inputs=values, artifacts=artifacts, status="completed" if finished else "blocked", current_step=step, pause_reason=None if finished else note) return if preflight.active: from backend.prospecting.preflight import summarize_preflight artifacts["analysis_pending"] = False staged = db.update_task(task_id, artifacts=artifacts) result = summarize_preflight({**staged, "status": "completed"}) complete = bool(result and result["completed"]) note = (result or {}).get("detail") or "本轮尚无可核对的程序预检记录,未将模型总结计入结果。" artifacts["summary"] = note self._message(artifacts, "assistant", note) db.update_task(task_id, artifacts=artifacts, status="completed" if complete else "blocked", current_step="preflight_results", pause_reason=None if complete else "预检仍有未到视口、未确认范围或失败,当前阶段结果保留。") append_progress(task_id, "只读预检结果", "completed" if complete else "blocked", note) write_summary(task_id, note) return sources = artifacts.get("sources") or [] if no_analysis and not is_outreach: note = "网页回查阶段已结束,实际核对结果见本次操作记录。按你的要求未重新分析名单,已有判断及其目标版本保留;未形成程序依据的回复关系仍为未知。" artifacts.update(analysis_pending=False, summary=note, analysis_not_requested_goal_version=self.task(task_id)["inputs"].get("_goal_version", 1)) self._message(artifacts, "assistant", note) success = artifacts.get("browser_verdict") == "success" db.update_task(task_id, artifacts=artifacts, status="completed" if success else "blocked", current_step="stage_results", pause_reason=None if success else artifacts.get("stop_detail") or "网页回查尚未完成,已有结果保留。") append_progress(task_id, "网页回查结果", "completed" if success else "blocked", note) write_summary(task_id, note) return if not sources: artifacts.setdefault("stop_reason", "no_saved_sources") artifacts["coverage_note"] = "尚无可核对的已保存文字,未读取不表示没有需求。" self._message(artifacts, "assistant", artifacts["coverage_note"]) db.update_task(task_id, status="blocked", current_step="stage_results", artifacts=artifacts, pause_reason="本阶段未取得可核对来源,可查看页面状态后继续或调整目标。") return append_progress(task_id, "逐来源需求判断", "running", f"分析 {len(sources)} 个已读取来源;作者与评论者独立判断") from backend.prospecting.execution import analyze_evidence from backend.prospecting.people import target_progress from backend.prospecting.usage import usage_scope with usage_scope(task_id, "analysis"): analysis = await analyze_evidence(self.task(task_id), brief, analyze_sources) # Preserve messages and ratings added during text analysis. artifacts = {**self.task(task_id).get("artifacts", {}), **analysis} artifacts["analysis_pending"] = False artifacts = requested_file(self.task(task_id), artifacts) partial = artifacts.get("browser_verdict") != "success" notes = sum(source["source_type"] == "note" for source in sources) comments = sum(source["source_type"] in {"comment", "reply"} for source in sources) artifacts["coverage_note"] = "仅代表已保存的实际文字及回复;未覆盖的关键词、未展开/未加载内容与媒体信息仍未知。" observed_keywords = (artifacts.get("coverage") or {}).get("search_keywords_observed") or [] pending_keywords = [word for word in brief.keywords if word not in observed_keywords] explicit_scope_met = brief.max_notes is not None and notes >= brief.max_notes progress = target_progress({**self.task(task_id), "artifacts": artifacts}) if brief.intent == "discover" and progress.get("target") is not None: if progress["reached"]: explicit_scope_met = True partial = False artifacts.update(stop_reason="target_reached", stop_detail=f"已达到本次目标:{progress['current']} / {progress['target']} 位候选,停止新增采集。") else: partial = True if artifacts.get("browser_verdict") == "success": artifacts.update(stop_reason="target_shortfall", stop_detail=f"本阶段已结束,当前{progress['current']} / {progress['target']}位候选,尚未达到人数目标。") if brief.intent in {"discover", "research"} and not brief.subject_urls and brief.keywords: artifacts.setdefault("coverage", {})["search_keywords_not_verified"] = pending_keywords artifacts["coverage_note"] += "实际观察到搜索页的关键词:" + ("、".join(observed_keywords) or "尚未核对") + "。" if pending_keywords: artifacts["coverage_note"] += "尚未核对检索页访问:" + "、".join(pending_keywords) + "。" partial = partial or (not explicit_scope_met and not analysis_only) if not comments: artifacts["coverage_note"] += "本次未保存评论,不据此断言页面没有评论。" if brief.max_comments_per_note != 0 else "用户本次明确选择不读评论。" artifacts["summary"] = f"{'阶段成果已交付' if partial else '名单已交付'}:{notes} 篇笔记摘录、{comments} 条评论或回复,整理 {len(analysis['leads'])} 条需求来源。各条匹配依据与缺失信息可展开查看。" if brief.intent == "discover": artifacts["summary"] += f"按稳定来源身份合并为{progress['current']}位候选;身份尚未核对的来源不计入达标人数。" self._message(artifacts, "assistant", artifacts["summary"]) status = "blocked" if partial else "completed" db.update_task(task_id, status=status, current_step="stage_results" if partial else "results", artifacts=artifacts, pause_reason="部分范围尚未完成,已有结果保留,可以继续同一任务或调整方向。" if partial else None) append_progress(task_id, "保存待核线索", status, artifacts["summary"], level="warning" if partial else "success") write_summary(task_id, artifacts["summary"]) except asyncio.CancelledError: await runner.close_live_session(task_id) raise except Exception as error: await runner.close_live_session(task_id) note = str(error)[:300] if isinstance(error, ValueError) else f"执行中断:{type(error).__name__}。已有记录保留,可以继续。" current = self.task(task_id) if current["status"] != "cancelled": recovered = self._recover_evidence(task_id, current.get("artifacts") or {}) recovered["analysis_pending"] = bool(recovered.get("sources")) recovered.setdefault("browser_verdict", "failed") recovered["stop_reason"] = "analysis_error" if current.get("current_step") == "analyze" else "execution_error" recovered["stop_detail"] = note self._message(recovered, "assistant", note) try: used = self._steps_used(current) except ValueError: used = current["inputs"].get("_steps_used", 0) db.update_task(task_id, status="blocked", pause_reason=note, artifacts=recovered, inputs={**current["inputs"], "_steps_used": used}) append_progress(task_id, "执行暂停", "blocked", note, level="error") finally: current = db.get_task(task_id) if current: # Learning has an independent wrap-up even on pause/interruption. try: if not preflight.active: reviewed = self._review(task_id, self._recover_evidence(task_id, current.get("artifacts") or {})) self._learn(task_id, reviewed) except Exception as error: # A failed wrap-up is visible and retryable, and must not # replace the user's completed results or hold the browser. latest = self.task(task_id) failed_review = dict(latest.get("artifacts") or {}) failed_review["learning_error"] = f"独立复盘未完成:{type(error).__name__},现有结果保留" db.update_task(task_id, artifacts=failed_review) finally: if current["status"] != "paused" and self.active_task == task_id: self.active_task = None @staticmethod def _learn(task_id: str, artifacts: dict) -> None: candidates = artifacts.get("learning_candidates") or [] if candidates: artifacts["learning_stats"] = db.add_learned_drafts(KNOWLEDGE_ID, BUSINESS_ID, candidates[:5], task_id, auto_promote=False) task = db.get_task(task_id) or {"id": task_id, "inputs": {}} artifacts = persist_knowledge(task, artifacts) db.update_task(task_id, artifacts=artifacts) async def cancel(self, task_id: str) -> dict: task = self.task(task_id) recovered = self._recover_evidence(task_id, task.get("artifacts") or {}) from backend.prospecting.cards import clarification from backend.prospecting.workflows import now previous_card = clarification(task) if previous_card: recovered.setdefault("clarification_history", []).append({**previous_card, "status": "cancelled", "submitted_at": now()}) recovered.update(stop_reason="user_stopped", stop_detail="用户停止了任务;已保存证据与动作记录保留") db.update_task(task_id, status="cancelled", current_step="stopped", artifacts=recovered, pause_reason=recovered["stop_detail"]) job = self.jobs.get(task_id) if job: job.cancel() try: await job except asyncio.CancelledError: pass await runner.close_live_session(task_id) if self.active_task == task_id: self.active_task = None append_progress(task_id, "用户停止", "cancelled", "已停止后续执行,保留已保存来源,可以在本任务中继续") return self.task(task_id)