PMFlow/backend/app/services/customer_answer_presentatio...

1967 lines
82 KiB
Python
Raw Permalink 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 json
import re
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import User
from ..schemas.customer_answer import (
CUSTOMER_EVIDENCE_PACKET_VERSION,
CustomerActiveWorkspaceSummary,
CustomerAnswerProcessPacket,
CustomerAnswerProcessStep,
CustomerAnswerVerification,
CustomerEvidenceFact,
CustomerEvidencePacket,
CustomerInteractionContextPacket,
CustomerVisibleObject,
PMFlowAIAnswerCanvas,
PMFlowAIAnswerCanvasEvidenceBlock,
PMFlowAIAnswerCanvasNextAction,
PMFlowAIAnswerCanvasObjectRef,
)
from ..schemas.context_workspace import normalize_research_workspace_payload
from ..schemas.rag import RAGAskRequest
from .ai_function_runtime_profile_service import AIFunctionRuntimeProfileService
from .llm_service import LLMService
from .prompt_runtime_service import PromptRuntimeService
PROMPT_CUSTOMER_ANSWER_COMPOSER_KEY = "PROMPT_CUSTOMER_ANSWER_COMPOSER"
PROMPT_CUSTOMER_ANSWER_VERIFIER_KEY = "PROMPT_CUSTOMER_ANSWER_VERIFIER"
PROMPT_SPACE_AI_INTERACTION_OPTION_PLANNER_KEY = "PROMPT_SPACE_AI_INTERACTION_OPTION_PLANNER"
CUSTOMER_ANSWER_COMPOSER_MAX_TOKENS = 10000
CUSTOMER_ANSWER_VERIFIER_MAX_TOKENS = 10000
PMFLOW_AI_ANSWER_PRESENTATION_SCENE_KEY = "pmflow_ai_answer_presentation"
CUSTOMER_ANSWER_PACKET_VERSION = CUSTOMER_EVIDENCE_PACKET_VERSION
_RUNTIME_ANSWER_SHAPES = {
"strategic_judgement",
"status_answer",
"clarification",
"execution_guidance",
"fact_list",
"composite_answer",
"general_answer",
}
_PROTECTED_ANSWER_TYPE = "protected_scope"
_INTERNAL_TERM_PATTERNS = (
r"System Snapshot",
r"User Snapshot",
r"Session State",
r"Project database",
r"Knowledge Graph",
r"Feishu history",
r"AICallLog",
r"SemanticDecision",
r"AdaptiveRAG",
r"PROMPT_[A-Z0-9_]+",
r"\bprojects\b",
r"\bteam_members\b",
r"\bassigned_projects\b",
r"\btenant_id\b",
r"\broute_decision\b",
r"\bsource_strategy\b",
r"\bprompt_key\b",
r"\btrace_id\b",
r"backend/",
r"frontend/",
r"app/services",
)
_INTERNAL_TERM_RE = re.compile("|".join(f"(?:{pattern})" for pattern in _INTERNAL_TERM_PATTERNS), re.IGNORECASE)
_INTERNAL_EVIDENCE_TONE_RE = re.compile(r"(字段|数组|快照|上下文明确|一致指向|出现在用户上下文|系统快照)", re.IGNORECASE)
_INTERNAL_ANCHOR_RE = re.compile(
r"("
r"\b(?:approval|change_set|task|evidence|project):\d+\b|"
r"\b(?:projectId|changeSetId|taskId|evidenceId|approvalId)=\d+\b|"
r"/(?:change-review|pmflow-os|execution-bridge|release-growth|project-launch)\?[^)\s,。;;]+"
r")",
re.IGNORECASE,
)
_PROJECT_LINE_RE = re.compile( # semantic-lint: ignore
r"(?:\*\*)?(?P<name>[^*\n((]{2,120})(?:\*\*)?[((]\s*(?:ID|项目编号)\s*[::]\s*(?P<id>\d+)\s*[,,]\s*状态\s*[::]\s*(?P<status>[^))]+)[))]"
)
class CustomerAnswerPresentationService:
"""Build customer-safe answer surfaces from internal/external evidence."""
def __init__(
self,
db: AsyncSession,
*,
tenant_id: int,
llm_service: LLMService | None = None,
) -> None:
self.db = db
self.tenant_id = int(tenant_id or 1)
self._llm_service_supplied = llm_service is not None
self.llm = llm_service or LLMService()
async def present(
self,
*,
request: RAGAskRequest,
current_user: User,
payload: dict[str, Any],
) -> dict[str, Any]:
working = dict(payload or {})
existing_packet = working.get("customer_evidence_packet") if isinstance(working.get("customer_evidence_packet"), dict) else None
preserve_packet = bool(
existing_packet
and (
working.get("preserve_customer_evidence_packet")
or working.get("pmflow_ai_turn_state")
)
)
packet = existing_packet if preserve_packet else self.build_customer_evidence_packet(
request=request,
current_user=current_user,
payload=working,
)
answer, verification, composer_used = await self.compose_answer_from_packet(
question=str(request.question or "").strip(),
packet=packet,
)
working["answer"] = answer
working["reply_text"] = answer
working["customer_evidence_packet"] = packet
working["customer_answer_verification"] = verification
working["customer_interaction_context_packet"] = self.build_customer_interaction_context_packet(
packet,
research_workspace=working.get("research_workspace"),
)
working["answer_process_packet"] = self.build_answer_process_packet(packet=packet, verification=verification)
working["answer_canvas"] = self.build_answer_canvas(
packet=packet,
answer=answer,
research_workspace=working.get("research_workspace"),
)
working["answer_mode"] = "customer_composed" if composer_used else "customer_deterministic"
working["policy_version"] = _append_policy_version(
working.get("policy_version"),
"customer-evidence-packet-v1",
)
return working
def build_customer_evidence_packet(
self,
*,
request: RAGAskRequest,
current_user: User,
payload: dict[str, Any],
) -> dict[str, Any]:
route_plan = _normalize_dict(payload.get("pmflow_ai_route_plan"))
research_contract = _normalize_dict(payload.get("research_contract"))
external_fit = _normalize_dict(payload.get("external_project_fit_packet"))
sources = _customer_source_summaries(payload)
facts = self._build_facts(request=request, payload=payload, route_plan=route_plan)
answer_type = self._resolve_answer_type(
request=request,
payload=payload,
route_plan=route_plan,
facts=facts,
)
fallback_summary = _strip_internal_answer_text(payload.get("reply_text") or payload.get("answer"))
limitations = _build_limitations(payload)
if external_fit:
facts.append(_external_project_fit_fact(external_fit))
return _packet_to_dict(
CustomerEvidencePacket(
tenant_id=_safe_int(getattr(current_user, "tenant_id", None)),
user_id=_safe_int(getattr(current_user, "id", None)),
question=_clean_text(request.question, limit=500),
answer_type=answer_type,
route_display=_route_display(route_plan, research_contract),
facts=[_normalize_fact_model(item) for item in facts[:20]],
source_summary=sources,
confidence=_confidence_label(payload),
limitations=limitations,
suggested_actions=_suggested_actions_from_surface(request.surface_context),
fallback_summary=fallback_summary,
)
)
def build_customer_interaction_context_packet(
self,
packet: dict[str, Any] | CustomerEvidencePacket,
*,
research_workspace: dict[str, Any] | None = None,
) -> dict[str, Any]:
packet_dict = _packet_to_dict(packet)
workspace = normalize_research_workspace_payload(research_workspace)
objects = _visible_objects_from_packet(packet_dict, research_workspace=workspace)
current_focus = objects[0] if len(objects) == 1 else None
if current_focus is None:
current_focus = _workspace_primary_focus(workspace)
return CustomerInteractionContextPacket(
current_focus_object=current_focus,
visible_objects=objects,
last_shown_objects=objects,
active_workspace=_active_workspace_summary(workspace),
).model_dump(mode="json", exclude_none=True)
def build_answer_process_packet(
self,
*,
packet: dict[str, Any] | CustomerEvidencePacket,
verification: dict[str, Any] | CustomerAnswerVerification | None = None,
) -> dict[str, Any]:
raw_packet = dict(packet) if isinstance(packet, dict) else {}
packet_dict = _packet_to_dict(packet)
answer_type = _canonical_runtime_answer_type(packet_dict.get("answer_type"))
route_display = _clean_text(packet_dict.get("route_display"), limit=120) or None
_ = verification
steps = _explicit_process_steps_from_packet(raw_packet) or _explicit_process_steps_from_packet(packet_dict)
return CustomerAnswerProcessPacket(
answer_type=_canonical_runtime_answer_type(answer_type),
route_display=route_display,
steps=steps[:8],
).model_dump(mode="json", exclude_none=True)
def build_answer_canvas(
self,
*,
packet: dict[str, Any] | CustomerEvidencePacket,
answer: str,
research_workspace: dict[str, Any] | None = None,
) -> dict[str, Any]:
packet_dict = _packet_to_dict(packet)
answer_type = _canonical_runtime_answer_type(packet_dict.get("answer_type"))
facts = [item for item in list(packet_dict.get("facts") or []) if isinstance(item, dict)]
canvas_facts = _canvas_relevant_facts(answer_type=answer_type, facts=facts)
interaction = self.build_customer_interaction_context_packet(
{**packet_dict, "facts": canvas_facts},
research_workspace=research_workspace,
)
visible_objects = [
item
for item in list(interaction.get("visible_objects") or [])
if isinstance(item, dict) and str(item.get("object_ref") or "").strip()
]
suggested_actions = [
item for item in list(packet_dict.get("suggested_actions") or []) if isinstance(item, dict)
]
suggested_actions = _canvas_relevant_actions(answer_type=answer_type, actions=suggested_actions)
evidence_blocks: list[PMFlowAIAnswerCanvasEvidenceBlock] = []
for fact in canvas_facts[:8]:
label = _clean_text(fact.get("display_label") or "依据", limit=160)
summary = _clean_text(fact.get("display_summary"), limit=1000)
if not label or not summary:
continue
evidence_blocks.append(
PMFlowAIAnswerCanvasEvidenceBlock(
label=label,
summary=summary,
source_type=_canvas_source_type(fact),
confidence=_confidence_value(fact.get("confidence")),
)
)
object_refs: list[PMFlowAIAnswerCanvasObjectRef] = []
for obj in visible_objects[:8]:
object_ref = _clean_text(obj.get("object_ref"), limit=120)
title = _clean_text(obj.get("title"), limit=160)
object_type = _clean_text(obj.get("object_type") or obj.get("type"), limit=64)
if not object_ref or not title or not object_type:
continue
object_refs.append(
PMFlowAIAnswerCanvasObjectRef(
object_ref=object_ref,
title=title,
type=object_type,
href=_clean_text(obj.get("href"), limit=240) or None,
status=_clean_text(obj.get("status"), limit=80) or None,
summary=_clean_text(obj.get("display_summary") or obj.get("summary"), limit=500) or None,
)
)
next_actions: list[PMFlowAIAnswerCanvasNextAction] = []
for action in suggested_actions[:5]:
title = _clean_text(action.get("title"), limit=160)
if not title:
continue
href = _clean_text(action.get("href"), limit=240) or None
next_actions.append(
PMFlowAIAnswerCanvasNextAction(
title=title,
reason=_clean_text(action.get("project") or action.get("priority"), limit=500),
action_type=_canvas_action_type_from_href(href),
href=href,
object_ref=_clean_text(action.get("object_ref"), limit=120) or None,
object_anchor=_clean_object_anchor(action.get("object_anchor") or action.get("objectAnchor")),
requires_user_confirm=True,
)
)
if not next_actions and object_refs:
first = object_refs[0]
if first.href:
next_actions.append(
PMFlowAIAnswerCanvasNextAction(
title=f"打开{first.title}",
reason="继续查看这个对象的主流程状态和可处理事项。",
action_type="navigate",
href=first.href,
object_ref=first.object_ref,
object_anchor={
"object_ref": first.object_ref,
"object_type": first.type,
"object_id": str(first.object_id) if getattr(first, "object_id", None) is not None else "",
"surface": "",
"project_id": str(first.object_id) if getattr(first, "object_id", None) is not None else "",
"project_title": first.title,
"href": first.href,
"parent_object_ref": None,
},
requires_user_confirm=True,
)
)
direct_answer = _standardize_canvas_direct_answer(answer) or _standardize_canvas_direct_answer(
packet_dict.get("fallback_summary")
)
limitations = [
_clean_text(item, limit=160)
for item in list(packet_dict.get("limitations") or [])
if _clean_text(item, limit=160)
][:6]
canvas = PMFlowAIAnswerCanvas(
headline=_answer_canvas_headline(answer_type=answer_type, answer=direct_answer),
direct_answer=direct_answer,
evidence_blocks=evidence_blocks[:8],
object_refs=object_refs[:8],
next_actions=next_actions[:5],
limitations=limitations,
)
return canvas.model_dump(mode="json", exclude_none=True)
def build_customer_evidence_packet_from_runtime(
self,
*,
question: str,
user_id: int | None = None,
surface_context: dict[str, Any] | None = None,
payload: dict[str, Any] | None = None,
sources: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a customer-safe packet before the model composes the answer."""
working = dict(payload or {})
if sources is not None:
working["sources"] = list(sources)
working["sources_used"] = list(sources)
request = RAGAskRequest(
question=str(question or "").strip() or "PMFlow 问答",
surface_context=dict(surface_context or {}),
)
facts = self._build_facts(
request=request,
payload=working,
route_plan=_normalize_dict(working.get("pmflow_ai_route_plan")),
)
answer_type = self._resolve_answer_type(
request=request,
payload=working,
route_plan=_normalize_dict(working.get("pmflow_ai_route_plan")),
facts=facts,
)
if str(working.get("grounding_status") or "").strip().lower() in {
"insufficient_evidence",
"blocked",
"needs_clarification",
}:
answer_type = "insufficient_evidence"
return _packet_to_dict(
CustomerEvidencePacket(
tenant_id=self.tenant_id,
user_id=_safe_int(user_id),
question=_clean_text(question, limit=500),
answer_type=answer_type,
route_display=_route_display(
_normalize_dict(working.get("pmflow_ai_route_plan")),
_normalize_dict(working.get("research_contract")),
),
facts=[_normalize_fact_model(item) for item in facts[:20]],
source_summary=_customer_source_summaries(working),
confidence=_confidence_label(working),
limitations=_build_limitations(working),
suggested_actions=_suggested_actions_from_surface(surface_context),
fallback_summary=_strip_internal_answer_text(working.get("answer") or working.get("reply_text")),
)
)
async def compose_answer_from_packet(
self,
*,
question: str,
packet: dict[str, Any] | CustomerEvidencePacket,
) -> tuple[str, dict[str, Any], bool]:
packet_dict = _packet_to_dict(packet)
answer = self._render_deterministic_answer(packet_dict)
composer_used = False
if not answer and self._should_use_llm_composer(packet_dict) and self._can_use_llm_composer():
answer = await self._compose_answer(question=question, packet=packet_dict)
composer_used = bool(answer)
if not answer:
answer = self._render_safe_fallback(packet_dict)
verification = await self._verify_answer(packet=packet_dict, answer=answer, use_llm=composer_used)
if not verification.get("ok"):
repaired = _repair_customer_answer_text(answer)
repaired_verification = self._verify_answer_deterministic(repaired)
if repaired_verification.get("ok"):
answer = repaired
verification = {
**repaired_verification,
"origin": "deterministic_soft_rewrite",
"rewrite_required": False,
"reason": "soft_display_repaired",
}
else:
answer = self._render_safe_fallback(packet_dict)
verification = self._verify_answer_deterministic(answer)
return answer, verification, composer_used
def _build_facts(
self,
*,
request: RAGAskRequest,
payload: dict[str, Any],
route_plan: dict[str, Any],
) -> list[dict[str, Any]]:
facts: list[dict[str, Any]] = []
quarantine_surface = _should_quarantine_surface_context(
request=request,
payload=payload,
route_plan=route_plan,
)
if not quarantine_surface:
facts.extend(
_merge_project_facts(
_project_facts_from_surface(request.surface_context),
_project_facts_from_answer(payload.get("answer") or payload.get("reply_text")),
)
)
facts.extend(_saved_idea_facts_from_surface(request.surface_context))
facts.extend(_space_status_facts_from_surface(request.surface_context))
facts.extend(_ai_readiness_facts_from_surface(request.surface_context))
facts.extend(_release_blocker_facts_from_surface(request.surface_context))
facts.extend(_evidence_findings_from_payload(payload))
facts.extend(_external_facts_from_payload(payload))
facts.extend(_retrieved_summary_facts_from_payload(payload))
return _dedupe_facts(facts)
def _resolve_answer_type(
self,
*,
request: RAGAskRequest,
payload: dict[str, Any],
route_plan: dict[str, Any],
facts: list[dict[str, Any]],
) -> str:
customer_packet = _normalize_dict(payload.get("customer_evidence_packet"))
turn_shape = _answer_shape_from_runtime_payload(payload)
if turn_shape:
return turn_shape
packet_answer_type = _canonical_runtime_answer_type(customer_packet.get("answer_type"))
if packet_answer_type:
return packet_answer_type
_ = request, route_plan, facts
return "general_answer"
def _render_deterministic_answer(self, packet: dict[str, Any]) -> str:
answer_type = _canonical_runtime_answer_type(packet.get("answer_type"))
if answer_type in _RUNTIME_ANSWER_SHAPES:
return _render_state_shape_answer(packet)
if answer_type == _PROTECTED_ANSWER_TYPE:
return "这个问题不在用户侧 PMFlow AI 的可回答范围内。你可以问项目状态、下一步、空间事项或带来源的外部公开信息。"
return ""
def _should_use_llm_composer(self, packet: dict[str, Any]) -> bool:
answer_type = _canonical_runtime_answer_type(packet.get("answer_type"))
if answer_type in _RUNTIME_ANSWER_SHAPES or answer_type == _PROTECTED_ANSWER_TYPE:
return False
return bool(packet.get("facts") or packet.get("fallback_summary"))
def _can_use_llm_composer(self) -> bool:
if not hasattr(self.llm, "generate"):
return False
if self._llm_service_supplied:
return True
return hasattr(self.db, "execute")
async def _compose_answer(self, *, question: str, packet: dict[str, Any]) -> str:
prompt_key = PROMPT_CUSTOMER_ANSWER_COMPOSER_KEY
function_name = "customer_answer_composer"
try:
prompt_result = await PromptRuntimeService(self.db).render(
tenant_id=self.tenant_id,
prompt_key=prompt_key,
question=question[:1000],
customer_evidence_packet=json.dumps(_packet_for_model_prompt(packet), ensure_ascii=False)[:7000],
)
prompt = prompt_result.prompt
routing_meta = prompt_result.routing_meta()
routing_meta["scene_key"] = "pmflow_ai_answer_presentation"
routing_meta["scene_contract"] = "customer_answer_presentation"
except Exception:
return ""
try:
runtime_profile = await self._resolve_runtime_profile(
function_name=function_name,
prompt_key=prompt_key,
fallback_max_tokens=CUSTOMER_ANSWER_COMPOSER_MAX_TOKENS,
fallback_temperature=0.2,
)
routing_meta.update(runtime_profile["routing_meta"])
text = await self.llm.generate(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name=function_name,
temperature=runtime_profile["temperature"],
max_tokens=runtime_profile["max_tokens"],
use_prompt_profile=False,
routing_meta=routing_meta,
)
except Exception:
return ""
return _strip_internal_answer_text(text)
async def compose_interaction_request(
self,
*,
question: str,
packet: dict[str, Any],
answer: str,
fallback_interaction_request: dict[str, Any],
) -> dict[str, Any]:
"""Generate customer-specific option candidates for a follow-up input request."""
fallback = dict(fallback_interaction_request or {})
if not fallback or not self._can_use_llm_composer():
return fallback
try:
prompt_result = await PromptRuntimeService(self.db).render(
tenant_id=self.tenant_id,
prompt_key=PROMPT_SPACE_AI_INTERACTION_OPTION_PLANNER_KEY,
question=str(question or "")[:1000],
customer_evidence_packet=json.dumps(_packet_for_model_prompt(packet), ensure_ascii=False)[:7000],
answer=str(answer or "")[:3000],
fallback_interaction_request=json.dumps(fallback, ensure_ascii=False)[:4000],
)
prompt = prompt_result.prompt
routing_meta = prompt_result.routing_meta()
routing_meta["scene_key"] = "pmflow_ai_answer_presentation"
routing_meta["scene_contract"] = "space_ai_interaction_option_planner"
except Exception:
return fallback
try:
runtime_profile = await self._resolve_runtime_profile(
function_name="space_ai_interaction_option_planner",
prompt_key=PROMPT_SPACE_AI_INTERACTION_OPTION_PLANNER_KEY,
fallback_max_tokens=4000,
fallback_temperature=0.1,
)
routing_meta.update(runtime_profile["routing_meta"])
raw = await self.llm.generate(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="space_ai_interaction_option_planner",
temperature=runtime_profile["temperature"],
max_tokens=runtime_profile["max_tokens"],
use_prompt_profile=False,
routing_meta=routing_meta,
)
parsed = _parse_json_object(raw)
except Exception:
return fallback
planned = _normalize_interaction_request_from_planner(parsed, fallback=fallback)
return planned or fallback
async def _verify_answer(self, *, packet: dict[str, Any], answer: str, use_llm: bool) -> dict[str, Any]:
deterministic = self._verify_answer_deterministic(answer)
relevance = _verify_answer_relevance(packet=packet, answer=answer)
if deterministic.get("ok") and not relevance.get("ok"):
return relevance
if not deterministic.get("ok") or not use_llm:
return deterministic
try:
prompt_result = await PromptRuntimeService(self.db).render(
tenant_id=self.tenant_id,
prompt_key=PROMPT_CUSTOMER_ANSWER_VERIFIER_KEY,
customer_evidence_packet=json.dumps(_packet_for_model_prompt(packet), ensure_ascii=False)[:6000],
answer=str(answer or "")[:2000],
)
prompt = prompt_result.prompt
routing_meta = prompt_result.routing_meta()
routing_meta["scene_key"] = "pmflow_ai_answer_presentation"
routing_meta["scene_contract"] = "customer_answer_presentation"
except Exception:
return deterministic
try:
runtime_profile = await self._resolve_runtime_profile(
function_name="customer_answer_verifier",
prompt_key=PROMPT_CUSTOMER_ANSWER_VERIFIER_KEY,
fallback_max_tokens=CUSTOMER_ANSWER_VERIFIER_MAX_TOKENS,
fallback_temperature=0.0,
)
routing_meta.update(runtime_profile["routing_meta"])
raw = await self.llm.generate(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="customer_answer_verifier",
temperature=runtime_profile["temperature"],
max_tokens=runtime_profile["max_tokens"],
use_prompt_profile=False,
routing_meta=routing_meta,
)
parsed = _parse_json_object(raw)
except Exception:
return deterministic
parsed_ok = bool(parsed.get("ok")) and not bool(parsed.get("leak_found")) and not bool(parsed.get("rewrite_required"))
if not parsed_ok and deterministic.get("ok") and not _has_hard_internal_anchor(answer) and not _needs_soft_display_repair(answer):
return {
"ok": True,
"origin": "llm_verifier_soft_allowed",
"leak_found": False,
"untranslated_terms": [],
"unanswered": False,
"overclaim": False,
"rewrite_required": False,
"reason": "llm_verifier_flagged_allowed_product_terms",
}
return {
"ok": parsed_ok,
"origin": "llm_verifier",
"leak_found": bool(parsed.get("leak_found")),
"untranslated_terms": [
_clean_text(item, limit=80)
for item in list(parsed.get("untranslated_terms") or [])[:8]
if _clean_text(item, limit=80)
],
"unanswered": bool(parsed.get("unanswered")),
"overclaim": bool(parsed.get("overclaim")),
"rewrite_required": bool(parsed.get("rewrite_required")) or not parsed_ok,
"reason": _clean_text(parsed.get("reason"), limit=200) or None,
}
def _verify_answer_deterministic(self, answer: str) -> dict[str, Any]:
return _verify_answer_deterministic(answer)
async def _resolve_runtime_profile(
self,
*,
function_name: str,
prompt_key: str,
fallback_max_tokens: int,
fallback_temperature: float,
) -> dict[str, Any]:
try:
profile = await AIFunctionRuntimeProfileService(self.db).resolve_profile(
function_name=function_name,
scene_key=PMFLOW_AI_ANSWER_PRESENTATION_SCENE_KEY,
prompt_key=prompt_key,
stage="active",
)
return {
"max_tokens": profile.max_tokens,
"temperature": profile.temperature,
"routing_meta": profile.routing_meta(),
}
except Exception:
return {
"max_tokens": fallback_max_tokens,
"temperature": fallback_temperature,
"routing_meta": {
"function_runtime_profile_source": "code_fallback",
"function_runtime_max_tokens": fallback_max_tokens,
"scene_key": PMFLOW_AI_ANSWER_PRESENTATION_SCENE_KEY,
},
}
def _render_safe_fallback(self, packet: dict[str, Any]) -> str:
answer = self._render_deterministic_answer(packet)
if answer:
return answer
fallback = _clean_text(packet.get("fallback_summary"), limit=1200)
if fallback and _verify_answer_deterministic(fallback).get("ok"):
return fallback
limitations = list(packet.get("limitations") or [])
if limitations:
return "我现在没有拿到足够明确的可展示证据来回答这个问题。你可以补充项目名、对象或范围后我再查。"
return "我现在没有生成足够明确的可展示回答。你可以换一种问法,或补充项目/对象范围后我再查。"
def _standardize_canvas_direct_answer(value: Any) -> str:
text = _strip_canvas_source_tail(_strip_internal_answer_text(value))
if not text:
return ""
return _strip_generated_canvas_sections(text)[:2000]
def _strip_canvas_source_tail(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
text = re.sub(r"(?:\n|\s)*(?:来源摘要|来源|参考来源)[::].*$", "", text, flags=re.IGNORECASE | re.DOTALL)
text = re.sub(r"\s*URL\[[^\]]+\]\s*[;;]?", "", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _packet_to_dict(value: dict[str, Any] | CustomerEvidencePacket) -> dict[str, Any]:
if isinstance(value, CustomerEvidencePacket):
return value.to_customer_prompt_json()
if isinstance(value, dict):
try:
return CustomerEvidencePacket.model_validate(value).to_customer_prompt_json()
except Exception:
return dict(value)
return CustomerEvidencePacket().to_customer_prompt_json()
def _packet_for_model_prompt(packet: dict[str, Any]) -> dict[str, Any]:
"""Strip navigation anchors before customer prose composition/verification."""
safe = _packet_to_dict(packet)
actions = []
for item in list(safe.get("suggested_actions") or []):
if not isinstance(item, dict):
continue
actions.append(
{
"title": _display_action_title(item.get("title")),
"project": _clean_text(item.get("project"), limit=120),
"priority": _clean_text(item.get("priority"), limit=60),
}
)
safe["suggested_actions"] = [item for item in actions if item.get("title")][:8]
return safe
def _normalize_fact_model(value: Any) -> CustomerEvidenceFact:
if isinstance(value, CustomerEvidenceFact):
return value
if isinstance(value, dict):
return CustomerEvidenceFact.model_validate(value)
return CustomerEvidenceFact(type="general", display_label="事实", display_summary=_clean_text(value, limit=400))
def _fact_type(value: dict[str, Any]) -> str:
return str(value.get("type") or value.get("fact_type") or "").strip().lower()
def _fact_dict(
*,
fact_type: str,
label: str,
summary: str,
values: dict[str, Any] | None = None,
source_labels: list[str] | None = None,
confidence: str = "unknown",
action_hint: str | None = None,
) -> dict[str, Any]:
return CustomerEvidenceFact(
type=str(fact_type or "general").strip() or "general",
display_label=_clean_text(label, limit=160),
display_summary=_clean_text(summary, limit=1200),
business_values=dict(values or {}),
source_display_labels=list(source_labels or []),
confidence=confidence if confidence in {"high", "medium", "low", "unknown"} else "unknown",
action_hint=_clean_text(action_hint, limit=240) or None,
).model_dump(mode="json", exclude_none=True)
def _external_project_fit_fact(value: dict[str, Any]) -> dict[str, Any]:
summary = _clean_text(
value.get("display_summary")
or value.get("summary")
or value.get("fit_summary")
or value.get("recommendation"),
limit=800,
)
action = _clean_text(value.get("action") or value.get("next_action") or value.get("suggestion"), limit=240)
return _fact_dict(
fact_type="external_project_fit",
label="外部信息与当前项目匹配判断",
summary=summary or action or "外部信息需要结合当前项目继续判断。",
values={"匹配判断": summary, "建议动作": action},
source_labels=["外部公开来源", "当前项目背景"],
confidence="medium" if summary else "low",
action_hint=action or None,
)
def _canvas_relevant_facts(*, answer_type: str, facts: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Keep answer-canvas evidence tied to the current answer instead of the whole page surface."""
normalized = str(answer_type or "").strip()
type_map: dict[str, set[str]] = {
"strategic_judgement": {"runtime_evidence_finding", "external_summary", "retrieved_context"},
"status_answer": {"runtime_evidence_finding", "project", "space_status", "retrieved_context"},
"clarification": {"runtime_evidence_finding", "space_status"},
"execution_guidance": {"runtime_evidence_finding", "project", "space_status", "retrieved_context"},
"fact_list": {"runtime_evidence_finding", "project", "saved_idea", "space_status", "retrieved_context"},
"composite_answer": {"runtime_evidence_finding", "project", "space_status", "retrieved_context"},
"general_answer": {"runtime_evidence_finding", "external_summary", "retrieved_context", "project", "space_status"},
}
allowed_types = type_map.get(normalized)
if not allowed_types:
return list(facts)
return [fact for fact in facts if _fact_type(fact) in allowed_types]
def _canvas_relevant_actions(*, answer_type: str, actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
_ = answer_type
return actions
def _route_display(route_plan: dict[str, Any], research_contract: dict[str, Any]) -> str | None:
strategy = str(route_plan.get("source_strategy") or research_contract.get("source_strategy") or "").strip().lower()
lane = str(research_contract.get("effective_lane") or "").strip().lower()
if strategy.startswith("hybrid") or lane == "hybrid":
return "结合当前项目与外部公开信息"
if "external" in strategy or lane == "external":
return "外部公开信息"
if "internal" in strategy or lane == "internal":
return "当前空间和项目数据"
if strategy == "clarify":
return "需要补充范围"
return None
def _visible_objects_from_packet(
packet: dict[str, Any],
*,
research_workspace: dict[str, Any] | None = None,
) -> list[CustomerVisibleObject]:
objects: list[CustomerVisibleObject] = []
seen: set[str] = set()
for fact in list(packet.get("facts") or []):
if not isinstance(fact, dict) or _fact_type(fact) != "project":
continue
values = _normalize_dict(fact.get("business_values"))
project_id = _safe_int(values.get("项目编号"))
title = _clean_text(values.get("项目名称") or fact.get("display_label"), limit=160)
if not title:
continue
object_ref = f"project:{project_id}" if project_id is not None else f"project:title:{title}"
if object_ref in seen:
continue
seen.add(object_ref)
objects.append(
CustomerVisibleObject(
object_type="project",
object_ref=object_ref,
object_id=project_id,
title=title,
status=_clean_text(values.get("状态"), limit=80) or None,
href=_clean_text(values.get("入口"), limit=240) or None,
display_summary=_clean_text(fact.get("display_summary"), limit=800),
)
)
for fact in list(packet.get("facts") or []):
if not isinstance(fact, dict) or _fact_type(fact) != "saved_idea":
continue
values = _normalize_dict(fact.get("business_values"))
idea_id = _clean_text(values.get("想法编号"), limit=120)
title = _clean_text(values.get("想法标题") or fact.get("display_label"), limit=160)
if not title:
continue
object_ref = f"saved_idea:{idea_id}" if idea_id else f"saved_idea:title:{title}"
if object_ref in seen:
continue
seen.add(object_ref)
objects.append(
CustomerVisibleObject(
object_type="saved_idea",
object_ref=object_ref,
title=title,
status=_clean_text(values.get("状态"), limit=80) or None,
href=_clean_text(values.get("入口"), limit=240) or None,
display_summary=_clean_text(fact.get("display_summary"), limit=800),
)
)
workspace = normalize_research_workspace_payload(research_workspace)
for item in list(workspace.get("candidate_items") or [])[:6]:
if not isinstance(item, dict):
continue
candidate_ref = _clean_text(item.get("candidate_ref"), limit=120)
title = _clean_text(item.get("display_name"), limit=160)
if not candidate_ref or not title or candidate_ref in seen:
continue
seen.add(candidate_ref)
objects.append(
CustomerVisibleObject(
object_type="research_candidate",
object_ref=candidate_ref,
title=title,
status=_clean_text(item.get("status"), limit=80) or None,
display_summary=_clean_text(item.get("summary"), limit=800),
)
)
return objects[:12]
def _workspace_primary_focus(workspace: dict[str, Any]) -> CustomerVisibleObject | None:
normalized = normalize_research_workspace_payload(workspace)
primary_ref = _clean_text(normalized.get("primary_object_ref"), limit=120)
primary_title = _clean_text(normalized.get("primary_object_title"), limit=160)
if not primary_ref or not primary_title:
return None
return CustomerVisibleObject(
object_type="project" if primary_ref.startswith("project:") else "workspace_object",
object_ref=primary_ref,
object_id=_safe_int(normalized.get("primary_object_id")),
title=primary_title,
display_summary=_clean_text(normalized.get("research_goal"), limit=400),
)
def _active_workspace_summary(workspace: dict[str, Any]) -> CustomerActiveWorkspaceSummary | None:
normalized = normalize_research_workspace_payload(workspace)
if not normalized:
return None
return CustomerActiveWorkspaceSummary(
workspace_kind=_clean_text(normalized.get("workspace_kind"), limit=64) or "external_research",
topic=_clean_text(normalized.get("topic"), limit=120),
primary_object_ref=_clean_text(normalized.get("primary_object_ref"), limit=120) or None,
primary_object_title=_clean_text(normalized.get("primary_object_title"), limit=160) or None,
candidate_set_binding=_clean_text(normalized.get("candidate_set_binding"), limit=120) or None,
candidate_count=len(list(normalized.get("candidate_items") or [])),
status=_clean_text(normalized.get("status"), limit=32) or None,
)
def _explicit_process_steps_from_packet(packet: dict[str, Any]) -> list[CustomerAnswerProcessStep]:
raw_steps = packet.get("process_steps") or packet.get("runtime_steps")
if not isinstance(raw_steps, list):
return []
steps: list[CustomerAnswerProcessStep] = []
for index, raw in enumerate(raw_steps[:8], start=1):
if not isinstance(raw, dict):
continue
label = _clean_text(raw.get("label") or raw.get("display_summary"), limit=160)
if not label:
continue
steps.append(
CustomerAnswerProcessStep(
key=_clean_text(raw.get("key"), limit=80) or f"runtime_step_{index}",
label=label,
status=_clean_text(raw.get("status"), limit=24) or "completed",
display_summary=_clean_text(raw.get("display_summary"), limit=240) or None,
duration_ms=_safe_int(raw.get("duration_ms")),
blocking=bool(raw.get("blocking", False)),
)
)
return steps
def _customer_anchor_sources_from_packet(packet: dict[str, Any]) -> list[dict[str, Any]]:
anchors = []
for obj in _visible_objects_from_packet(packet):
if obj.object_type != "project" or obj.object_id is None:
continue
anchors.append(
{
"source": "pmflow",
"label": "项目数据",
"score": 1.0,
"metadata": {
"project_id": int(obj.object_id),
"query_keyword": obj.title,
"subject_name": obj.title,
"object_ref": obj.object_ref,
"source_type": "customer_visible_object",
},
}
)
return anchors
def _answer_shape_from_runtime_payload(payload: dict[str, Any]) -> str:
turn_state = _normalize_dict(payload.get("pmflow_ai_turn_state"))
answer_state = _normalize_dict(turn_state.get("answer_state"))
shape = _clean_text(answer_state.get("answer_shape"), limit=80)
if shape in _RUNTIME_ANSWER_SHAPES:
return shape
display_contract = _normalize_dict(
payload.get("display_contract")
or _normalize_dict(payload.get("pmflow_ai_route_plan")).get("display_contract")
)
shape = _clean_text(display_contract.get("answer_shape"), limit=80)
if shape in _RUNTIME_ANSWER_SHAPES:
return shape
return ""
def _canonical_runtime_answer_type(value: Any) -> str:
text = _clean_text(value, limit=80)
if text in _RUNTIME_ANSWER_SHAPES or text == _PROTECTED_ANSWER_TYPE:
return text
return "general_answer" if text else "general_answer"
def _render_state_shape_answer(packet: dict[str, Any]) -> str:
fallback = _clean_text(packet.get("fallback_summary"), limit=1400)
if fallback:
return _strip_generated_canvas_sections(fallback)
facts = [fact for fact in list(packet.get("facts") or []) if isinstance(fact, dict)]
summaries: list[str] = []
for fact in facts[:6]:
summary = _clean_text(fact.get("display_summary") or fact.get("display_label"), limit=300)
if summary and summary not in summaries:
summaries.append(summary)
if not summaries:
return ""
return _strip_generated_canvas_sections(";".join(summaries[:4]) + "。")
def _strip_generated_canvas_sections(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
rows = [row.rstrip() for row in text.splitlines()]
cleaned: list[str] = []
skip_next_blank = False
for row in rows:
stripped = row.strip()
if re.fullmatch(r"\*\*[^*\n]{1,16}\*\*", stripped) or re.fullmatch(r"[^,。;.!?!?\n::]{1,16}[::]", stripped):
skip_next_blank = True
continue
inline_heading = re.match(r"^([^,。;.!?!?\n::]{1,16})[::]\s*(.+)$", stripped)
if inline_heading:
row = inline_heading.group(2).strip()
stripped = row
if skip_next_blank and not stripped:
continue
skip_next_blank = False
cleaned.append(row)
result = "\n".join(cleaned).strip()
result = re.sub(r"\n{3,}", "\n\n", result)
return result
def _display_action_title(value: Any) -> str:
text = _clean_text(value, limit=160)
if not text:
return ""
text = re.sub(r"\bapproval:\d+\b", "待确认事项", text, flags=re.IGNORECASE)
text = re.sub(r"\bchange_set:\d+\b", "审查退回事项", text, flags=re.IGNORECASE)
text = re.sub(r"\btask:\d+\b", "任务事项", text, flags=re.IGNORECASE)
text = re.sub(r"(确认变更|变更)\s*\d+\b", "确认一项变更", text)
text = re.sub(r"修改后再审\s*\d+\b", "复审一项变更", text)
text = re.sub(r"暂缓处理\s*\d+\b", "处理一项暂缓事项", text)
return _strip_internal_anchors(text)
def _project_facts_from_surface(surface_context: Any) -> list[dict[str, Any]]:
surface = _normalize_dict(surface_context)
workbench = _normalize_dict(surface.get("workbench"))
projects = []
project_rows = list(
workbench.get("recent_projects")
or workbench.get("recentProjects")
or workbench.get("projects")
or []
)
for item in project_rows[:12]:
if not isinstance(item, dict):
continue
name = _clean_text(item.get("title") or item.get("name"), limit=120)
if not name:
continue
status = _display_status(item.get("status"))
next_action = _clean_text(item.get("next_action") or item.get("nextAction"), limit=160)
project_id = _safe_int(item.get("id") or item.get("project_id") or item.get("projectId"))
projects.append(
_fact_dict(
fact_type="project",
label=name,
summary=f"{name},状态:{status}" + (f",下一步:{next_action}" if next_action else ""),
values={
"项目名称": name,
"项目编号": project_id,
"状态": status,
"阶段": _clean_text(item.get("stage"), limit=80),
"进度": _safe_int(item.get("progress")),
"待确认": _safe_int(item.get("pendingCount") or item.get("pending_count")),
"进行中任务": _safe_int(item.get("activeTaskCount") or item.get("active_task_count")),
"风险": _clean_text(item.get("riskLabel") or item.get("risk_label"), limit=80),
"下一步": next_action,
"入口": _clean_text(item.get("href"), limit=200),
},
source_labels=["当前空间状态"],
confidence="high",
action_hint=next_action or None,
)
)
return projects
def _should_quarantine_surface_context(
*,
request: RAGAskRequest,
payload: dict[str, Any],
route_plan: dict[str, Any],
) -> bool:
if bool(payload.get("preserve_customer_evidence_packet")):
return True
turn_state = _normalize_dict(payload.get("pmflow_ai_turn_state"))
answer_state = _normalize_dict(turn_state.get("answer_state"))
object_state = _normalize_dict(turn_state.get("object_state"))
primary_object = _normalize_dict(object_state.get("primary_object"))
shape = str(answer_state.get("answer_shape") or "").strip().lower()
object_type = str(primary_object.get("object_type") or primary_object.get("kind") or "").strip().lower()
if shape == "strategic_judgement" and object_type in {"new_idea", "external_subject", "unknown"}:
return True
_ = request, route_plan
return False
def _saved_idea_facts_from_surface(surface_context: Any) -> list[dict[str, Any]]:
surface = _normalize_dict(surface_context)
workbench = _normalize_dict(surface.get("workbench"))
ideas: list[dict[str, Any]] = []
for item in list(workbench.get("saved_ideas") or workbench.get("savedIdeas") or [])[:8]:
if not isinstance(item, dict):
continue
title = _clean_text(item.get("title") or item.get("name"), limit=120)
if not title:
continue
summary = _clean_text(item.get("summary") or item.get("ai_summary") or item.get("raw_text"), limit=500)
status = _display_saved_idea_status(item.get("status"))
idea_id = _clean_text(item.get("id") or item.get("idea_id") or item.get("ideaId"), limit=120)
ideas.append(
_fact_dict(
fact_type="saved_idea",
label=title,
summary=f"{title},状态:{status}" + (f",摘要:{summary}" if summary else ""),
values={
"想法编号": idea_id,
"想法标题": title,
"状态": status,
"摘要": summary,
"准备度": item.get("readiness_score") or item.get("readinessScore"),
"入口": _clean_text(item.get("href"), limit=200),
},
source_labels=["当前空间状态"],
confidence="high",
action_hint="继续完善或生成项目草案",
)
)
return ideas
def _project_facts_from_answer(value: Any) -> list[dict[str, Any]]:
text = str(value or "")
facts: list[dict[str, Any]] = []
for match in _PROJECT_LINE_RE.finditer(text):
name = _clean_text(match.group("name"), limit=120).strip("-:: ")
if not name:
continue
status = _display_status(match.group("status"))
facts.append(
_fact_dict(
fact_type="project",
label=name,
summary=f"{name},状态:{status}",
values={"项目名称": name, "项目编号": _safe_int(match.group("id")), "状态": status},
source_labels=["项目数据"],
confidence="medium",
)
)
return facts
def _space_status_facts_from_surface(surface_context: Any) -> list[dict[str, Any]]:
surface = _normalize_dict(surface_context)
workbench = _normalize_dict(surface.get("workbench"))
counts = _normalize_dict(workbench.get("counts"))
if not counts:
return []
label_map = {
"nextActions": "下一步",
"pendingConfirmations": "待确认",
"aiProgress": "AI 正在推进",
"projects": "项目",
"savedIdeas": "暂存想法",
"drafts": "草案",
"releaseAttention": "发布关注",
"feedbackAttention": "反馈关注",
}
display_counts = {
label_map.get(str(key), str(key)): _safe_int(value)
for key, value in counts.items()
if label_map.get(str(key), str(key))
}
summary = ",".join(f"{key} {value} 个" for key, value in display_counts.items() if value is not None)
return [
_fact_dict(
fact_type="space_status",
label="当前空间状态",
summary=summary or "当前空间已有状态数据",
values=display_counts,
source_labels=["当前空间状态"],
confidence="high",
)
]
def _ai_readiness_facts_from_surface(surface_context: Any) -> list[dict[str, Any]]:
surface = _normalize_dict(surface_context)
readiness = _normalize_dict(surface.get("ai_readiness"))
operating_packet = _normalize_dict(surface.get("space_operating_packet"))
if not readiness:
readiness = _normalize_dict(operating_packet.get("readiness"))
if not readiness:
return []
status = _clean_text(readiness.get("status") or readiness.get("title"), limit=120) or "状态待确认"
blockers = []
for item in list(readiness.get("blockers") or [])[:6]:
if isinstance(item, dict):
text = _clean_text(item.get("description") or item.get("label"), limit=160)
else:
text = _clean_text(item, limit=160)
if text:
blockers.append(text)
return [
_fact_dict(
fact_type="ai_readiness",
label="AI 能力状态",
summary=status,
values={
"状态": status,
"可生成项目草案": bool(readiness.get("can_generate_project_draft")),
"可管理": bool(readiness.get("can_manage")),
"影响说明": blockers,
},
source_labels=["当前空间状态"],
confidence="high",
)
]
def _release_blocker_facts_from_surface(surface_context: Any) -> list[dict[str, Any]]:
surface = _normalize_dict(surface_context)
workbench = _normalize_dict(surface.get("workbench"))
rows = list(workbench.get("release_attention") or workbench.get("releaseAttention") or [])
facts: list[dict[str, Any]] = []
for item in rows[:8]:
if not isinstance(item, dict):
continue
title = _clean_text(item.get("title") or item.get("next_action"), limit=160)
project = _clean_text(item.get("project") or item.get("projectTitle"), limit=120)
summary = _clean_text(item.get("summary") or item.get("reason"), limit=500)
reasons = [
_clean_text(reason, limit=140)
for reason in list(item.get("reasons") or [])
if _clean_text(reason, limit=140)
]
severity = _clean_text(item.get("severity"), limit=40)
gate_status = _clean_text(item.get("gate_status") or item.get("gateStatus"), limit=60)
is_blocking = gate_status == "blocked" or severity in {"high", "critical"} or "阻塞" in title
if not title or not is_blocking:
continue
facts.append(
_fact_dict(
fact_type="release_blocker",
label=title,
summary=summary or "发布门禁或验证状态需要处理。",
values={
"项目名称": project,
"阻塞事项": title,
"原因": reasons,
"严重程度": severity,
"门禁状态": gate_status,
"入口": _clean_text(item.get("href"), limit=200),
},
source_labels=["当前空间状态", "发布验证记录"],
confidence="high" if reasons or summary else "medium",
action_hint=_clean_text(item.get("next_action") or "处理发布阻塞", limit=160),
)
)
return facts
def _external_facts_from_payload(payload: dict[str, Any]) -> list[dict[str, Any]]:
facts: list[dict[str, Any]] = []
recommendation = _normalize_dict(payload.get("external_recommendation"))
if recommendation:
summary = _clean_text(recommendation.get("external_summary"), limit=600)
impact = _clean_text(recommendation.get("external_impact"), limit=600)
action = _clean_text(recommendation.get("external_action"), limit=240)
facts.append(
_fact_dict(
fact_type="external_summary",
label="外部公开信息摘要",
summary=summary or impact or "外部公开信息暂未形成明确摘要",
values={"外部摘要": summary, "对当前项目的影响": impact, "建议动作": action},
source_labels=["外部公开来源"],
confidence="medium" if summary or impact else "low",
action_hint=action or None,
)
)
return facts
def _evidence_findings_from_payload(payload: dict[str, Any]) -> list[dict[str, Any]]:
rows = payload.get("evidence_findings")
if not isinstance(rows, list):
turn_state = _normalize_dict(payload.get("pmflow_ai_turn_state"))
evidence_state = _normalize_dict(turn_state.get("evidence_state"))
rows = evidence_state.get("known_facts") if isinstance(evidence_state.get("known_facts"), list) else []
facts: list[dict[str, Any]] = []
for item in list(rows or [])[:10]:
if not isinstance(item, dict):
continue
fact = _clean_text(item.get("fact"), limit=700)
implication = _clean_text(item.get("implication"), limit=700)
if not fact:
continue
source = _clean_text(item.get("source") or "PMFlow 运行时证据", limit=120)
relevance = _clean_text(item.get("relevance_to_goal"), limit=120)
facts.append(
_fact_dict(
fact_type="runtime_evidence_finding",
label=source,
summary=implication or fact,
values={
"事实": fact,
"含义": implication,
"目标相关性": relevance,
"来源": source,
},
source_labels=[source],
confidence=_confidence_value(item.get("confidence")),
action_hint=implication or None,
)
)
return facts
def _retrieved_summary_facts_from_payload(payload: dict[str, Any]) -> list[dict[str, Any]]:
rows = list(payload.get("customer_retrieved_summaries") or [])
facts: list[dict[str, Any]] = []
for item in rows[:8]:
if not isinstance(item, dict):
continue
summary = _clean_text(item.get("display_summary") or item.get("content"), limit=700)
if not summary:
continue
source_label = _clean_text(item.get("display_label") or "本轮证据", limit=80)
facts.append(
_fact_dict(
fact_type=_clean_text(item.get("type") or "retrieved_context", limit=60),
label=source_label,
summary=summary,
values={"内容摘要": summary},
source_labels=[source_label],
confidence="medium",
)
)
return facts
def _dedupe_facts(facts: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for fact in facts:
values = _normalize_dict(fact.get("business_values"))
key = f"{_fact_type(fact)}:{values.get('项目编号')}:{fact.get('display_label')}:{fact.get('display_summary')}"
if key in seen:
continue
seen.add(key)
out.append(fact)
return out
def _merge_project_facts(primary: list[dict[str, Any]], secondary: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not primary:
return secondary
merged = [dict(item) for item in primary]
for source in secondary:
source_values = _normalize_dict(source.get("business_values"))
source_name = _clean_text(source_values.get("项目名称") or source.get("display_label"), limit=120)
source_id = _safe_int(source_values.get("项目编号"))
matched = None
for target in merged:
target_values = _normalize_dict(target.get("business_values"))
target_name = _clean_text(target_values.get("项目名称") or target.get("display_label"), limit=120)
target_id = _safe_int(target_values.get("项目编号"))
if source_id is not None and target_id == source_id:
matched = target
break
if source_name and target_name and source_name == target_name:
matched = target
break
if matched is None:
merged.append(dict(source))
continue
matched_values = _normalize_dict(matched.get("business_values"))
for key, value in source_values.items():
if matched_values.get(key) in (None, "", [], {}) and value not in (None, "", [], {}):
matched_values[key] = value
matched["business_values"] = matched_values
if not matched.get("action_hint") and source.get("action_hint"):
matched["action_hint"] = source.get("action_hint")
return merged
def _customer_source_summaries(payload: dict[str, Any]) -> list[str]:
rows = []
for key in ("sources_used", "citations", "sources", "evidence"):
raw = payload.get(key)
if isinstance(raw, list):
rows.extend(item for item in raw if isinstance(item, dict))
labels: list[str] = []
for item in rows:
label = _customer_source_label(item)
if label and label not in labels:
labels.append(label)
return labels[:6]
def _customer_source_label(item: dict[str, Any]) -> str:
raw = str(item.get("display_label") or item.get("label") or item.get("raw_source_label") or "").strip()
source = str(item.get("source") or item.get("source_type") or "").strip().lower()
lower = raw.lower()
if source in {"url", "web", "external", "mcp_search"} or lower.startswith(("http", "url[")):
return "外部公开来源"
if "project database" in lower or source in {"db", "pmflow"}:
return "项目数据"
if "knowledge graph" in lower or source == "graph":
return "项目记忆"
if "feishu" in lower or source == "feishu":
return "飞书记录"
if "snapshot" in lower or source == "system":
return "当前空间状态"
if raw and not _INTERNAL_TERM_RE.search(raw):
return raw
return ""
def _build_limitations(payload: dict[str, Any]) -> list[str]:
fields = []
for item in list(payload.get("missing_fields") or [])[:5]:
text = _clean_text(item, limit=80)
if text:
fields.append(text)
grounding = str(payload.get("grounding_status") or "").strip().lower()
if grounding in {"insufficient_evidence", "blocked", "needs_clarification"}:
fields.append("当前证据不足")
return fields[:6]
def _suggested_actions_from_surface(surface_context: Any) -> list[dict[str, Any]]:
surface = _normalize_dict(surface_context)
workbench = _normalize_dict(surface.get("workbench"))
operating_packet = _normalize_dict(surface.get("space_operating_packet"))
actions: list[dict[str, Any]] = []
for item in list(
workbench.get("attention_queue")
or workbench.get("attentionQueue")
or operating_packet.get("attention_queue")
or []
)[:5]:
if not isinstance(item, dict):
continue
title = _clean_text(item.get("next_action") or item.get("title"), limit=120)
if not title:
continue
action = {
"title": title,
"project": _clean_text(
(_normalize_dict(item.get("object_anchor") or item.get("objectAnchor")).get("project_title"))
or item.get("project")
or item.get("projectTitle")
or item.get("reason"),
limit=120,
),
"priority": _clean_text(item.get("urgency"), limit=40),
"href": _clean_text(item.get("href"), limit=200),
"object_ref": _clean_text(
item.get("object_ref")
or item.get("objectRef")
or (_normalize_dict(item.get("object_anchor") or item.get("objectAnchor")).get("object_ref")),
limit=120,
),
"object_anchor": _clean_object_anchor(item.get("object_anchor") or item.get("objectAnchor")),
"action_type": _clean_text(item.get("action_type") or item.get("actionType"), limit=80),
"object_type": _clean_text(item.get("object_type") or item.get("objectType"), limit=80),
"surface": _clean_text(item.get("surface"), limit=80),
}
actions.append(_normalize_surface_action(action))
for item in list(workbench.get("top_next_actions") or workbench.get("topNextActions") or [])[:5]:
if not isinstance(item, dict):
continue
title = _clean_text(item.get("title"), limit=120)
if not title:
continue
action = {
"title": title,
"project": _clean_text(item.get("project"), limit=80),
"priority": _clean_text(item.get("priority"), limit=40),
"href": _clean_text(item.get("href"), limit=200),
"object_ref": _clean_text(
item.get("object_ref")
or item.get("objectRef")
or (_normalize_dict(item.get("object_anchor") or item.get("objectAnchor")).get("object_ref")),
limit=120,
),
"object_anchor": _clean_object_anchor(item.get("object_anchor") or item.get("objectAnchor")),
"action_type": _clean_text(item.get("action_type") or item.get("actionType"), limit=80),
"object_type": _clean_text(item.get("object_type") or item.get("objectType"), limit=80),
"surface": _clean_text(item.get("surface"), limit=80),
}
actions.append(_normalize_surface_action(action))
deduped: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for action in actions:
key = (str(action.get("title") or ""), str(action.get("href") or ""))
if key in seen:
continue
seen.add(key)
deduped.append(action)
return deduped[:5]
def _confidence_label(payload: dict[str, Any]) -> str:
try:
score = float(payload.get("confidence") or 0)
except Exception:
score = 0
grounding = str(payload.get("grounding_status") or "").strip().lower()
if grounding in {"insufficient_evidence", "blocked", "needs_clarification"}:
return "low"
if score >= 0.75:
return "high"
if score >= 0.4:
return "medium"
return "unknown"
def _confidence_value(value: Any) -> str:
normalized = str(value or "").strip().lower()
return normalized if normalized in {"high", "medium", "low", "unknown"} else "unknown"
def _normalize_surface_action(action: dict[str, Any]) -> dict[str, Any]:
out = dict(action or {})
anchor = _normalize_dict(out.get("object_anchor") or out.get("objectAnchor"))
if not _clean_text(out.get("surface"), limit=80):
out["surface"] = _clean_text(anchor.get("surface"), limit=80)
if not _clean_text(out.get("object_type") or out.get("objectType"), limit=80):
out["object_type"] = _clean_text(anchor.get("object_type") or anchor.get("objectType"), limit=80)
if not _clean_text(out.get("action_type") or out.get("actionType"), limit=80):
out["action_type"] = "navigate"
return out
def _canvas_source_type(fact: dict[str, Any]) -> str:
fact_type = _fact_type(fact)
labels = " ".join(str(item or "") for item in list(fact.get("source_display_labels") or [])).lower()
if fact_type.startswith("external") or "外部" in labels:
return "external"
if fact_type == "project" or "项目数据" in labels:
return "project"
if fact_type == "release_blocker":
return "execution"
if fact_type == "space_status" or "当前空间" in labels:
return "space"
if "项目记忆" in labels:
return "memory"
return "pmflow"
def _canvas_action_type_from_href(href: str | None) -> str:
target = str(href or "").strip().lower()
return "navigate" if target else "none"
def _answer_canvas_headline(*, answer_type: str, answer: str) -> str:
answer_type = _canonical_runtime_answer_type(answer_type)
mapping = {
"strategic_judgement": "方向判断",
"status_answer": "状态判断",
"clarification": "需要补充范围",
"execution_guidance": "执行建议",
"fact_list": "事实依据",
"composite_answer": "综合判断",
"general_answer": "回答",
"protected_scope": "用户侧不可回答范围",
}
headline = mapping.get(str(answer_type or "").strip(), "")
if headline:
return headline
first_line = _clean_text(str(answer or "").splitlines()[0] if answer else "", limit=80)
return first_line or "PMFlow AI 回答"
def _strip_internal_answer_text(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
lines: list[str] = []
for raw in text.splitlines():
line = raw.rstrip()
compact = line.strip()
if not compact:
lines.append(line)
continue
compact = _strip_internal_anchors(compact)
if _INTERNAL_TERM_RE.search(compact) or _INTERNAL_EVIDENCE_TONE_RE.search(compact):
continue
lines.append(_display_status_in_text(compact))
cleaned = "\n".join(lines)
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
return cleaned
def _verify_answer_deterministic(answer: str) -> dict[str, Any]:
text = str(answer or "").strip()
leak_terms = sorted({match.group(0) for match in _INTERNAL_TERM_RE.finditer(text)})
anchor_terms = sorted({match.group(0) for match in _INTERNAL_ANCHOR_RE.finditer(text)})
tone_leak = bool(_INTERNAL_EVIDENCE_TONE_RE.search(text))
incomplete = _is_incomplete_customer_answer(text)
ok = bool(text) and not leak_terms and not anchor_terms and not tone_leak and not incomplete
if leak_terms or anchor_terms or tone_leak:
reason = "internal_term_or_anchor_or_evidence_tone"
elif incomplete:
reason = "incomplete_answer"
elif text:
reason = None
else:
reason = "empty_answer"
return {
"ok": ok,
"origin": "deterministic_guard",
"leak_found": bool(leak_terms or anchor_terms or tone_leak),
"untranslated_terms": (leak_terms + anchor_terms)[:10],
"unanswered": not bool(text),
"overclaim": False,
"rewrite_required": not ok,
"reason": reason,
}
_INCOMPLETE_ANSWER_TAIL_RE = re.compile(
r"("
r"[,、:;((《“\"']$|"
r"(?:我会|我将|会|将|把|并|和|与|以及|或者|然后|同时|如果|但|因为|所以|包括|例如|比如|作为|进入|生成|整理|提交|补充)$"
r")"
)
def _is_incomplete_customer_answer(value: Any) -> bool:
text = str(value or "").strip()
if not text:
return False
if text.count("```") % 2 == 1:
return True
last_line = next((line.strip() for line in reversed(text.splitlines()) if line.strip()), "")
if not last_line:
return False
if len(text) >= 20 and _INCOMPLETE_ANSWER_TAIL_RE.search(last_line):
return True
if len(text) < 80:
return False
if last_line.startswith(("- ", "* ", "1. ", "2. ", "3. ", "4. ")) and not re.search(r"[。!?.!?))】]$", last_line):
return True
return False
def _verify_answer_relevance(*, packet: dict[str, Any], answer: str) -> dict[str, Any]:
_ = answer
packet_dict = packet if isinstance(packet, dict) else {}
answer_type = str(packet_dict.get("answer_type") or "").strip().lower()
object_binding = _normalize_dict(packet_dict.get("object_binding"))
if answer_type == "strategic_judgement" and object_binding.get("bound_existing_project") is True:
return {
"ok": False,
"origin": "deterministic_relevance_guard",
"leak_found": False,
"untranslated_terms": [],
"unanswered": False,
"overclaim": True,
"rewrite_required": True,
"reason": "strategic_judgement_bound_existing_project",
}
return {
"ok": True,
"origin": "deterministic_relevance_guard",
"leak_found": False,
"untranslated_terms": [],
"unanswered": False,
"overclaim": False,
"rewrite_required": False,
"reason": None,
}
def _strip_internal_anchors(value: Any) -> str:
text = str(value or "")
if not text:
return ""
text = re.sub(r"\[([^\]]+)\]\(/[^)\s]+\)", r"\1", text)
text = _INTERNAL_ANCHOR_RE.sub("", text)
text = re.sub(r"\s+([,。;:,.!?])", r"\1", text)
text = re.sub(r"\s{2,}", " ", text)
return text.strip()
def _repair_customer_answer_text(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
text = re.sub(r"\[([^\]]+)\]\(/[^)\s]+\)", r"\1", text)
text = re.sub(r"\bapproval:\d+\b", "待确认事项", text, flags=re.IGNORECASE)
text = re.sub(r"\bchange_set:\d+\b", "审查退回事项", text, flags=re.IGNORECASE)
text = re.sub(r"\btask:\d+\b", "任务事项", text, flags=re.IGNORECASE)
text = re.sub(r"(确认变更|变更)\s*\d+\b", "确认一项变更", text)
text = re.sub(r"修改后再审\s*\d+\b", "复审一项变更", text)
text = re.sub(r"暂缓处理\s*\d+\b", "处理一项暂缓事项", text)
text = _strip_internal_anchors(text)
text = re.sub(r"(来源摘要|来源|参考来源)[::]\s*(Knowledge Graph|Project database|System Snapshot|User Snapshot)", "", text, flags=re.IGNORECASE)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _has_hard_internal_anchor(value: Any) -> bool:
return bool(_INTERNAL_ANCHOR_RE.search(str(value or "")))
def _needs_soft_display_repair(value: Any) -> bool:
text = str(value or "")
if not text:
return False
return bool(
re.search(r"(确认变更|变更)\s*\d+\b", text)
or re.search(r"修改后再审\s*\d+\b", text)
or re.search(r"暂缓处理\s*\d+\b", text)
or re.search(r"\bidea_[0-9a-f]{8,}\b", text, flags=re.IGNORECASE)
or re.search(r"项目编号\s*[::]?\s*\d+\b", text)
)
def _display_status(value: Any) -> str:
raw = str(value or "").strip().lower()
mapping = {
"active": "进行中",
"in_progress": "进行中",
"running": "进行中",
"draft": "草稿",
"archived": "已归档",
"completed": "已完成",
"done": "已完成",
"blocked": "受阻",
}
return mapping.get(raw, _clean_text(value, limit=60) or "未知")
def _display_saved_idea_status(value: Any) -> str:
raw = str(value or "").strip().lower()
mapping = {
"saved": "未整理",
"enriched": "已整理",
"needs_more_info": "信息不足",
"draft_ready": "可生成草案",
"promoted": "已转为草案",
"archived": "已归档",
}
return mapping.get(raw, _clean_text(value, limit=60) or "未整理")
def _display_status_in_text(value: str) -> str:
out = str(value or "")
out = re.sub(r"状态[::]\s*active", "状态:进行中", out, flags=re.IGNORECASE)
out = re.sub(r"状态[::]\s*draft", "状态:草稿", out, flags=re.IGNORECASE)
out = re.sub(r"状态[::]\s*archived", "状态:已归档", out, flags=re.IGNORECASE)
out = re.sub(r"状态[::]\s*completed", "状态:已完成", out, flags=re.IGNORECASE)
return out
def _normalize_interaction_request_from_planner(value: Any, *, fallback: dict[str, Any]) -> dict[str, Any]:
raw = dict(value) if isinstance(value, dict) else {}
fallback = dict(fallback or {})
fallback_questions = [
item for item in list(fallback.get("questions") or []) if isinstance(item, dict)
]
raw_questions = [
item for item in list(raw.get("questions") or []) if isinstance(item, dict)
]
questions: list[dict[str, Any]] = []
for index, fallback_question in enumerate(fallback_questions):
planned = raw_questions[index] if index < len(raw_questions) else {}
merged = _normalize_interaction_question_from_planner(planned, fallback_question=fallback_question, index=index + 1)
if merged:
questions.append(merged)
if not questions:
for index, planned in enumerate(raw_questions[:6], start=1):
merged = _normalize_interaction_question_from_planner(planned, fallback_question={}, index=index)
if merged:
questions.append(merged)
if not questions:
return {}
supplement = raw.get("supplement") if isinstance(raw.get("supplement"), dict) else {}
fallback_supplement = fallback.get("supplement") if isinstance(fallback.get("supplement"), dict) else {}
return {
"id": _clean_text(raw.get("id") or fallback.get("id"), limit=100) or "pmflow-ai-interaction-planned",
"type": "confirm_and_supplement",
"title": _clean_text(raw.get("title") or fallback.get("title"), limit=80) or "还要你确认的点",
"mode": _clean_text(raw.get("mode") or fallback.get("mode"), limit=24) or "blocking",
"presentation": "stepper",
"questions": questions[:8],
"supplement": {
"enabled": True,
"required": bool(supplement.get("required", fallback_supplement.get("required", False))),
"placeholder": _clean_text(
supplement.get("placeholder") or fallback_supplement.get("placeholder"),
limit=180,
)
or "如果这些选项都不准确,直接补充你的真实对象、边界、限制或判断...",
},
"submit_label": _clean_text(raw.get("submit_label") or fallback.get("submit_label"), limit=40) or "确认并继续",
"cancel_label": _clean_text(raw.get("cancel_label") or fallback.get("cancel_label"), limit=40) or "先不补充",
}
def _normalize_interaction_question_from_planner(
value: dict[str, Any],
*,
fallback_question: dict[str, Any],
index: int,
) -> dict[str, Any]:
raw = dict(value or {})
fallback = dict(fallback_question or {})
label = _clean_text(raw.get("label") or raw.get("question") or fallback.get("label") or fallback.get("question"), limit=180)
if not label or _has_hard_internal_anchor(label):
return {}
options = _normalize_interaction_options_from_planner(raw.get("options"))
if len(options) < 2:
options = _normalize_interaction_options_from_planner(fallback.get("options"))
if len(options) < 2:
return {}
option_ids = {item["id"] for item in options}
if "custom_in_supplement" not in option_ids:
options.append({"id": "custom_in_supplement", "label": "都不准确,我最后补充", "description": None})
input_type = _clean_text(raw.get("input_type") or fallback.get("input_type"), limit=32) or "single_choice"
if input_type not in {"single_choice", "multi_choice"}:
input_type = "single_choice"
return {
"id": _clean_text(raw.get("id") or fallback.get("id"), limit=80) or f"question_{index}",
"label": label,
"input_type": input_type,
"required": bool(raw.get("required", fallback.get("required", index == 1))),
"placeholder": None,
"options": options[:6],
}
def _normalize_interaction_options_from_planner(value: Any) -> list[dict[str, Any]]:
rows = list(value or []) if isinstance(value, list) else []
out: list[dict[str, Any]] = []
seen: set[str] = set()
for index, item in enumerate(rows[:8], start=1):
raw = dict(item) if isinstance(item, dict) else {"label": item}
label = _clean_text(raw.get("label") or raw.get("title") or raw.get("name"), limit=120)
if not label or _has_hard_internal_anchor(label):
continue
description = _clean_text(raw.get("description") or raw.get("summary") or raw.get("reason"), limit=180)
if description and _has_hard_internal_anchor(description):
description = ""
option_id = _clean_text(raw.get("id") or raw.get("value") or raw.get("key"), limit=80)
if not option_id:
option_id = re.sub(r"[^a-zA-Z0-9_]+", "_", label).strip("_").lower()[:48] or f"option_{index}"
if option_id in seen:
continue
seen.add(option_id)
out.append({"id": option_id, "label": label, "description": description or None})
return out
def _parse_json_object(value: Any) -> dict[str, Any]:
text = str(value or "").strip()
if not text:
return {}
try:
parsed = json.loads(text)
return parsed if isinstance(parsed, dict) else {}
except Exception:
pass
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
return {}
try:
parsed = json.loads(match.group(0))
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def _append_policy_version(value: Any, suffix: str) -> str:
base = str(value or "customer-answer-policy-v1").strip() or "customer-answer-policy-v1"
if suffix in base:
return base
return f"{base}|{suffix}"
def _normalize_dict(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
def _safe_int(value: Any) -> int | None:
try:
if value is None or value == "":
return None
return int(value)
except Exception:
return None
def _safe_float(value: Any) -> float | None:
try:
if value is None or value == "":
return None
return float(value)
except Exception:
return None
def _clean_text(value: Any, *, limit: int) -> str:
return " ".join(str(value or "").strip().split())[:limit]
def _clean_object_anchor(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
object_ref = _clean_text(value.get("object_ref") or value.get("objectRef"), limit=120)
object_type = _clean_text(value.get("object_type") or value.get("objectType"), limit=60)
object_id = _clean_text(value.get("object_id") or value.get("objectId"), limit=120)
if not object_ref and object_type and object_id:
object_ref = f"{object_type}:{object_id}"
if not object_ref:
return None
return {
"object_ref": object_ref,
"object_type": object_type,
"object_id": object_id,
"surface": _clean_text(value.get("surface"), limit=80),
"project_id": _clean_text(value.get("project_id") or value.get("projectId"), limit=80),
"project_title": _clean_text(value.get("project_title") or value.get("projectTitle"), limit=160),
"href": _clean_text(value.get("href"), limit=240),
"parent_object_ref": _clean_text(value.get("parent_object_ref") or value.get("parentObjectRef"), limit=120) or None,
}
__all__ = [
"CustomerAnswerPresentationService",
"CUSTOMER_ANSWER_PACKET_VERSION",
"PROMPT_CUSTOMER_ANSWER_COMPOSER_KEY",
"PROMPT_CUSTOMER_ANSWER_VERIFIER_KEY",
"_customer_anchor_sources_from_packet",
]