From ed3f6e85167ed9a19e29a0a39134f60a6ec6ecd7 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 14 Jul 2026 13:47:36 +0800 Subject: [PATCH] feat: add versioned skill configuration --- backend/apps/skills/__init__.py | 1 + backend/apps/skills/api.py | 176 ++++++++++++++++++ backend/apps/skills/apps.py | 6 + .../apps/skills/migrations/0001_initial.py | 121 ++++++++++++ backend/apps/skills/migrations/__init__.py | 1 + backend/apps/skills/models.py | 73 ++++++++ backend/apps/skills/schemas.py | 53 ++++++ backend/apps/skills/services.py | 96 ++++++++++ backend/apps/skills/tests/__init__.py | 1 + backend/apps/skills/tests/test_services.py | 58 ++++++ backend/apps/skills/tests/test_skills_api.py | 79 ++++++++ backend/config/settings/base.py | 1 + backend/config/urls.py | 2 + 13 files changed, 668 insertions(+) create mode 100644 backend/apps/skills/__init__.py create mode 100644 backend/apps/skills/api.py create mode 100644 backend/apps/skills/apps.py create mode 100644 backend/apps/skills/migrations/0001_initial.py create mode 100644 backend/apps/skills/migrations/__init__.py create mode 100644 backend/apps/skills/models.py create mode 100644 backend/apps/skills/schemas.py create mode 100644 backend/apps/skills/services.py create mode 100644 backend/apps/skills/tests/__init__.py create mode 100644 backend/apps/skills/tests/test_services.py create mode 100644 backend/apps/skills/tests/test_skills_api.py diff --git a/backend/apps/skills/__init__.py b/backend/apps/skills/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/skills/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/skills/api.py b/backend/apps/skills/api.py new file mode 100644 index 0000000..555eaaa --- /dev/null +++ b/backend/apps/skills/api.py @@ -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) diff --git a/backend/apps/skills/apps.py b/backend/apps/skills/apps.py new file mode 100644 index 0000000..5fb1dd4 --- /dev/null +++ b/backend/apps/skills/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SkillsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.skills" diff --git a/backend/apps/skills/migrations/0001_initial.py b/backend/apps/skills/migrations/0001_initial.py new file mode 100644 index 0000000..2b03c09 --- /dev/null +++ b/backend/apps/skills/migrations/0001_initial.py @@ -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", + ), + ), + ] diff --git a/backend/apps/skills/migrations/__init__.py b/backend/apps/skills/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/skills/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/skills/models.py b/backend/apps/skills/models.py new file mode 100644 index 0000000..7191dfe --- /dev/null +++ b/backend/apps/skills/models.py @@ -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", + ), + ] diff --git a/backend/apps/skills/schemas.py b/backend/apps/skills/schemas.py new file mode 100644 index 0000000..0777dd1 --- /dev/null +++ b/backend/apps/skills/schemas.py @@ -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] diff --git a/backend/apps/skills/services.py b/backend/apps/skills/services.py new file mode 100644 index 0000000..6950a8b --- /dev/null +++ b/backend/apps/skills/services.py @@ -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, + ) diff --git a/backend/apps/skills/tests/__init__.py b/backend/apps/skills/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/skills/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/skills/tests/test_services.py b/backend/apps/skills/tests/test_services.py new file mode 100644 index 0000000..79e417c --- /dev/null +++ b/backend/apps/skills/tests/test_services.py @@ -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) diff --git a/backend/apps/skills/tests/test_skills_api.py b/backend/apps/skills/tests/test_skills_api.py new file mode 100644 index 0000000..66253e4 --- /dev/null +++ b/backend/apps/skills/tests/test_skills_api.py @@ -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 diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index f8e8de8..fcc29f8 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -22,6 +22,7 @@ INSTALLED_APPS = [ "apps.solutions", "apps.knowledge", "apps.modelhub", + "apps.skills", ] MIDDLEWARE = [ diff --git a/backend/config/urls.py b/backend/config/urls.py index ee5aa8a..a9ca9a7 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -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),