Files

188 lines
9.0 KiB
Python
Raw Permalink Normal View History

"""提示模板渲染和结构化输出校验。
模板只负责组织输入,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, ""