267 lines
11 KiB
Python
267 lines
11 KiB
Python
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]
|