PMFlow/backend/app/services/pmflow_ai_context_evidence_...

454 lines
18 KiB
Python
Raw Permalink 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.

"""Context evidence absorption for PMFlow AI.
This module turns already-selected thread, memory and topic context into
answer-grade obligations. It is deliberately based on source role, recency,
scope and generic semantic overlap rather than business-scene keyword routing.
"""
from __future__ import annotations
import hashlib
import re
from typing import Any
from ..schemas.pmflow_ai_agent_runtime import (
PMFlowAIAgentBoundary,
PMFlowAIAgentClaim,
PMFlowAIAgentWorkspace,
clean_text,
)
CONTEXT_EVIDENCE_PACKET_VERSION = "pmflow_ai_context_evidence_packet.v1"
TOPIC_WORKSPACE_PACKET_VERSION = "pmflow_ai_topic_workspace_packet.v1"
def build_context_evidence_packet(
*,
context_pack: dict[str, Any] | None,
user_question: str,
) -> dict[str, Any]:
"""Build customer-safe context claims and obligations from a context pack."""
pack = context_pack if isinstance(context_pack, dict) else {}
question = clean_text(user_question, limit=1200)
rows = _context_rows(pack)
scored = sorted(
rows,
key=lambda row: (
-_row_score(row=row, question=question),
_source_rank(row.get("source_kind")),
-int(row.get("message_index") or 0),
),
)
claims: list[dict[str, Any]] = []
for index, row in enumerate(scored[:16], start=1):
text = clean_text(row.get("text"), limit=1000)
if not text:
continue
role = _semantic_role(row)
claim_key = f"{row.get('source_ref')}:{text[:160]}"
claim_id = f"ctxclaim_{hashlib.sha1(claim_key.encode('utf-8')).hexdigest()[:12]}"
score = _row_score(row=row, question=question)
claims.append(
{
"claim_id": claim_id,
"semantic_role": role,
"claim_text": text,
"customer_value": _customer_value_for_role(role),
"source_kind": str(row.get("source_kind") or "")[:80],
"source_role": str(row.get("source_role") or "")[:40],
"source_ref": str(row.get("source_ref") or "")[:160],
"support_refs": [str(row.get("source_ref") or "")[:160]] if row.get("source_ref") else [],
"relevance_score": round(float(score), 4),
"required_for_answer": bool(index <= 6 and score > 0),
"confidence": row.get("confidence") or ("high" if row.get("source_kind") == "thread_message" else "medium"),
}
)
if claims and not any(item.get("required_for_answer") for item in claims):
claims[0]["required_for_answer"] = True
obligations = [
{
"obligation_id": f"ctxobl_{item['claim_id'].replace('ctxclaim_', '')}",
"claim_id": item["claim_id"],
"semantic_role": item["semantic_role"],
"requirement": "The final answer must preserve this prior context unless it explicitly marks a narrower missing artifact boundary.",
"source_ref": item.get("source_ref"),
}
for item in claims
if item.get("required_for_answer")
]
topic = build_topic_workspace_packet(
user_question=question,
context_claims=claims,
context_pack=pack,
)
conflicts = _context_conflict_boundaries(pack=pack, context_claims=claims)
return {
"packet_version": CONTEXT_EVIDENCE_PACKET_VERSION,
"current_user_message": question,
"claims": claims,
"continuation_obligations": obligations[:8],
"conflict_boundaries": conflicts[:8],
"topic_workspace": topic,
"policy": {
"context_claims_are_answer_grade_evidence": True,
"tool_empty_results_cannot_negate_prior_context": True,
"visible_objects_are_candidates_until_explicitly_bound": True,
"business_scene_keyword_routing": False,
},
}
def append_context_evidence_to_workspace(
*,
workspace: PMFlowAIAgentWorkspace,
evidence_packet: dict[str, Any] | None,
) -> int:
"""Promote context evidence claims into the Agent Workspace claim ledger."""
packet = evidence_packet if isinstance(evidence_packet, dict) else {}
rows = list(packet.get("claims") or [])
if not rows:
return 0
existing_refs = {
ref
for claim in list(getattr(workspace, "claim_ledger", []) or [])
for ref in list(getattr(claim, "support_refs", []) or [])
if ref
}
claims: list[PMFlowAIAgentClaim] = []
for row in rows[:16]:
if not isinstance(row, dict):
continue
text = clean_text(row.get("claim_text"), limit=1000)
source_ref = clean_text(row.get("source_ref"), limit=160)
if not text or (source_ref and source_ref in existing_refs):
continue
semantic_role = clean_text(row.get("semantic_role"), limit=80) or "context_evidence"
claims.append(
PMFlowAIAgentClaim(
claim_id=clean_text(row.get("claim_id"), limit=80) or "",
claim_type=f"context_{semantic_role}"[:80],
claim_text=text,
customer_value=clean_text(row.get("customer_value"), limit=500),
support_refs=list(row.get("support_refs") or ([source_ref] if source_ref else []))[:12],
source_tool="context_evidence_absorber",
source_kind=clean_text(row.get("source_kind"), limit=120),
relevance_to_user_goal="direct" if bool(row.get("required_for_answer")) else "partial",
target_match="direct" if bool(row.get("required_for_answer")) else "partial",
time_match="recent" if row.get("source_kind") == "thread_message" else "unknown",
authority_level="primary" if row.get("source_kind") == "thread_message" else "credible_secondary",
claim_kind="substantive_fact",
confidence=_claim_confidence(row),
allowed_to_state=True,
)
)
boundaries = []
for row in list(packet.get("conflict_boundaries") or [])[:8]:
if not isinstance(row, dict):
continue
reason = clean_text(row.get("customer_safe_reason"), limit=800)
if not reason:
continue
boundaries.append(
PMFlowAIAgentBoundary(
boundary_type=clean_text(row.get("boundary_type"), limit=80) or "context_boundary",
blocked_claim=clean_text(row.get("blocked_claim"), limit=800),
customer_safe_reason=reason,
allowed_alternative=clean_text(row.get("allowed_alternative"), limit=800),
support_refs=list(row.get("support_refs") or [])[:12],
)
)
if claims:
workspace.claim_ledger = [*list(getattr(workspace, "claim_ledger", []) or []), *claims][-80:]
if boundaries:
workspace.boundary_ledger = [*list(getattr(workspace, "boundary_ledger", []) or []), *boundaries][-40:]
topic = packet.get("topic_workspace") if isinstance(packet.get("topic_workspace"), dict) else {}
if topic:
workspace.known_facts = [
*list(getattr(workspace, "known_facts", []) or []),
{
"type": "topic_workspace_context",
"summary": clean_text(topic.get("topic_summary"), limit=800),
"topic_signature": topic.get("topic_signature"),
"current_objective": clean_text(topic.get("current_objective"), limit=500),
},
][-64:]
return len(claims)
def build_topic_workspace_packet(
*,
user_question: str,
context_claims: list[dict[str, Any]],
context_pack: dict[str, Any] | None = None,
) -> dict[str, Any]:
pack = context_pack if isinstance(context_pack, dict) else {}
topic_pack = _topic_pack_from_context(pack)
seed_claims = [item for item in context_claims if isinstance(item, dict) and item.get("claim_text")][:8]
seed_texts = [clean_text(user_question, limit=500), *[clean_text(item.get("claim_text"), limit=500) for item in seed_claims[:6]]]
summary = clean_text(topic_pack.get("topic_summary") or _summarize_seed_texts(seed_texts), limit=1200)
linked_memory_ids = [
str(item.get("source_ref") or "")[:80]
for item in seed_claims
if str(item.get("source_kind") or "") == "memory" and str(item.get("source_ref") or "").strip()
]
linked_thread_refs = [
str(item.get("source_ref") or "")[:80]
for item in seed_claims
if str(item.get("source_kind") or "") == "thread_message" and str(item.get("source_ref") or "").strip()
]
return {
"packet_version": TOPIC_WORKSPACE_PACKET_VERSION,
"topic_signature": _topic_signature(seed_texts),
"topic_summary": summary,
"current_objective": clean_text(user_question, limit=800),
"known_assumptions": _claims_by_role(seed_claims, {"user_historical_goal", "semantic_memory"}, limit=5),
"prior_proposals": _claims_by_role(seed_claims, {"assistant_prior_proposal"}, limit=6),
"open_questions": _topic_text_list(topic_pack.get("open_questions"), limit=6),
"monetization_hypothesis": clean_text(topic_pack.get("monetization_hypothesis"), limit=600),
"next_actionable_package": clean_text(topic_pack.get("next_actionable_package"), limit=800),
"linked_thread_refs": _dedupe(linked_thread_refs)[:12],
"linked_run_ids": _topic_text_list(topic_pack.get("linked_run_ids"), limit=12),
"linked_memory_ids": _dedupe(linked_memory_ids)[:12],
"selection_policy": {
"semantic_overlap_and_scope_not_title_equality": True,
"topic_workspace_is_not_a_project_truth_state": True,
},
}
def context_evidence_obligation_packet(value: Any) -> dict[str, Any]:
packet = value if isinstance(value, dict) else {}
claims = [item for item in list(packet.get("claims") or []) if isinstance(item, dict)]
obligations = [item for item in list(packet.get("continuation_obligations") or []) if isinstance(item, dict)]
return {
"packet_version": "pmflow_ai_context_obligation_summary.v1",
"claim_count": len(claims),
"required_claim_ids": [str(item.get("claim_id") or "")[:80] for item in claims if item.get("required_for_answer")][:12],
"obligation_count": len(obligations),
"topic_signature": str((packet.get("topic_workspace") or {}).get("topic_signature") or "")[:80]
if isinstance(packet.get("topic_workspace"), dict)
else "",
}
def _context_rows(pack: dict[str, Any]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
thread_context = pack.get("thread_context") if isinstance(pack.get("thread_context"), dict) else {}
memory_context = pack.get("memory_context") if isinstance(pack.get("memory_context"), dict) else {}
topic_context = pack.get("topic_context") if isinstance(pack.get("topic_context"), dict) else {}
seen: set[str] = set()
for item in [*list(thread_context.get("relevant_messages") or []), *list(thread_context.get("recent_messages") or [])]:
if not isinstance(item, dict):
continue
text = clean_text(item.get("content") or item.get("summary"), limit=1200)
source_ref = str(item.get("message_id") or f"thread:{item.get('message_index') or ''}")[:160]
key = f"thread:{source_ref}:{text[:120]}"
if not text or key in seen:
continue
seen.add(key)
rows.append(
{
"source_kind": "thread_message",
"source_role": str(item.get("role") or "")[:40],
"source_ref": source_ref,
"message_index": item.get("message_index") or 0,
"text": text,
"confidence": "high",
}
)
for item in list(memory_context.get("memories") or []):
if not isinstance(item, dict):
continue
text = clean_text(item.get("summary"), limit=1200)
source_ref = str(item.get("memory_id") or item.get("scope_ref") or "")[:160]
key = f"memory:{source_ref}:{text[:120]}"
if not text or key in seen:
continue
seen.add(key)
rows.append(
{
"source_kind": "memory",
"source_role": str(item.get("memory_scope") or "")[:40],
"source_ref": source_ref,
"message_index": 0,
"text": text,
"confidence": _confidence_from_score(item.get("confidence_score")),
}
)
for item in list(topic_context.get("topic_workspaces") or []):
if not isinstance(item, dict):
continue
text = clean_text(
";".join(
str(value)
for value in (
item.get("topic_summary"),
item.get("current_objective"),
item.get("next_actionable_package"),
)
if value
),
limit=1200,
)
source_ref = str(item.get("topic_id") or item.get("topic_signature") or "")[:160]
key = f"topic:{source_ref}:{text[:120]}"
if not text or key in seen:
continue
seen.add(key)
rows.append(
{
"source_kind": "topic_workspace",
"source_role": "topic",
"source_ref": source_ref,
"message_index": 0,
"text": text,
"confidence": _confidence_from_score(item.get("confidence_score")),
}
)
return rows
def _semantic_role(row: dict[str, Any]) -> str:
source_kind = str(row.get("source_kind") or "")
if source_kind == "memory":
return "semantic_memory"
if source_kind == "topic_workspace":
return "topic_workspace"
role = str(row.get("source_role") or "").strip().lower()
if role == "assistant":
return "assistant_prior_proposal"
if role == "user":
return "user_historical_goal"
return "thread_context"
def _row_score(*, row: dict[str, Any], question: str) -> float:
question_terms = _text_terms(question)
text_terms = _text_terms(row.get("text"))
overlap = len(question_terms & text_terms)
union = len(question_terms | text_terms) or 1
score = overlap / union
if row.get("source_kind") == "thread_message":
score += 0.18
if row.get("source_kind") == "topic_workspace":
score += 0.15
if row.get("source_kind") == "memory":
score += 0.12
if str(row.get("source_role") or "").lower() == "assistant":
score += 0.05
try:
index = int(row.get("message_index") or 0)
except Exception:
index = 0
if index:
score += min(0.08, index / 1000)
return score
def _context_conflict_boundaries(*, pack: dict[str, Any], context_claims: list[dict[str, Any]]) -> list[dict[str, Any]]:
topic_context = pack.get("topic_context") if isinstance(pack.get("topic_context"), dict) else {}
rows = list(topic_context.get("topic_workspaces") or [])
if not context_claims or not rows:
return []
return []
def _topic_pack_from_context(pack: dict[str, Any]) -> dict[str, Any]:
topic_context = pack.get("topic_context") if isinstance(pack.get("topic_context"), dict) else {}
rows = [item for item in list(topic_context.get("topic_workspaces") or []) if isinstance(item, dict)]
return rows[0] if rows else {}
def _claims_by_role(claims: list[dict[str, Any]], roles: set[str], *, limit: int) -> list[str]:
return _dedupe(
[
clean_text(item.get("claim_text"), limit=360)
for item in claims
if str(item.get("semantic_role") or "") in roles and clean_text(item.get("claim_text"), limit=360)
]
)[:limit]
def _topic_text_list(value: Any, *, limit: int) -> list[str]:
rows = value if isinstance(value, list) else []
return _dedupe([clean_text(item, limit=360) for item in rows if clean_text(item, limit=360)])[:limit]
def _summarize_seed_texts(values: list[str]) -> str:
rows = _dedupe([clean_text(item, limit=360) for item in values if clean_text(item, limit=360)])
return ";".join(rows[:4])[:1200]
def _topic_signature(values: list[str]) -> str:
terms: list[str] = []
for value in values:
terms.extend(sorted(_text_terms(value)))
key_terms = _dedupe(terms)[:24]
raw = "|".join(key_terms) or "|".join(clean_text(item, limit=80) for item in values[:3])
return f"topic_{hashlib.sha1(raw.encode('utf-8')).hexdigest()[:16]}"
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}
def _source_rank(value: Any) -> int:
source = str(value or "")
return {"thread_message": 0, "topic_workspace": 1, "memory": 2}.get(source, 9)
def _customer_value_for_role(role: str) -> str:
return {
"assistant_prior_proposal": "保留上一轮已经给过用户的方案和判断,支持连续追问。",
"user_historical_goal": "保留用户此前表达过的目标、约束和关注点。",
"semantic_memory": "保留长期语义记忆,支持跨轮和跨线程连续性。",
"topic_workspace": "保留持续主题工作区,避免新线程丢失同一主题进展。",
}.get(role, "保留当前上下文中与本轮目标相关的可复用材料。")
def _confidence_from_score(value: Any) -> str:
try:
score = float(value)
except Exception:
return "medium"
if score >= 0.8:
return "high"
if score >= 0.45:
return "medium"
return "low"
def _claim_confidence(row: dict[str, Any]) -> str:
confidence = str(row.get("confidence") or "medium").strip()
return confidence if confidence in {"high", "medium", "low", "unknown"} else "medium"
def _dedupe(values: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for value in values:
text = clean_text(value, limit=600)
key = re.sub(r"\s+", "", text).lower()
if not text or key in seen:
continue
seen.add(key)
out.append(text)
return out
__all__ = [
"CONTEXT_EVIDENCE_PACKET_VERSION",
"TOPIC_WORKSPACE_PACKET_VERSION",
"append_context_evidence_to_workspace",
"build_context_evidence_packet",
"build_topic_workspace_packet",
"context_evidence_obligation_packet",
]