PMFlow/backend/app/services/external_query_service/presenter.py

621 lines
27 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.

from __future__ import annotations
import re
from uuid import uuid4
from typing import Any
from ...config.ai_prompts import (
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
PROMPT_EXTERNAL_DIRECT_FACT_QA,
PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET,
PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_QA,
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
)
from ..prompt_release import PromptReleaseService
from .schemas import (
ExternalContextPacket,
SignalSourceRecommendationCandidate,
SignalSourceRecommendationPacket,
)
class ExternalAnswerPresenter:
_PROMPT_CONFIG = {
"direct_fact": (
"PROMPT_EXTERNAL_DIRECT_FACT_QA",
PROMPT_EXTERNAL_DIRECT_FACT_QA,
"external_direct_fact",
),
"summary_with_sources": (
"PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA",
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
"external_summary_with_sources",
),
"compare_with_sources": (
"PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA",
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
"external_compare_with_sources",
),
}
def __init__(
self,
*,
llm_service: Any,
db: Any,
tenant_id: int,
) -> None:
self.llm_service = llm_service
self.db = db
self.tenant_id = int(tenant_id or 1)
@classmethod
def _normalize_contract(cls, value: Any) -> str:
normalized = str(value or "").strip().lower()
if normalized in cls._PROMPT_CONFIG:
return normalized
return "summary_with_sources"
@staticmethod
def _build_evidence_blocks(packet: ExternalContextPacket) -> str:
blocks: list[str] = []
for idx, item in enumerate(list(packet.verified_evidence or [])[:5], start=1):
source_label = str(item.source_label or item.title or item.target_url or "").strip()
title = str(item.title or "").strip()
snippet = str(item.snippet or "").strip()
target_url = str(item.target_url or "").strip()
block_lines = [f"[{idx}] 来源: {source_label or '外部来源'}"]
if title and title != source_label:
block_lines.append(f"标题: {title}")
if snippet:
block_lines.append(f"证据: {snippet}")
if target_url:
block_lines.append(f"链接: {target_url}")
blocks.append("\n".join(block_lines))
return "\n\n".join(blocks).strip() or "无"
@staticmethod
def _build_source_summaries(packet: ExternalContextPacket) -> str:
labels: list[str] = []
for item in list(packet.verified_evidence or [])[:5]:
label = str(item.source_label or item.title or item.target_url or "").strip()
if label and label not in labels:
labels.append(label)
return ";".join(labels[:5]) if labels else "无"
@staticmethod
def _build_plan_summary(packet: ExternalContextPacket) -> str:
plan = packet.plan
rows = [
f"主题: {str(plan.topic or '').strip() or '未指定'}",
f"回答合同: {str(plan.answer_contract or '').strip() or 'summary_with_sources'}",
]
if plan.time_range_hint:
rows.append(f"时间范围: {plan.time_range_hint}")
if plan.location_hint:
rows.append(f"地点: {plan.location_hint}")
if plan.subject_entities:
rows.append(f"对象: {'、'.join([str(item).strip() for item in plan.subject_entities[:5] if str(item).strip()])}")
if plan.signal_source_objectives:
rows.append(
"任务目标: "
+ ";".join([str(item).strip() for item in plan.signal_source_objectives[:3] if str(item).strip()])
)
return "\n".join(rows)
@classmethod
def _is_signal_source_recommendation_request(
cls,
*,
packet: ExternalContextPacket,
) -> bool:
interaction_level = str(packet.plan.interaction_level or "").strip().lower()
if interaction_level and interaction_level != "recommendation":
return False
if interaction_level != "recommendation":
return False
recommendation_goal = str(packet.plan.recommendation_goal or "").strip()
return bool(recommendation_goal or packet.plan.project_object_anchors or packet.plan.signal_source_objectives)
@staticmethod
def _build_recommendation_uncertainty_text(packet: ExternalContextPacket) -> str:
retrieval_status = str(packet.retrieval_status or "").strip().lower()
if retrieval_status == "verifier_blocked":
return "当前外部公开资料还在校验,这里先给推荐,不把它当成已验证事实。"
if retrieval_status == "no_relevant_results":
return "当前外部公开资料还没找到直接证据,这里先按项目目标给推荐。"
if retrieval_status == "retrieval_failed":
return "这次外部检索失败,这里先按项目目标给推荐,后面还需要补核验。"
return ""
@staticmethod
def _collect_signal_source_objectives(packet: ExternalContextPacket) -> list[str]:
objectives: list[str] = []
for item in list(packet.plan.signal_source_objectives or []):
normalized = str(item or "").strip()
if normalized and normalized not in objectives:
objectives.append(normalized)
if objectives:
return objectives[:4]
for item in list(packet.plan.project_object_anchors or []):
normalized = str(item or "").strip()
if normalized and normalized not in objectives:
objectives.append(normalized)
return objectives[:4]
@staticmethod
def _objective_label(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
for separator in (":", ":"):
if separator in text:
return str(text.split(separator, 1)[0] or "").strip() or text
return text
@classmethod
def _extract_objective_parts(cls, value: Any) -> tuple[str, str]:
text = str(value or "").strip()
if not text:
return "", ""
for separator in (":", ":"):
if separator in text:
label, detail = text.split(separator, 1)
return str(label or "").strip(), str(detail or "").strip()
return text, ""
@staticmethod
def _sanitize_recommendation_candidate_text(value: Any) -> str:
text = re.sub(r"\s+", " ", str(value or "")).strip(" ,,。;;::")
text = re.sub(r"^(?:当前|建议|推荐)\s*", "", text)
return text[:36]
@classmethod
def _objective_candidate_name(cls, *, label: str, detail: str) -> str:
candidate = detail or label
candidate = re.sub(r"^(?:编写脚本|收集|跟踪|监控|处理|实现|支持|自动|实时|用于|把|对|完成|生成并推送)\s*", "", candidate)
candidate = re.sub(r"^(?:收到|基于|围绕|用于)\s*", "", candidate)
candidate = re.sub(r"(?:后自动|并在.+)$", "", candidate).strip()
candidate = cls._sanitize_recommendation_candidate_text(candidate)
if candidate and candidate not in {"数据与阈值", "结果发给客户", "话题波动"}:
return candidate
return cls._sanitize_recommendation_candidate_text(label) or "外部变化线索"
@classmethod
def _objective_candidate_reason(cls, *, label: str, detail: str) -> str:
detail_text = cls._sanitize_recommendation_candidate_text(detail)
label_text = cls._sanitize_recommendation_candidate_text(label)
if detail_text and label_text:
return f"它最早支撑“{label_text}”,也更容易先看出这件事有没有开始偏。"
if label_text:
return f"它能更早暴露“{label_text}”是不是开始出问题。"
return "它更容易先把问题从项目现状里提前暴露出来。"
@classmethod
def _build_signal_source_recommendation_fallback_packet(
cls,
*,
packet: ExternalContextPacket,
) -> SignalSourceRecommendationPacket:
candidates: list[SignalSourceRecommendationCandidate] = []
for raw in cls._collect_signal_source_objectives(packet)[:3]:
label, detail = cls._extract_objective_parts(raw)
name = cls._objective_candidate_name(label=label, detail=detail)
reason = cls._objective_candidate_reason(label=label, detail=detail)
if name and all(existing.name != name for existing in candidates):
candidates.append(
SignalSourceRecommendationCandidate(
candidate_ref=f"cand_{uuid4().hex[:12]}",
name=name,
display_name=name,
reason=reason,
business_value=reason,
why_now=reason,
source_origin="recommendation",
materialization_payload={
"display_name": name,
"source_origin": "recommendation",
},
)
)
fallback_seed = [
("最近异常反馈", "它能看出问题是不是已经开始影响到真实结果,而不是只停在内部波动。"),
("上游变化记录", "它更容易解释这次波动到底是内部原因还是外部变化带出来的。"),
("结果失败回执", "它能最早把真正失败的位置暴露出来,避免只看到表面症状。"),
]
for name, reason in fallback_seed:
if len(candidates) >= 3:
break
if all(existing.name != name for existing in candidates):
candidates.append(
SignalSourceRecommendationCandidate(
candidate_ref=f"cand_{uuid4().hex[:12]}",
name=name,
display_name=name,
reason=reason,
business_value=reason,
why_now=reason,
source_origin="recommendation",
materialization_payload={
"display_name": name,
"source_origin": "recommendation",
},
)
)
return SignalSourceRecommendationPacket(
candidate_set_id=f"recset_{uuid4().hex[:12]}",
resource_role="signal_source",
source_kind="external",
evidence_mode="verified" if str(packet.retrieval_status or "").strip().lower() == "evidence_found" else "advisory",
candidates=candidates[:3],
caveat=cls._build_recommendation_uncertainty_text(packet)
or "这些先算建议,不代表已经接入或验证完成。",
)
@staticmethod
def _ensure_recommendation_packet_defaults(
packet: SignalSourceRecommendationPacket,
*,
fallback_packet: SignalSourceRecommendationPacket,
) -> SignalSourceRecommendationPacket:
if not str(packet.candidate_set_id or "").strip():
packet.candidate_set_id = str(fallback_packet.candidate_set_id or "").strip() or f"recset_{uuid4().hex[:12]}"
packet.resource_role = "signal_source"
packet.source_kind = str(packet.source_kind or fallback_packet.source_kind or "external").strip() or "external"
packet.evidence_mode = (
str(packet.evidence_mode or fallback_packet.evidence_mode or "advisory").strip() or "advisory"
)
for item in list(packet.candidates or []):
if not str(item.candidate_ref or "").strip():
item.candidate_ref = f"cand_{uuid4().hex[:12]}"
payload = dict(item.materialization_payload or {})
payload.setdefault("display_name", str(item.display_name or item.name or "").strip())
payload.setdefault("candidate_ref", str(item.candidate_ref or "").strip())
payload.setdefault("source_origin", str(item.source_origin or "recommendation").strip() or "recommendation")
item.materialization_payload = payload
return packet
@classmethod
def _recommendation_packet_has_risky_or_weak_content(
cls,
*,
recommendation_packet: SignalSourceRecommendationPacket,
packet: ExternalContextPacket,
) -> bool:
if len(recommendation_packet.candidates) < 2:
return True
risky_markers = (
"%",
"已确认",
"无需额外对接验证",
"相关性达",
"普遍可采集",
"近一年",
"今年",
"本月",
"这一层",
"主链",
"packet",
"route",
"verifier",
)
for item in list(recommendation_packet.candidates or []):
text = f"{str(item.name or '')} {str(item.reason or '')}".strip()
if not text:
return True
if any(marker in text for marker in risky_markers):
return True
if str(packet.impact_status or "").strip().lower() == "direct_match":
return False
return False
@classmethod
def _merge_recommendation_packets(
cls,
*,
primary: SignalSourceRecommendationPacket,
fallback: SignalSourceRecommendationPacket,
) -> SignalSourceRecommendationPacket:
merged: list[SignalSourceRecommendationCandidate] = []
for item in list(primary.candidates or []) + list(fallback.candidates or []):
name = cls._sanitize_recommendation_candidate_text(item.display_name or item.name)
reason = str(item.reason or item.business_value or item.why_now or "").strip()
if not name or not reason:
continue
if any(existing.display_name == name for existing in merged):
continue
merged.append(
SignalSourceRecommendationCandidate(
candidate_ref=str(item.candidate_ref or "").strip() or f"cand_{uuid4().hex[:12]}",
name=name,
display_name=name,
reason=reason,
business_value=str(item.business_value or reason).strip(),
why_now=str(item.why_now or reason).strip(),
source_origin=str(item.source_origin or "recommendation").strip() or "recommendation",
materialization_payload=dict(item.materialization_payload or {}),
)
)
caveat = str(primary.caveat or "").strip() or str(fallback.caveat or "").strip()
packet = SignalSourceRecommendationPacket(
candidate_set_id=str(primary.candidate_set_id or fallback.candidate_set_id or "").strip() or f"recset_{uuid4().hex[:12]}",
project_binding=primary.project_binding or fallback.project_binding,
resource_role="signal_source",
source_kind=str(primary.source_kind or fallback.source_kind or "external").strip() or "external",
evidence_mode=str(primary.evidence_mode or fallback.evidence_mode or "advisory").strip() or "advisory",
candidates=merged[:3],
caveat=caveat,
)
return cls._ensure_recommendation_packet_defaults(packet, fallback_packet=fallback)
@classmethod
def _render_signal_source_recommendation_text(
cls,
*,
recommendation_packet: SignalSourceRecommendationPacket,
packet: ExternalContextPacket,
) -> str:
candidates = list(recommendation_packet.candidates or [])[:3]
if not candidates:
recommendation_packet = cls._build_signal_source_recommendation_fallback_packet(packet=packet)
candidates = list(recommendation_packet.candidates or [])[:3]
lines = ["如果只是先给建议,我会先看这 3 类外部信号:"]
for index, item in enumerate(candidates, start=1):
name = cls._sanitize_recommendation_candidate_text(item.name)
reason = str(item.reason or "").strip().rstrip("。")
if reason.startswith("因为"):
lines.append(f"{index}. {name},{reason}")
else:
lines.append(f"{index}. {name},因为{reason}")
caveat = str(recommendation_packet.caveat or "").strip() or cls._build_recommendation_uncertainty_text(packet)
if caveat:
lines.append(caveat.rstrip("。") + "。")
return "\n".join(lines)
async def _build_signal_source_recommendation_packet(
self,
*,
question: str,
packet: ExternalContextPacket,
) -> SignalSourceRecommendationPacket:
fallback_packet = self._build_signal_source_recommendation_fallback_packet(packet=packet)
generator = getattr(self.llm_service, "generate_structured_object", None)
if not callable(generator):
return fallback_packet
plan_summary = self._build_plan_summary(packet)
fit_brief_summary = str(packet.plan.fit_brief_summary or "").strip() or "未提供"
recommendation_goal = str(packet.plan.recommendation_goal or "").strip() or "给当前项目推荐最值得先关注的外部信号源。"
project_object_anchors = "、".join(
[str(item).strip() for item in list(packet.plan.project_object_anchors or []) if str(item).strip()][:4]
) or "无"
signal_source_objectives = ";".join(
[str(item).strip() for item in list(packet.plan.signal_source_objectives or []) if str(item).strip()][:4]
) or "无"
prompt = await self._render_prompt(
prompt_key="PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET",
fallback_template=PROMPT_EXTERNAL_SIGNAL_SOURCE_RECOMMENDATION_PACKET,
question=str(question or "").strip(),
plan_summary=plan_summary,
fit_brief_summary=fit_brief_summary,
recommendation_goal=recommendation_goal,
project_object_anchors=project_object_anchors,
signal_source_objectives=signal_source_objectives,
retrieval_status=str(packet.retrieval_status or "").strip() or "unknown",
uncertainties=";".join([str(item).strip() for item in list(packet.uncertainties or []) if str(item).strip()]) or "无",
source_summaries=self._build_source_summaries(packet),
)
try:
structured = await generator(
prompt=prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="external_signal_source_recommendation_packet",
use_prompt_profile=False,
temperature=0.1,
response_mode="fast",
routing_meta={
"tier_alias": "low",
"response_contract": "structured",
"fallback_policy": "fast_local",
"execution_mode": "query",
},
)
payload = getattr(structured, "payload", None)
if payload is None and hasattr(structured, "model_dump"):
payload = structured.model_dump()
recommendation_packet = SignalSourceRecommendationPacket.model_validate(payload or {})
recommendation_packet = self._merge_recommendation_packets(
primary=recommendation_packet,
fallback=fallback_packet,
)
recommendation_packet = self._ensure_recommendation_packet_defaults(
recommendation_packet,
fallback_packet=fallback_packet,
)
if self._recommendation_packet_has_risky_or_weak_content(
recommendation_packet=recommendation_packet,
packet=packet,
):
return fallback_packet
return recommendation_packet
except Exception:
return fallback_packet
async def _build_signal_source_recommendation_response(
self,
*,
question: str,
packet: ExternalContextPacket,
thinking_mode: str,
answer_max_tokens: int | None = None,
) -> dict[str, str]:
_ = (thinking_mode, answer_max_tokens)
recommendation_packet = await self._build_signal_source_recommendation_packet(
question=question,
packet=packet,
)
text = self._render_signal_source_recommendation_text(
recommendation_packet=recommendation_packet,
packet=packet,
)
return {
"answer_text": text,
"prompt_variant_id": "external_signal_source_recommendation",
"recommendation_packet": recommendation_packet.model_dump(),
}
@staticmethod
def _build_failure_response(*, question: str, retrieval_status: str) -> dict[str, str]:
short_question = str(question or "").strip()[:80]
if retrieval_status == "verifier_blocked":
return {
"answer_text": (
f"关于“{short_question}”,我拿到过外部候选结果,但它们没有通过核验,"
"所以先不给你直接结论。你如果愿意,我可以继续按更明确的对象或范围再检一次。"
),
"prompt_variant_id": "external_verifier_blocked",
}
if retrieval_status == "no_relevant_results":
return {
"answer_text": (
f"关于“{short_question}”,外部检索已经执行完成,但没有找到足够相关的外部公开证据。"
"现阶段我不建议把它直接当成当前项目的有效外部变化。"
),
"prompt_variant_id": "external_no_relevant_results",
}
return {
"answer_text": (
f"关于“{short_question}”,外部检索这次执行失败,没有产出可用的外部公开证据。"
"你可以稍后重试,或把要核验的公开对象说得更具体一些。"
),
"prompt_variant_id": "external_retrieval_failed",
}
@classmethod
def _build_indirect_only_response(cls, *, question: str, packet: ExternalContextPacket) -> dict[str, str]:
short_question = str(question or "").strip()[:80]
source_summaries = cls._build_source_summaries(packet)
return {
"answer_text": (
f"关于“{short_question}”,当前外部证据只能说明它和当前项目有背景相关性,"
"不能直接说明它已经命中当前项目对象。"
f"现有来源:{source_summaries}。如果你要,我可以继续把它整理成一份映射验证清单。"
),
"prompt_variant_id": "external_indirect_only",
}
async def _render_prompt(
self,
*,
prompt_key: str,
fallback_template: str,
**kwargs: Any,
) -> str:
if not hasattr(self.db, "execute"):
return str(fallback_template).format(**kwargs).strip()
return await PromptReleaseService(self.db).render_prompt(
tenant_id=self.tenant_id,
prompt_key=prompt_key,
fallback_template=fallback_template,
**kwargs,
)
async def present(
self,
*,
question: str,
context_packet: dict[str, Any] | ExternalContextPacket,
thinking_mode: str,
answer_max_tokens: int | None = None,
) -> dict[str, str]:
packet = (
context_packet
if isinstance(context_packet, ExternalContextPacket)
else ExternalContextPacket.model_validate(context_packet)
)
if self._is_signal_source_recommendation_request(
packet=packet,
):
return await self._build_signal_source_recommendation_response(
question=question,
packet=packet,
thinking_mode=thinking_mode,
answer_max_tokens=answer_max_tokens,
)
retrieval_status = str(packet.retrieval_status or "").strip().lower() or "retrieval_failed"
impact_status = str(packet.impact_status or "").strip().lower() or "no_direct_impact"
if retrieval_status != "evidence_found":
return self._build_failure_response(
question=question,
retrieval_status=retrieval_status,
)
if impact_status == "indirect_only":
return self._build_indirect_only_response(
question=question,
packet=packet,
)
verifier = dict(packet.verifier or {})
if not packet.verified_evidence or not bool(verifier.get("ok")):
return {
"answer_text": (
f"当前关于“{str(question or '').strip()[:80]}”的外部证据不足。"
"请补充更明确的对象、范围或继续提供可核验来源后,我再继续分析。"
),
"prompt_variant_id": "external_clarify",
}
contract = self._normalize_contract(packet.plan.answer_contract)
prompt_key, fallback_template, prompt_variant_id = self._PROMPT_CONFIG[contract]
prompt = await self._render_prompt(
prompt_key=prompt_key,
fallback_template=fallback_template,
question=str(question or "").strip(),
plan_summary=self._build_plan_summary(packet),
evidence_blocks=self._build_evidence_blocks(packet),
source_summaries=self._build_source_summaries(packet),
time_range=str(packet.plan.time_range_hint or "").strip() or "未指定",
uncertainties=";".join([str(item).strip() for item in list(packet.uncertainties or []) if str(item).strip()]) or "无",
)
deep_thinking = str(thinking_mode or "").strip().lower() == "deep"
if deep_thinking:
prompt = (
f"{prompt}\n\n"
"补充要求(深入思考模式):\n"
"1. 先给结论,再给证据链;\n"
"2. 明确区分已验证事实与推断;\n"
"3. 若存在不确定项,给出待确认清单。"
)
effective_max_tokens = max(256, min(int(answer_max_tokens or (1400 if deep_thinking else 900)), 3000))
routing_meta = None
function_name = "adaptive_rag_answer_deep" if deep_thinking else "adaptive_rag_answer_normal"
if deep_thinking:
routing_meta = {
"tier_alias": "low",
"planned_tier": "low",
"executed_tier": "low",
"response_contract": "complete",
"fallback_policy": "low_to_high",
"execution_mode": "query",
"routing_decision": "force_low_first",
}
answer = await self.llm_service.generate(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name=function_name,
temperature=0.15 if deep_thinking else 0.2,
max_tokens=effective_max_tokens,
use_prompt_profile=False,
routing_meta=routing_meta,
)
text = str(answer or "").strip()
if not text:
raise ValueError("external_answer_presenter_empty_response")
return {
"answer_text": text,
"prompt_variant_id": prompt_variant_id,
}