188 lines
9.0 KiB
Python
188 lines
9.0 KiB
Python
"""提示模板渲染和结构化输出校验。
|
||||
|
|
|
|||
|
|
模板只负责组织输入,Agent rules 和 Runtime 硬规则仍由系统注入,避免
|
|||
|
|
用户模板覆盖知识来源、消费锁和运行状态约束。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
|
|||
|
|
PROMPT_PLACEHOLDERS = {
|
|||
|
|
"agent_name", "agent_role", "title", "content_tag", "time_range",
|
|||
|
|
"mindmap_node", "product_ref", "next_context", "previous_context",
|
|||
|
|
"qc_block", "anchor_schema",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
DEFAULT_ROLE_PROMPT = "你是{agent_name},负责{agent_role}类型的内容任务。"
|
|||
|
|
DEFAULT_TASK_TEMPLATE = """请完成以下任务:
|
|||
|
|
标题:{title}
|
|||
|
|
内容标签:{content_tag}
|
|||
|
|
时间范围:{time_range}
|
|||
|
|
导图节点:{mindmap_node}
|
|||
|
|
产品节点:{product_ref}
|
|||
|
|
"""
|
|||
|
|
DEFAULT_CONSTRAINTS = [
|
|||
|
|
"只围绕本段任务和已提供材料作答,缺少事实时标记待确认。",
|
|||
|
|
"输出自然口语,先讲清楚为什么,再给出本段要求的内容。",
|
|||
|
|
"正文后输出完整锚点卡 JSON,字段必须与 Schema 一致。",
|
|||
|
|
]
|
|||
|
|
DEFAULT_OUTPUT_SCHEMA: dict[str, Any] = {
|
|||
|
|
"type": "object",
|
|||
|
|
"required": ["core_concepts", "techniques", "products_ref", "golden_lines", "emotion_tone", "ending_hook"],
|
|||
|
|
"properties": {
|
|||
|
|
"core_concepts": {"type": "string"},
|
|||
|
|
"techniques": {"type": "string"},
|
|||
|
|
"products_ref": {"type": "string"},
|
|||
|
|
"golden_lines": {"type": "string"},
|
|||
|
|
"emotion_tone": {"type": "string"},
|
|||
|
|
"ending_hook": {"type": "string"},
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _string(value: object, limit: int = 20000) -> str:
|
|||
|
|
return str(value or "").strip()[:limit]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def normalize_template_payload(payload: dict[str, Any], *, partial: bool = False) -> dict[str, Any]:
|
|||
|
|
"""校验并规范模板输入,供 API 和 seed 共用。"""
|
|||
|
|
result: dict[str, Any] = {}
|
|||
|
|
if not partial or "name" in payload:
|
|||
|
|
name = _string(payload.get("name"), 120)
|
|||
|
|
if not name:
|
|||
|
|
raise ValueError("提示模板名称不能为空")
|
|||
|
|
result["name"] = name
|
|||
|
|
for key, limit in (("desc", 500), ("role_prompt", 6000), ("task_template", 12000), ("style", 6000)):
|
|||
|
|
if key in payload:
|
|||
|
|
result[key] = _string(payload.get(key), limit)
|
|||
|
|
if "constraints" in payload:
|
|||
|
|
constraints = payload.get("constraints")
|
|||
|
|
if not isinstance(constraints, list) or any(not _string(item, 1000) for item in constraints):
|
|||
|
|
raise ValueError("提示模板约束必须是非空字符串列表")
|
|||
|
|
result["constraints"] = [_string(item, 1000) for item in constraints[:30]]
|
|||
|
|
if "examples" in payload:
|
|||
|
|
examples = payload.get("examples")
|
|||
|
|
if not isinstance(examples, list):
|
|||
|
|
raise ValueError("提示模板示例必须是列表")
|
|||
|
|
normalized: list[dict[str, str]] = []
|
|||
|
|
for index, item in enumerate(examples[:20], start=1):
|
|||
|
|
if not isinstance(item, dict):
|
|||
|
|
raise ValueError(f"第 {index} 个提示示例不是对象")
|
|||
|
|
example_input = _string(item.get("input"), 4000)
|
|||
|
|
example_output = _string(item.get("output"), 6000)
|
|||
|
|
if not example_input or not example_output:
|
|||
|
|
raise ValueError(f"第 {index} 个提示示例缺少 input 或 output")
|
|||
|
|
normalized.append({"input": example_input, "output": example_output})
|
|||
|
|
result["examples"] = normalized
|
|||
|
|
if "output_schema" in payload:
|
|||
|
|
schema = payload.get("output_schema")
|
|||
|
|
if isinstance(schema, str):
|
|||
|
|
try:
|
|||
|
|
schema = json.loads(schema)
|
|||
|
|
except json.JSONDecodeError as exc:
|
|||
|
|
raise ValueError("输出 Schema 不是合法 JSON") from exc
|
|||
|
|
if not isinstance(schema, dict):
|
|||
|
|
raise ValueError("输出 Schema 必须是 JSON 对象")
|
|||
|
|
required = schema.get("required", [])
|
|||
|
|
if required and (not isinstance(required, list) or any(not isinstance(item, str) for item in required)):
|
|||
|
|
raise ValueError("输出 Schema 的 required 必须是字符串列表")
|
|||
|
|
result["output_schema"] = schema
|
|||
|
|
if "enabled" in payload:
|
|||
|
|
result["enabled"] = bool(payload.get("enabled"))
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
def template_from_snapshot(snapshot: dict[str, Any] | None) -> dict[str, Any]:
|
|||
|
|
data = snapshot if isinstance(snapshot, dict) else {}
|
|||
|
|
return {
|
|||
|
|
"id": int(data.get("id") or 0),
|
|||
|
|
"name": _string(data.get("name") or "默认内容生成模板", 120),
|
|||
|
|
"role_prompt": _string(data.get("role_prompt") or DEFAULT_ROLE_PROMPT, 6000),
|
|||
|
|
"task_template": _string(data.get("task_template") or DEFAULT_TASK_TEMPLATE, 12000),
|
|||
|
|
"constraints": list(data.get("constraints") or DEFAULT_CONSTRAINTS),
|
|||
|
|
"examples": list(data.get("examples") or []),
|
|||
|
|
"output_schema": dict(data.get("output_schema") or DEFAULT_OUTPUT_SCHEMA),
|
|||
|
|
"style": _string(data.get("style") or "大白话、自然口语、适合直接播读", 6000),
|
|||
|
|
"version": int(data.get("version") or 1),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_prompt(template: dict[str, Any] | None, values: dict[str, object], *, rules: str = "",
|
|||
|
|
skills: str = "", memories: str = "", fusion: str = "", anchors: str = "",
|
|||
|
|
previous: str = "", knowledge: str = "", runtime_rules: str = "",
|
|||
|
|
next_context: str = "", qc_block: str = "", persona: str = "") -> str:
|
|||
|
|
"""按固定层次拼装提示,模板文本只替换已知变量,保留未知花括号。"""
|
|||
|
|
spec = template_from_snapshot(template)
|
|||
|
|
|
|||
|
|
def interpolate(text: str) -> str:
|
|||
|
|
rendered = text
|
|||
|
|
for key in PROMPT_PLACEHOLDERS:
|
|||
|
|
rendered = rendered.replace("{" + key + "}", _string(values.get(key)))
|
|||
|
|
rendered = rendered.replace("{{" + key + "}}", _string(values.get(key)))
|
|||
|
|
return rendered.strip()
|
|||
|
|
|
|||
|
|
sections = [
|
|||
|
|
("Agent 角色", interpolate(spec["role_prompt"])),
|
|||
|
|
("任务模板", interpolate(spec["task_template"])),
|
|||
|
|
]
|
|||
|
|
if spec["style"]:
|
|||
|
|
sections.append(("模板基础风格(可被 Agent 规则与人格档案覆盖)", interpolate(spec["style"])))
|
|||
|
|
if rules:
|
|||
|
|
sections.append(("Agent 规则", rules))
|
|||
|
|
if persona and persona.strip():
|
|||
|
|
sections.append(("人格档案", persona.strip()))
|
|||
|
|
if spec["constraints"]:
|
|||
|
|
sections.append(("模板约束", "\n".join(f"- {item}" for item in spec["constraints"])))
|
|||
|
|
if spec["examples"]:
|
|||
|
|
examples = ["## 示例(仅用于学习格式,不是事实来源)"]
|
|||
|
|
for index, item in enumerate(spec["examples"], start=1):
|
|||
|
|
examples.append(f"示例 {index} 输入:\n{item.get('input', '')}\n示例 {index} 输出:\n{item.get('output', '')}")
|
|||
|
|
sections.append(("上下文示例", "\n\n".join(examples)))
|
|||
|
|
for title, content in (("已挂接技能", skills), ("融合协作", fusion), ("已确认记忆", memories),
|
|||
|
|
("上游锚点", anchors), ("上一次任务衔接", previous),
|
|||
|
|
("知识库材料", knowledge), ("运行硬规则", runtime_rules),
|
|||
|
|
("相邻段约束", next_context), ("上次质检意见", qc_block)):
|
|||
|
|
if content and content.strip():
|
|||
|
|
sections.append((title, content.strip()))
|
|||
|
|
schema = json.dumps(spec["output_schema"], ensure_ascii=False, indent=2)
|
|||
|
|
sections.append(("输出协议", f"正文完成后输出锚点卡 JSON,必须符合以下 Schema:\n```json\n{schema}\n```"))
|
|||
|
|
return "\n\n".join(f"## {title}\n{content}" for title, content in sections if content)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_schema(value: object, schema: dict[str, Any] | None) -> tuple[bool, str]:
|
|||
|
|
"""提供轻量 JSON Schema 校验,重点覆盖 required/type,避免新增运行依赖。"""
|
|||
|
|
if not schema:
|
|||
|
|
return True, ""
|
|||
|
|
if schema.get("type") == "object" and not isinstance(value, dict):
|
|||
|
|
return False, "输出不是 JSON 对象"
|
|||
|
|
if isinstance(value, dict):
|
|||
|
|
required = schema.get("required") or []
|
|||
|
|
properties = schema.get("properties") or {}
|
|||
|
|
missing: list[str] = []
|
|||
|
|
for key in required:
|
|||
|
|
if key not in value:
|
|||
|
|
missing.append(key)
|
|||
|
|
continue
|
|||
|
|
rule = properties.get(key)
|
|||
|
|
actual = value[key]
|
|||
|
|
if isinstance(rule, dict) and rule.get("type") == "string" and not isinstance(actual, str):
|
|||
|
|
return False, f"字段 {key} 不是字符串"
|
|||
|
|
if actual is None or (isinstance(actual, str) and not actual.strip()):
|
|||
|
|
missing.append(key)
|
|||
|
|
if missing:
|
|||
|
|
return False, "缺少必填字段:" + "、".join(missing)
|
|||
|
|
for key, rule in properties.items():
|
|||
|
|
if key not in value or not isinstance(rule, dict) or "type" not in rule:
|
|||
|
|
continue
|
|||
|
|
expected = rule["type"]
|
|||
|
|
actual = value[key]
|
|||
|
|
if expected == "string" and not isinstance(actual, str):
|
|||
|
|
return False, f"字段 {key} 不是字符串"
|
|||
|
|
if expected == "array" and not isinstance(actual, list):
|
|||
|
|
return False, f"字段 {key} 不是数组"
|
|||
|
|
if expected == "object" and not isinstance(actual, dict):
|
|||
|
|
return False, f"字段 {key} 不是对象"
|
|||
|
|
return True, ""
|