PMFlow/backend/app/services/adaptive_rag_service/mixin_answer_payload.py

1034 lines
44 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.

"""Adaptive RAG mixin module."""
from __future__ import annotations
from app.schemas.project_operating_context import ExecutionRecipe, ProjectFitBrief, ProjectOperatingContext
from .shared import *
from .shared_utils import _env_bool
from .internal_query_context import (
internal_context_block_reason,
internal_context_operation_contract,
internal_context_query_plan,
)
class AdaptiveRAGAnswerPayloadMixin:
_SOURCE_PRIORITY_MAP: dict[str, int] = {
"pmflow": 100,
"db": 90,
"graph": 80,
"doc": 70,
"mcp_search": 68,
"url": 65,
"feishu": 60,
"system": 10,
}
_INTERNAL_SOURCE_TYPES = {"db", "graph", "doc", "pmflow", "feishu"}
_EXTERNAL_RETRIEVAL_STATUSES = {
"evidence_found",
"verifier_blocked",
"no_relevant_results",
"retrieval_failed",
}
_EXTERNAL_IMPACT_STATUSES = {
"direct_match",
"indirect_only",
"no_direct_impact",
}
_ENV_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING = "PMFLOW_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING"
@staticmethod
def _normalize_project_operating_context(payload: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ProjectOperatingContext.model_validate(payload).model_dump()
except Exception:
return {}
@staticmethod
def _normalize_execution_recipe(payload: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ExecutionRecipe.model_validate(payload).model_dump()
except Exception:
return {}
@staticmethod
def _normalize_fit_brief(payload: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(payload, dict) or not payload:
return {}
try:
return ProjectFitBrief.model_validate(payload).model_dump()
except Exception:
return {}
@classmethod
def _normalize_external_recommendation(cls, payload: Optional[dict[str, Any]]) -> dict[str, Any]:
if not isinstance(payload, dict) or not payload:
return {}
retrieval_status = str(payload.get("retrieval_status") or "").strip().lower()
impact_status = str(payload.get("impact_status") or "").strip().lower()
if retrieval_status not in cls._EXTERNAL_RETRIEVAL_STATUSES:
retrieval_status = "retrieval_failed"
if impact_status not in cls._EXTERNAL_IMPACT_STATUSES:
impact_status = "no_direct_impact"
return {
"retrieval_status": retrieval_status,
"impact_status": impact_status,
"external_summary": str(payload.get("external_summary") or "").strip(),
"external_impact": str(payload.get("external_impact") or "").strip(),
"affected_signal_sources": [
str(item).strip()
for item in list(payload.get("affected_signal_sources") or [])
if str(item).strip()
][:6],
"external_action": str(payload.get("external_action") or "").strip(),
}
@classmethod
def _normalize_external_anchor_text(cls, value: Any) -> str:
raw = str(value or "").strip().lower()
if not raw:
return ""
chars: list[str] = []
for char in raw:
if char.isalnum() or "\u4e00" <= char <= "\u9fff":
chars.append(char)
return "".join(chars)
@classmethod
def _derive_external_retrieval_status(cls, packet: Optional[dict[str, Any]]) -> str:
normalized = packet if isinstance(packet, dict) else {}
explicit = str(normalized.get("retrieval_status") or "").strip().lower()
if explicit in cls._EXTERNAL_RETRIEVAL_STATUSES:
return explicit
verifier = normalized.get("verifier") if isinstance(normalized.get("verifier"), dict) else {}
verified_evidence = list(normalized.get("verified_evidence") or [])
if verified_evidence and bool(verifier.get("ok")):
return "evidence_found"
if verified_evidence and not bool(verifier.get("ok")):
return "verifier_blocked"
attempts = [item for item in list(normalized.get("adapter_attempts") or []) if isinstance(item, dict)]
if any(int(item.get("evidence_count") or 0) > 0 and not bool(item.get("verifier_ok")) for item in attempts):
return "verifier_blocked"
statuses = {
str(item.get("status") or "").strip().lower()
for item in attempts
if str(item.get("status") or "").strip()
}
if any(status in {"success", "empty"} for status in statuses):
return "no_relevant_results"
return "retrieval_failed"
@classmethod
def _collect_fit_brief_signal_source_anchors(cls, fit_brief: Optional[dict[str, Any]]) -> list[str]:
normalized_fit_brief = cls._normalize_fit_brief(fit_brief)
anchors: list[str] = []
for item in list(normalized_fit_brief.get("signal_source_anchors") or []):
normalized = str(item or "").strip()
compact = cls._normalize_external_anchor_text(normalized)
if len(compact) >= 2 and normalized not in anchors:
anchors.append(normalized)
return anchors[:6]
@classmethod
def _collect_external_evidence_texts(cls, packet: Optional[dict[str, Any]]) -> list[str]:
normalized = packet if isinstance(packet, dict) else {}
texts: list[str] = []
for item in list(normalized.get("verified_evidence") or []):
if not isinstance(item, dict):
continue
for value in (
item.get("source_label"),
item.get("title"),
item.get("snippet"),
item.get("target_url"),
):
text = str(value or "").strip()
if text:
texts.append(text)
return texts
@classmethod
def _build_external_recommendation(
cls,
*,
external_context_packet: Optional[dict[str, Any]],
fit_brief: Optional[dict[str, Any]],
) -> dict[str, Any]:
packet = dict(external_context_packet or {}) if isinstance(external_context_packet, dict) else {}
retrieval_status = cls._derive_external_retrieval_status(packet)
if retrieval_status != "evidence_found":
return {
"retrieval_status": retrieval_status,
"impact_status": "no_direct_impact",
"external_summary": "",
"external_impact": "",
"affected_signal_sources": [],
"external_action": "",
}
anchors = cls._collect_fit_brief_signal_source_anchors(fit_brief)
evidence_texts = [cls._normalize_external_anchor_text(item) for item in cls._collect_external_evidence_texts(packet)]
affected_signal_sources: list[str] = []
for anchor in anchors:
compact_anchor = cls._normalize_external_anchor_text(anchor)
if len(compact_anchor) < 2:
continue
if any(compact_anchor in text for text in evidence_texts if text):
affected_signal_sources.append(anchor)
first_evidence = next(
(item for item in list(packet.get("verified_evidence") or []) if isinstance(item, dict)),
{},
)
external_summary = str(
first_evidence.get("snippet")
or first_evidence.get("title")
or first_evidence.get("source_label")
or ""
).strip()[:240]
if affected_signal_sources:
return {
"retrieval_status": "evidence_found",
"impact_status": "direct_match",
"external_summary": external_summary,
"external_impact": f"外部证据已和当前项目对象建立直接映射:{'、'.join(affected_signal_sources[:3])}",
"affected_signal_sources": affected_signal_sources[:6],
"external_action": "先做映射验证,再决定是否把这条外部变化正式纳入当前项目计划。",
}
return {
"retrieval_status": "evidence_found",
"impact_status": "indirect_only",
"external_summary": external_summary,
"external_impact": "当前外部证据只支持背景相关性,暂不能直接映射到当前项目对象。",
"affected_signal_sources": [],
"external_action": "先做映射验证,确认这些外部变化是否真的影响当前项目。",
}
def _build_plan_handoff_fields(
self,
*,
execution_recipe: Optional[dict[str, Any]],
project_operating_context: Optional[dict[str, Any]],
) -> dict[str, Any]:
normalized_recipe = self._normalize_execution_recipe(execution_recipe)
normalized_context = self._normalize_project_operating_context(project_operating_context)
handoff_mode = str(normalized_recipe.get("plan_handoff_mode") or "").strip().lower() or None
if (
handoff_mode == "offer_after_answer"
and not _env_bool(self._ENV_ENABLE_DEFAULT_PLAN_HANDOFF_RENDERING, True)
):
handoff_mode = None
can_enter_plan = handoff_mode in {"offer_after_answer", "explicit_plan_now"}
recommended_next_step = None
if handoff_mode == "offer_after_answer":
if normalized_context.get("current_project_id") is not None:
recommended_next_step = "如果你要,我可以基于当前结论进入 plan,继续把需求范围和下一步收清楚。"
else:
recommended_next_step = "如果你要,我可以基于当前结论继续进入 plan。"
elif handoff_mode == "explicit_plan_now":
recommended_next_step = "当前已命中明确的计划上下文,可以直接进入 plan 继续推进。"
return {
"handoff_mode": handoff_mode,
"can_enter_plan": can_enter_plan,
"recommended_next_step": recommended_next_step,
}
@staticmethod
def _safe_source_metadata(raw: Any) -> dict[str, Any]:
return raw if isinstance(raw, dict) else {}
@staticmethod
def _source_ref_value(value: Any) -> Any:
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
text = str(value).strip()
return text or None
if isinstance(value, list):
rows = [str(item).strip() for item in value if str(item).strip()]
return rows or None
if isinstance(value, dict):
try:
return json.loads(json.dumps(value, ensure_ascii=False))
except Exception:
return dict(value)
text = str(value).strip()
return text or None
@staticmethod
def _isoformat_or_none(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
try:
return value.isoformat()
except Exception:
return None
text = str(value).strip()
return text or None
def _normalize_source_type(self, value: Any) -> str:
raw = str(self._source_ref_value(value) or "").strip().lower()
return raw or "unknown"
def _resolve_source_id(
self,
*,
source_type: str,
metadata: dict[str, Any],
explicit_source_id: Any = None,
) -> Any:
resolved = self._source_ref_value(explicit_source_id or metadata.get("source_id"))
if resolved is not None:
return resolved
if source_type == "pmflow":
record_type = self._source_ref_value(metadata.get("record_type"))
record_id = self._source_ref_value(metadata.get("record_id"))
if record_type and record_id is not None:
return f"{record_type}:{record_id}"
return record_id
if source_type in {"doc", "url", "mcp_search"}:
return self._source_ref_value(
metadata.get("doc_id")
or metadata.get("knowledge_doc_id")
or metadata.get("doc_url")
or metadata.get("file_url")
or metadata.get("url")
)
if source_type == "feishu":
return self._source_ref_value(
metadata.get("message_id")
or metadata.get("attachment_id")
or metadata.get("file_key")
or metadata.get("key")
or metadata.get("chat_id")
)
if source_type == "graph":
relation_id = self._source_ref_value(metadata.get("relation_id"))
if relation_id is not None:
return f"relation:{relation_id}"
entity_id = self._source_ref_value(metadata.get("entity_id"))
if entity_id is not None:
return f"entity:{entity_id}"
return self._source_ref_value(
metadata.get("path")
or metadata.get("source_ref")
or metadata.get("subject_entity_id")
)
if source_type == "db":
table = self._source_ref_value(metadata.get("table"))
if table is None:
return None
project_id = self._source_ref_value(metadata.get("project_id"))
if project_id is not None:
return f"{table}:{project_id}"
return table
return self._source_ref_value(
metadata.get("doc_id")
or metadata.get("knowledge_doc_id")
or metadata.get("message_id")
or metadata.get("url")
or metadata.get("table")
or metadata.get("matched_project_ids")
)
def _resolve_chunk_id(self, metadata: dict[str, Any], *, explicit_chunk_id: Any = None) -> Any:
return self._source_ref_value(explicit_chunk_id or metadata.get("chunk_id") or metadata.get("chunk_index"))
def _resolve_target_url(
self,
*,
source_type: str,
metadata: dict[str, Any],
explicit_target_url: Any = None,
project_id: Any = None,
) -> Any:
target_url = self._source_ref_value(
explicit_target_url
or metadata.get("target_url")
or metadata.get("doc_url")
or metadata.get("file_url")
or metadata.get("url")
or metadata.get("permalink")
)
if target_url is not None:
return target_url
if source_type != "pmflow" or not hasattr(self, "_build_pmflow_target_url"):
return None
try:
return self._source_ref_value(
self._build_pmflow_target_url(
record_type=str(metadata.get("record_type") or ""),
record_id=metadata.get("record_id"),
project_id=project_id if project_id is not None else metadata.get("project_id"),
)
)
except Exception:
return None
def _resolve_updated_at(self, metadata: dict[str, Any], *, explicit_updated_at: Any = None) -> Any:
return self._source_ref_value(
self._isoformat_or_none(explicit_updated_at)
or metadata.get("updated_at")
or metadata.get("doc_updated_at")
or metadata.get("source_updated_at")
or metadata.get("captured_at")
or metadata.get("last_seen_at")
or metadata.get("last_fetched_at")
)
def _resolve_permission_scope(self, metadata: dict[str, Any], *, project_id: Any = None) -> str:
explicit_scope = str(self._source_ref_value(metadata.get("permission_scope")) or "").strip().lower()
if explicit_scope in {"project", "tenant"}:
return explicit_scope
resolved_project_id = self._source_ref_value(project_id if project_id is not None else metadata.get("project_id"))
return "project" if resolved_project_id is not None else "tenant"
def _resolve_source_priority(self, metadata: dict[str, Any], *, source_type: str) -> int:
explicit_priority = metadata.get("source_priority")
if explicit_priority is not None:
try:
return int(explicit_priority)
except Exception:
pass
return int(self._SOURCE_PRIORITY_MAP.get(source_type, 50))
def _normalize_source_registry_metadata(
self,
*,
source_type: Any,
metadata: Any,
explicit_source_id: Any = None,
explicit_chunk_id: Any = None,
explicit_target_url: Any = None,
explicit_project_id: Any = None,
explicit_updated_at: Any = None,
) -> dict[str, Any]:
normalized = self._safe_source_metadata(metadata).copy()
resolved_source_type = self._normalize_source_type(source_type or normalized.get("source_type"))
resolved_project_id = self._source_ref_value(
explicit_project_id if explicit_project_id is not None else normalized.get("project_id")
)
normalized["source_type"] = resolved_source_type
normalized["source_id"] = self._resolve_source_id(
source_type=resolved_source_type,
metadata=normalized,
explicit_source_id=explicit_source_id,
)
normalized["chunk_id"] = self._resolve_chunk_id(normalized, explicit_chunk_id=explicit_chunk_id)
normalized["target_url"] = self._resolve_target_url(
source_type=resolved_source_type,
metadata=normalized,
explicit_target_url=explicit_target_url,
project_id=resolved_project_id,
)
normalized["project_id"] = resolved_project_id
normalized["updated_at"] = self._resolve_updated_at(normalized, explicit_updated_at=explicit_updated_at)
normalized["permission_scope"] = self._resolve_permission_scope(normalized, project_id=resolved_project_id)
normalized["source_priority"] = self._resolve_source_priority(normalized, source_type=resolved_source_type)
return normalized
def _source_priority_sort_value(self, metadata: Any) -> int:
normalized = self._safe_source_metadata(metadata)
source_type = self._normalize_source_type(normalized.get("source_type"))
return self._resolve_source_priority(normalized, source_type=source_type)
@staticmethod
def _normalize_answer_mode(value: Optional[str]) -> str:
raw = str(value or "").strip().lower()
if raw in {
ANSWER_MODE_FAST_PATH,
ANSWER_MODE_RETRIEVAL_NORMAL,
ANSWER_MODE_RETRIEVAL_DEEP,
ANSWER_MODE_CLARIFY,
ANSWER_MODE_BLOCKED,
}:
return raw
return ANSWER_MODE_RETRIEVAL_NORMAL
def _normalize_grounding_status(
self,
value: Optional[str],
*,
sources: Optional[list[dict[str, Any]]] = None,
missing_fields: Optional[list[str]] = None,
answer_mode: Optional[str] = None,
reason_code: Optional[str] = None,
) -> str:
raw = str(value or "").strip().lower()
if raw in {
GROUNDING_STATUS_GROUNDED,
GROUNDING_STATUS_PARTIALLY_GROUNDED,
GROUNDING_STATUS_INSUFFICIENT_EVIDENCE,
GROUNDING_STATUS_BLOCKED,
}:
return raw
normalized_mode = self._normalize_answer_mode(answer_mode)
if normalized_mode == ANSWER_MODE_BLOCKED:
return GROUNDING_STATUS_BLOCKED
if normalized_mode == ANSWER_MODE_CLARIFY:
return GROUNDING_STATUS_INSUFFICIENT_EVIDENCE
source_rows = sources if isinstance(sources, list) else []
snapshot_only = False
if hasattr(self, "_is_snapshot_only_sources"):
try:
snapshot_only = bool(self._is_snapshot_only_sources(source_rows))
except Exception:
snapshot_only = False
if not snapshot_only and source_rows:
snapshot_only = all(str(item.get("source") or "").strip().lower() == "system" for item in source_rows if isinstance(item, dict))
missing = [str(item).strip() for item in (missing_fields or []) if str(item).strip()]
normalized_reason = str(reason_code or "").strip().lower()
if normalized_reason == "insufficient_evidence":
return GROUNDING_STATUS_INSUFFICIENT_EVIDENCE
if snapshot_only or missing:
return GROUNDING_STATUS_PARTIALLY_GROUNDED
if source_rows:
return GROUNDING_STATUS_GROUNDED
return GROUNDING_STATUS_PARTIALLY_GROUNDED
def _build_citations_from_sources(self, sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
seen: set[tuple[str, str, str, str]] = set()
for item in (sources or [])[:8]:
if not isinstance(item, dict):
continue
metadata = self._normalize_source_registry_metadata(
source_type=item.get("source"),
metadata=item.get("metadata"),
)
source_type = self._source_ref_value(metadata.get("source_type"))
source_label = self._source_ref_value(item.get("label") or metadata.get("source_label"))
source_id = self._source_ref_value(metadata.get("source_id"))
chunk_id = self._source_ref_value(metadata.get("chunk_id"))
target_url = self._source_ref_value(metadata.get("target_url"))
project_id = self._source_ref_value(metadata.get("project_id"))
updated_at = self._source_ref_value(metadata.get("updated_at"))
citation = {
"source_type": source_type,
"source_label": source_label,
"source_id": source_id,
"chunk_id": chunk_id,
"target_url": target_url,
"project_id": project_id,
"updated_at": updated_at,
"permission_scope": self._source_ref_value(metadata.get("permission_scope")),
"source_priority": metadata.get("source_priority"),
"confidence": round(self._to_float(item.get("score"), 0.0), 4),
}
key = (
str(source_type or ""),
str(source_id or ""),
str(chunk_id or ""),
str(target_url or ""),
)
if key in seen:
continue
seen.add(key)
normalized.append(citation)
return normalized
@staticmethod
def _build_sources_used_from_citations(citations: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in citations:
if not isinstance(item, dict):
continue
rows.append(
{
"source_type": item.get("source_type"),
"source_label": item.get("source_label"),
"source_id": item.get("source_id"),
"chunk_id": item.get("chunk_id"),
"target_url": item.get("target_url"),
"project_id": item.get("project_id"),
"updated_at": item.get("updated_at"),
"permission_scope": item.get("permission_scope"),
"source_priority": item.get("source_priority"),
}
)
return rows
def _build_evidence_pack(
self,
*,
answer: str,
citations: list[dict[str, Any]],
tool_trace: list[dict[str, Any]],
missing_fields: Optional[list[str]] = None,
grounding_status: Optional[str] = None,
reason_code: Optional[str] = None,
) -> dict[str, Any]:
evidence_labels: list[str] = []
for item in citations[:5]:
if not isinstance(item, dict):
continue
label = str(item.get("source_label") or item.get("source_id") or item.get("source_type") or "").strip()
if label:
evidence_labels.append(label)
uncertainties = [str(item).strip() for item in (missing_fields or []) if str(item).strip()]
normalized_grounding = str(grounding_status or "").strip().lower()
normalized_reason = str(reason_code or "").strip()
if normalized_grounding in {GROUNDING_STATUS_INSUFFICIENT_EVIDENCE, GROUNDING_STATUS_BLOCKED} and normalized_reason:
if normalized_reason not in uncertainties:
uncertainties.append(normalized_reason)
return {
"summary": {
"conclusion": str(answer or "").strip(),
"evidence": evidence_labels,
"uncertainties": uncertainties,
},
"tool_trace": tool_trace if isinstance(tool_trace, list) else [],
}
def _is_internal_answer_contract_route(
self,
*,
route_decision: Optional[dict[str, Any]] = None,
sources: Optional[list[dict[str, Any]]] = None,
) -> bool:
normalized_route = (
self._normalize_query_route_decision(route_decision)
if hasattr(self, "_normalize_query_route_decision")
else {}
)
if str(normalized_route.get("route_mode") or "").strip().lower() == "internal":
return True
if str(normalized_route.get("effective_source_policy") or "").strip().lower() == "internal":
return True
for item in sources or []:
if not isinstance(item, dict):
continue
if str(item.get("source") or "").strip().lower() in self._INTERNAL_SOURCE_TYPES:
return True
return False
def _verify_external_answer_contract(
self,
*,
answer: str,
sources: Optional[list[dict[str, Any]]] = None,
route_decision: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
normalized_route = (
self._normalize_query_route_decision(route_decision)
if hasattr(self, "_normalize_query_route_decision")
else {}
)
if str(normalized_route.get("effective_source_policy") or "").strip().lower() != "external":
return {"ok": True, "reason_code": None, "missing_fields": []}
source_rows = sources if isinstance(sources, list) else []
if not source_rows:
return {
"ok": False,
"reason_code": "missing_external_evidence",
"missing_fields": ["external_evidence"],
}
external_source_seen = False
for item in source_rows:
if not isinstance(item, dict):
continue
source = str(item.get("source") or "").strip().lower()
metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {}
if source == "system":
continue
if source not in {"url", "mcp_search"}:
return {
"ok": False,
"reason_code": "external_source_contract_violation",
"missing_fields": ["external_source_type"],
}
target_url = str(
metadata.get("target_url")
or metadata.get("file_url")
or metadata.get("url")
or ""
).strip().lower()
if not target_url.startswith("http://") and not target_url.startswith("https://"):
return {
"ok": False,
"reason_code": "external_target_url_missing",
"missing_fields": ["target_url"],
}
external_source_seen = True
if not external_source_seen:
return {
"ok": False,
"reason_code": "missing_external_grounding",
"missing_fields": ["external_evidence"],
}
_ = answer
return {"ok": True, "reason_code": None, "missing_fields": []}
def _build_external_answer_contract_guard_payload(
self,
*,
reason_code: Optional[str],
missing_fields: Optional[list[str]] = None,
) -> dict[str, Any]:
normalized_reason = str(reason_code or "").strip().lower() or "external_answer_contract_violation"
normalized_missing = [str(item).strip() for item in (missing_fields or []) if str(item).strip()]
return {
"answer": "当前没有拿到可核验的外部实时证据,已阻止直接返回外部结论。请稍后重试,或把要核验的公开对象说得更具体一些。",
"sources": [
{
"source": "system",
"label": "External Query Contract Guard",
"score": 0.99,
"metadata": {
"mode": "guard",
"reason": normalized_reason,
},
}
],
"missing_fields": normalized_missing or ["external_answer_contract"],
"reason_code": normalized_reason,
"answer_mode": ANSWER_MODE_CLARIFY,
"grounding_status": GROUNDING_STATUS_INSUFFICIENT_EVIDENCE,
}
def _verify_internal_answer_contract(
self,
*,
query_type: str,
answer: str,
sources: Optional[list[dict[str, Any]]] = None,
route_decision: Optional[dict[str, Any]] = None,
internal_context_packet: Any = None,
) -> dict[str, Any]:
source_rows = sources if isinstance(sources, list) else []
if not self._is_internal_answer_contract_route(route_decision=route_decision, sources=source_rows):
return {"ok": True, "reason_code": None, "missing_fields": []}
packet_block = internal_context_block_reason(
internal_context_packet,
route_decision=route_decision,
)
if packet_block is not None:
reason_code, missing_fields = packet_block
return {
"ok": False,
"reason_code": reason_code,
"missing_fields": missing_fields,
}
operation_contract = internal_context_operation_contract(packet=internal_context_packet)
query_plan = internal_context_query_plan(packet=internal_context_packet)
normalized_query_type = str(
operation_contract.get("query_type")
or query_plan.get("query_type")
or query_type
or QUERY_TYPE_UNKNOWN
).strip().lower()
normalized_answer = str(answer or "").strip()
compact_answer = re.sub(r"\s+", "", normalized_answer.lower())
if normalized_query_type == QUERY_TYPE_EXISTENCE:
if "项目总数" in normalized_answer or "总数为" in normalized_answer:
return {
"ok": False,
"reason_code": "no_total_count_substitution",
"missing_fields": ["matched_evidence", "existence_contract"],
}
if (
"总数" in compact_answer
and "匹配到" not in normalized_answer
and "未检索到" not in normalized_answer
and "任务总数" not in normalized_answer
and "信号总数" not in normalized_answer
):
return {
"ok": False,
"reason_code": "no_total_count_substitution",
"missing_fields": ["matched_evidence", "existence_contract"],
}
if not source_rows:
return {
"ok": False,
"reason_code": "missing_internal_evidence",
"missing_fields": ["internal_evidence"],
}
return {"ok": True, "reason_code": None, "missing_fields": []}
def _build_internal_answer_contract_guard_payload(
self,
*,
query_type: str,
reason_code: Optional[str],
missing_fields: Optional[list[str]] = None,
) -> dict[str, Any]:
normalized_query_type = str(query_type or QUERY_TYPE_UNKNOWN).strip().lower()
normalized_reason = str(reason_code or "").strip().lower() or "internal_answer_contract_violation"
normalized_missing = [str(item).strip() for item in (missing_fields or []) if str(item).strip()]
if normalized_reason == "no_total_count_substitution" and normalized_query_type == QUERY_TYPE_EXISTENCE:
answer = "当前内部查询结果与问题类型不一致,已阻止直接返回。你问的是“有没有”,请给我更明确的对象条件后我按存在性重新查询。"
elif normalized_reason == "missing_internal_evidence":
answer = "当前内部查询证据不足,已阻止直接返回结论。请补充更明确的对象或范围后我再查。"
elif normalized_reason == "missing_internal_context_packet":
answer = "当前内部查询上下文未完成,已停止直接回答。请稍后重试,或补充更明确的对象或范围后我再查。"
elif normalized_reason == "missing_internal_operation_contract":
answer = "当前内部查询合同不完整,已停止直接回答。请补充更明确的对象或范围后我再查。"
elif normalized_reason == "target_resolution_clarify":
answer = "当前内部查询对象仍有歧义,已停止直接回答。请先明确你要查的项目、配置或对象后我再继续。"
elif normalized_reason == "missing_internal_query_plan_contract":
answer = "当前内部查询计划不完整,已停止直接回答。请补充更明确的对象或范围后我再查。"
else:
answer = "当前内部查询结果未通过回答合同校验,已阻止直接返回。请补充更明确的对象或范围后我再查。"
answer_mode = ANSWER_MODE_BLOCKED
grounding_status = GROUNDING_STATUS_BLOCKED
if normalized_reason in {
"missing_internal_context_packet",
"missing_internal_operation_contract",
"target_resolution_clarify",
"missing_internal_query_plan_contract",
}:
answer_mode = ANSWER_MODE_CLARIFY
grounding_status = GROUNDING_STATUS_INSUFFICIENT_EVIDENCE
return {
"answer": answer,
"sources": [
{
"source": "system",
"label": "Internal Query Contract Guard",
"score": 0.99,
"metadata": {
"mode": "guard",
"reason": normalized_reason,
"query_type": normalized_query_type,
},
}
],
"missing_fields": normalized_missing or ["internal_answer_contract"],
"reason_code": normalized_reason,
"answer_mode": answer_mode,
"grounding_status": grounding_status,
}
def _build_response_payload(
self,
*,
answer: str,
sources: list[dict[str, Any]],
domains_used: list[str],
response_time_ms: int,
db_query_ms: int,
vector_search_ms: int,
graph_query_ms: int = 0,
db_timeout: bool = False,
vector_timeout: bool = False,
graph_timeout: bool = False,
qa_id: Optional[int] = None,
session_id: Optional[int] = None,
turn_index: int = 1,
relevance_max: float = 0.0,
query_type: str = QUERY_TYPE_UNKNOWN,
confidence: float = 0.0,
missing_fields: Optional[list[str]] = None,
tool_trace: Optional[list[dict[str, Any]]] = None,
thinking_mode: str = THINKING_MODE_NORMAL,
action_taken: Optional[str] = None,
signal_id: Optional[int] = None,
reply_required: bool = False,
reply_text: Optional[str] = None,
ack_latency_ms: Optional[int] = None,
trace_id: Optional[str] = None,
processing_stage: Optional[str] = None,
clarification_code: Optional[str] = None,
clarification_candidates: Optional[list[dict[str, Any]]] = None,
anchor_mode: Optional[str] = None,
anchor_kind: Optional[str] = None,
meta_followup_kind: Optional[str] = None,
reason_code: Optional[str] = None,
answer_mode: Optional[str] = None,
grounding_status: Optional[str] = None,
policy_version: Optional[str] = None,
citations: Optional[list[dict[str, Any]]] = None,
sources_used: Optional[list[dict[str, Any]]] = None,
evidence_pack: Optional[dict[str, Any]] = None,
prompt_variant_id: Optional[str] = None,
route_decision: Optional[dict[str, Any]] = None,
project_operating_context: Optional[dict[str, Any]] = None,
execution_recipe: Optional[dict[str, Any]] = None,
fit_brief: Optional[dict[str, Any]] = None,
external_recommendation: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
evidence: list[dict[str, Any]] = []
for item in sources[:8]:
if not isinstance(item, dict):
continue
evidence.append(
{
"source": str(item.get("source") or "unknown"),
"label": str(item.get("label") or "unknown"),
"score": round(self._to_float(item.get("score"), 0.0), 4),
"metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {},
}
)
normalized_tool_trace = tool_trace if isinstance(tool_trace, list) else []
normalized_citations = citations if isinstance(citations, list) else self._build_citations_from_sources(sources)
normalized_sources_used = (
sources_used
if isinstance(sources_used, list)
else self._build_sources_used_from_citations(normalized_citations)
)
normalized_answer_mode = self._normalize_answer_mode(answer_mode)
normalized_grounding_status = self._normalize_grounding_status(
grounding_status,
sources=sources,
missing_fields=missing_fields,
answer_mode=normalized_answer_mode,
reason_code=reason_code,
)
normalized_policy_version = str(
policy_version
or self._build_policy_version(prompt_variant_id=prompt_variant_id)
).strip() or self._build_policy_version(prompt_variant_id=prompt_variant_id)
normalized_evidence_pack = (
evidence_pack
if isinstance(evidence_pack, dict)
else self._build_evidence_pack(
answer=answer,
citations=normalized_citations,
tool_trace=normalized_tool_trace,
missing_fields=missing_fields,
grounding_status=normalized_grounding_status,
reason_code=reason_code,
)
)
normalized_route_decision = (
self._normalize_query_route_decision(route_decision)
if hasattr(self, "_normalize_query_route_decision")
else {}
)
normalized_project_operating_context = self._normalize_project_operating_context(
project_operating_context
)
normalized_execution_recipe = self._normalize_execution_recipe(execution_recipe)
normalized_fit_brief = self._normalize_fit_brief(fit_brief)
normalized_external_recommendation = self._normalize_external_recommendation(external_recommendation)
handoff_fields = self._build_plan_handoff_fields(
execution_recipe=normalized_execution_recipe,
project_operating_context=normalized_project_operating_context,
)
return {
"answer": str(answer or "").strip(),
"evidence": evidence,
"confidence": round(max(0.0, min(1.0, self._to_float(confidence, 0.0))), 4),
"thinking_mode": self._normalize_thinking_mode(thinking_mode),
"missing_fields": [str(x) for x in (missing_fields or []) if str(x).strip()],
"query_type": str(query_type or QUERY_TYPE_UNKNOWN),
"sources": sources,
"domains_used": domains_used,
"response_time_ms": int(response_time_ms or 0),
"retrieval_metrics": {
"db_query_ms": int(db_query_ms or 0),
"vector_search_ms": int(vector_search_ms or 0),
"graph_query_ms": int(graph_query_ms or 0),
"db_timeout": bool(db_timeout),
"vector_timeout": bool(vector_timeout),
"graph_timeout": bool(graph_timeout),
},
"tool_trace": normalized_tool_trace,
"qa_id": int(qa_id) if qa_id is not None else None,
"session_id": int(session_id) if session_id is not None else None,
"turn_index": int(turn_index or 1),
"relevance_max": round(max(0.0, self._to_float(relevance_max, 0.0)), 4),
"action_taken": str(action_taken).strip() if action_taken else None,
"signal_id": int(signal_id) if signal_id is not None else None,
"reply_required": bool(reply_required),
"reply_text": str(reply_text or "").strip() if reply_text else None,
"ack_latency_ms": int(ack_latency_ms) if ack_latency_ms is not None else None,
"trace_id": str(trace_id or "").strip() or None,
"processing_stage": str(processing_stage or "").strip() or None,
"clarification_code": str(clarification_code or "").strip() or None,
"clarification_candidates": (
clarification_candidates[:3]
if isinstance(clarification_candidates, list)
else []
),
"anchor_mode": str(anchor_mode or "").strip() or None,
"anchor_kind": str(anchor_kind or "").strip() or None,
"meta_followup_kind": str(meta_followup_kind or "").strip() or None,
"reason_code": str(reason_code or "").strip() or None,
"answer_mode": normalized_answer_mode,
"grounding_status": normalized_grounding_status,
"policy_version": normalized_policy_version,
"citations": normalized_citations,
"sources_used": normalized_sources_used,
"evidence_pack": normalized_evidence_pack,
"route_decision": normalized_route_decision,
"project_operating_context": normalized_project_operating_context,
"execution_recipe": normalized_execution_recipe,
"fit_brief": normalized_fit_brief,
"external_recommendation": normalized_external_recommendation,
"handoff_mode": handoff_fields["handoff_mode"],
"can_enter_plan": handoff_fields["can_enter_plan"],
"recommended_next_step": handoff_fields["recommended_next_step"],
}
@staticmethod
def _extract_ack_metadata_from_sources(
sources: Any,
*,
answer: str = "",
) -> dict[str, Any]:
rows = sources if isinstance(sources, list) else []
action_taken = ""
signal_id: Optional[int] = None
reply_required = False
ack_latency_ms: Optional[int] = None
trace_id = ""
processing_stage = ""
for source in rows:
if not isinstance(source, dict):
continue
meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {}
candidate_action = str(meta.get("action_taken") or "").strip()
if not candidate_action:
continue
action_taken = candidate_action
try:
signal_id = int(meta.get("signal_id")) if meta.get("signal_id") is not None else None
except Exception:
signal_id = None
trace_id = str(meta.get("trace_id") or "").strip()
processing_stage = str(meta.get("processing_stage") or meta.get("action_mode") or "").strip()
try:
ack_latency_ms = (
int(meta.get("ack_latency_ms")) if meta.get("ack_latency_ms") is not None else None
)
except Exception:
ack_latency_ms = None
reply_required = True
break
reply_text = str(answer or "").strip() if reply_required else ""
normalized_stage = processing_stage
if normalized_stage == "write":
normalized_stage = "ack_only"
return {
"action_taken": action_taken or None,
"signal_id": signal_id,
"reply_required": bool(reply_required),
"reply_text": reply_text or None,
"ack_latency_ms": ack_latency_ms,
"trace_id": trace_id or None,
"processing_stage": normalized_stage or None,
}