feat: add versioned skill configuration
This commit is contained in:
1
backend/apps/skills/__init__.py
Normal file
1
backend/apps/skills/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
176
backend/apps/skills/api.py
Normal file
176
backend/apps/skills/api.py
Normal 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)
|
||||||
6
backend/apps/skills/apps.py
Normal file
6
backend/apps/skills/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SkillsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.skills"
|
||||||
121
backend/apps/skills/migrations/0001_initial.py
Normal file
121
backend/apps/skills/migrations/0001_initial.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/apps/skills/migrations/__init__.py
Normal file
1
backend/apps/skills/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
73
backend/apps/skills/models.py
Normal file
73
backend/apps/skills/models.py
Normal 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",
|
||||||
|
),
|
||||||
|
]
|
||||||
53
backend/apps/skills/schemas.py
Normal file
53
backend/apps/skills/schemas.py
Normal 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]
|
||||||
96
backend/apps/skills/services.py
Normal file
96
backend/apps/skills/services.py
Normal 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,
|
||||||
|
)
|
||||||
1
backend/apps/skills/tests/__init__.py
Normal file
1
backend/apps/skills/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
58
backend/apps/skills/tests/test_services.py
Normal file
58
backend/apps/skills/tests/test_services.py
Normal 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)
|
||||||
79
backend/apps/skills/tests/test_skills_api.py
Normal file
79
backend/apps/skills/tests/test_skills_api.py
Normal 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
|
||||||
@@ -22,6 +22,7 @@ INSTALLED_APPS = [
|
|||||||
"apps.solutions",
|
"apps.solutions",
|
||||||
"apps.knowledge",
|
"apps.knowledge",
|
||||||
"apps.modelhub",
|
"apps.modelhub",
|
||||||
|
"apps.skills",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from apps.accounts.api import router as accounts_router
|
|||||||
from apps.audit.api import router as audit_router
|
from apps.audit.api import router as audit_router
|
||||||
from apps.knowledge.api import router as knowledge_router
|
from apps.knowledge.api import router as knowledge_router
|
||||||
from apps.modelhub.api import router as modelhub_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.solutions.api import router as solutions_router
|
||||||
from apps.system.api import router as system_router
|
from apps.system.api import router as system_router
|
||||||
from apps.tickets.api import router as tickets_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("/solutions", solutions_router)
|
||||||
api.add_router("/knowledge", knowledge_router)
|
api.add_router("/knowledge", knowledge_router)
|
||||||
api.add_router("/modelhub", modelhub_router)
|
api.add_router("/modelhub", modelhub_router)
|
||||||
|
api.add_router("/skills", skills_router)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
|||||||
Reference in New Issue
Block a user