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:
parent
8a526599ab
commit
43e05ee38d
@ -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 测试)
|
||||
|
||||
@ -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-0001):API 传入值会被忽略
|
||||
version: int = 1
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -70,6 +70,7 @@ export interface Scenario {
|
||||
tags: string[]
|
||||
cases: any[]
|
||||
model_bindings: Record<string, string>
|
||||
version: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@ -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>),
|
||||
|
||||
29
migrations/versions/c8e2f5a7b901_add_version_to_scenarios.py
Normal file
29
migrations/versions/c8e2f5a7b901_add_version_to_scenarios.py
Normal 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")
|
||||
128
tests/integration/test_scenarios_api.py
Normal file
128
tests/integration/test_scenarios_api.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user