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]