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

267 lines
11 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 json
import re
from typing import Any, Callable
from ...config.ai_prompts import PROMPT_EXTERNAL_QUERY_PLAN
from ...schemas.project_operating_context import ProjectFitBrief
from .schemas import ExternalQueryPlan
class ExternalQueryPlanner:
FUNCTION_NAME = "external_query_plan"
def __init__(
self,
*,
llm_service: Any,
db: Any,
tenant_id: int,
query_builder: Callable[[str], list[str]] | None = None,
) -> None:
self.llm_service = llm_service
self.db = db
self.tenant_id = int(tenant_id or 1)
self.query_builder = query_builder
async def build_plan(
self,
*,
question: str,
query_semantics: dict[str, Any] | None = None,
fit_brief: dict[str, Any] | None = None,
) -> tuple[ExternalQueryPlan, dict[str, Any]]:
fallback_plan = self._build_fallback_plan(
question=question,
query_semantics=query_semantics,
fit_brief=fit_brief,
)
generator = getattr(self.llm_service, "generate_structured_object", None)
if not callable(generator):
return fallback_plan, {
"planner": "heuristic",
"fallback_taken": True,
"fit_brief_summary": fallback_plan.fit_brief_summary,
}
normalized_fit_brief = self._normalize_fit_brief(fit_brief)
prompt = PROMPT_EXTERNAL_QUERY_PLAN.format(
question=str(question or "").strip()[:1200],
query_semantics=json.dumps(query_semantics or {}, ensure_ascii=False)[:4000],
fit_brief=json.dumps(normalized_fit_brief, ensure_ascii=False)[:2500],
fallback_queries=json.dumps(fallback_plan.search_queries, ensure_ascii=False),
).strip()
try:
structured = await generator(
prompt=prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name=self.FUNCTION_NAME,
use_prompt_profile=False,
temperature=0.0,
response_mode="fast",
)
payload = getattr(structured, "payload", None)
if payload is None and hasattr(structured, "model_dump"):
payload = structured.model_dump()
if not isinstance(payload, dict):
raise ValueError("external_query_plan_payload_not_dict")
merged = fallback_plan.model_dump()
for key, value in payload.items():
if value in (None, "", [], {}):
continue
merged[key] = value
plan = ExternalQueryPlan.model_validate(merged)
if not plan.search_queries:
plan.search_queries = list(fallback_plan.search_queries)
if not plan.preferred_domains:
plan.preferred_domains = list(fallback_plan.preferred_domains)
if not plan.topic:
plan.topic = fallback_plan.topic
return plan, {
"planner": "llm_structured",
"fallback_taken": False,
"fit_brief_summary": plan.fit_brief_summary or fallback_plan.fit_brief_summary,
}
except Exception as exc:
return fallback_plan, {
"planner": "heuristic",
"fallback_taken": True,
"reason": str(exc)[:200],
"fit_brief_summary": fallback_plan.fit_brief_summary,
}
def _build_fallback_plan(
self,
*,
question: str,
query_semantics: dict[str, Any] | None = None,
fit_brief: dict[str, Any] | None = None,
) -> ExternalQueryPlan:
text = re.sub(r"\s+", " ", str(question or "")).strip()
semantics = query_semantics if isinstance(query_semantics, dict) else {}
query_profile = semantics.get("query_profile") if isinstance(semantics.get("query_profile"), dict) else {}
normalized_fit_brief = self._normalize_fit_brief(fit_brief)
object_mentions = [
str(item or "").strip()
for item in list(semantics.get("object_mentions") or [])
if str(item or "").strip()
]
query_type = str(semantics.get("query_type") or "").strip().lower()
if query_type == "compare":
answer_contract = "compare_with_sources"
elif bool(query_profile.get("needs_freshness")):
answer_contract = "summary_with_sources"
else:
answer_contract = "direct_fact"
topic = text[:120] or "外部查询"
search_queries = self._build_search_queries(
text,
fit_brief=normalized_fit_brief,
)
preferred_domains = self._infer_preferred_domains(text)
subject_entities = object_mentions[:6]
location_hint = None
time_range_hint = self._infer_time_range_hint(text)
requires_multi_source = answer_contract == "compare_with_sources"
clarify_on_ambiguity = False
result_limit = 3 if requires_multi_source or bool(query_profile.get("needs_freshness")) else 2
fit_brief_summary = self._summarize_fit_brief(normalized_fit_brief)
project_object_anchors = self._collect_project_object_anchors(normalized_fit_brief)
return ExternalQueryPlan(
plan_version="v1",
query_kind="generic_web" if text else "unknown",
answer_contract=answer_contract,
freshness_required=bool(query_profile.get("needs_freshness")),
topic=topic,
subject_entities=subject_entities,
location_hint=location_hint,
time_range_hint=time_range_hint,
preferred_domains=preferred_domains,
search_queries=search_queries,
result_limit=result_limit,
requires_multi_source=requires_multi_source,
clarify_on_ambiguity=clarify_on_ambiguity,
fit_brief_summary=fit_brief_summary,
project_scope_hint="current_project_and_repo" if fit_brief_summary else None,
recommendation_goal=self._build_recommendation_goal(
question=text,
fit_brief=normalized_fit_brief,
),
project_object_anchors=project_object_anchors,
)
def _build_search_queries(
self,
question: str,
*,
fit_brief: dict[str, Any] | None = None,
) -> list[str]:
if callable(self.query_builder):
try:
queries = self.query_builder(question)
normalized = [str(item or "").strip() for item in list(queries or []) if str(item or "").strip()]
if normalized:
return self._merge_fit_brief_queries(normalized, fit_brief=fit_brief)
except Exception:
pass
text = re.sub(r"\s+", " ", str(question or "")).strip()
queries = [text] if text else []
return self._merge_fit_brief_queries(queries, fit_brief=fit_brief)
@staticmethod
def _infer_preferred_domains(question: str) -> list[str]:
normalized = str(question or "").lower()
domains: list[str] = []
if any(token in normalized for token in ("codex", "gpt", "chatgpt", "openai")):
domains.extend(["openai.com", "developers.openai.com"])
if any(token in normalized for token in ("copilot", "github")):
domains.extend(["github.com", "docs.github.com"])
uniq: list[str] = []
for domain in domains:
if domain not in uniq:
uniq.append(domain)
return uniq[:4]
@staticmethod
def _infer_time_range_hint(question: str) -> str | None:
text = str(question or "")
for pattern in (r"未来\d+天", r"未来一周", r"今天", r"截至今天", r"截至现在", r"目前", r"最新"):
match = re.search(pattern, text)
if match:
return str(match.group(0) or "").strip() or None
return None
@staticmethod
def _normalize_fit_brief(payload: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
try:
return ProjectFitBrief.model_validate(payload).model_dump()
except Exception:
return {}
@staticmethod
def _summarize_fit_brief(fit_brief: dict[str, Any] | None) -> str:
brief = fit_brief if isinstance(fit_brief, dict) else {}
parts: list[str] = []
project_summary = str(brief.get("project_summary") or "").strip()
repo_summary = str(brief.get("repo_summary") or "").strip()
capabilities = [
str(item or "").strip()
for item in list(brief.get("current_capabilities") or [])
if str(item or "").strip()
]
if project_summary:
parts.append(project_summary)
if repo_summary:
parts.append(repo_summary)
if capabilities:
parts.append(f"当前能力:{'、'.join(capabilities[:4])}")
return " ".join(parts)[:600]
@staticmethod
def _collect_project_object_anchors(fit_brief: dict[str, Any] | None) -> list[str]:
brief = fit_brief if isinstance(fit_brief, dict) else {}
anchors: list[str] = []
for pool_name in ("signal_source_anchors", "current_capabilities", "recommendation_focus"):
for item in list(brief.get(pool_name) or []):
normalized = str(item or "").strip()
if normalized and normalized not in anchors:
anchors.append(normalized)
return anchors[:6]
def _build_recommendation_goal(self, *, question: str, fit_brief: dict[str, Any] | None) -> str | None:
focus = [
str(item or "").strip()
for item in list((fit_brief or {}).get("recommendation_focus") or [])
if str(item or "").strip()
]
if focus:
return ";".join(focus[:3])[:240]
text = str(question or "").strip()
if not text:
return None
return f"判断“{text[:80]}”是否适合当前项目接入或跟进。"
def _merge_fit_brief_queries(
self,
queries: list[str],
*,
fit_brief: dict[str, Any] | None = None,
) -> list[str]:
normalized = [str(item or "").strip() for item in list(queries or []) if str(item or "").strip()]
if not normalized:
return []
anchors = self._collect_project_object_anchors(fit_brief)
if anchors:
anchor = anchors[0]
candidate = f"{normalized[0]} {anchor}".strip()
if candidate and candidate not in normalized:
normalized.append(candidate)
return normalized[:5]