PMFlow/backend/app/services/pmflow_ai_context_grounding...

167 lines
6.2 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.

"""Generic context grounding verifier for PMFlow AI answers."""
from __future__ import annotations
import re
from typing import Any
def verify_answer_context_grounding(
*,
answer: str,
used_context_packet: dict[str, Any] | None,
min_required_overlap: int = 1,
) -> dict[str, Any]:
"""Check whether an answer respects supplied thread/memory context.
This verifier is intentionally domain-agnostic: it extracts textual signals
from the already-selected context packet and checks whether non-empty context
has any material overlap with the answer. It does not classify the user's
scene or branch on business object names.
"""
packet = used_context_packet if isinstance(used_context_packet, dict) else {}
answer_terms = _text_terms(answer)
required_items = _required_context_items(packet)
missing = []
used = []
for item in required_items:
terms = _text_terms(item.get("text"))
if not terms:
continue
overlap = sorted(answer_terms & terms)
row = {
"source": item.get("source"),
"ref_id": item.get("ref_id"),
"overlap_count": len(overlap),
"preview": item.get("preview"),
}
if len(overlap) >= max(1, int(min_required_overlap or 1)):
used.append(row)
else:
missing.append(row)
dropped_risks = list(packet.get("dropped_risks") or []) if isinstance(packet.get("dropped_risks"), list) else []
budget_dropped_core = [
item
for item in dropped_risks
if isinstance(item, dict) and str(item.get("section") or "") in {"thread_context", "memory_context"}
]
required_count = len(required_items)
used_required_count = len(used)
minimum_required = min(3, max(1, required_count // 2)) if required_count else 0
passed = not required_items or used_required_count >= minimum_required or not answer_terms
if budget_dropped_core:
passed = False
findings = []
if required_items and not used and answer_terms:
findings.append(
{
"finding_type": "context_not_reflected",
"severity": "blocking",
"summary": "Answer has supplied context but does not reflect any selected context signal.",
}
)
if required_items and used_required_count < minimum_required and answer_terms:
findings.append(
{
"finding_type": "context_obligation_underused",
"severity": "blocking",
"summary": "Answer did not satisfy enough required thread, memory or topic context obligations.",
}
)
if budget_dropped_core:
findings.append(
{
"finding_type": "critical_context_dropped",
"severity": "blocking",
"summary": "Thread or memory context was dropped by budget before answer generation.",
}
)
return {
"packet_version": "pmflow_ai_context_grounding_verification.v1",
"grounding_passed": passed,
"used_context_count": len(used),
"missing_context_count": len(missing),
"used_context_refs": used[:12],
"missing_required_context": missing[:12],
"dropped_risks": dropped_risks[:12],
"findings": findings[:8],
}
def revise_answer_with_context_boundary(*, answer: str, grounding_packet: dict[str, Any]) -> str:
if grounding_packet.get("grounding_passed"):
return str(answer or "").strip()
missing = list(grounding_packet.get("missing_required_context") or [])
if not missing:
return str(answer or "").strip()
previews = [
str(item.get("preview") or "").strip()
for item in missing[:3]
if isinstance(item, dict) and str(item.get("preview") or "").strip()
]
if not previews:
return str(answer or "").strip()
boundary = "这轮回答需要先对齐已选上下文:" + ";".join(previews)
original = str(answer or "").strip()
if not original:
return boundary
return f"{original}\n\n{boundary}"
def _required_context_items(packet: dict[str, Any]) -> list[dict[str, str]]:
rows = []
for item in list(packet.get("thread_message_refs") or [])[:8]:
if not isinstance(item, dict):
continue
text = str(item.get("content_preview") or "").strip()
if text:
rows.append(
{
"source": "thread_message",
"ref_id": str(item.get("message_id") or item.get("message_index") or "")[:80],
"text": text,
"preview": text[:180],
}
)
evidence = packet.get("context_evidence") if isinstance(packet.get("context_evidence"), dict) else {}
for item in list(evidence.get("claims") or [])[:12]:
if not isinstance(item, dict) or not bool(item.get("required_for_answer")):
continue
text = str(item.get("claim_preview") or "").strip()
if text:
rows.insert(
0,
{
"source": f"context_evidence:{str(item.get('semantic_role') or '')[:60]}",
"ref_id": str(item.get("claim_id") or item.get("source_ref") or "")[:80],
"text": text,
"preview": text[:180],
},
)
for item in list(packet.get("memory_refs") or [])[:8]:
if not isinstance(item, dict):
continue
text = str(item.get("summary_preview") or "").strip()
if text:
rows.append(
{
"source": "memory",
"ref_id": str(item.get("memory_id") or "")[:80],
"text": text,
"preview": text[:180],
}
)
return rows
def _text_terms(value: Any) -> set[str]:
text = str(value or "").lower()
alnum = {item for item in re.findall(r"[a-z0-9_]{2,}", text)}
cjk = re.findall(r"[\u4e00-\u9fff]", text)
cjk_terms = {"".join(cjk[index : index + 2]) for index in range(max(0, len(cjk) - 1))}
return {item for item in [*alnum, *cjk_terms] if item}
__all__ = ["revise_answer_with_context_boundary", "verify_answer_context_grounding"]