assistant/backend/prospecting/workflows.py

233 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Task-bound review, feedback and delivery. No browser or model side effects."""
from __future__ import annotations
import csv
import hashlib
import io
import json
import os
import re
import uuid
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from backend.db import database as db
from backend.orchestrator.progress_doc import task_doc_dir
def now() -> str:
return datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(timespec="seconds")
STAGE_TEMPLATES = [
{"id": "read_discussion", "version": 1, "title": "阅读笔记和讨论", "capabilities": ["inspect_source_candidates", "save_source"], "postcondition": "已保存且逐字核对的来源", "status": "trial"},
{"id": "verify_identity", "version": 1, "title": "核对候选主页", "capabilities": ["inspect_profile"], "postcondition": "来源发言人与当前主页可核对", "status": "trial"},
{"id": "deliver_list", "version": 1, "title": "整理并导出名单", "capabilities": ["export"], "postcondition": "已导出,未知字段保持未知", "status": "available"},
{"id": "outreach", "version": 1, "title": "按授权范围跟进", "capabilities": ["follow", "message"], "postcondition": "动作结果可核对,受限立即暂停", "status": "requires_authorization"},
]
def result_version(task: dict) -> str:
artifacts = task.get("artifacts") or {}
snapshot = [task.get("inputs", {}).get("goal"), task.get("inputs", {}).get("_goal_version", 1),
*[artifacts.get(key, []) for key in ("decisions", "leads", "profiles", "corrections", "sources")]]
return hashlib.sha256(json.dumps(snapshot, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:16]
def record_feedback(task: dict, rating: str, reason: str = "") -> dict:
artifacts = dict(task.get("artifacts") or {})
version = result_version(task)
snapshots = dict(artifacts.get("result_snapshots") or {})
snapshots.setdefault(version, json.loads(json.dumps({
"goal": task.get("inputs", {}).get("goal"), "plan": artifacts.get("plan"),
"leads": artifacts.get("leads", []), "model": artifacts.get("analysis_model"),
"workflow_version": artifacts.get("workflow_version", 1),
}, ensure_ascii=False)))
event = {"id": uuid.uuid4().hex, "rating": rating, "reason": reason.strip(), "at": now(), "result_version": version}
artifacts["feedback"] = [*(artifacts.get("feedback") or []), event]
artifacts["result_snapshots"] = snapshots
db.update_task(task["id"], artifacts=artifacts)
return event
def record_correction(task: dict, source_id: str, text: str) -> dict:
if not text.strip():
raise ValueError("请输入要纠正的内容")
artifacts = dict(task.get("artifacts") or {})
known = {row.get("source_id", row.get("id")) for row in [*(artifacts.get("sources") or []), *(artifacts.get("leads") or [])]}
if source_id not in known:
raise ValueError("该来源不属于当前任务")
event = {"id": uuid.uuid4().hex, "source_id": source_id, "text": text.strip(), "at": now(), "result_version": result_version(task)}
artifacts["corrections"] = [*(artifacts.get("corrections") or []), event]
# Corrections are evidence from the user, not retroactive edits to observations.
db.update_task(task["id"], artifacts=artifacts)
return event
def _safe_cell(value) -> str:
text = str(value or "")
# CSV imports must not interpret account names or website text as formulas.
return "'" + text if text.lstrip().startswith(("=", "+", "-", "@")) or text.startswith(("\t", "\r", "\n")) else text
def export_leads(task: dict) -> dict:
from backend.prospecting.people import group_candidates, target_progress, _profile_key
artifacts = task.get("artifacts") or {}
profiles = {row.get("source_id"): row for row in artifacts.get("profiles") or []}
sources = {row.get("source_id"): row for row in artifacts.get("sources") or []}
corrections = {row.get("source_id"): row.get("text", "") for row in artifacts.get("corrections") or []}
output = io.StringIO(newline="")
writer = csv.writer(output)
writer.writerow(["昵称", "小红书号", "主页链接", "需求原话", "来源链接", "观察时间", "判断依据", "跟进状态", "用户纠正",
"交付分组", "需求表达", "身份关联", "地区判断", "地区原话", "账号字段", "必要缺项", "目标版本", "结果版本"])
count = 0
grouped = group_candidates(task)
for group in grouped["groups"]:
lead = group["representative"]
sid = lead.get("source_id", lead.get("id"))
source = sources.get(sid, {})
verified = [profiles[source_id] for source_id in group["source_ids"] if source_id in profiles
and profiles[source_id].get("observed_at")
and profiles[source_id].get("evidence") == "matched_source_author_link_and_visible_profile_header"
and _profile_key(profiles[source_id].get("profile_url")) == group["person_key"]]
profile = next((row for row in verified if row.get("xiaohongshu_id")), verified[0] if verified else {})
profile_url = profile.get("profile_url") or source.get("author_profile_url", "")
evidence = group["evidence"]
quotes = "\n".join(dict.fromkeys(row.get("quote", "") for row in evidence))
links = "\n".join(dict.fromkeys(sources.get(row.get("source_id", row.get("id")), {}).get("source_url") or row.get("source_url", "") for row in evidence))
reasons = "\n".join(dict.fromkeys(row.get("reason", "") for row in evidence))
corrected = "\n".join(dict.fromkeys(corrections[source_id] for source_id in group["source_ids"] if corrections.get(source_id)))
badges = group["badges"]
writer.writerow([_safe_cell(value) for value in [
profile.get("nickname") or profile.get("author") or source.get("author") or lead.get("author", ""),
profile.get("xiaohongshu_id", ""), profile_url,
quotes, links, source.get("observed_at") or lead.get("observed_at", ""), reasons,
badges["outreach"]["label"], corrected,
{"eligible": "符合本次条件", "supplemental": "补充来源", "unresolved": "身份无法区分"}[group["group"]],
badges["demand"]["label"], badges["identity"]["label"], badges["region"]["label"],
"\n".join(item["quote"] for item in badges["region"]["evidence"]), badges["account"]["label"],
";".join(item["label"] for item in group["missing"]), task.get("inputs", {}).get("_goal_version", 1), result_version(task),
]])
count += 1
directory = task_doc_dir(task["id"]) / "exports"
directory.mkdir(parents=True, exist_ok=True)
export_id = uuid.uuid4().hex
filename = "小红书候选名单-" + datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S") + "-" + export_id[:8] + ".csv"
path = directory / filename
path.write_text(output.getvalue(), encoding="utf-8-sig")
entry = {"id": export_id, "path": str(path.resolve()), "filename": filename, "count": count, "format": "csv",
"candidate_count": target_progress(task)["current"], "known_identity_count": grouped["counts"]["candidates"],
"supplemental_count": sum(group["group"] == "supplemental" for group in grouped["groups"]),
"unresolved_count": grouped["counts"]["unresolved_sources"],
"generated_at": now(), "goal_version": int(task.get("inputs", {}).get("_goal_version", 1)),
"result_version": result_version(task), "download_url": f"/tasks/{task['id']}/exports/{export_id}"}
# The per-task manifest is authoritative. A later browser/analysis write of
# an older artifacts dict cannot erase delivered files or their versions.
entries = _export_manifest(task)
entries.append(entry)
temporary = directory / ("manifest-" + export_id + ".tmp")
temporary.write_text(json.dumps(entries, ensure_ascii=False), encoding="utf-8")
os.replace(temporary, directory / "manifest.json")
# This operation is synchronous, with no await between the caller's current
# task read and append, so it cannot interleave with another async writer.
updated = dict(task.get("artifacts") or {})
updated["exports"] = entries
db.update_task(task["id"], artifacts=updated)
return {**entry, "is_current": True, "available": True}
def _export_manifest(task: dict) -> list[dict]:
path = task_doc_dir(task["id"]) / "exports" / "manifest.json"
if not path.exists():
return list((task.get("artifacts") or {}).get("exports") or [])
try:
entries = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(entries, list) or any(not isinstance(row, dict) for row in entries):
raise ValueError("invalid manifest")
return entries
except (ValueError, OSError) as error:
raise ValueError("导出文件记录无法读取,已有文件保留,请检查任务记录") from error
def list_exports(task: dict) -> list[dict]:
version = result_version(task)
try:
entries = _export_manifest(task)
except ValueError:
entries = list((task.get("artifacts") or {}).get("exports") or [])
result = []
for row in entries:
try:
path, _ = export_path(task, row.get("id", ""))
safe = {**row, "path": str(path), "available": True}
except (ValueError, TypeError):
safe = {**row, "path": "", "available": False}
result.append({**safe, "is_current": row.get("result_version") == version})
return result
def export_path(task: dict, export_id: str) -> tuple[Path, dict]:
if not re.fullmatch(r"[a-f0-9]{32}", export_id):
raise ValueError("导出文件不存在")
entry = next((row for row in _export_manifest(task) if row.get("id") == export_id), None)
if not entry:
raise ValueError("导出文件不属于当前任务")
root = (task_doc_dir(task["id"]) / "exports").resolve()
filename = entry.get("filename", "")
if not isinstance(filename, str) or Path(filename).name != filename or not filename.endswith(".csv"):
raise ValueError("导出文件位置无法核对")
path = (root / filename).resolve()
if not path.is_relative_to(root) or not path.is_file() or str(path) != entry.get("path"):
raise ValueError("导出文件位置无法核对或文件已不存在")
if not isinstance(entry.get("goal_version"), int) or not re.fullmatch(r"[a-f0-9]{16}", str(entry.get("result_version", ""))):
raise ValueError("导出文件版本无法核对")
return path, entry
def alternatives(task: dict) -> list[dict]:
artifacts = task.get("artifacts") or {}
rows = []
if artifacts.get("sources"):
rows.append({"id": "review", "title": "复核已保存资料", "reason": "按当前标准复核已有原话,不新增网页操作", "deliverable": "更新的需求判断和待核事项", "status": "可用", "action": "review_saved"})
if artifacts.get("leads"):
rows.append({"id": "export", "title": "导出当前名单", "reason": "保留已有成果,不执行新的触达", "deliverable": "CSV 名单", "status": "可用", "action": "export"})
active = task.get("status") == "running"
return [{**row, "enabled": not active,
**({"disabled_reason": "请先等待当前阶段结束或停止,再选择后续路线"} if active else {})} for row in rows]
def finalize_review(task_id: str, artifacts: dict) -> dict:
"""Separate, deterministic wrap-up based on actual saved page evidence.
It proposes operation contracts, never customers' words as shared knowledge.
Verification/promotion remains the existing cross-task receipt mechanism.
"""
reviewed = dict(artifacts)
sources = [row for row in reviewed.get("sources") or [] if row.get("source_selector") and row.get("observed_at")]
kinds = {row.get("source_type") for row in sources}
contracts = []
for kind, title in [("note", "笔记"), ("comment", "首层评论"), ("reply", "讨论回复")]:
if kind not in kinds:
continue
contracts.append({
"key": f"xhs_operation_save_source_{kind}_v1", "category": "control_tip",
"value": f"具体笔记页的{title}摘录:先 inspect_source_candidates 取得当前可见且唯一的来源容器,再 save_source;成功须逐字核对并保存。定位失效应重新观察,未读内容保持未知。",
"capability": "save_source", "version": 1, "status": "draft",
"source_ids": [row["source_id"] for row in sources if row.get("source_type") == kind],
"precondition": "具体笔记页且来源实际可见", "postcondition": "来源容器和原话已验证",
})
candidates = list(reviewed.get("learning_candidates") or [])
# These reserved entries are generated from verified saved sources, not
# from a model-supplied prose alias. Keep their identity and value aligned.
contract_keys = {row["key"] for row in contracts}
candidates = [row for row in candidates if row.get("key") not in contract_keys]
for contract in contracts:
candidates.append({key: contract[key] for key in ("key", "category", "value")})
reviewed["learning_candidates"] = candidates
reviewed["workflow_drafts"] = contracts
reviewed["workflow_version"] = 1
reviewed["review"] = {"at": now(), "observed_source_count": len(sources), "draft_count": len(contracts),
"note": "草稿来自已保存页面事实,尚需不同对象或参数验证;不代表获客效果。"}
return reviewed