feat: init commint
This commit is contained in:
@@ -74,6 +74,7 @@ def create_skill(request, payload: SkillCreateIn):
|
||||
prompt_template=payload.prompt_template,
|
||||
input_schema=payload.input_schema,
|
||||
output_schema=payload.output_schema,
|
||||
output_enforcement=payload.output_enforcement,
|
||||
example_input=payload.example_input,
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
TRUSTED_SKILL_CODES = {
|
||||
"ticket-fact-extraction",
|
||||
"ticket-draft",
|
||||
"support-analysis",
|
||||
"knowledge-distillation",
|
||||
}
|
||||
|
||||
|
||||
def classify_existing_skills(apps, schema_editor):
|
||||
SkillVersion = apps.get_model("skills", "SkillVersion")
|
||||
SkillVersion.objects.filter(
|
||||
definition__code__in=TRUSTED_SKILL_CODES,
|
||||
).update(output_enforcement="native_json_schema")
|
||||
|
||||
|
||||
def unclassify_existing_skills(apps, schema_editor):
|
||||
SkillVersion = apps.get_model("skills", "SkillVersion")
|
||||
SkillVersion.objects.filter(
|
||||
definition__code__in=TRUSTED_SKILL_CODES,
|
||||
).update(output_enforcement="validated_json")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("skills", "0001_initial"),
|
||||
("modelhub", "0003_structured_output_governance"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="skillversion",
|
||||
name="output_enforcement",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("native_json_schema", "原生 JSON Schema"),
|
||||
("json_object", "JSON Object"),
|
||||
("validated_json", "校验型 JSON"),
|
||||
],
|
||||
default="validated_json",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
classify_existing_skills,
|
||||
unclassify_existing_skills,
|
||||
),
|
||||
]
|
||||
@@ -29,6 +29,11 @@ class SkillDefinition(models.Model):
|
||||
|
||||
|
||||
class SkillVersion(models.Model):
|
||||
class OutputEnforcement(models.TextChoices):
|
||||
NATIVE_JSON_SCHEMA = "native_json_schema", "原生 JSON Schema"
|
||||
JSON_OBJECT = "json_object", "JSON Object"
|
||||
VALIDATED_JSON = "validated_json", "校验型 JSON"
|
||||
|
||||
definition = models.ForeignKey(
|
||||
SkillDefinition,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -39,6 +44,11 @@ class SkillVersion(models.Model):
|
||||
prompt_template = models.TextField()
|
||||
input_schema = models.JSONField(default=dict)
|
||||
output_schema = models.JSONField(default=dict)
|
||||
output_enforcement = models.CharField(
|
||||
max_length=32,
|
||||
choices=OutputEnforcement.choices,
|
||||
default=OutputEnforcement.VALIDATED_JSON,
|
||||
)
|
||||
example_input = models.JSONField(default=dict, blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -71,3 +81,9 @@ class SkillVersion(models.Model):
|
||||
name="unique_skill_draft",
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
def structured_output_metrics(self) -> dict:
|
||||
from apps.agents.structured_metrics import build_structured_output_metrics
|
||||
|
||||
return build_structured_output_metrics(skill_version_id=self.id)
|
||||
|
||||
@@ -11,6 +11,7 @@ class SkillCreateIn(Schema):
|
||||
prompt_template: str = ""
|
||||
input_schema: dict[str, Any] = {"type": "object"}
|
||||
output_schema: dict[str, Any] = {"type": "object"}
|
||||
output_enforcement: str = "validated_json"
|
||||
example_input: dict[str, Any] = {}
|
||||
|
||||
|
||||
@@ -23,6 +24,7 @@ class SkillDraftUpdateIn(Schema):
|
||||
prompt_template: str | None = None
|
||||
input_schema: dict[str, Any] | None = None
|
||||
output_schema: dict[str, Any] | None = None
|
||||
output_enforcement: str | None = None
|
||||
example_input: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@@ -33,6 +35,8 @@ class SkillVersionOut(Schema):
|
||||
prompt_template: str
|
||||
input_schema: dict[str, Any]
|
||||
output_schema: dict[str, Any]
|
||||
output_enforcement: str
|
||||
structured_output_metrics: dict[str, Any]
|
||||
example_input: dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -5,6 +5,10 @@ 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*}}")
|
||||
@@ -32,10 +36,48 @@ def validate_prompt_template(template: str) -> None:
|
||||
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)
|
||||
@@ -91,6 +133,7 @@ def ensure_draft(definition: SkillDefinition, user=None) -> SkillVersion:
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ VALID_OUTPUT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {"summary": {"type": "string"}},
|
||||
"required": ["summary"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -56,3 +57,76 @@ def test_rejects_example_that_does_not_match_schema(db):
|
||||
|
||||
with pytest.raises(SkillValidationError, match="示例输入不符合"):
|
||||
validate_skill_version(version)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"output_schema",
|
||||
[
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"summary": {"type": "string"}},
|
||||
"required": ["summary"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"summary": {"type": "string"}},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_rejects_incomplete_output_contracts_for_every_enforcement(db, output_schema):
|
||||
version = make_version(
|
||||
output_schema=output_schema,
|
||||
output_enforcement="validated_json",
|
||||
)
|
||||
|
||||
with pytest.raises(SkillValidationError):
|
||||
validate_skill_version(version)
|
||||
|
||||
|
||||
def test_native_enforcement_rejects_unsupported_schema_keyword_at_publish(db):
|
||||
output_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"missing_fields": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"uniqueItems": True,
|
||||
}
|
||||
},
|
||||
"required": ["missing_fields"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
version = make_version(
|
||||
output_schema=output_schema,
|
||||
output_enforcement="native_json_schema",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
SkillValidationError,
|
||||
match=r"\$\.properties\.missing_fields\.uniqueItems",
|
||||
):
|
||||
validate_skill_version(version)
|
||||
|
||||
|
||||
def test_validated_json_keeps_business_only_schema_constraints(db):
|
||||
output_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"uniqueItems": True,
|
||||
}
|
||||
},
|
||||
"required": ["values"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
version = make_version(
|
||||
output_schema=output_schema,
|
||||
output_enforcement="validated_json",
|
||||
)
|
||||
|
||||
validate_skill_version(version)
|
||||
|
||||
@@ -15,6 +15,7 @@ VALID_SKILL = {
|
||||
"type": "object",
|
||||
"properties": {"summary": {"type": "string"}},
|
||||
"required": ["summary"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"example_input": {"problem": "采集任务没有进度"},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user