PMFlow/backend/app/services/pmflow_ai_evidence_absorber.py

619 lines
25 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.

"""Evidence absorption for the PMFlow AI turn runtime.
The absorber is the only layer that turns tool evidence into findings,
implications, remaining gaps, and judgement state. Runtime owns the loop;
tools only return evidence; renderer only expresses the final state.
"""
from __future__ import annotations
from typing import Any
from ..schemas.pmflow_ai_turn_runtime import (
EvidenceConfidence,
PMFlowAIEvidenceAbsorptionResult,
PMFlowAIEvidenceFinding,
PMFlowAIJudgementState,
PMFlowAIRuntimeAction,
PMFlowAITurnState,
)
class PMFlowAIEvidenceAbsorber:
"""Convert bounded tool payloads into TurnState evidence semantics."""
def absorb(
self,
*,
turn_state: PMFlowAITurnState,
action: PMFlowAIRuntimeAction,
payload: dict[str, Any] | None,
) -> PMFlowAIEvidenceAbsorptionResult:
if not isinstance(payload, dict) or _payload_is_final_answer(payload):
return PMFlowAIEvidenceAbsorptionResult(
judgement_state=_build_judgement_state(turn_state=turn_state, new_findings=[]),
evidence_quality=turn_state.evidence_state.evidence_quality,
)
tool_result = _dict(payload.get("runtime_evidence_tool_result"))
findings = _tool_findings_from_payload(
tool_result=tool_result,
turn_state=turn_state,
action=action,
)
findings = _merge_findings(
[
*findings,
*_structured_findings_from_tool_result(
tool_result=tool_result,
turn_state=turn_state,
action=action,
),
],
limit=12,
)
if not findings:
findings = _fallback_findings_from_external_payload(
payload=payload,
turn_state=turn_state,
action=action,
)
if not findings:
findings = _fallback_findings_from_customer_packet(
payload=payload,
turn_state=turn_state,
action=action,
)
sources = _payload_sources(payload)
remaining_gaps = _unique(
[
*_remaining_gaps(payload=payload, tool_result=tool_result),
*_structured_remaining_gaps(tool_result=tool_result),
],
limit=10,
)
if _has_off_topic_tool_evidence(tool_result=tool_result, action=action, turn_state=turn_state) and not findings:
remaining_gaps = _unique([*remaining_gaps, "off_topic_evidence_excluded"], limit=10)
merged_state = turn_state.model_copy(deep=True)
merged_state.evidence_state.known_facts.extend(findings)
merged_state.evidence_state.structured_evidence = _merge_structured_evidence(
merged_state.evidence_state.structured_evidence,
_structured_evidence_from_tool_result(tool_result=tool_result),
)
merged_state.evidence_state.stale_or_missing.extend(remaining_gaps)
quality = _evidence_quality(findings=findings, payload=payload)
return PMFlowAIEvidenceAbsorptionResult(
findings=findings[:10],
structured_evidence=_structured_evidence_from_tool_result(tool_result=tool_result),
sources=sources,
remaining_gaps=remaining_gaps,
judgement_state=_build_judgement_state(turn_state=merged_state, new_findings=findings),
evidence_quality=quality,
summary=str(tool_result.get("summary") or _payload_summary(payload))[:500],
)
def _tool_findings_from_payload(
*,
tool_result: dict[str, Any],
turn_state: PMFlowAITurnState,
action: PMFlowAIRuntimeAction,
) -> list[PMFlowAIEvidenceFinding]:
findings: list[PMFlowAIEvidenceFinding] = []
for item in list(tool_result.get("findings") or [])[:12]:
if not isinstance(item, dict):
continue
fact = _clean(item.get("fact"), limit=700)
if not fact:
continue
if not _tool_finding_is_relevant(fact=fact, action=action, turn_state=turn_state):
continue
implication = _clean(item.get("implication"), limit=700) or _default_implication(turn_state=turn_state)
findings.append(
PMFlowAIEvidenceFinding(
fact=fact,
source=_clean(item.get("source") or tool_result.get("tool_name") or "PMFlow 证据工具", limit=240),
implication=implication,
relevance_to_goal=_clean(item.get("relevance_to_goal") or turn_state.turn_goal.user_goal, limit=160),
confidence=_confidence(item.get("confidence") or tool_result.get("confidence")),
)
)
return findings
def _structured_findings_from_tool_result(
*,
tool_result: dict[str, Any],
turn_state: PMFlowAITurnState,
action: PMFlowAIRuntimeAction,
) -> list[PMFlowAIEvidenceFinding]:
if action.action_type != "inspect_project_status":
return []
packets = _project_operating_packets(tool_result=tool_result)
findings: list[PMFlowAIEvidenceFinding] = []
for packet in packets[:4]:
if not _project_packet_matches_primary(packet=packet, turn_state=turn_state):
continue
title = _clean(packet.get("project_title") or packet.get("project_ref") or "当前项目", limit=160)
for key in (
"command_state",
"workbench_state",
"decision_review_state",
"execution_state",
"quality_test_state",
"release_growth_state",
"team_state",
"project_memory_state",
"risk_blocker_state",
"recent_timeline_state",
):
facet = _dict(packet.get(key))
summary = _clean(facet.get("summary"), limit=420)
if not summary:
continue
label = _clean(facet.get("display_label") or key, limit=80)
coverage = _clean(facet.get("coverage_status"), limit=40)
findings.append(
PMFlowAIEvidenceFinding(
fact=f"{title} / {label}:{summary}",
source="PMFlow 项目全流程证据包",
implication=_project_operating_implication(label=label, coverage=coverage),
relevance_to_goal=turn_state.turn_goal.user_goal,
confidence="high" if coverage == "covered" else "medium",
)
)
if len(findings) >= 8:
break
return findings
def _structured_remaining_gaps(*, tool_result: dict[str, Any]) -> list[str]:
gaps: list[str] = []
for packet in _project_operating_packets(tool_result=tool_result)[:4]:
gaps.extend(str(item or "") for item in list(packet.get("remaining_gaps") or [])[:12])
return _unique(gaps, limit=10)
def _project_operating_packets(*, tool_result: dict[str, Any]) -> list[dict[str, Any]]:
structured = _dict(tool_result.get("structured_evidence"))
rows = structured.get("project_operating_evidence_packets")
if isinstance(rows, list):
return [dict(item) for item in rows if isinstance(item, dict)]
single = structured.get("project_operating_evidence_packet")
return [dict(single)] if isinstance(single, dict) and single else []
def _structured_evidence_from_tool_result(*, tool_result: dict[str, Any]) -> dict[str, Any]:
structured = _dict(tool_result.get("structured_evidence"))
packets = _project_operating_packets(tool_result=tool_result)
if packets:
return {
"project_operating_evidence_packets": packets,
"project_operating_evidence_packet": packets[0] if len(packets) == 1 else {},
}
return structured
def _merge_structured_evidence(existing: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
merged = dict(existing or {})
incoming_packets = incoming.get("project_operating_evidence_packets")
if isinstance(incoming_packets, list):
existing_packets = merged.get("project_operating_evidence_packets")
packets = [dict(item) for item in existing_packets if isinstance(item, dict)] if isinstance(existing_packets, list) else []
by_ref = {
str(packet.get("project_ref") or packet.get("project_id") or index): packet
for index, packet in enumerate(packets)
}
for packet in incoming_packets:
if not isinstance(packet, dict):
continue
key = str(packet.get("project_ref") or packet.get("project_id") or len(by_ref))
by_ref[key] = dict(packet)
merged_packets = list(by_ref.values())[:12]
merged["project_operating_evidence_packets"] = merged_packets
merged["project_operating_evidence_packet"] = merged_packets[0] if len(merged_packets) == 1 else {}
for key, value in incoming.items():
if key not in {"project_operating_evidence_packets", "project_operating_evidence_packet"}:
merged[key] = value
return merged
def _project_packet_matches_primary(*, packet: dict[str, Any], turn_state: PMFlowAITurnState) -> bool:
primary = turn_state.object_state.primary_object
if primary is None:
return False
project_ref = _clean(packet.get("project_ref"), limit=160)
title = _clean(packet.get("project_title"), limit=160)
return bool(
(project_ref and project_ref == _clean(primary.object_ref, limit=160))
or (title and title == _clean(primary.title, limit=160))
)
def _project_operating_implication(*, label: str, coverage: str) -> str:
if coverage == "missing":
return f"{label}证据缺失,最终推进建议必须显式保留该边界。"
if coverage == "partial":
return f"{label}只拿到部分证据,适合给有限建议,不能假装已完成全量判断。"
return f"{label}已形成可用证据,可参与当前项目推进判断。"
def _merge_findings(values: list[PMFlowAIEvidenceFinding], *, limit: int) -> list[PMFlowAIEvidenceFinding]:
out: list[PMFlowAIEvidenceFinding] = []
seen: set[str] = set()
for item in values:
key = _clean(item.fact, limit=700)
if not key or key in seen:
continue
seen.add(key)
out.append(item)
if len(out) >= limit:
break
return out
def _has_off_topic_tool_evidence(
*,
tool_result: dict[str, Any],
action: PMFlowAIRuntimeAction,
turn_state: PMFlowAITurnState,
) -> bool:
return any(
isinstance(item, dict)
and _clean(item.get("fact"), limit=700)
and not _tool_finding_is_relevant(fact=_clean(item.get("fact"), limit=700), action=action, turn_state=turn_state)
for item in list(tool_result.get("findings") or [])[:12]
)
def _tool_finding_is_relevant(
*,
fact: str,
action: PMFlowAIRuntimeAction,
turn_state: PMFlowAITurnState,
) -> bool:
if action.action_type in {"external_search", "external_fetch", "inspect_saved_ideas", "resolve_project_objects"}:
return True
selected = turn_state.selected_frame
operation = selected.operation_kind if selected is not None else "answer_general"
if action.action_type == "inspect_surface_context":
return operation == "list_collection" or _has_strong_project_primary(turn_state)
if action.action_type in {"inspect_project_status", "inspect_internal_projects"}:
return _has_strong_project_primary(turn_state) and _fact_mentions_primary(fact=fact, turn_state=turn_state)
return True
def _has_strong_project_primary(turn_state: PMFlowAITurnState) -> bool:
primary = turn_state.object_state.primary_object
if primary is None:
return False
binding = str(primary.binding_strength or "").strip().lower()
if binding in {"", "none", "context", "visible_context"}:
return False
return str(primary.object_type or "").strip().lower() == "project" and bool(str(primary.object_ref or primary.title or "").strip())
def _fact_mentions_primary(*, fact: str, turn_state: PMFlowAITurnState) -> bool:
primary = turn_state.object_state.primary_object
if primary is None:
return False
title = _clean(primary.title, limit=160)
ref = _clean(primary.object_ref, limit=160)
return bool((title and title in fact) or (ref and ref in fact))
def _fallback_findings_from_external_payload(
*,
payload: dict[str, Any],
turn_state: PMFlowAITurnState,
action: PMFlowAIRuntimeAction,
) -> list[PMFlowAIEvidenceFinding]:
if action.action_type not in {"external_search", "external_fetch"}:
return []
findings: list[PMFlowAIEvidenceFinding] = []
for item in _external_evidence_items_from_payload(payload)[:8]:
title = _clean(item.get("title") or item.get("source_label") or "外部公开来源", limit=240)
snippet = _clean(item.get("snippet"), limit=500)
url = _clean(item.get("target_url") or item.get("url"), limit=240)
if not title and not snippet:
continue
fact = f"{title}:{snippet}".strip(":")
findings.append(
PMFlowAIEvidenceFinding(
fact=fact,
source=url or title or "外部公开来源",
implication=_default_implication(turn_state=turn_state),
relevance_to_goal=turn_state.turn_goal.user_goal,
confidence="high" if url and snippet else "medium",
)
)
return findings
def _fallback_findings_from_customer_packet(
*,
payload: dict[str, Any],
turn_state: PMFlowAITurnState,
action: PMFlowAIRuntimeAction,
) -> list[PMFlowAIEvidenceFinding]:
packet = _dict(payload.get("customer_evidence_packet"))
findings: list[PMFlowAIEvidenceFinding] = []
for fact in list(packet.get("facts") or [])[:8]:
if not isinstance(fact, dict):
continue
summary = _clean(fact.get("display_summary") or fact.get("display_label"), limit=700)
if not summary:
continue
findings.append(
PMFlowAIEvidenceFinding(
fact=summary,
source=_source_from_fact(fact=fact, action=action),
implication=_customer_fact_implication(fact=fact, turn_state=turn_state),
relevance_to_goal=turn_state.turn_goal.user_goal,
confidence=_confidence(fact.get("confidence")),
)
)
return findings
def _build_judgement_state(
*,
turn_state: PMFlowAITurnState,
new_findings: list[PMFlowAIEvidenceFinding],
) -> PMFlowAIJudgementState:
findings = list(turn_state.evidence_state.known_facts or [])
if not findings:
return PMFlowAIJudgementState(confidence="unknown")
implications = _unique([item.implication for item in findings if item.implication], limit=8)
facts = _unique([item.fact for item in findings if item.fact], limit=8)
gaps = _unique([*turn_state.evidence_state.stale_or_missing], limit=6)
confidence: EvidenceConfidence = "medium" if any(item.confidence in {"high", "medium"} for item in findings) else "unknown"
if new_findings and all(item.confidence == "high" for item in new_findings[:3]):
confidence = "high"
shape = turn_state.answer_state.answer_shape
update: dict[str, Any] = {"confidence": confidence}
if shape == "strategic_judgement":
update.update(
{
"opportunity": _join_sentence(implications[:3]) or "已有证据可支撑方向判断,但仍需要把机会收敛到可验证切口。",
"can_do": implications[:4] or facts[:4],
"should_not_do": _gap_guidance(gaps),
"better_angle": _strategic_better_angle(turn_state=turn_state, implications=implications),
"mvp": _mvp_from_implications(implications=implications, facts=facts),
"risks": _risk_from_gaps(gaps=gaps, findings=findings),
"validation_questions": _validation_questions(turn_state=turn_state, gaps=gaps),
"next_actions": _next_actions_for_shape(shape=shape),
}
)
elif shape in {"status_answer", "composite_answer", "fact_list"}:
update.update(
{
"direct_answer": _join_sentence(implications[:3] or facts[:3]),
"can_do": implications[:4] or facts[:4],
"risks": _risk_from_gaps(gaps=gaps, findings=findings),
"validation_questions": _validation_questions(turn_state=turn_state, gaps=gaps),
"next_actions": _next_actions_for_shape(shape=shape),
}
)
elif shape == "execution_guidance":
update.update(
{
"direct_answer": _join_sentence(implications[:3] or facts[:3]),
"should_not_do": ["写入、提交、分派或执行类动作必须先经过确认边界。"],
"risks": _risk_from_gaps(gaps=gaps, findings=findings),
"validation_questions": _validation_questions(turn_state=turn_state, gaps=gaps),
"next_actions": _next_actions_for_shape(shape=shape),
}
)
else:
update.update(
{
"direct_answer": _join_sentence(implications[:3] or facts[:3]),
"risks": _risk_from_gaps(gaps=gaps, findings=findings),
"next_actions": _next_actions_for_shape(shape=shape),
}
)
return turn_state.judgement_state.model_copy(update=update)
def _payload_sources(payload: dict[str, Any] | None) -> list[dict[str, Any]]:
if not isinstance(payload, dict):
return []
rows: list[dict[str, Any]] = []
tool_result = _dict(payload.get("runtime_evidence_tool_result"))
tool_sources = tool_result.get("sources")
if isinstance(tool_sources, list):
rows.extend(item for item in tool_sources if isinstance(item, dict))
for key in ("sources_used", "sources", "citations"):
value = payload.get(key)
if isinstance(value, list):
rows.extend(item for item in value if isinstance(item, dict))
return _dedupe_sources(rows)[:12]
def _remaining_gaps(*, payload: dict[str, Any], tool_result: dict[str, Any]) -> list[str]:
gaps: list[str] = []
for value in list(tool_result.get("remaining_gaps") or []):
text = _clean(value, limit=180)
if text:
gaps.append(text)
context_packet = _dict(payload.get("external_context_packet"))
for key in ("remaining_gaps", "warnings"):
value = context_packet.get(key)
if isinstance(value, list):
gaps.extend(_clean(item, limit=180) for item in value if _clean(item, limit=180))
return _unique(gaps, limit=10)
def _payload_summary(payload: dict[str, Any] | None) -> str:
if not isinstance(payload, dict):
return ""
canvas = _dict(payload.get("answer_canvas"))
headline = _clean(canvas.get("headline"), limit=180)
if headline:
return headline
return _clean(payload.get("answer") or payload.get("reply_text"), limit=180)
def _payload_is_final_answer(payload: dict[str, Any] | None) -> bool:
if not isinstance(payload, dict):
return False
kind = _clean(payload.get("_runtime_payload_kind"), limit=80)
if kind == "evidence":
return False
if kind == "final_answer":
return True
return bool(_clean(payload.get("answer") or payload.get("reply_text"), limit=80))
def _external_evidence_items_from_payload(payload: dict[str, Any]) -> list[dict[str, Any]]:
rows = payload.get("external_evidence_items")
if isinstance(rows, list):
return [dict(item) for item in rows if isinstance(item, dict)]
context_packet = _dict(payload.get("external_context_packet"))
verified = context_packet.get("verified_evidence")
if isinstance(verified, list):
return [dict(item) for item in verified if isinstance(item, dict)]
return []
def _source_from_fact(*, fact: dict[str, Any], action: PMFlowAIRuntimeAction) -> str:
labels = [_clean(item, limit=180) for item in list(fact.get("source_display_labels") or [])]
labels = [item for item in labels if item]
if labels:
return labels[0]
if action.action_type in {"external_search", "external_fetch"}:
return "外部公开来源"
if action.action_type in {"inspect_surface_context", "inspect_saved_ideas", "resolve_project_objects"}:
return "当前空间状态"
if action.action_type == "inspect_project_status":
return "PMFlow 项目状态证据"
return "PMFlow 项目证据"
def _customer_fact_implication(*, fact: dict[str, Any], turn_state: PMFlowAITurnState) -> str:
values = _dict(fact.get("business_values"))
for value in values.values():
text = _clean(value, limit=500)
if text:
return text
return _default_implication(turn_state=turn_state)
def _default_implication(*, turn_state: PMFlowAITurnState) -> str:
shape = turn_state.answer_state.answer_shape
if shape == "strategic_judgement":
return "这条证据用于判断机会窗口、定位边界、风险和最小验证切口。"
if shape == "status_answer":
return "这条证据用于判断当前状态、缺口、风险和下一步。"
if shape == "execution_guidance":
return "这条证据用于判断可执行动作、风险和确认边界。"
if shape == "fact_list":
return "这条证据用于对象归位和匹配依据。"
return "这条证据用于支撑本轮回答。"
def _strategic_better_angle(*, turn_state: PMFlowAITurnState, implications: list[str]) -> str:
if implications:
return implications[0][:500]
subject = _clean(turn_state.problem_frame.subject, limit=120) or "这个方向"
return f"{subject}应先收敛到可验证的客户、数据、场景和闭环指标,再扩展为完整方案。"
def _mvp_from_implications(*, implications: list[str], facts: list[str]) -> list[str]:
candidates = implications[:3] or facts[:3]
if candidates:
return candidates[:3]
return ["先做最小证据闭环:输入、判断、证据、人工确认和结果回流。"]
def _risk_from_gaps(*, gaps: list[str], findings: list[PMFlowAIEvidenceFinding]) -> list[str]:
risks = list(gaps[:4])
if not risks and any(item.confidence in {"low", "unknown"} for item in findings):
risks.append("部分证据置信度不足,不能把方向判断当成已验证结论。")
if not risks:
risks.append("仍需用真实用户、数据可得性和落地成本验证判断。")
return risks[:5]
def _gap_guidance(gaps: list[str]) -> list[str]:
if gaps:
return [f"不要把未补齐的缺口当成已验证结论:{item}" for item in gaps[:4]]
return ["不要把外部趋势或内部可见对象直接等同于真实付费需求。"]
def _validation_questions(*, turn_state: PMFlowAITurnState, gaps: list[str]) -> list[str]:
if gaps:
return gaps[:4]
if turn_state.problem_frame.ambiguity_points:
return turn_state.problem_frame.ambiguity_points[:4]
return ["目标客户是谁", "可获得哪些关键数据", "最小可验证场景是什么"]
def _next_actions_for_shape(*, shape: str) -> list[str]:
if shape == "strategic_judgement":
return ["收敛目标客户和高频场景", "补齐近期外部证据和竞品证据", "定义最小验证闭环"]
if shape == "status_answer":
return ["核对目标对象范围", "补齐缺失证据", "按风险和下一步排序处理"]
if shape == "execution_guidance":
return ["确认对象", "确认动作边界", "确认写入或执行风险"]
return ["补齐关键证据", "给出可验证下一步"]
def _evidence_quality(*, findings: list[PMFlowAIEvidenceFinding], payload: dict[str, Any]) -> EvidenceConfidence:
if not findings:
return "medium" if _payload_sources(payload) else "unknown"
if any(item.confidence == "high" for item in findings):
return "high"
if any(item.confidence == "medium" for item in findings):
return "medium"
return "unknown"
def _confidence(value: Any) -> EvidenceConfidence:
normalized = str(value or "").strip().lower()
if normalized in {"high", "medium", "low"}:
return normalized # type: ignore[return-value]
return "unknown"
def _dict(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
def _clean(value: Any, *, limit: int) -> str:
return " ".join(str(value or "").strip().split())[:limit]
def _join_sentence(values: list[str]) -> str:
return ";".join(_clean(item, limit=220) for item in values if _clean(item, limit=220))[:500]
def _unique(values: list[str], *, limit: int) -> list[str]:
out: list[str] = []
for value in values:
text = _clean(value, limit=240)
if text and text not in out:
out.append(text)
if len(out) >= limit:
break
return out
def _dedupe_sources(values: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for item in values:
key = _clean(item.get("target_url") or item.get("url") or item.get("display_label") or item.get("source"), limit=240)
if not key or key in seen:
continue
seen.add(key)
out.append(item)
return out
__all__ = ["PMFlowAIEvidenceAbsorber"]