|
|
@@ -13,15 +13,39 @@ from query_planning.service import UnifiedQueryGenerationService
|
|
|
|
|
|
|
|
|
PROMPT_NAME = "topic_table_query_generation"
|
|
|
-PROMPT_VERSION = "topic_table_query_v3"
|
|
|
+PROMPT_VERSION = "topic_table_query_v4"
|
|
|
|
|
|
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},
|
|
|
+ "unique_hook": {
|
|
|
+ "label": "灵感点",
|
|
|
+ "source_description": "来自选题的灵感点字段",
|
|
|
+ "priority": 0,
|
|
|
+ },
|
|
|
+ "goal_execution": {
|
|
|
+ "label": "创作目标",
|
|
|
+ "source_description": "来自目的点字段",
|
|
|
+ "priority": 0,
|
|
|
+ },
|
|
|
+ "key_execution": {
|
|
|
+ "label": "关键镜头和动作",
|
|
|
+ "source_description": "来自关键点字段",
|
|
|
+ "priority": 0,
|
|
|
+ },
|
|
|
+ "cross_dimension": {
|
|
|
+ "label": "实质、形式、意图及元素关系",
|
|
|
+ "source_description": "来自组成元素、最终选题描述和元素关系字段",
|
|
|
+ "priority": 0,
|
|
|
+ },
|
|
|
+ "account_fit": {
|
|
|
+ "label": "账号内容偏好",
|
|
|
+ "source_description": "来自账号的实质偏好、形式偏好和意图偏好字段",
|
|
|
+ "priority": 0,
|
|
|
+ },
|
|
|
+ "evidence_gap": {
|
|
|
+ "label": "参考案例和选取理由",
|
|
|
+ "source_description": "来自每个元素引用的参考来源说明字段",
|
|
|
+ "priority": 0,
|
|
|
+ },
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -124,7 +148,9 @@ def build_topic_table_user_prompt(
|
|
|
) -> str:
|
|
|
return (
|
|
|
"请从 field_catalog 中选择真正能产生创作方法搜索词的具体字段。"
|
|
|
+ "field_catalog 已经按照六种数据来源标明 source_group_key 和 source_group_label。"
|
|
|
"每一项 field_mapping 只能引用一个真实 field_key;不能合并多个字段,也不能自己编字段。"
|
|
|
+ "六种数据来源必须全部覆盖,每种来源至少选择一个字段。"
|
|
|
f"所有字段合计生成约 {target_query_count} 条具体搜索词,每个字段生成 1~3 条。"
|
|
|
"judgement 必须是一句不超过 45 个汉字的大白话,只说明大模型针对该字段要判断什么。"
|
|
|
"不要提前编造答案、参数、比例或效果数据,不要启动搜索。\n\n"
|
|
|
@@ -165,7 +191,8 @@ def _field_catalog(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
"field_path": field_path,
|
|
|
"field_label": field_label,
|
|
|
"field_value": value,
|
|
|
- "route": route,
|
|
|
+ "source_group_key": route,
|
|
|
+ "source_group_label": ROUTES[route]["label"],
|
|
|
"point_id": point_id,
|
|
|
"item_ids": list(item_ids or []),
|
|
|
}
|
|
|
@@ -213,7 +240,6 @@ def _field_catalog(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
for item in topic.get("composition_items") or []:
|
|
|
item_id = item.get("id")
|
|
|
dimension = str(item.get("dimension") or "元素")
|
|
|
- point_type = str(item.get("point_type") or "")
|
|
|
add(
|
|
|
field_key=f"item:{item_id}:element_name",
|
|
|
field_path=(
|
|
|
@@ -221,7 +247,7 @@ def _field_catalog(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
),
|
|
|
field_label=f"{dimension}字段",
|
|
|
field_value=item.get("element_name"),
|
|
|
- route=point_routes.get(point_type, "cross_dimension"),
|
|
|
+ route="cross_dimension",
|
|
|
item_ids=[item_id],
|
|
|
)
|
|
|
for source_index, source in enumerate(item.get("sources") or []):
|
|
|
@@ -273,9 +299,15 @@ def _planning_payload(
|
|
|
topic_id = snapshot["topic_id"]
|
|
|
catalog_by_key = {field["field_key"]: field for field in field_catalog}
|
|
|
needs: list[dict[str, Any]] = []
|
|
|
- candidates: list[dict[str, Any]] = []
|
|
|
+ route_mapping_candidates: dict[str, list[list[dict[str, Any]]]] = {
|
|
|
+ route: [] for route in ROUTES
|
|
|
+ }
|
|
|
used_field_keys: set[str] = set()
|
|
|
- position = 0
|
|
|
+ used_need_keys: set[str] = set()
|
|
|
+ route_mapping_counts: dict[str, int] = {route: 0 for route in ROUTES}
|
|
|
+ selected_labels_by_route: dict[str, set[str]] = {
|
|
|
+ route: set() for route in ROUTES
|
|
|
+ }
|
|
|
for index, raw in enumerate(raw_mappings):
|
|
|
if not isinstance(raw, dict):
|
|
|
continue
|
|
|
@@ -289,9 +321,24 @@ def _planning_payload(
|
|
|
judgement = str(raw.get("judgement") or "").strip()
|
|
|
if not judgement:
|
|
|
continue
|
|
|
- route = field["route"]
|
|
|
+ raw_queries = raw.get("queries") or []
|
|
|
+ if isinstance(raw_queries, str):
|
|
|
+ raw_queries = [raw_queries]
|
|
|
+ query_texts = [
|
|
|
+ str(query or "").strip()
|
|
|
+ for query in raw_queries
|
|
|
+ if str(query or "").strip()
|
|
|
+ ]
|
|
|
+ if not query_texts:
|
|
|
+ continue
|
|
|
+ route = field["source_group_key"]
|
|
|
+ route_mapping_counts[route] += 1
|
|
|
+ selected_labels_by_route[route].add(field["field_label"])
|
|
|
priority = ROUTES[route]["priority"]
|
|
|
need_key = _safe_need_key(field_key, route, index)
|
|
|
+ if need_key in used_need_keys:
|
|
|
+ need_key = f"{need_key}_{index + 1}"
|
|
|
+ used_need_keys.add(need_key)
|
|
|
source_ref = {
|
|
|
"generator_kind": GeneratorKind.TOPIC_TABLE.value,
|
|
|
"topic_build_id": topic_build_id,
|
|
|
@@ -318,14 +365,9 @@ def _planning_payload(
|
|
|
},
|
|
|
}
|
|
|
)
|
|
|
- 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(
|
|
|
+ mapping_candidates: list[dict[str, Any]] = []
|
|
|
+ for query_text in query_texts:
|
|
|
+ mapping_candidates.append(
|
|
|
{
|
|
|
"query_text": query_text,
|
|
|
"axes": {
|
|
|
@@ -334,7 +376,7 @@ def _planning_payload(
|
|
|
"大模型判断": judgement,
|
|
|
},
|
|
|
"priority": priority,
|
|
|
- "original_position": position,
|
|
|
+ "original_position": 0,
|
|
|
"source_refs": [source_ref],
|
|
|
"knowledge_need_keys": [need_key],
|
|
|
"metadata": {
|
|
|
@@ -345,6 +387,60 @@ def _planning_payload(
|
|
|
},
|
|
|
}
|
|
|
)
|
|
|
+ route_mapping_candidates[route].append(mapping_candidates)
|
|
|
+ missing_routes = [
|
|
|
+ ROUTES[route]["label"]
|
|
|
+ for route, count in route_mapping_counts.items()
|
|
|
+ if count == 0
|
|
|
+ ]
|
|
|
+ if missing_routes:
|
|
|
+ raise TopicTableGenerationError(
|
|
|
+ "大模型没有覆盖全部六种数据来源:" + "、".join(missing_routes)
|
|
|
+ )
|
|
|
+ required_dimension_labels = {
|
|
|
+ field["field_label"]
|
|
|
+ for field in field_catalog
|
|
|
+ if field["source_group_key"] == "cross_dimension"
|
|
|
+ and field["field_label"] in {"实质字段", "形式字段", "意图字段"}
|
|
|
+ }
|
|
|
+ missing_dimension_labels = sorted(
|
|
|
+ required_dimension_labels - selected_labels_by_route["cross_dimension"]
|
|
|
+ )
|
|
|
+ if missing_dimension_labels:
|
|
|
+ raise TopicTableGenerationError(
|
|
|
+ "组成元素没有同时覆盖实质、形式、意图字段:"
|
|
|
+ + "、".join(missing_dimension_labels)
|
|
|
+ )
|
|
|
+ required_account_labels = {
|
|
|
+ field["field_label"]
|
|
|
+ for field in field_catalog
|
|
|
+ if field["source_group_key"] == "account_fit"
|
|
|
+ }
|
|
|
+ missing_account_labels = sorted(
|
|
|
+ required_account_labels - selected_labels_by_route["account_fit"]
|
|
|
+ )
|
|
|
+ if missing_account_labels:
|
|
|
+ raise TopicTableGenerationError(
|
|
|
+ "账号内容偏好没有覆盖完整:" + "、".join(missing_account_labels)
|
|
|
+ )
|
|
|
+ route_candidates: dict[str, list[dict[str, Any]]] = {
|
|
|
+ route: [] for route in ROUTES
|
|
|
+ }
|
|
|
+ for route, mapping_lists in route_mapping_candidates.items():
|
|
|
+ max_length = max((len(items) for items in mapping_lists), default=0)
|
|
|
+ for query_index in range(max_length):
|
|
|
+ for mapping_candidates in mapping_lists:
|
|
|
+ if query_index < len(mapping_candidates):
|
|
|
+ route_candidates[route].append(mapping_candidates[query_index])
|
|
|
+ candidates: list[dict[str, Any]] = []
|
|
|
+ position = 0
|
|
|
+ while any(route_candidates.values()):
|
|
|
+ for route in ROUTES:
|
|
|
+ if not route_candidates[route]:
|
|
|
+ continue
|
|
|
+ candidate = route_candidates[route].pop(0)
|
|
|
+ candidate["original_position"] = position
|
|
|
+ candidates.append(candidate)
|
|
|
position += 1
|
|
|
if not needs or not candidates:
|
|
|
raise TopicTableGenerationError("大模型返回内容无法形成字段与搜索词的对应关系")
|
|
|
@@ -370,21 +466,40 @@ def generate_topic_table_preview(
|
|
|
topic = _select_topic(source_payload, topic_id)
|
|
|
snapshot = _compact_snapshot(source_payload, topic)
|
|
|
field_catalog = _field_catalog(snapshot)
|
|
|
- llm_output = chat_fn(
|
|
|
- topic_table_prompt(),
|
|
|
- build_topic_table_user_prompt(
|
|
|
- snapshot,
|
|
|
- field_catalog,
|
|
|
- target_query_count=max_queries or 18,
|
|
|
- ),
|
|
|
- )
|
|
|
- if not isinstance(llm_output, dict):
|
|
|
- raise TopicTableGenerationError("大模型返回格式不正确")
|
|
|
- payload = _planning_payload(
|
|
|
- llm_output,
|
|
|
- snapshot=snapshot,
|
|
|
- field_catalog=field_catalog,
|
|
|
+ system_prompt = topic_table_prompt()
|
|
|
+ user_prompt = build_topic_table_user_prompt(
|
|
|
+ snapshot,
|
|
|
+ field_catalog,
|
|
|
+ target_query_count=max_queries or 18,
|
|
|
)
|
|
|
+ llm_output: dict[str, Any] = {}
|
|
|
+ payload: dict[str, Any] | None = None
|
|
|
+ validation_error: TopicTableGenerationError | None = None
|
|
|
+ for attempt in range(2):
|
|
|
+ current_user_prompt = user_prompt
|
|
|
+ if attempt and validation_error is not None:
|
|
|
+ current_user_prompt += (
|
|
|
+ "\n\n上一次输出没有满足字段覆盖要求。"
|
|
|
+ f"具体问题:{validation_error}。"
|
|
|
+ "请重新检查六种数据来源、实质/形式/意图字段和三种账号偏好后,"
|
|
|
+ "重新输出完整 JSON。"
|
|
|
+ )
|
|
|
+ candidate_output = chat_fn(system_prompt, current_user_prompt)
|
|
|
+ if not isinstance(candidate_output, dict):
|
|
|
+ validation_error = TopicTableGenerationError("大模型返回格式不正确")
|
|
|
+ continue
|
|
|
+ llm_output = candidate_output
|
|
|
+ try:
|
|
|
+ payload = _planning_payload(
|
|
|
+ llm_output,
|
|
|
+ snapshot=snapshot,
|
|
|
+ field_catalog=field_catalog,
|
|
|
+ )
|
|
|
+ break
|
|
|
+ except TopicTableGenerationError as exc:
|
|
|
+ validation_error = exc
|
|
|
+ if payload is None:
|
|
|
+ raise validation_error or TopicTableGenerationError("大模型返回格式不正确")
|
|
|
request = GenerationRequest(
|
|
|
generator_kind=GeneratorKind.TOPIC_TABLE,
|
|
|
payload=payload,
|
|
|
@@ -471,11 +586,34 @@ def preview_to_dict(preview: TopicTablePreview) -> dict[str, Any]:
|
|
|
"field_label": need.source_ref.get("field_label"),
|
|
|
"field_value": need.source_ref.get("field_value"),
|
|
|
"judgement": need.decision_context,
|
|
|
+ "source_group_key": need.metadata.get("route"),
|
|
|
+ "source_group_label": need.metadata.get("route_label"),
|
|
|
"queries": queries_by_need.get(need.need_key, []),
|
|
|
}
|
|
|
for need in result.knowledge_needs
|
|
|
if queries_by_need.get(need.need_key)
|
|
|
],
|
|
|
+ "source_groups": [
|
|
|
+ {
|
|
|
+ "key": route,
|
|
|
+ "label": config["label"],
|
|
|
+ "source_description": config["source_description"],
|
|
|
+ "field_mappings": [
|
|
|
+ {
|
|
|
+ "field_key": need.source_ref.get("field_key"),
|
|
|
+ "field_path": need.source_ref.get("field_path"),
|
|
|
+ "field_label": need.source_ref.get("field_label"),
|
|
|
+ "field_value": need.source_ref.get("field_value"),
|
|
|
+ "judgement": need.decision_context,
|
|
|
+ "queries": queries_by_need.get(need.need_key, []),
|
|
|
+ }
|
|
|
+ for need in result.knowledge_needs
|
|
|
+ if need.metadata.get("route") == route
|
|
|
+ and queries_by_need.get(need.need_key)
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ for route, config in ROUTES.items()
|
|
|
+ ],
|
|
|
"queries": [
|
|
|
{
|
|
|
"query": query.query_text,
|