"""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}