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

820 lines
37 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 typing import Any
from urllib.parse import urlparse
from uuid import uuid4
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 ...schemas.context_workspace import ResearchCandidateItem, ResearchWorkspace
from ..prompt_runtime_service import PromptRuntimeRenderResult, PromptRuntimeService
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 _display_source_label(*, source_label: Any = None, title: Any = None, target_url: Any = None) -> str:
raw_label = str(source_label or "").strip()
raw_title = str(title or "").strip()
raw_url = str(target_url or "").strip()
url_wrapper = re.match(r"^url\[(?P<label>.+)\]$", raw_label, flags=re.IGNORECASE)
if url_wrapper:
raw_label = str(url_wrapper.group("label") or "").strip()
if raw_label and not re.match(r"^https?://", raw_label, flags=re.IGNORECASE):
return raw_label
if raw_title:
return raw_title
if raw_url:
try:
host = str(urlparse(raw_url).netloc or "").strip().lower()
except Exception:
host = ""
if host:
return host.removeprefix("www.")
return "外部来源"
@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 = ExternalAnswerPresenter._display_source_label(
source_label=item.source_label,
title=item.title,
target_url=item.target_url,
)
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 = ExternalAnswerPresenter._display_source_label(
source_label=item.source_label,
title=item.title,
target_url=item.target_url,
)
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.fit_brief_summary:
rows.append(f"当前项目背景: {str(plan.fit_brief_summary or '').strip()}")
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)
@staticmethod
def _slug(value: Any) -> str:
text = re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower())
return text.strip("_")[:48] or uuid4().hex[:12]
@staticmethod
def _entity_key(value: Any) -> str:
return re.sub(r"[\W_]+", "", str(value or "").strip().lower())
@classmethod
def _project_binding_from_plan(cls, packet: ExternalContextPacket) -> tuple[str | None, int | None, str | None]:
explicit_project_id = packet.plan.project_binding
explicit_title = str(packet.plan.project_title or "").strip() or None
if explicit_project_id is not None:
return f"project:{int(explicit_project_id)}", int(explicit_project_id), explicit_title
summary = str(packet.plan.fit_brief_summary or "").strip()
match = re.search(r"当前项目:#(?P<id>\d+)\s+(?P<title>.+?)(?:\s*\(|$)", summary)
if not match:
return None, None, None
project_id = int(match.group("id"))
title = str(match.group("title") or "").strip()
return f"project:{project_id}", project_id, title or None
@classmethod
def _default_comparison_dimensions(cls, packet: ExternalContextPacket) -> list[str]:
topic = str(packet.plan.topic or "").strip()
combined = " ".join([topic, str(packet.question or "").strip()])
if any(token in combined for token in ("竞品", "对比", "比较", "差异")):
return ["目标用户", "核心问题", "AI能力形态", "数据采集与隐私", "交付形态", "差异化机会"]
return ["核心定位", "证据基础", "适配风险", "下一步建议"]
@classmethod
def _build_research_workspace(cls, *, packet: ExternalContextPacket) -> dict[str, Any]:
subject_entities = [str(item).strip() for item in list(packet.plan.subject_entities or []) if str(item).strip()]
primary_ref, primary_id, primary_title = cls._project_binding_from_plan(packet)
primary_tokens = {
str(primary_title or "").strip().lower(),
str(primary_ref or "").strip().lower(),
cls._entity_key(primary_title),
cls._entity_key(primary_ref),
}
candidates: list[ResearchCandidateItem] = []
source_urls = [
str(item.target_url or "").strip()
for item in list(packet.verified_evidence or [])[:8]
if str(item.target_url or "").strip()
]
source_labels = [
str(item.source_label or item.title or "").strip()
for item in list(packet.verified_evidence or [])[:8]
if str(item.source_label or item.title or "").strip()
]
for name in subject_entities:
lower_name = name.lower()
if (
lower_name in primary_tokens
or cls._entity_key(name) in primary_tokens
or lower_name.startswith("project:")
):
continue
candidates.append(
ResearchCandidateItem(
candidate_ref=f"candidate:{cls._slug(name)}",
display_name=name,
summary=f"当前轮外部公开证据里命中的候选对象:{name}",
confidence="medium" if str(packet.impact_status or "").strip().lower() == "direct_match" else "low",
status="adjacent" if str(packet.impact_status or "").strip().lower() == "indirect_only" else "unknown",
source_labels=source_labels[:3],
source_urls=source_urls[:3],
)
)
if not candidates:
for item in list(packet.verified_evidence or [])[:3]:
title = str(item.title or item.source_label or "").strip()
if not title:
continue
candidates.append(
ResearchCandidateItem(
candidate_ref=f"candidate:{cls._slug(title)}",
display_name=title[:80],
summary=str(item.snippet or title).strip()[:240],
confidence="low",
status="weak_candidate",
source_labels=[str(item.source_label or "").strip()] if str(item.source_label or "").strip() else [],
source_urls=[str(item.target_url or "").strip()] if str(item.target_url or "").strip() else [],
)
)
if not candidates:
return {}
workspace = ResearchWorkspace(
workspace_id=f"ws_{cls._slug((primary_ref or primary_title or 'global') + ':' + str(packet.plan.topic or 'external'))}",
workspace_kind="hybrid_research" if primary_ref else "external_research",
topic=str(packet.plan.topic or packet.plan.answer_contract or "external_research").strip()[:120],
primary_object_ref=primary_ref,
primary_object_id=primary_id,
primary_object_title=primary_title,
candidate_set_binding=f"cset_{cls._slug('|'.join(item.display_name for item in candidates[:4]))}",
candidate_items=candidates[:6],
selected_candidate_refs=[item.candidate_ref for item in candidates[:6]],
research_goal=str(packet.plan.fit_brief_summary or packet.plan.topic or "").strip()[:240] or None,
analysis_dimensions=cls._default_comparison_dimensions(packet),
evidence_refs=source_urls[:5],
confidence_state="medium" if str(packet.retrieval_status or "").strip().lower() == "evidence_found" else "low",
open_questions=[
"哪些候选属于直接竞品,哪些只是邻近替代"
] if str(packet.impact_status or "").strip().lower() == "indirect_only" else [],
next_recommended_actions=["按当前项目与候选对象继续做详细对比"],
status="active",
)
return workspace.model_dump()
@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_result = await self._render_prompt_result(
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),
)
prompt = prompt_result.prompt
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",
**prompt_result.routing_meta(),
},
)
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(),
"research_workspace": self._build_research_workspace(packet=packet),
}
@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, Any]:
short_question = str(question or "").strip()[:80]
source_summaries = cls._build_source_summaries(packet)
research_workspace = cls._build_research_workspace(packet=packet)
research_workspace["next_recommended_actions"] = ["生成映射验证清单"]
research_workspace["open_questions"] = ["是否继续把候选对象整理成映射验证清单"]
candidate_names = [
str(item.get("display_name") or "").strip()
for item in list(research_workspace.get("candidate_items") or [])
if isinstance(item, dict) and str(item.get("display_name") or "").strip()
]
candidate_hint = (
f" 当前可以继续围绕这些候选对象往下查:{'、'.join(candidate_names[:3])}。"
if candidate_names
else ""
)
return {
"answer_text": (
f"关于“{short_question}”,当前外部证据只能说明它和当前项目有背景相关性,"
"不能直接说明它已经命中当前项目对象。"
f"现有来源:{source_summaries}。"
f"{candidate_hint}"
"如果你要,我可以继续把它整理成一份映射验证清单。"
),
"prompt_variant_id": "external_indirect_only",
"research_workspace": research_workspace,
}
async def _render_prompt(
self,
*,
prompt_key: str,
fallback_template: str,
**kwargs: Any,
) -> str:
return (
await self._render_prompt_result(
prompt_key=prompt_key,
fallback_template=fallback_template,
**kwargs,
)
).prompt
async def _render_prompt_result(
self,
*,
prompt_key: str,
fallback_template: str,
**kwargs: Any,
) -> PromptRuntimeRenderResult:
if not hasattr(self.db, "execute"):
return PromptRuntimeRenderResult(
prompt=str(fallback_template).format(**kwargs).strip(),
prompt_key=prompt_key,
source="default_fallback",
)
return await PromptRuntimeService(self.db).render(
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,
)
research_workspace = self._build_research_workspace(packet=packet)
contract = self._normalize_contract(packet.plan.answer_contract)
if impact_status == "indirect_only" and contract != "compare_with_sources":
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",
}
prompt_key, fallback_template, prompt_variant_id = self._PROMPT_CONFIG[contract]
prompt_result = await self._render_prompt_result(
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()],
*(
["当前仍是候选级比较,部分对象还没完成直接竞品映射。"]
if impact_status == "indirect_only" and contract == "compare_with_sources"
else []
),
]
) or "无",
)
prompt = prompt_result.prompt
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",
**prompt_result.routing_meta(),
}
else:
routing_meta = prompt_result.routing_meta()
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,
"research_workspace": research_workspace,
}