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.knowledge",
|
||||
"apps.modelhub",
|
||||
"apps.skills",
|
||||
]
|
||||
|
||||
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.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),
|
||||
|
||||
Reference in New Issue
Block a user