assistant/backend/prospecting/cards.py

140 lines
8.3 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.

"""Controlled UI projections derived from task state, never model-generated HTML."""
from __future__ import annotations
import hashlib
import re
from backend.orchestrator.progress_doc import progress_doc_path
from backend.prospecting import workflows
def goal_version(task: dict) -> int:
return int(task.get("inputs", {}).get("_goal_version", 1))
def clarification(task: dict) -> dict | None:
if task.get("status") != "collecting" or task.get("current_step") != "brief":
return None
artifacts = task.get("artifacts") or {}
from backend.prospecting.contracts import ProspectingBrief, TARGET_QUESTION
brief = ProspectingBrief.model_validate({key: value for key, value in task.get("inputs", {}).items()
if key in ProspectingBrief.model_fields})
required = brief.missing()
planned = (artifacts.get("plan") or {}).get("missing") or []
missing = list(dict.fromkeys([*[label for label in planned if label != TARGET_QUESTION or TARGET_QUESTION in required], *required]))
if not missing:
return None
# Exact known labels can have a short input. Free-form model questions stay
# context, rather than becoming mandatory product/region fields by guess.
keys = {"产品": "product", "产品或服务": "product", "目标客户": "target_customer",
"目标人群": "target_customer", "地区": "region", "要操作的对象范围": "action_scope",
"私信内容": "message_text", "这次先整理多少位候选?": "target_candidates"}
fields = []
for label in missing:
if not isinstance(label, str) or not label.strip():
continue
label = label.strip()
key = keys.get(label, "context")
fields.append({"id": "answer_" + hashlib.sha256(label.encode()).hexdigest()[:12],
"key": key, "label": label,
"input_kind": "number" if key == "target_candidates" else "text" if key in {"product", "target_customer", "region"} else "textarea",
"required": True, "prefill": str(task.get("inputs", {}).get(key) or "") if key != "context" else "", "options": [],
**({"min": 1, "step": 1, "hint": "按去重后的候选人数统计,同一人的多条需求只算一位。"} if key == "target_candidates" else {})})
if not fields:
return None
fingerprint = hashlib.sha256("\n".join(field["id"] for field in fields).encode()).hexdigest()[:12]
card_id = f"clarification-{task['id']}-v{goal_version(task)}-{fingerprint}"
if any(row.get("card_id") == card_id for row in artifacts.get("clarification_submissions") or []):
return None
return {"id": card_id, "goal_version": goal_version(task), "status": "active", "fields": fields}
def validate_answers(task: dict, card_id: str, version: int, answers: dict[str, str]) -> str:
card = clarification(task)
if not card or card["id"] != card_id or card["goal_version"] != version:
raise ValueError("这张补充卡已更新或已回答,请查看当前任务")
expected = {field["id"] for field in card["fields"]}
if set(answers) != expected:
raise ValueError("请只填写当前补充卡中的全部必要信息")
parts = []
for field in card["fields"]:
value = answers[field["id"]]
if not isinstance(value, str) or not value.strip() or len(value) > 2000:
raise ValueError(f"请填写{field['label']},每项最多2000字")
if field["key"] == "target_candidates" and not re.fullmatch(r"[1-9][0-9]*", value.strip()):
raise ValueError("候选人数请填写正整数,例如10;这不是笔记或评论阅读数量")
parts.append(field["label"] + ":" + value.strip())
text = "补充当前任务的信息:\n" + "\n".join(parts)
if len(text) > 2000:
raise ValueError("补充信息合计最多2000字,可在对话中继续补充")
return text
def progress_events(task: dict) -> list[dict]:
path = progress_doc_path(task["id"])
if not path.exists():
return []
try:
# The progress file is append-only and can be large after long tasks.
# Read only the tail and expose known fields, not executable source.
with path.open("rb") as stream:
stream.seek(0, 2)
stream.seek(max(0, stream.tell() - 32000))
text = stream.read().decode("utf-8", errors="replace")
except OSError:
return []
events = []
for match in re.finditer(r"^### .*?\[([^\]\n]+)\] ([^\n]+)\n\n- \*\*状态\*\*:([^\n]+)(?:\n- \*\*详情\*\*:([^\n]+))?", text, re.MULTILINE):
at, title, status, detail = match.groups()
identity = hashlib.sha256(match.group(0).encode()).hexdigest()[:16]
events.append({"id": identity, "title": title, "status": status, "detail": detail or "", "at": at})
return events[-8:]
def project(task: dict) -> dict:
from backend.prospecting.people import target_progress, group_candidates
from backend.prospecting.delivery import project as delivery_project
from backend.prospecting.usage import snapshot
artifacts = task.get("artifacts") or {}
sources = artifacts.get("sources") or []
coverage = artifacts.get("coverage") or {}
labels = {"brief": "补充必要信息", "intent": "理解任务", "model_setup": "配置模型",
"search_read": "搜索与阅读", "analyze": "核对需求与整理结果", "source_capture": "保存页面证据",
"outreach_preflight": "跟进前置核对", "outreach_authorization": "核对跟进计划",
"outreach_results": "跟进结果", "results": "当前阶段完成", "stage_results": "阶段结果",
"preflight_results": "只读关系预检结果",
"export_ready": "名单交付", "stopped": "任务已停止", "login_or_question": "等待页面处理",
"interrupted": "执行中断", "checkpoint": "核对执行记录", "waiting": "等待浏览器"}
step = task.get("current_step") or "brief"
counts = {"notes": sum(row.get("source_type") == "note" for row in sources),
"comments": sum(row.get("source_type") == "comment" for row in sources),
"replies": sum(row.get("source_type") == "reply" for row in sources),
"profiles": len(artifacts.get("profiles") or []), "leads": len(artifacts.get("leads") or [])}
version = workflows.result_version(task)
reason = artifacts.get("stop_reason")
if isinstance(reason, dict):
reason = reason.get("code") or reason.get("message")
original_detail = artifacts.get("stop_detail") or task.get("pause_reason") or ""
review_error = artifacts.get("review_error")
review_detail = review_error.get("message") if isinstance(review_error, dict) else review_error
detail = review_detail or original_detail
routes = workflows.alternatives(task)
exports = workflows.list_exports(task)
return {"version": 1, "goal_version": goal_version(task), "result_version": version,
"goal_progress": {**target_progress(task), **group_candidates(task)},
"delivery_status": delivery_project(task, exports=exports),
"usage": snapshot(task["id"]),
"clarification": clarification(task),
"phase": {"id": f"phase-{task['id']}-v{goal_version(task)}", "title": labels.get(step, "任务处理"),
"status": task.get("status", "collecting"), "current_step": step, "detail": detail,
"counts": counts, "events": progress_events(task),
"coverage": {"observed_keywords": coverage.get("search_keywords_observed") or [],
"pending_keywords": coverage.get("search_keywords_not_verified") or []},
"stages": (artifacts.get("plan") or {}).get("stages") or []},
"delivery": {"result_version": version, "goal_version": goal_version(task),
"lead_count": counts["leads"], "source_count": len(sources),
"exports": exports},
"recovery": {"reason": "review_error" if review_detail else reason or task.get("status", ""), "detail": detail, "routes": routes,
**({"underlying_reason": reason, "underlying_detail": original_detail} if review_detail and reason else {})}
if task.get("status") in {"blocked", "paused", "cancelled", "failed"} else None}