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

185 lines
7.1 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
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)
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)
)
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,
}