Files
yawan-workbench/server/app/agents/prompting.py
T

188 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""提示模板渲染和结构化输出校验。
模板只负责组织输入,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, ""