619 lines
25 KiB
Python
619 lines
25 KiB
Python
"""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"]
|