"""Legacy deterministic answer composer for PMFlow Agent OS compatibility. The PMFlow AI product path now uses `PMFlowAIAgentAnswerRuntime` and terminal answer state. This module must not become the `/space/pmflow-ai` final answer source again. """ from __future__ import annotations import re from dataclasses import dataclass, field from typing import Any from .loop import PMFlowAgentLoopResult BANNED_USER_VISIBLE_TERMS = ( "project_object_required_for_world_state", "world_state", "packet", "trace", "prompt", "function", "route_plan", "当前对象(空对象)", "Claim Ledger", "Boundary Ledger", "TurnState", ) @dataclass(slots=True) class ClaimLedger: claims: list[str] = field(default_factory=list) @dataclass(slots=True) class BoundaryLedger: boundaries: list[str] = field(default_factory=list) @dataclass(slots=True) class ActionLedger: suggested: list[dict[str, Any]] = field(default_factory=list) waiting_confirmation: list[dict[str, Any]] = field(default_factory=list) blocked: list[dict[str, Any]] = field(default_factory=list) @dataclass(slots=True) class PMFlowAgentAnswerLedgers: claim_ledger: ClaimLedger = field(default_factory=ClaimLedger) boundary_ledger: BoundaryLedger = field(default_factory=BoundaryLedger) action_ledger: ActionLedger = field(default_factory=ActionLedger) answer_focus: str = "space" specialist_answer: str = "" confidence: str = "medium" class AnswerArbitrator: """Decide which verified observations and artifacts can enter the answer.""" def build_ledgers(self, *, question: str, loop_result: PMFlowAgentLoopResult) -> PMFlowAgentAnswerLedgers: ledgers = PMFlowAgentAnswerLedgers(answer_focus="project" if loop_result.binding.scope_kind == "project" else "space") specialist = loop_result.specialist_result if specialist.answer: ledgers.specialist_answer = specialist.answer ledgers.claim_ledger.claims.append(specialist.answer) for observation in loop_result.observations: for fact in list(observation.facts or [])[:24]: if not isinstance(fact, dict): continue claim = _claim_from_fact(fact) if claim: ledgers.claim_ledger.claims.append(claim) for gap in list(observation.gaps or [])[:12]: if isinstance(gap, dict): boundary = _boundary_from_gap(gap) if boundary: ledgers.boundary_ledger.boundaries.append(boundary) if specialist.autonomy_gate is not None: ledgers.action_ledger.suggested.extend(_records(getattr(specialist.autonomy_gate, "prepared_action_packets", None))) ledgers.action_ledger.waiting_confirmation.extend(_records(getattr(specialist.autonomy_gate, "confirmation_requests", None))) ledgers.action_ledger.blocked.extend(_records(getattr(specialist.autonomy_gate, "blocked_actions", None))) ledgers.boundary_ledger.boundaries.append("确认前我不会写项目记忆、改任务、发起执行 handoff、发布或调用外部系统。") ledgers.claim_ledger.claims = _dedupe(ledgers.claim_ledger.claims)[:12] ledgers.boundary_ledger.boundaries = _dedupe(ledgers.boundary_ledger.boundaries)[:8] ledgers.action_ledger.suggested = _dedupe_records(ledgers.action_ledger.suggested)[:8] ledgers.action_ledger.waiting_confirmation = _dedupe_records(ledgers.action_ledger.waiting_confirmation)[:8] ledgers.action_ledger.blocked = _dedupe_records(ledgers.action_ledger.blocked)[:8] if any(_is_high_confidence_observation(item.confidence) for item in loop_result.observations): ledgers.confidence = "high" return ledgers class NaturalAnswerComposer: """Compose a customer-facing Chinese answer from ledgers, not raw tool output.""" def compose(self, *, question: str, ledgers: PMFlowAgentAnswerLedgers) -> str: if ledgers.specialist_answer: answer = ledgers.specialist_answer if ledgers.action_ledger.waiting_confirmation and "确认" not in answer: answer += f"\n\n我已经把 {len(ledgers.action_ledger.waiting_confirmation)} 个需要你确认的动作停在门禁里;确认前不会直接写入或执行。" return answer.strip() capability_claims = [item for item in ledgers.claim_ledger.claims if _claim_kind(item) == "capability"] project_claims = [item for item in ledgers.claim_ledger.claims if _claim_kind(item) == "project"] space_claims = [item for item in ledgers.claim_ledger.claims if _claim_kind(item) == "space"] paragraphs: list[str] = [] intro = "我是 PMFlow AI,负责帮你读取当前空间和项目状态,组织下一步建议,并把需要确认的动作停在确认链路里。" if capability_claims: intro = "我是 PMFlow AI。" + _unmark(capability_claims[0]) paragraphs.append(intro) if project_claims: summary = _compact_project_claims(project_claims) if summary: paragraphs.append(summary) elif space_claims and not capability_claims: paragraphs.append("我刚读了当前空间状态:" + ";".join(_unmark(item) for item in space_claims[:3]) + "。") if ledgers.boundary_ledger.boundaries: paragraphs.append(_compact_boundary(ledgers.boundary_ledger.boundaries)) return "\n\n".join(_dedupe(paragraphs)).strip() class CustomerAnswerCritic: """Block internal terms, fixed headings and raw English reasoning from the answer.""" def review(self, answer: str) -> list[str]: text = str(answer or "").strip() findings: list[str] = [] if not text: findings.append("empty_answer") lowered = text.lower() for term in BANNED_USER_VISIBLE_TERMS: if term.lower() in lowered: findings.append("internal_term") break if re.search(r"(^|\n)\s*(判断依据|证据边界|下一步)\s*[::]", text): findings.append("fixed_template_heading") if re.search(r"\b(current object|world state|route plan|prompt key|function name)\b", text, re.IGNORECASE): findings.append("english_internal_reasoning") return findings[:8] def repair(self, *, answer: str, ledgers: PMFlowAgentAnswerLedgers) -> str: cleaned = _sanitize_answer(answer) if cleaned and not self.review(cleaned): return cleaned fallback = "我是 PMFlow AI,可以帮你读取空间和项目状态、整理下一步建议,并把所有写入、执行和发布动作停在确认链路里。" if ledgers.claim_ledger.claims: fallback += "\n\n" + _sanitize_answer(ledgers.claim_ledger.claims[0]) if ledgers.boundary_ledger.boundaries: fallback += "\n\n" + _sanitize_answer(ledgers.boundary_ledger.boundaries[0]) return fallback.strip() def _claim_from_fact(fact: dict[str, Any]) -> str: fact_type = str(fact.get("type") or "").strip() if fact_type == "allowed_boundary": return _mark( "capability", "我可以说明 PMFlow 的产品能力,读取你当前空间和项目的客户安全状态,整理下一步建议,并在确认链路里准备可执行动作。", ) if fact_type == "blocked_boundary": return "" if fact_type == "project_collection_summary": count = _safe_int(fact.get("count")) or 0 titles = [str(item).strip() for item in list(fact.get("titles") or []) if str(item).strip()] if count > 0 and titles: return _mark("project", f"当前空间能看到 {count} 个项目:" + "、".join(titles[:8]) + "。") if count > 0: return _mark("project", f"当前空间能看到 {count} 个项目。") return _mark("project", "当前空间还没有读到可见项目。") if fact_type == "project_item": title = str(fact.get("title") or fact.get("name") or "").strip() if not title: return "" status = str(fact.get("status") or "").strip() return _mark("project", f"{title}{f'({_local_status(status)})' if status else ''}") if fact_type == "surface_collection_count": collection = str(fact.get("collection") or "").strip() count = _safe_int(fact.get("count")) or 0 label = _surface_collection_label(collection) if label: return _mark("space", f"{label} {count} 项") if fact_type == "visible_project_candidate_count": count = _safe_int(fact.get("count")) or 0 if count: return _mark("space", f"可见项目候选 {count} 个") return "" def _boundary_from_gap(gap: dict[str, Any]) -> str: summary = str(gap.get("customer_safe_summary") or gap.get("summary") or "").strip() if summary: return summary if str(gap.get("type") or "").strip() == "object_anchor_gap": return "如果要推进某一个项目,需要从项目页进入或点名项目;我不会替你默认选择。" return "" def _compact_project_claims(claims: list[str]) -> str: stripped = [_unmark(item) for item in claims] summary = next((item for item in stripped if item.startswith("当前空间能看到")), "") items = [item for item in stripped if item and not item.startswith("当前空间能看到")] if summary and items: return summary + " " + ";".join(items[:6]) + "。" if summary: return summary if items: return "当前空间能看到:" + ";".join(items[:6]) + "。" return "" def _compact_boundary(boundaries: list[str]) -> str: rows = _dedupe([_sanitize_answer(item) for item in boundaries if _sanitize_answer(item)]) if not rows: return "" return rows[0] if len(rows) == 1 else rows[0] + " " + rows[1] def _claim_kind(value: str) -> str: if value.startswith("[capability]"): return "capability" if value.startswith("[project]"): return "project" if value.startswith("[space]"): return "space" return "other" def _mark(kind: str, value: str) -> str: text = _sanitize_answer(value) return f"[{kind}]{text}" if text else "" def _unmark(value: str) -> str: return re.sub(r"^\[(capability|project|space)\]", "", str(value or "")).strip() def _sanitize_answer(value: Any) -> str: text = str(value or "").strip() if not text: return "" for term in BANNED_USER_VISIBLE_TERMS: text = re.sub(re.escape(term), "", text, flags=re.IGNORECASE) text = re.sub(r"\b(current object|world state|route plan|prompt key|function name)\b", "", text, flags=re.IGNORECASE) text = text.replace("active", "进行中") text = re.sub(r"\n{3,}", "\n\n", text) return " ".join(text.split()) if "\n" not in text else text.strip() def _surface_collection_label(value: str) -> str: labels = { "projects": "项目", "recent_projects": "最近项目", "top_next_actions": "下一步", "pending_confirmations": "待确认", "ai_progress": "执行中", "saved_ideas": "暂存想法", } return labels.get(value, "") def _local_status(value: str) -> str: status = str(value or "").strip().lower() if status == "active": return "进行中" if status == "archived": return "已归档" if status == "draft": return "草稿" return status def _records(value: Any) -> list[dict[str, Any]]: return [dict(item) for item in list(value or []) if isinstance(item, dict)] def _dedupe(rows: list[str]) -> list[str]: out: list[str] = [] seen: set[str] = set() for row in rows: text = str(row or "").strip() key = re.sub(r"\s+", "", text.lower()) if not text or key in seen: continue seen.add(key) out.append(text) return out def _dedupe_records(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] seen: set[str] = set() for row in rows: key = str(row.get("prepared_action_id") or row.get("confirmation_id") or row.get("title") or row) if key in seen: continue seen.add(key) out.append(row) return out def _is_high_confidence_observation(value: Any) -> bool: return str(value or "").strip().lower() == "high" def _safe_int(value: Any) -> int | None: try: if value is None or value == "": return None return int(value) except Exception: return None __all__ = [ "ActionLedger", "AnswerArbitrator", "BoundaryLedger", "ClaimLedger", "CustomerAnswerCritic", "NaturalAnswerComposer", "PMFlowAgentAnswerLedgers", ]