140 lines
8.3 KiB
Python
140 lines
8.3 KiB
Python
"""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}
|