325 lines
13 KiB
Python
325 lines
13 KiB
Python
"""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",
|
||
]
|