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, )