assistant/backend/prospecting/delivery.py

215 lines
13 KiB
Python

"""Pure v0.3 result projection: goal fulfillment, artifacts and runtime differ.
``project`` does not read files, update tasks or contact a model/platform. The
caller may supply ``exports`` from workflows.list_exports to attest real file
availability; plain historic manifest rows cannot attest an existing file.
Person badges always contain status/label/detail/evidence; required missing
fields are objects with key/label/required. This is the frontend contract.
"""
from __future__ import annotations
from urllib.parse import urlsplit
from backend.config import settings
from backend.prospecting.people import group_candidates, required_fields, target_progress
from backend.prospecting.discussion_scope import conditions as discussion_conditions
def _page_key(url: object) -> tuple[str, str] | None:
if not isinstance(url, str):
return None
try:
parsed = urlsplit(url)
if parsed.scheme != "https" or parsed.hostname not in {"www.xiaohongshu.com", "xiaohongshu.com"}:
return None
return ("xiaohongshu.com", parsed.path.rstrip("/"))
except ValueError:
return None
def _business_badge(task: dict, source_ids: list[str]) -> dict | None:
"""User-reported business feedback, separate from observed action results."""
labels = {"replied": "用户记录已回复", "need_confirmed": "用户记录已确认需求", "cooperated": "用户记录已合作",
"no_reply": "用户记录未回复", "not_relevant": "用户记录不匹配"}
events = [row for row in (task.get("artifacts") or {}).get("business_outcomes") or [] if isinstance(row, dict)
and row.get("source") == "user" and row.get("source_id") in source_ids and row.get("outcome") in labels]
if not events:
return None
event = events[-1]
return {"status": "user_" + event["outcome"], "label": labels[event["outcome"]],
"detail": "用户主动记录的业务结果,未经页面自动核验;获客归因未证实,不增加方法技术成功次数。",
"evidence": [{"source_id": event["source_id"]}], "source": "user", "at": event.get("at"),
"attribution": "unproven"}
def scope_met(task: dict) -> bool:
"""Whether the explicit acquisition scope is proved, independent of files.
A numeric goal is established by eligible source-bound people. Finite page
and route checks additionally need successful collection plus completed
per-source analysis. Old ``status=completed`` alone is never proof.
"""
inputs = task.get("inputs") or {}
artifacts = task.get("artifacts") or {}
progress = target_progress(task)
intent = inputs.get("intent", "discover")
if intent in {"follow", "message"}:
grants = [row for row in artifacts.get("outreach_grants") or [] if isinstance(row, dict)
and row.get("action") == intent and row.get("goal_version") == inputs.get("_goal_version", 1)
and row.get("authorized_by") == "user_batch_request" and row.get("status") in {"authorized", "completed"}]
if not grants or artifacts.get("outreach_pause"):
return False
grant = grants[-1]
targets = set(grant.get("source_ids") or [])
actions = [row for row in artifacts.get("action_ledger") or [] if isinstance(row, dict) and row.get("grant_id") == grant.get("id")]
resolved = {row.get("source_id") for row in actions if row.get("status") == "success"
or (intent == "follow" and row.get("status") == "skipped_already_followed")}
unresolved = any(row.get("status") in {"failed", "unknown", "prepared", "executing"} for row in actions)
return bool(targets) and targets <= resolved and not unresolved
if not progress["assessment_current"]:
return False
if any(not condition["met"] for condition in discussion_conditions(task)):
return False
if progress["target"] is not None:
return progress["reached"]
if intent == "export":
return False # Its validated file contract establishes completion.
history = artifacts.get("browser_history") or {}
if (history.get("is_successful") is False or artifacts.get("browser_verdict") in {"failed", "interrupted", "paused"}
or not (history.get("is_successful") is True or artifacts.get("browser_verdict") == "success")):
return False
sources = [row for row in artifacts.get("sources") or [] if isinstance(row, dict) and row.get("source_id") and row.get("content")]
decisions = {row.get("source_id") for row in artifacts.get("decisions") or [] if isinstance(row, dict)}
if not sources or artifacts.get("analysis_pending") or any(row["source_id"] not in decisions for row in sources):
return False
notes = {row.get("note_id") or _page_key(row.get("source_url")) or row["source_id"] for row in sources if row.get("source_type") == "note"}
if type(inputs.get("max_notes")) is int:
return len(notes) >= inputs["max_notes"]
requested = inputs.get("subject_urls") or []
if requested:
# Shared short links cannot be equated with a different destination
# without a recorded redirect; retain unknown instead of guessing.
observed = {_page_key(row.get("source_url")) for row in sources}
return all(_page_key(url) is not None and _page_key(url) in observed for url in requested)
if inputs.get("discovery_scope") == "route_pass" or inputs.get("intent") in {"inspect", "research"}:
coverage = artifacts.get("coverage") or {}
observed = set(coverage.get("search_keywords_observed") or [])
keywords = inputs.get("keywords") or []
return not coverage.get("search_keywords_not_verified") and bool(keywords) and all(word in observed for word in keywords)
return False
def project(task: dict, *, exports: list[dict] | None = None) -> dict:
"""Return a view of evidence without rewriting legacy runtime outcomes."""
from backend.prospecting.workflows import result_version
from backend.prospecting.preflight import delivery_projection
preflight = delivery_projection(task) if settings.reply_relation_preflight_enabled else None
if preflight is not None:
return preflight
artifacts = task.get("artifacts") or {}
inputs = task.get("inputs") or {}
grouped = group_candidates(task)
progress = target_progress(task)
fields = required_fields(task)
people = []
for group in grouped["groups"]:
person = {key: group[key] for key in ("person_key", "eligible", "assessment_current", "group", "badges", "missing")}
business = _business_badge(task, group["source_ids"])
if business:
person["badges"] = {**person["badges"], "business": business}
people.append(person)
source_rows = [row for row in artifacts.get("sources") or [] if isinstance(row, dict) and row.get("content")]
discussion = discussion_conditions(task)
decisions = [row for row in artifacts.get("decisions") or [] if isinstance(row, dict)]
target = progress["target"]
current = progress["current"]
fulfilled_scope = scope_met(task)
file_required = "csv" in fields
version = result_version(task)
export_rows = exports if exports is not None else artifacts.get("exports") or []
current_files = [row for row in export_rows if row.get("available") is True
and row.get("is_current") is True and row.get("result_version") == version
and row.get("goal_version") == inputs.get("_goal_version", 1)]
file_available = bool(current_files)
known = grouped["counts"]["candidates"]
counts = {"eligible": current, "known_identity": known,
"supplemental": sum(person["group"] == "supplemental" for person in people),
"unresolved": sum(person["group"] == "unresolved" for person in people),
"signals": grouped["counts"]["signals"], "verified_accounts": grouped["counts"]["verified_accounts"]}
conditions = []
if target is not None:
conditions.append({"key": "candidate_count", "label": "符合本次条件的去重人数", "required": True,
"met": fulfilled_scope, "current": current, "target": target})
elif inputs.get("intent") not in {"export", "follow", "message"}:
conditions.append({"key": "scope", "label": "本次明确检查范围", "required": True,
"met": fulfilled_scope, "current": None, "target": None})
if people:
conditions.append({"key": "identity", "label": "来源身份可区分", "required": True,
"met": counts["unresolved"] == 0, "current": known, "target": len(people)})
conditions.extend(discussion)
for key, label, badge, status in (
("xiaohongshu_id", "已取得且正确归属的小红书号", "account", "obtained"),
("explicit_demand", "本人明确需求原话", "demand", "explicit"),
("region", "本人原话支持指定地区", "region", "matched"),
):
obtained = sum(person["badges"][badge]["status"] == status and person["group"] != "unresolved" for person in people) if progress["assessment_current"] else 0
finite_field_met = fulfilled_scope and all(person["group"] != "unresolved" and person["badges"][badge]["status"] == status for person in people)
conditions.append({"key": key, "label": label, "required": key in fields,
"met": obtained >= target if target is not None else finite_field_met if key in fields else obtained > 0,
"current": obtained, "target": target if key in fields else None})
conditions.append({"key": "csv", "label": "当前结果版本的CSV文件", "required": file_required,
"met": file_available, "current": len(current_files), "target": 1 if file_required else None})
list_available = bool(people)
# Missing fields or unclear judgments do not establish a negative business
# conclusion. Only actual exclusions across the read scope can prove an
# empty result; supplemental and unresolved groups remain available.
by_source = {row.get("source_id"): row for row in decisions}
rejected_scope = bool(source_rows) and all(by_source.get(row.get("source_id"), {}).get("decision") == "exclude" for row in source_rows)
conclusion_available = fulfilled_scope and not people and rejected_scope
requirements_met = all(condition["met"] for condition in conditions if condition["required"] and condition["key"] != "scope")
if inputs.get("intent", "discover") in {"discover", "research", "inspect"} and not people and not conclusion_available:
requirements_met = False
complete = fulfilled_scope and requirements_met
if inputs.get("intent") == "export":
complete = file_available
requirements_met = file_available
task_status = task.get("status") or "unknown"
running = task_status == "running"
stale = not progress["assessment_current"]
if inputs.get("intent") == "export" and file_available:
status, headline = "fulfilled", "文件已生成"
elif inputs.get("intent") in {"follow", "message"} and complete:
status, headline = "fulfilled", "跟进已完成"
elif stale and list_available:
status, headline = "unknown", "已有名单保留 本次标准尚未重新分析"
elif running:
status = "in_progress"
headline = (f"已整理{current}位 正在继续" if current else f"已保存{len(people)}组来源 正在继续") if people else "正在读取与整理资料"
elif complete and discussion:
status, headline = "fulfilled", "本次讨论检查已完成"
elif complete and current:
status, headline = "fulfilled", f"名单已交付 共{current}位"
elif complete and conclusion_available:
status, headline = "no_matches", "本次检查已完成 未发现符合条件的候选"
elif list_available or (discussion and source_rows):
status = "partial"
headline = ((f"阶段成果已交付 共{current}位" + (f" 目标{target}位" if target is not None else "")) if current
else f"阶段资料已交付 共{len(people)}组来源") if list_available else f"阶段资料已交付 共{len(source_rows)}条来源"
elif not source_rows:
status, headline = "no_evidence", "本阶段未取得可判断的资料"
else:
status, headline = "unknown", "已有资料保留 本次范围尚未完成"
history = artifacts.get("browser_history") or {}
return {"version": 1, "status": status, "headline": headline,
"runtime": {"status": task_status, "step": task.get("current_step"),
"stop_reason": artifacts.get("stop_reason"), "stop_detail": artifacts.get("stop_detail") or task.get("pause_reason") or "",
"raw_browser_success": history.get("is_successful"), "browser_verdict": artifacts.get("browser_verdict")},
"fulfillment": {"scope_met": fulfilled_scope, "requirements_met": requirements_met, "complete": complete,
"assessment_current": progress["assessment_current"], "target": target, "current": current,
"remaining": progress["remaining"]},
"artifacts": {"list_available": list_available, "file_required": file_required, "file_available": file_available,
"delivered": list_available or conclusion_available or file_available or bool(discussion and source_rows), "export_count": len(current_files)},
"counts": counts, "conditions": conditions, "people": people}