140 lines
5.4 KiB
Python
140 lines
5.4 KiB
Python
import json
|
|
import re
|
|
from copy import deepcopy
|
|
from typing import Any
|
|
|
|
from jsonschema import Draft202012Validator, ValidationError
|
|
|
|
from apps.modelhub.structured_output import (
|
|
StructuredOutputError,
|
|
validate_native_schema_subset,
|
|
)
|
|
from apps.skills.models import SkillDefinition, SkillVersion
|
|
|
|
VARIABLE_PATTERN = re.compile(r"{{\s*([^{}]+?)\s*}}")
|
|
ALLOWED_PATH = re.compile(r"^input(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
|
|
|
|
|
|
class SkillValidationError(ValueError):
|
|
pass
|
|
|
|
|
|
def validate_json_schema(schema: dict[str, Any], label: str) -> None:
|
|
if not isinstance(schema, dict) or not schema:
|
|
raise SkillValidationError(f"{label}不能为空")
|
|
try:
|
|
Draft202012Validator.check_schema(schema)
|
|
except Exception as error:
|
|
raise SkillValidationError(f"{label}不是合法的 JSON Schema: {error}") from error
|
|
|
|
|
|
def validate_prompt_template(template: str) -> None:
|
|
if not template.strip():
|
|
raise SkillValidationError("Prompt 模板不能为空")
|
|
for variable in VARIABLE_PATTERN.findall(template):
|
|
if not ALLOWED_PATH.fullmatch(variable.strip()):
|
|
raise SkillValidationError(f"不允许的模板变量: {variable.strip()}")
|
|
|
|
|
|
def _validate_output_object_shapes(schema: dict[str, Any], path: str = "$") -> None:
|
|
schema_type = schema.get("type")
|
|
if schema_type == "object":
|
|
properties = schema.get("properties")
|
|
if not isinstance(properties, dict):
|
|
raise SkillValidationError(f"输出 Schema 对象必须声明 properties: {path}")
|
|
if schema.get("additionalProperties") is not False:
|
|
raise SkillValidationError(
|
|
f"输出 Schema 对象必须设置 additionalProperties=false: {path}"
|
|
)
|
|
required = schema.get("required")
|
|
if not isinstance(required, list) or set(required) != set(properties):
|
|
raise SkillValidationError(
|
|
f"输出 Schema 对象的全部字段都必须列入 required: {path}"
|
|
)
|
|
for code, child in properties.items():
|
|
if isinstance(child, dict):
|
|
_validate_output_object_shapes(child, f"{path}.{code}")
|
|
elif schema_type == "array":
|
|
items = schema.get("items")
|
|
if isinstance(items, dict):
|
|
_validate_output_object_shapes(items, f"{path}[]")
|
|
|
|
|
|
def validate_skill_version(version: SkillVersion) -> None:
|
|
validate_prompt_template(version.prompt_template)
|
|
validate_json_schema(version.input_schema, "输入 Schema")
|
|
validate_json_schema(version.output_schema, "输出 Schema")
|
|
if version.output_schema.get("type") != "object":
|
|
raise SkillValidationError("输出 Schema 根节点必须是 object")
|
|
if version.output_enforcement not in {
|
|
"native_json_schema",
|
|
"json_object",
|
|
"validated_json",
|
|
}:
|
|
raise SkillValidationError("未知的结构化输出保障要求")
|
|
_validate_output_object_shapes(version.output_schema)
|
|
if version.output_enforcement == "native_json_schema":
|
|
try:
|
|
validate_native_schema_subset(version.output_schema)
|
|
except StructuredOutputError as error:
|
|
raise SkillValidationError(str(error)) from error
|
|
if version.example_input:
|
|
try:
|
|
Draft202012Validator(version.input_schema).validate(version.example_input)
|
|
except ValidationError as error:
|
|
raise SkillValidationError(f"示例输入不符合输入 Schema: {error.message}") from error
|
|
|
|
|
|
def validate_payload(schema: dict[str, Any], payload: Any, label: str = "数据") -> None:
|
|
try:
|
|
Draft202012Validator(schema).validate(payload)
|
|
except ValidationError as error:
|
|
path = ".".join(str(item) for item in error.absolute_path)
|
|
location = f" ({path})" if path else ""
|
|
raise SkillValidationError(f"{label}不符合 Schema{location}: {error.message}") from error
|
|
|
|
|
|
def _resolve_path(payload: dict[str, Any], path: str) -> Any:
|
|
value: Any = payload
|
|
for part in path.split(".")[1:]:
|
|
if not isinstance(value, dict) or part not in value:
|
|
raise SkillValidationError(f"模板变量不存在: {path}")
|
|
value = value[part]
|
|
return value
|
|
|
|
|
|
def render_prompt(template: str, input_payload: dict[str, Any]) -> str:
|
|
validate_prompt_template(template)
|
|
|
|
def replace(match: re.Match[str]) -> str:
|
|
path = match.group(1).strip()
|
|
value = _resolve_path(input_payload, path)
|
|
if isinstance(value, (dict, list)):
|
|
return json.dumps(value, ensure_ascii=False)
|
|
if value is None:
|
|
return ""
|
|
return str(value)
|
|
|
|
return VARIABLE_PATTERN.sub(replace, template)
|
|
|
|
|
|
def ensure_draft(definition: SkillDefinition, user=None) -> SkillVersion:
|
|
existing = definition.versions.filter(status="draft").first()
|
|
if existing:
|
|
return existing
|
|
source = definition.active_version
|
|
next_version = (
|
|
definition.versions.order_by("-version").values_list("version", flat=True).first() or 0
|
|
) + 1
|
|
return SkillVersion.objects.create(
|
|
definition=definition,
|
|
version=next_version,
|
|
status="draft",
|
|
prompt_template=source.prompt_template if source else "",
|
|
input_schema=deepcopy(source.input_schema) if source else {"type": "object"},
|
|
output_schema=deepcopy(source.output_schema) if source else {"type": "object"},
|
|
output_enforcement=source.output_enforcement if source else "validated_json",
|
|
example_input=deepcopy(source.example_input) if source else {},
|
|
created_by=user,
|
|
)
|