PMFlow/backend/app/services/pmflow_agent_runtime/answer.py

325 lines
13 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.

"""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",
]