|
|
@@ -0,0 +1,339 @@
|
|
|
+"""Application service for deriving Query candidates from one topic-table snapshot."""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import json
|
|
|
+import re
|
|
|
+from copy import deepcopy
|
|
|
+from dataclasses import dataclass
|
|
|
+from typing import Any, Callable
|
|
|
+
|
|
|
+from core.prompts import load_prompt
|
|
|
+from query_planning.domain import GenerationRequest, GenerationResult, GeneratorKind
|
|
|
+from query_planning.service import UnifiedQueryGenerationService
|
|
|
+
|
|
|
+
|
|
|
+PROMPT_NAME = "topic_table_query_generation"
|
|
|
+PROMPT_VERSION = "topic_table_query_v1"
|
|
|
+
|
|
|
+ROUTES: dict[str, dict[str, Any]] = {
|
|
|
+ "unique_hook": {"label": "独特切口落地", "priority": 100},
|
|
|
+ "goal_execution": {"label": "创作目标实现", "priority": 90},
|
|
|
+ "key_execution": {"label": "关键点执行", "priority": 80},
|
|
|
+ "cross_dimension": {"label": "跨维度组合", "priority": 70},
|
|
|
+ "account_fit": {"label": "账号适配", "priority": 60},
|
|
|
+ "evidence_gap": {"label": "来源与推导缺口", "priority": 50},
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+class TopicTableGenerationError(ValueError):
|
|
|
+ """Raised when a topic snapshot or LLM result cannot become a Query plan."""
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class TopicTablePreview:
|
|
|
+ result: GenerationResult
|
|
|
+ source: dict[str, Any]
|
|
|
+ topic: dict[str, Any]
|
|
|
+ llm_output: dict[str, Any]
|
|
|
+
|
|
|
+
|
|
|
+def topic_table_prompt() -> str:
|
|
|
+ return load_prompt(PROMPT_NAME)
|
|
|
+
|
|
|
+
|
|
|
+def _select_topic(payload: dict[str, Any], topic_id: int | None) -> dict[str, Any]:
|
|
|
+ topics = [topic for topic in payload.get("topics") or [] if isinstance(topic, dict)]
|
|
|
+ if topic_id is not None:
|
|
|
+ for topic in topics:
|
|
|
+ if int(topic.get("id") or 0) == topic_id:
|
|
|
+ return topic
|
|
|
+ raise TopicTableGenerationError(f"选题构建中找不到 topic_id={topic_id}")
|
|
|
+ mature = next((topic for topic in topics if topic.get("status") == "mature"), None)
|
|
|
+ if mature is not None:
|
|
|
+ return mature
|
|
|
+ if topics:
|
|
|
+ return topics[0]
|
|
|
+ raise TopicTableGenerationError("选题构建中没有可用 topic")
|
|
|
+
|
|
|
+
|
|
|
+def _source_summary(source: dict[str, Any]) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "derivation_type": source.get("derivation_type"),
|
|
|
+ "source_type": source.get("source_type"),
|
|
|
+ "source_reference_id": source.get("source_reference_id"),
|
|
|
+ "reason": source.get("reason"),
|
|
|
+ "dataset_from": source.get("dataset_from"),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _compact_snapshot(
|
|
|
+ payload: dict[str, Any],
|
|
|
+ topic: dict[str, Any],
|
|
|
+) -> dict[str, Any]:
|
|
|
+ build = payload.get("build") or {}
|
|
|
+ items = []
|
|
|
+ for item in topic.get("composition_items") or []:
|
|
|
+ if not isinstance(item, dict) or item.get("is_active") is False:
|
|
|
+ continue
|
|
|
+ items.append(
|
|
|
+ {
|
|
|
+ "id": item.get("id"),
|
|
|
+ "item_level": item.get("item_level"),
|
|
|
+ "dimension": item.get("dimension"),
|
|
|
+ "point_type": item.get("point_type"),
|
|
|
+ "element_name": item.get("element_name"),
|
|
|
+ "category_path": item.get("category_path"),
|
|
|
+ "reason": item.get("reason"),
|
|
|
+ "is_active": item.get("is_active"),
|
|
|
+ "sources": [
|
|
|
+ _source_summary(source)
|
|
|
+ for source in item.get("sources") or []
|
|
|
+ if isinstance(source, dict)
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "topic_build_id": build.get("id"),
|
|
|
+ "topic_id": topic.get("id"),
|
|
|
+ "build": {
|
|
|
+ "demand": build.get("demand"),
|
|
|
+ "demand_constraints": build.get("demand_constraints"),
|
|
|
+ "strategies_config": build.get("strategies_config"),
|
|
|
+ "status": build.get("status"),
|
|
|
+ },
|
|
|
+ "topic": {
|
|
|
+ "status": topic.get("status"),
|
|
|
+ "result": topic.get("result"),
|
|
|
+ "topic_direction": topic.get("topic_direction"),
|
|
|
+ "points": [
|
|
|
+ deepcopy(point)
|
|
|
+ for point in topic.get("points") or []
|
|
|
+ if isinstance(point, dict) and point.get("is_active") is not False
|
|
|
+ ],
|
|
|
+ "composition_items": items,
|
|
|
+ "item_relations": deepcopy(topic.get("item_relations") or []),
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def build_topic_table_user_prompt(
|
|
|
+ snapshot: dict[str, Any],
|
|
|
+ *,
|
|
|
+ target_query_count: int,
|
|
|
+) -> str:
|
|
|
+ return (
|
|
|
+ "请根据下面的真实选题表快照,沿六条固定路径识别 Knowledge Need。"
|
|
|
+ "knowledge_needs 数组必须正好有 6 项,每条路径正好 1 项,不能把 Query 数量误当成 Need 数量。"
|
|
|
+ f"六个 Need 合计生成约 {target_query_count} 条原子 Query;预算允许时,每个 Need 给 2~3 条。"
|
|
|
+ "只输出待搜索的问题,不要在 decision_context 或 unknown_information 中提前编造答案、参数、比例或效果数据。"
|
|
|
+ "不要启动搜索。\n\n"
|
|
|
+ + json.dumps(snapshot, ensure_ascii=False, indent=2, default=str)
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _safe_need_key(value: Any, route: str, index: int) -> str:
|
|
|
+ raw = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(value or "").strip()).strip("_")
|
|
|
+ return raw[:80] or f"{route}_{index + 1}"
|
|
|
+
|
|
|
+
|
|
|
+def _planning_payload(
|
|
|
+ llm_output: dict[str, Any],
|
|
|
+ *,
|
|
|
+ snapshot: dict[str, Any],
|
|
|
+) -> dict[str, Any]:
|
|
|
+ raw_needs = llm_output.get("knowledge_needs")
|
|
|
+ if not isinstance(raw_needs, list) or not raw_needs:
|
|
|
+ raise TopicTableGenerationError("LLM 没有返回 knowledge_needs")
|
|
|
+ topic_build_id = snapshot["topic_build_id"]
|
|
|
+ topic_id = snapshot["topic_id"]
|
|
|
+ needs: list[dict[str, Any]] = []
|
|
|
+ candidates: list[dict[str, Any]] = []
|
|
|
+ used_keys: set[str] = set()
|
|
|
+ route_counts: dict[str, int] = {route: 0 for route in ROUTES}
|
|
|
+ position = 0
|
|
|
+ for index, raw in enumerate(raw_needs):
|
|
|
+ if not isinstance(raw, dict):
|
|
|
+ continue
|
|
|
+ route = str(raw.get("route") or "").strip()
|
|
|
+ if route not in ROUTES:
|
|
|
+ continue
|
|
|
+ route_counts[route] += 1
|
|
|
+ need_key = _safe_need_key(raw.get("need_key"), route, index)
|
|
|
+ if need_key in used_keys:
|
|
|
+ need_key = f"{need_key}_{index + 1}"
|
|
|
+ used_keys.add(need_key)
|
|
|
+ decision_context = str(raw.get("decision_context") or "").strip()
|
|
|
+ unknown_information = str(raw.get("unknown_information") or "").strip()
|
|
|
+ if not decision_context or not unknown_information:
|
|
|
+ continue
|
|
|
+ priority = max(0, min(100, int(raw.get("priority") or ROUTES[route]["priority"])))
|
|
|
+ raw_ref = raw.get("source_ref") if isinstance(raw.get("source_ref"), dict) else {}
|
|
|
+ source_ref = {
|
|
|
+ "generator_kind": GeneratorKind.TOPIC_TABLE.value,
|
|
|
+ "topic_build_id": topic_build_id,
|
|
|
+ "topic_id": topic_id,
|
|
|
+ "route": route,
|
|
|
+ "point_id": raw_ref.get("point_id"),
|
|
|
+ "item_ids": list(raw_ref.get("item_ids") or []),
|
|
|
+ }
|
|
|
+ needs.append(
|
|
|
+ {
|
|
|
+ "need_key": need_key,
|
|
|
+ "decision_context": decision_context,
|
|
|
+ "unknown_information": unknown_information,
|
|
|
+ "source_ref": source_ref,
|
|
|
+ "priority": priority,
|
|
|
+ "metadata": {"route": route, "route_label": ROUTES[route]["label"]},
|
|
|
+ }
|
|
|
+ )
|
|
|
+ raw_queries = raw.get("queries") or []
|
|
|
+ if isinstance(raw_queries, str):
|
|
|
+ raw_queries = [raw_queries]
|
|
|
+ for query in raw_queries:
|
|
|
+ query_text = str(query or "").strip()
|
|
|
+ if not query_text:
|
|
|
+ continue
|
|
|
+ candidates.append(
|
|
|
+ {
|
|
|
+ "query_text": query_text,
|
|
|
+ "axes": {
|
|
|
+ "需求路径": ROUTES[route]["label"],
|
|
|
+ "创作决定": decision_context,
|
|
|
+ "未知信息": unknown_information,
|
|
|
+ },
|
|
|
+ "priority": priority,
|
|
|
+ "original_position": position,
|
|
|
+ "source_refs": [source_ref],
|
|
|
+ "knowledge_need_keys": [need_key],
|
|
|
+ "metadata": {
|
|
|
+ "family_key": GeneratorKind.TOPIC_TABLE.value,
|
|
|
+ "route": route,
|
|
|
+ "route_label": ROUTES[route]["label"],
|
|
|
+ "need_key": need_key,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ )
|
|
|
+ position += 1
|
|
|
+ invalid_routes = [
|
|
|
+ ROUTES[route]["label"]
|
|
|
+ for route, count in route_counts.items()
|
|
|
+ if count != 1
|
|
|
+ ]
|
|
|
+ if invalid_routes:
|
|
|
+ raise TopicTableGenerationError(
|
|
|
+ "LLM 返回的六路 Knowledge Need 不完整或有重复:" + "、".join(invalid_routes)
|
|
|
+ )
|
|
|
+ if not needs or not candidates:
|
|
|
+ raise TopicTableGenerationError("LLM 返回内容无法形成 Knowledge Need 和 Query")
|
|
|
+ return {
|
|
|
+ "input_snapshot": snapshot,
|
|
|
+ "knowledge_needs": needs,
|
|
|
+ "candidates": candidates,
|
|
|
+ "generator_config": {
|
|
|
+ "prompt_name": PROMPT_NAME,
|
|
|
+ "prompt_version": PROMPT_VERSION,
|
|
|
+ "routes": list(ROUTES),
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def generate_topic_table_preview(
|
|
|
+ source_payload: dict[str, Any],
|
|
|
+ *,
|
|
|
+ topic_id: int | None,
|
|
|
+ max_queries: int | None,
|
|
|
+ chat_fn: Callable[[str, str], dict[str, Any]],
|
|
|
+) -> TopicTablePreview:
|
|
|
+ topic = _select_topic(source_payload, topic_id)
|
|
|
+ snapshot = _compact_snapshot(source_payload, topic)
|
|
|
+ llm_output = chat_fn(
|
|
|
+ topic_table_prompt(),
|
|
|
+ build_topic_table_user_prompt(
|
|
|
+ snapshot,
|
|
|
+ target_query_count=max_queries or 18,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ if not isinstance(llm_output, dict):
|
|
|
+ raise TopicTableGenerationError("LLM 返回值不是 JSON 对象")
|
|
|
+ payload = _planning_payload(llm_output, snapshot=snapshot)
|
|
|
+ request = GenerationRequest(
|
|
|
+ generator_kind=GeneratorKind.TOPIC_TABLE,
|
|
|
+ payload=payload,
|
|
|
+ name=f"topic-table-{snapshot['topic_build_id']}-{snapshot['topic_id']}",
|
|
|
+ max_queries=max_queries,
|
|
|
+ metadata={
|
|
|
+ "source": "pattern_topic_build_api",
|
|
|
+ "prompt_version": PROMPT_VERSION,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ result = UnifiedQueryGenerationService().generate(request)
|
|
|
+ return TopicTablePreview(
|
|
|
+ result=result,
|
|
|
+ source={
|
|
|
+ "topic_build_id": snapshot["topic_build_id"],
|
|
|
+ "topic_id": snapshot["topic_id"],
|
|
|
+ "source_url": source_payload.get("source_url"),
|
|
|
+ },
|
|
|
+ topic=snapshot["topic"],
|
|
|
+ llm_output=llm_output,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def preview_to_dict(preview: TopicTablePreview) -> dict[str, Any]:
|
|
|
+ result = preview.result
|
|
|
+ route_counts: dict[str, int] = {route: 0 for route in ROUTES}
|
|
|
+ for need in result.knowledge_needs:
|
|
|
+ route = str(need.metadata.get("route") or "")
|
|
|
+ if route in route_counts:
|
|
|
+ route_counts[route] += 1
|
|
|
+ dimensions: dict[str, list[str]] = {"实质": [], "形式": [], "意图": []}
|
|
|
+ for item in preview.topic.get("composition_items") or []:
|
|
|
+ dimension = item.get("dimension")
|
|
|
+ name = item.get("element_name")
|
|
|
+ if dimension in dimensions and name and item.get("is_active"):
|
|
|
+ dimensions[dimension].append(name)
|
|
|
+ return {
|
|
|
+ "generator_kind": GeneratorKind.TOPIC_TABLE.value,
|
|
|
+ "source": preview.source,
|
|
|
+ "topic": {
|
|
|
+ "status": preview.topic.get("status"),
|
|
|
+ "result": preview.topic.get("result"),
|
|
|
+ "topic_direction": preview.topic.get("topic_direction"),
|
|
|
+ "dimensions": dimensions,
|
|
|
+ "points": preview.topic.get("points") or [],
|
|
|
+ },
|
|
|
+ "knowledge_needs": [
|
|
|
+ {
|
|
|
+ "need_key": need.need_key,
|
|
|
+ "route": need.metadata.get("route"),
|
|
|
+ "route_label": need.metadata.get("route_label"),
|
|
|
+ "decision_context": need.decision_context,
|
|
|
+ "unknown_information": need.unknown_information,
|
|
|
+ "priority": need.priority,
|
|
|
+ "source_ref": need.source_ref,
|
|
|
+ }
|
|
|
+ for need in result.knowledge_needs
|
|
|
+ ],
|
|
|
+ "queries": [
|
|
|
+ {
|
|
|
+ "query": query.query_text,
|
|
|
+ "priority": query.priority,
|
|
|
+ "route": query.metadata.get("route"),
|
|
|
+ "route_label": query.metadata.get("route_label"),
|
|
|
+ "need_key": query.metadata.get("need_key"),
|
|
|
+ "axes": query.axes,
|
|
|
+ "source_refs": query.source_refs,
|
|
|
+ }
|
|
|
+ for query in result.selected_queries
|
|
|
+ ],
|
|
|
+ "summary": {
|
|
|
+ "generated_count": result.stats.generated_count,
|
|
|
+ "unique_count": result.stats.unique_count,
|
|
|
+ "selected_count": result.stats.selected_count,
|
|
|
+ "dropped_count": result.stats.dropped_count,
|
|
|
+ "route_counts": route_counts,
|
|
|
+ "persisted": False,
|
|
|
+ "search_started": False,
|
|
|
+ },
|
|
|
+ "prompt": {"name": PROMPT_NAME, "version": PROMPT_VERSION},
|
|
|
+ }
|