| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339 |
- """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},
- }
|