233 lines
13 KiB
Python
233 lines
13 KiB
Python
"""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
|