1167 lines
51 KiB
Python
1167 lines
51 KiB
Python
"""Adaptive RAG mixin module."""
|
||
from __future__ import annotations
|
||
|
||
from app.schemas.analysis_packets import normalize_analysis_packet_payload
|
||
from app.schemas.context_workspace import normalize_research_workspace_payload
|
||
from app.schemas.project_operating_context import ExecutionRecipe, ProjectFitBrief, ProjectOperatingContext
|
||
from app.services.analysis_packet_builder import build_analysis_packet
|
||
from app.services.analysis_project_snapshot_loader import load_analysis_project_snapshot
|
||
from app.services.external_query_service.schemas import normalize_signal_source_recommendation_payload
|
||
|
||
from .shared import *
|
||
from .internal_domain_catalog import build_internal_target_clarification_text, build_internal_target_selection_protocol
|
||
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_ANALYSIS_PACKET_FAMILY = "PMFLOW_ENABLE_ANALYSIS_PACKET_FAMILY"
|
||
_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 {}
|
||
|
||
@staticmethod
|
||
def _normalize_analysis_packet(payload: Any) -> dict[str, Any]:
|
||
return normalize_analysis_packet_payload(payload)
|
||
|
||
@staticmethod
|
||
def _normalize_signal_source_recommendation(
|
||
payload: Any,
|
||
*,
|
||
project_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
normalized = normalize_signal_source_recommendation_payload(payload)
|
||
if normalized and normalized.get("project_binding") is None and project_id is not None:
|
||
normalized["project_binding"] = int(project_id)
|
||
return normalized
|
||
|
||
async def _load_analysis_project_snapshot_for_packet(
|
||
self,
|
||
*,
|
||
project_operating_context: Optional[dict[str, Any]],
|
||
fallback_project_id: Optional[int] = None,
|
||
) -> dict[str, Any]:
|
||
current_project_id = None
|
||
if isinstance(project_operating_context, dict):
|
||
current_project_id = project_operating_context.get("current_project_id")
|
||
try:
|
||
project_id = int(current_project_id) if current_project_id is not None else None
|
||
except Exception:
|
||
project_id = None
|
||
if project_id is None and fallback_project_id is not None:
|
||
project_id = int(fallback_project_id)
|
||
return await load_analysis_project_snapshot(
|
||
db=getattr(self, "db", None),
|
||
tenant_id=getattr(self, "tenant_id", None),
|
||
project_id=project_id,
|
||
)
|
||
|
||
def _build_analysis_packet_payload(
|
||
self,
|
||
*,
|
||
route_decision: Optional[dict[str, Any]],
|
||
project_operating_context: Optional[dict[str, Any]],
|
||
execution_recipe: Optional[dict[str, Any]],
|
||
fit_brief: Optional[dict[str, Any]],
|
||
external_recommendation: Optional[dict[str, Any]],
|
||
recommended_next_step: Optional[str],
|
||
answer: str,
|
||
evidence_pack: Optional[dict[str, Any]],
|
||
query_type: str,
|
||
clarification_code: Optional[str],
|
||
anchor_kind: Optional[str],
|
||
meta_followup_kind: Optional[str],
|
||
internal_context_packet: Any = None,
|
||
project_snapshot: Optional[dict[str, Any]] = None,
|
||
) -> dict[str, Any]:
|
||
if not _env_bool(self._ENV_ENABLE_ANALYSIS_PACKET_FAMILY, True):
|
||
return {}
|
||
return self._normalize_analysis_packet(
|
||
build_analysis_packet(
|
||
route_decision=route_decision,
|
||
project_operating_context=project_operating_context,
|
||
execution_recipe=execution_recipe,
|
||
fit_brief=fit_brief,
|
||
external_recommendation=external_recommendation,
|
||
recommended_next_step=recommended_next_step,
|
||
answer=answer,
|
||
evidence_pack=evidence_pack,
|
||
query_type=query_type,
|
||
clarification_code=clarification_code,
|
||
anchor_kind=anchor_kind,
|
||
meta_followup_kind=meta_followup_kind,
|
||
internal_context_packet=internal_context_packet,
|
||
project_snapshot=project_snapshot,
|
||
)
|
||
)
|
||
|
||
@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,
|
||
target_resolution: Optional[dict[str, Any]] = 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()]
|
||
clarification_protocol = None
|
||
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":
|
||
candidate_summary = list((target_resolution or {}).get("candidate_summary") or [])
|
||
clarification_protocol = build_internal_target_selection_protocol(
|
||
candidate_summary=candidate_summary,
|
||
)
|
||
answer = (
|
||
build_internal_target_clarification_text(candidate_summary=candidate_summary)
|
||
or "当前内部查询对象仍有歧义,已停止直接回答。请先明确你要查的项目、配置或对象后我再继续。"
|
||
)
|
||
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,
|
||
"clarification_protocol": clarification_protocol,
|
||
}
|
||
|
||
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,
|
||
analysis_packet: Optional[dict[str, Any]] = None,
|
||
internal_context_packet: Any = None,
|
||
signal_source_recommendation: Optional[dict[str, Any]] = None,
|
||
research_workspace: Optional[dict[str, Any]] = None,
|
||
customer_evidence_packet: Optional[dict[str, Any]] = None,
|
||
customer_interaction_context_packet: Optional[dict[str, Any]] = None,
|
||
answer_process_packet: 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)
|
||
normalized_signal_source_recommendation = self._normalize_signal_source_recommendation(
|
||
signal_source_recommendation,
|
||
project_id=normalized_project_operating_context.get("current_project_id")
|
||
if isinstance(normalized_project_operating_context, dict)
|
||
else None,
|
||
)
|
||
normalized_research_workspace = normalize_research_workspace_payload(research_workspace)
|
||
handoff_fields = self._build_plan_handoff_fields(
|
||
execution_recipe=normalized_execution_recipe,
|
||
project_operating_context=normalized_project_operating_context,
|
||
)
|
||
normalized_analysis_packet = self._normalize_analysis_packet(analysis_packet)
|
||
if not normalized_analysis_packet:
|
||
normalized_analysis_packet = self._build_analysis_packet_payload(
|
||
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,
|
||
recommended_next_step=handoff_fields["recommended_next_step"],
|
||
answer=str(answer or "").strip(),
|
||
evidence_pack=normalized_evidence_pack,
|
||
query_type=str(query_type or QUERY_TYPE_UNKNOWN),
|
||
clarification_code=str(clarification_code or "").strip() or None,
|
||
anchor_kind=str(anchor_kind or "").strip() or None,
|
||
meta_followup_kind=str(meta_followup_kind or "").strip() or None,
|
||
internal_context_packet=internal_context_packet,
|
||
)
|
||
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,
|
||
"signal_source_recommendation": normalized_signal_source_recommendation,
|
||
"research_workspace": normalized_research_workspace,
|
||
"analysis_packet": normalized_analysis_packet,
|
||
"customer_evidence_packet": dict(customer_evidence_packet or {}) if isinstance(customer_evidence_packet, dict) else {},
|
||
"customer_interaction_context_packet": (
|
||
dict(customer_interaction_context_packet or {})
|
||
if isinstance(customer_interaction_context_packet, dict)
|
||
else {}
|
||
),
|
||
"answer_process_packet": dict(answer_process_packet or {}) if isinstance(answer_process_packet, dict) else {},
|
||
"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,
|
||
}
|