diff --git a/.scratch/v0.5/issues/03-scenario-version-field.md b/.scratch/v0.5/issues/03-scenario-version-field.md index be4fdb0..aa69f08 100644 --- a/.scratch/v0.5/issues/03-scenario-version-field.md +++ b/.scratch/v0.5/issues/03-scenario-version-field.md @@ -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 测试) diff --git a/backend/agenteval/models.py b/backend/agenteval/models.py index bdc92b4..4783bbe 100644 --- a/backend/agenteval/models.py +++ b/backend/agenteval/models.py @@ -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 diff --git a/backend/agenteval/storage/db.py b/backend/agenteval/storage/db.py index 2997814..6b83890 100644 --- a/backend/agenteval/storage/db.py +++ b/backend/agenteval/storage/db.py @@ -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) diff --git a/backend/agenteval/storage/repository.py b/backend/agenteval/storage/repository.py index 819e8d6..da4ae1e 100644 --- a/backend/agenteval/storage/repository.py +++ b/backend/agenteval/storage/repository.py @@ -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) diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index 1aa7fc2..9f056a3 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -70,6 +70,7 @@ export interface Scenario { tags: string[] cases: any[] model_bindings: Record + version: number created_at: string updated_at: string } diff --git a/frontend/web/src/pages/Scenarios.tsx b/frontend/web/src/pages/Scenarios.tsx index 1be5c7a..9d4f665 100644 --- a/frontend/web/src/pages/Scenarios.tsx +++ b/frontend/web/src/pages/Scenarios.tsx @@ -175,6 +175,9 @@ export default function ScenariosPage() { { title: '名称', dataIndex: 'name', key: 'name', width: 200, render: (v: string) => {v}, }, + { title: '版本', dataIndex: 'version', key: 'version', width: 70, + render: (v: number) => v{v ?? 1}, + }, { title: '描述', dataIndex: 'description', key: 'description', ellipsis: true }, { title: '标签', dataIndex: 'tags', key: 'tags', width: 200, render: (tags: string[]) => tags.map((t) => {t}), diff --git a/migrations/versions/c8e2f5a7b901_add_version_to_scenarios.py b/migrations/versions/c8e2f5a7b901_add_version_to_scenarios.py new file mode 100644 index 0000000..9041744 --- /dev/null +++ b/migrations/versions/c8e2f5a7b901_add_version_to_scenarios.py @@ -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") diff --git a/tests/integration/test_scenarios_api.py b/tests/integration/test_scenarios_api.py new file mode 100644 index 0000000..19079af --- /dev/null +++ b/tests/integration/test_scenarios_api.py @@ -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