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, }