feat(scenario): system-maintained syllabus version (ticket 03)

场景新增整型 version(迁移回填 1,batch mode)。仅考纲字段
(cases / model_bindings / llm_config)变更时升版,元数据编辑不升版,
API 传入的 version 被忽略(ADR-0001)。前端场景列表展示版本标签。
This commit is contained in:
sinohqb 2026-07-29 10:42:35 +08:00
parent 8a526599ab
commit 43e05ee38d
8 changed files with 186 additions and 9 deletions

View File

@ -4,11 +4,11 @@
**Blocked by:** None — can start immediately.
**Status:** ready-for-agent
**Status:** done
- [ ] 新建场景 version=1数据库迁移batch mode为存量场景回填 1
- [ ] 修改用例集 / model_bindings / llm_config 任一项后 version+1
- [ ] 仅改名称/描述/标签时 version 不变
- [ ] API 请求体中携带 version 被忽略(不可外部指定)
- [ ] 场景列表/详情 API 返回 version前端场景页展示
- [ ] 集成测试覆盖升版与不升版两类编辑(先例:现有场景 API 测试)
- [x] 新建场景 version=1数据库迁移batch mode为存量场景回填 1
- [x] 修改用例集 / model_bindings / llm_config 任一项后 version+1
- [x] 仅改名称/描述/标签时 version 不变
- [x] API 请求体中携带 version 被忽略(不可外部指定)
- [x] 场景列表/详情 API 返回 version前端场景页展示
- [x] 集成测试覆盖升版与不升版两类编辑(先例:现有场景 API 测试)

View File

@ -130,6 +130,8 @@ class Scenario(BaseModel):
cases: list[Case] = Field(default_factory=list)
model_bindings: dict[ModelPurpose, str] = Field(default_factory=dict)
llm_config: Optional[dict[str, Any]] = None
# 考纲版本由系统维护ADR-0001API 传入值会被忽略
version: int = 1
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View File

@ -82,6 +82,7 @@ class ScenarioDB(SQLModel, table=True):
tags: str = "[]"
cases: str = "[]"
llm_config: Optional[str] = None
version: int = Field(default=1)
created_at: Optional[datetime] = Field(default_factory=utc_now)
updated_at: Optional[datetime] = Field(default_factory=utc_now)

View File

@ -56,7 +56,8 @@ def _scenario_to_db(scenario: Scenario) -> ScenarioDB:
updated_at=scenario.updated_at or utc_now(),
)
db.set_tags(scenario.tags)
db.set_cases([case.model_dump() for case in scenario.cases])
# mode="json" 与 update() 的考纲比较保持同一序列化形态,避免假升版
db.set_cases([case.model_dump(mode="json") for case in scenario.cases])
db.set_llm_config(scenario.llm_config)
return db
@ -71,6 +72,7 @@ def _scenario_from_db(db: ScenarioDB, session: Session) -> Scenario:
cases=[Case(**case) for case in db.get_cases()],
model_bindings=bindings,
llm_config=db.get_llm_config(),
version=db.version or 1,
created_at=db.created_at,
updated_at=db.updated_at,
)
@ -212,10 +214,21 @@ class ScenarioRepository:
bindings = {purpose.value: config_id for purpose, config_id in scenario.model_bindings.items()}
try:
ModelConfigService(self.session).validate_bindings(bindings)
# 考纲字段cases / model_bindings / llm_config变更才升版ADR-0001
# 版本由系统维护,忽略 scenario.version 的外部传入值。
new_cases = [case.model_dump(mode="json") for case in scenario.cases]
old_bindings = ScenarioModelBindingRepository(self.session).get_for_scenario(existing.id or "")
syllabus_changed = (
existing.get_cases() != new_cases
or existing.get_llm_config() != scenario.llm_config
or old_bindings != bindings
)
if syllabus_changed:
existing.version = (existing.version or 1) + 1
existing.name = scenario.name
existing.description = scenario.description
existing.set_tags(scenario.tags)
existing.set_cases([case.model_dump() for case in scenario.cases])
existing.set_cases(new_cases)
existing.set_llm_config(scenario.llm_config)
existing.updated_at = utc_now()
self.session.add(existing)

View File

@ -70,6 +70,7 @@ export interface Scenario {
tags: string[]
cases: any[]
model_bindings: Record<string, string>
version: number
created_at: string
updated_at: string
}

View File

@ -175,6 +175,9 @@ export default function ScenariosPage() {
{ title: '名称', dataIndex: 'name', key: 'name', width: 200,
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
},
{ title: '版本', dataIndex: 'version', key: 'version', width: 70,
render: (v: number) => <Tag color="geekblue">v{v ?? 1}</Tag>,
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '标签', dataIndex: 'tags', key: 'tags', width: 200,
render: (tags: string[]) => tags.map((t) => <Tag key={t}>{t}</Tag>),

View File

@ -0,0 +1,29 @@
"""add version to scenarios
Revision ID: c8e2f5a7b901
Revises: b7d4e6f81c22
Create Date: 2026-07-29
"""
from typing import Sequence, Union
import sqlalchemy as sa
import sqlmodel # noqa: F401
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c8e2f5a7b901"
down_revision: Union[str, Sequence[str], None] = "b7d4e6f81c22"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("scenarios") as batch_op:
batch_op.add_column(
sa.Column("version", sa.Integer(), nullable=False, server_default="1")
)
def downgrade() -> None:
with op.batch_alter_table("scenarios") as batch_op:
batch_op.drop_column("version")

View File

@ -0,0 +1,128 @@
"""Integration tests for /api/scenarios versioning (ticket 03).
场景版本由系统维护ADR-0001仅考纲字段cases / model_bindings /
llm_config变更时递增元数据编辑不升版API 不接受外部指定版本
"""
import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from agenteval.services.model_configs import ModelConfigService, SecretCipher
from agenteval.web.app import app
pytestmark = pytest.mark.anyio
@pytest.fixture()
def scenario_client(db_session, monkeypatch):
from agenteval.web import app as app_module
monkeypatch.setattr(app_module, "init_db", lambda: None)
from agenteval.web.deps import get_db
def _test_get_db():
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = _test_get_db
yield db_session
app.dependency_overrides.clear()
def _client() -> AsyncClient:
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
_BODY = {
"name": "版本测试场景",
"description": "初始",
"tags": ["v"],
"cases": [{"id": "c1", "type": "single", "messages": ["hi"]}],
}
async def _create(client) -> dict:
resp = await client.post("/api/scenarios", json=_BODY)
assert resp.status_code == 200, resp.text
return resp.json()
async def test_create_scenario_version_starts_at_1(scenario_client):
async with _client() as client:
created = await _create(client)
assert created["version"] == 1
async def test_editing_cases_bumps_version(scenario_client):
async with _client() as client:
created = await _create(client)
body = {**_BODY, "cases": _BODY["cases"] + [{"id": "c2", "type": "single", "messages": ["yo"]}]}
resp = await client.put(f"/api/scenarios/{created['id']}", json=body)
assert resp.status_code == 200
assert resp.json()["version"] == 2
async def test_editing_llm_config_bumps_version(scenario_client):
async with _client() as client:
created = await _create(client)
body = {**_BODY, "llm_config": {"model": "gpt-x"}}
resp = await client.put(f"/api/scenarios/{created['id']}", json=body)
assert resp.json()["version"] == 2
async def test_editing_model_bindings_bumps_version(scenario_client, db_session):
service = ModelConfigService(db_session, SecretCipher(Fernet.generate_key().decode("ascii")))
config = service.create(
name="judge", provider="openai_compatible", capability="chat",
endpoint_url="https://m.example.com/v1/chat/completions",
model_name="m", api_key="k", enabled=True, is_default=False, description="",
)
async with _client() as client:
created = await _create(client)
body = {**_BODY, "model_bindings": {"judge": config.id}}
resp = await client.put(f"/api/scenarios/{created['id']}", json=body)
assert resp.status_code == 200, resp.text
assert resp.json()["version"] == 2
async def test_metadata_edit_does_not_bump_version(scenario_client):
async with _client() as client:
created = await _create(client)
body = {**_BODY, "name": "改名了", "description": "新描述", "tags": ["x", "y"]}
resp = await client.put(f"/api/scenarios/{created['id']}", json=body)
assert resp.status_code == 200
assert resp.json()["version"] == 1
assert resp.json()["name"] == "改名了"
async def test_resaving_identical_syllabus_does_not_bump_version(scenario_client):
"""原样重存(考纲逐字节相同)不升版——序列化形态回归护栏。"""
async with _client() as client:
created = await _create(client)
resp = await client.put(f"/api/scenarios/{created['id']}", json=_BODY)
assert resp.status_code == 200
assert resp.json()["version"] == 1
async def test_external_version_is_ignored(scenario_client):
async with _client() as client:
resp = await client.post("/api/scenarios", json={**_BODY, "version": 99})
assert resp.json()["version"] == 1
created = resp.json()
body = {**_BODY, "version": 42} # 元数据未变、考纲未变 → 版本保持 1
resp = await client.put(f"/api/scenarios/{created['id']}", json=body)
assert resp.json()["version"] == 1
async def test_list_and_get_return_version(scenario_client):
async with _client() as client:
created = await _create(client)
listed = (await client.get("/api/scenarios")).json()
assert all("version" in s for s in listed)
got = (await client.get(f"/api/scenarios/{created['id']}")).json()
assert got["version"] == 1