237 lines
9.7 KiB
Python
237 lines
9.7 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from ...config.ai_prompts import (
|
||
PROMPT_EXTERNAL_COMPARE_WITH_SOURCES_QA,
|
||
PROMPT_EXTERNAL_DIRECT_FACT_QA,
|
||
PROMPT_EXTERNAL_SUMMARY_WITH_SOURCES_QA,
|
||
)
|
||
from ..prompt_release import PromptReleaseService
|
||
from .schemas import ExternalContextPacket
|
||
|
||
|
||
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()])}")
|
||
return "\n".join(rows)
|
||
|
||
@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)
|
||
)
|
||
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,
|
||
}
|