feat: add versioned skill configuration

This commit is contained in:
hailin
2026-07-14 13:47:36 +08:00
parent 11bab3e98f
commit ed3f6e8516
13 changed files with 668 additions and 0 deletions

View File

@@ -0,0 +1 @@

176
backend/apps/skills/api.py Normal file
View File

@@ -0,0 +1,176 @@
from django.db import IntegrityError, transaction
from django.utils import timezone
from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_permission
from apps.audit.models import OperationAuditLog
from apps.skills.models import SkillDefinition, SkillVersion
from apps.skills.schemas import (
SkillCreateIn,
SkillDefinitionOut,
SkillDefinitionUpdateIn,
SkillDetailOut,
SkillDraftUpdateIn,
SkillVersionOut,
)
from apps.skills.services import SkillValidationError, ensure_draft, validate_skill_version
router = Router(tags=["skills"])
def serialize_definition(definition: SkillDefinition, detailed: bool = False) -> dict:
draft = definition.versions.filter(status="draft").first()
payload = {
"id": definition.id,
"code": definition.code,
"name": definition.name,
"description": definition.description,
"active_version": definition.active_version.version
if definition.active_version_id
else None,
"draft_version": draft.version if draft else None,
"updated_at": definition.updated_at,
}
if detailed:
payload["versions"] = list(definition.versions.all())
return payload
def audit(user, action: str, definition: SkillDefinition, snapshot: dict) -> None:
OperationAuditLog.objects.create(
user=user,
module="skills",
action=action,
target_type="skill",
target_id=str(definition.id),
request_snapshot=snapshot,
result="success",
)
@router.get("", response=list[SkillDefinitionOut])
def list_skills(request):
require_permission(request, "skill.manage")
queryset = SkillDefinition.objects.select_related("active_version").prefetch_related("versions")
return [serialize_definition(item) for item in queryset]
@router.post("", response=SkillDetailOut)
def create_skill(request, payload: SkillCreateIn):
require_permission(request, "skill.manage")
try:
with transaction.atomic():
definition = SkillDefinition.objects.create(
code=payload.code,
name=payload.name,
description=payload.description,
created_by=request.user,
)
SkillVersion.objects.create(
definition=definition,
version=1,
status="draft",
prompt_template=payload.prompt_template,
input_schema=payload.input_schema,
output_schema=payload.output_schema,
example_input=payload.example_input,
created_by=request.user,
)
except IntegrityError as error:
raise HttpError(400, "Skill code already exists") from error
audit(request.user, "create", definition, {"code": definition.code})
return serialize_definition(definition, detailed=True)
@router.get("/{skill_id}", response=SkillDetailOut)
def get_skill(request, skill_id: int):
require_permission(request, "skill.manage")
definition = SkillDefinition.objects.select_related("active_version").get(id=skill_id)
return serialize_definition(definition, detailed=True)
@router.patch("/{skill_id}", response=SkillDetailOut)
def update_skill(request, skill_id: int, payload: SkillDefinitionUpdateIn):
require_permission(request, "skill.manage")
definition = SkillDefinition.objects.select_related("active_version").get(id=skill_id)
values = payload.dict(exclude_unset=True)
for field, value in values.items():
setattr(definition, field, value)
definition.save()
audit(request.user, "update", definition, {"fields": sorted(values)})
return serialize_definition(definition, detailed=True)
@router.post("/{skill_id}/draft", response=SkillVersionOut)
def create_skill_draft(request, skill_id: int):
require_permission(request, "skill.manage")
definition = SkillDefinition.objects.select_related("active_version").get(id=skill_id)
return ensure_draft(definition, request.user)
@router.get("/{skill_id}/draft", response=SkillVersionOut)
def get_skill_draft(request, skill_id: int):
require_permission(request, "skill.manage")
return SkillVersion.objects.get(definition_id=skill_id, status="draft")
@router.patch("/{skill_id}/draft", response=SkillVersionOut)
def update_skill_draft(request, skill_id: int, payload: SkillDraftUpdateIn):
require_permission(request, "skill.manage")
definition = SkillDefinition.objects.select_related("active_version").get(id=skill_id)
draft = ensure_draft(definition, request.user)
values = payload.dict(exclude_unset=True)
for field, value in values.items():
setattr(draft, field, value)
try:
validate_skill_version(draft)
except SkillValidationError as error:
raise HttpError(400, str(error)) from error
draft.save()
audit(request.user, "update_draft", definition, {"version": draft.version})
return draft
@router.get("/{skill_id}/versions", response=list[SkillVersionOut])
def list_skill_versions(request, skill_id: int):
require_permission(request, "skill.manage")
return SkillVersion.objects.filter(definition_id=skill_id)
@router.post("/{skill_id}/publish", response=SkillVersionOut)
def publish_skill(request, skill_id: int):
require_permission(request, "skill.manage")
with transaction.atomic():
definition = SkillDefinition.objects.select_for_update().get(id=skill_id)
draft = SkillVersion.objects.select_for_update().get(
definition=definition,
status="draft",
)
try:
validate_skill_version(draft)
except SkillValidationError as error:
raise HttpError(400, str(error)) from error
draft.status = "published"
draft.published_by = request.user
draft.published_at = timezone.now()
draft.save(update_fields=["status", "published_by", "published_at", "updated_at"])
definition.active_version = draft
definition.save(update_fields=["active_version", "updated_at"])
audit(request.user, "publish", definition, {"version": draft.version})
return draft
@router.post("/{skill_id}/rollback/{version}", response=SkillDetailOut)
def rollback_skill(request, skill_id: int, version: int):
require_permission(request, "skill.manage")
definition = SkillDefinition.objects.select_related("active_version").get(id=skill_id)
target = SkillVersion.objects.get(
definition=definition,
version=version,
status="published",
)
definition.active_version = target
definition.save(update_fields=["active_version", "updated_at"])
audit(request.user, "rollback", definition, {"version": target.version})
return serialize_definition(definition, detailed=True)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SkillsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.skills"

View File

@@ -0,0 +1,121 @@
# Generated by Django 5.2.15 on 2026-07-14 05:47
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="SkillDefinition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("code", models.SlugField(max_length=80, unique=True)),
("name", models.CharField(max_length=120)),
("description", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_skills",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="SkillVersion",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("version", models.PositiveIntegerField()),
("status", models.CharField(default="draft", max_length=24)),
("prompt_template", models.TextField()),
("input_schema", models.JSONField(default=dict)),
("output_schema", models.JSONField(default=dict)),
("example_input", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("published_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_skill_versions",
to=settings.AUTH_USER_MODEL,
),
),
(
"definition",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="skills.skilldefinition",
),
),
(
"published_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="published_skill_versions",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-version"],
},
),
migrations.AddField(
model_name="skilldefinition",
name="active_version",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="active_for_definitions",
to="skills.skillversion",
),
),
migrations.AddConstraint(
model_name="skillversion",
constraint=models.UniqueConstraint(
fields=("definition", "version"), name="unique_skill_version"
),
),
migrations.AddConstraint(
model_name="skillversion",
constraint=models.UniqueConstraint(
condition=models.Q(("status", "draft")),
fields=("definition",),
name="unique_skill_draft",
),
),
]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,73 @@
from django.conf import settings
from django.db import models
from django.db.models import Q
class SkillDefinition(models.Model):
code = models.SlugField(max_length=80, unique=True)
name = models.CharField(max_length=120)
description = models.TextField(blank=True)
active_version = models.ForeignKey(
"SkillVersion",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="active_for_definitions",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_skills",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
class SkillVersion(models.Model):
definition = models.ForeignKey(
SkillDefinition,
on_delete=models.CASCADE,
related_name="versions",
)
version = models.PositiveIntegerField()
status = models.CharField(max_length=24, default="draft")
prompt_template = models.TextField()
input_schema = models.JSONField(default=dict)
output_schema = models.JSONField(default=dict)
example_input = models.JSONField(default=dict, blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_skill_versions",
)
published_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="published_skill_versions",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
published_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-version"]
constraints = [
models.UniqueConstraint(
fields=["definition", "version"],
name="unique_skill_version",
),
models.UniqueConstraint(
fields=["definition"],
condition=Q(status="draft"),
name="unique_skill_draft",
),
]

View File

@@ -0,0 +1,53 @@
from datetime import datetime
from typing import Any
from ninja import Schema
class SkillCreateIn(Schema):
code: str
name: str
description: str = ""
prompt_template: str = ""
input_schema: dict[str, Any] = {"type": "object"}
output_schema: dict[str, Any] = {"type": "object"}
example_input: dict[str, Any] = {}
class SkillDefinitionUpdateIn(Schema):
name: str | None = None
description: str | None = None
class SkillDraftUpdateIn(Schema):
prompt_template: str | None = None
input_schema: dict[str, Any] | None = None
output_schema: dict[str, Any] | None = None
example_input: dict[str, Any] | None = None
class SkillVersionOut(Schema):
id: int
version: int
status: str
prompt_template: str
input_schema: dict[str, Any]
output_schema: dict[str, Any]
example_input: dict[str, Any]
created_at: datetime
updated_at: datetime
published_at: datetime | None
class SkillDefinitionOut(Schema):
id: int
code: str
name: str
description: str
active_version: int | None
draft_version: int | None
updated_at: datetime
class SkillDetailOut(SkillDefinitionOut):
versions: list[SkillVersionOut]

View File

@@ -0,0 +1,96 @@
import json
import re
from copy import deepcopy
from typing import Any
from jsonschema import Draft202012Validator, ValidationError
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_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.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"},
example_input=deepcopy(source.example_input) if source else {},
created_by=user,
)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,58 @@
import pytest
from apps.skills.models import SkillDefinition, SkillVersion
from apps.skills.services import (
SkillValidationError,
render_prompt,
validate_skill_version,
)
VALID_INPUT_SCHEMA = {
"type": "object",
"properties": {"problem": {"type": "string"}},
"required": ["problem"],
}
VALID_OUTPUT_SCHEMA = {
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
}
def make_version(**overrides):
definition = SkillDefinition.objects.create(code="summarize", name="问题摘要")
values = {
"definition": definition,
"version": 1,
"prompt_template": "请总结:{{ input.problem }}",
"input_schema": VALID_INPUT_SCHEMA,
"output_schema": VALID_OUTPUT_SCHEMA,
"example_input": {"problem": "无法登录"},
}
values.update(overrides)
return SkillVersion.objects.create(**values)
def test_validates_and_renders_restricted_template(db):
version = make_version()
validate_skill_version(version)
assert render_prompt(version.prompt_template, {"problem": "无法登录"}) == "请总结:无法登录"
def test_rejects_expression_or_missing_variable(db):
version = make_version(prompt_template="{{ steps.previous.output }}")
with pytest.raises(SkillValidationError, match="不允许的模板变量"):
validate_skill_version(version)
with pytest.raises(SkillValidationError, match="模板变量不存在"):
render_prompt("{{ input.environment }}", {"problem": "无法登录"})
def test_rejects_example_that_does_not_match_schema(db):
version = make_version(example_input={"problem": 123})
with pytest.raises(SkillValidationError, match="示例输入不符合"):
validate_skill_version(version)

View File

@@ -0,0 +1,79 @@
from django.contrib.auth import get_user_model
from django.core.management import call_command
VALID_SKILL = {
"code": "issue-structure",
"name": "问题结构化",
"description": "提取问题关键信息",
"prompt_template": "请分析:{{ input.problem }}",
"input_schema": {
"type": "object",
"properties": {"problem": {"type": "string"}},
"required": ["problem"],
},
"output_schema": {
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
},
"example_input": {"problem": "采集任务没有进度"},
}
def login_admin(client):
call_command("bootstrap_governance")
user = get_user_model().objects.get(username="admin")
client.force_login(user)
return user
def test_skill_draft_publish_clone_and_rollback(client, db):
login_admin(client)
created = client.post("/api/skills", data=VALID_SKILL, content_type="application/json")
assert created.status_code == 200
skill_id = created.json()["id"]
published_v1 = client.post(f"/api/skills/{skill_id}/publish")
assert published_v1.status_code == 200
assert published_v1.json()["version"] == 1
assert published_v1.json()["status"] == "published"
draft_v2 = client.post(f"/api/skills/{skill_id}/draft")
assert draft_v2.status_code == 200
assert draft_v2.json()["version"] == 2
updated = client.patch(
f"/api/skills/{skill_id}/draft",
data={"prompt_template": "新版:{{ input.problem }}"},
content_type="application/json",
)
assert updated.status_code == 200
assert updated.json()["prompt_template"].startswith("新版")
published_v2 = client.post(f"/api/skills/{skill_id}/publish")
assert published_v2.status_code == 200
rolled_back = client.post(f"/api/skills/{skill_id}/rollback/1")
assert rolled_back.status_code == 200
assert rolled_back.json()["active_version"] == 1
def test_invalid_schema_or_template_cannot_be_saved(client, db):
login_admin(client)
created = client.post("/api/skills", data=VALID_SKILL, content_type="application/json")
skill_id = created.json()["id"]
invalid = client.patch(
f"/api/skills/{skill_id}/draft",
data={"prompt_template": "{{ input.problem.upper() }}"},
content_type="application/json",
)
assert invalid.status_code == 400
def test_skill_permission_is_required(client, db):
user = get_user_model().objects.create_user(username="requester", password="password")
client.force_login(user)
response = client.get("/api/skills")
assert response.status_code == 403

View File

@@ -22,6 +22,7 @@ INSTALLED_APPS = [
"apps.solutions",
"apps.knowledge",
"apps.modelhub",
"apps.skills",
]
MIDDLEWARE = [

View File

@@ -6,6 +6,7 @@ from apps.accounts.api import router as accounts_router
from apps.audit.api import router as audit_router
from apps.knowledge.api import router as knowledge_router
from apps.modelhub.api import router as modelhub_router
from apps.skills.api import router as skills_router
from apps.solutions.api import router as solutions_router
from apps.system.api import router as system_router
from apps.tickets.api import router as tickets_router
@@ -18,6 +19,7 @@ api.add_router("/tickets", tickets_router)
api.add_router("/solutions", solutions_router)
api.add_router("/knowledge", knowledge_router)
api.add_router("/modelhub", modelhub_router)
api.add_router("/skills", skills_router)
urlpatterns = [
path("admin/", admin.site.urls),