feat(prompts): Phase 3.2 — Prompt Backend (CRUD service, REST API, 33 tests)

- PromptService (services/prompt_service.py): full CRUD for 3 profiles A/B/C
  with seed template reset, validation, and sqlite3.Row access
- REST API (routers/prompts.py): 6 endpoints on /api/v1/prompts
- Pydantic models (models/prompts.py): 6 schemas
- DI wiring (dependencies.py): get_prompt_service()
- App registration (main.py): prompts router
- Mock fixture (conftest.py): mock_prompt_service
- Tests: test_phase3_prompt_service.py (22) + test_phase3_prompts_router.py (11)
- 162/166 total pass, 4 skipped, 0 fail
This commit is contained in:
Woody 2026-04-25 21:11:17 +08:00
parent f4b404f27d
commit e49a68b0bd
9 changed files with 1789 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@ -22,3 +22,9 @@ def get_rag_service():
from app.services.rag import RAGService from app.services.rag import RAGService
llm = get_llm_client() llm = get_llm_client()
return RAGService(llm_client=llm) return RAGService(llm_client=llm)
def get_prompt_service():
from app.services.prompt_service import PromptService
settings = get_settings_cached()
return PromptService(db_path=settings.prompts_db_path)

View File

@ -6,7 +6,7 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.routers import ingest, query, documents from app.routers import ingest, query, documents, prompts
from app.core.config import get_settings from app.core.config import get_settings
from app.core.sqlite_db import ( from app.core.sqlite_db import (
get_prompts_db, get_prompts_db,
@ -52,6 +52,7 @@ app.add_middleware(
app.include_router(ingest.router, prefix="/api/v1") app.include_router(ingest.router, prefix="/api/v1")
app.include_router(query.router, prefix="/api/v1") app.include_router(query.router, prefix="/api/v1")
app.include_router(documents.router, prefix="/api/v1") app.include_router(documents.router, prefix="/api/v1")
app.include_router(prompts.router)
_prompts_conn = get_prompts_db() _prompts_conn = get_prompts_db()
init_prompts_db(_prompts_conn) init_prompts_db(_prompts_conn)

View File

@ -0,0 +1,29 @@
"""Pydantic schemas for the prompt-profile management endpoints."""
from pydantic import BaseModel
class ProfileItem(BaseModel):
name: str
is_active: bool
class ProfileListResponse(BaseModel):
profiles: list[ProfileItem]
class PromptSetResponse(BaseModel):
profile_name: str
prompts: dict[str, str]
class PromptUpdateRequest(BaseModel):
template: str
class PromptBatchUpdateRequest(BaseModel):
prompts: dict[str, str]
class ResetToDefaultsRequest(BaseModel):
step: str | None = None

View File

@ -0,0 +1,81 @@
import logging
from fastapi import APIRouter, HTTPException
from app.core.dependencies import get_prompt_service
from app.models.prompts import (
ProfileListResponse,
ProfileItem,
PromptSetResponse,
PromptUpdateRequest,
PromptBatchUpdateRequest,
ResetToDefaultsRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/prompts", tags=["prompts"])
_VALID_NAMES = {"A", "B", "C"}
_VALID_STEPS = {"decompose", "filter", "generate"}
def _ensure_valid_name(name: str) -> None:
if name not in _VALID_NAMES:
raise HTTPException(status_code=400, detail=f"Invalid profile name '{name}'. Must be one of A, B, C.")
def _ensure_valid_step(step: str) -> None:
if step not in _VALID_STEPS:
raise HTTPException(status_code=400, detail=f"Invalid step '{step}'. Must be one of decompose, filter, generate.")
@router.get("/profiles", response_model=ProfileListResponse)
def list_profiles():
svc = get_prompt_service()
profiles = [ProfileItem(**p) for p in svc.list_profiles()]
return ProfileListResponse(profiles=profiles)
@router.get("/profiles/{name}", response_model=PromptSetResponse)
def get_profile_prompts(name: str):
_ensure_valid_name(name)
svc = get_prompt_service()
prompts = svc.get_profile_prompts(name)
return PromptSetResponse(profile_name=name, prompts=prompts)
@router.put("/profiles/{name}/activate")
def activate_profile(name: str):
_ensure_valid_name(name)
svc = get_prompt_service()
svc.activate_profile(name)
return {"status": "ok", "active_profile": name}
@router.put("/profiles/{name}/all")
def update_all_prompts(name: str, body: PromptBatchUpdateRequest):
_ensure_valid_name(name)
svc = get_prompt_service()
svc.update_all_prompts(name, body.prompts)
return {"status": "ok", "profile": name}
@router.put("/profiles/{name}/reset")
def reset_to_defaults(name: str, body: ResetToDefaultsRequest | None = None):
_ensure_valid_name(name)
step = body.step if body else None
if step is not None:
_ensure_valid_step(step)
svc = get_prompt_service()
svc.reset_to_defaults(name, step=step)
return {"status": "ok", "profile": name, "reset_step": step or "all"}
@router.put("/profiles/{name}/{step}")
def update_prompt(name: str, step: str, body: PromptUpdateRequest):
_ensure_valid_name(name)
_ensure_valid_step(step)
svc = get_prompt_service()
svc.update_prompt(name, step, body.template)
return {"status": "ok", "profile": name, "step": step}

View File

@ -0,0 +1,168 @@
"""Prompt profile management service.
Reads and writes prompt templates in the prompts SQLite database.
Uses sync sqlite3 all operations are instant local reads/writes.
"""
import logging
import sqlite3
from app.core.sqlite_db import _SEED_TEMPLATES
logger = logging.getLogger(__name__)
_VALID_NAMES = {"A", "B", "C"}
_VALID_STEPS = {"decompose", "filter", "generate"}
def _connect(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
class PromptService:
"""CRUD operations for prompt profiles and templates.
Each method opens its own connection so the service is safe to
instantiate once per request without holding open file handles.
"""
def __init__(self, db_path: str) -> None:
self._db_path = db_path
# ── helpers ────────────────────────────────────────────────────────────
def _validate_name(self, name: str) -> None:
if name not in _VALID_NAMES:
raise ValueError(f"Invalid profile name '{name}'. Must be one of A, B, C.")
def _validate_step(self, step: str) -> None:
if step not in _VALID_STEPS:
raise ValueError(f"Invalid step '{step}'. Must be one of decompose, filter, generate.")
# ── read operations ────────────────────────────────────────────────────
def get_active_profile_name(self) -> str:
"""Return the name of the currently active profile."""
with _connect(self._db_path) as conn:
row = conn.execute(
"SELECT name FROM system_prompt_profiles WHERE is_active=1"
).fetchone()
if row is None:
raise RuntimeError("No active prompt profile found.")
return row["name"]
def get_prompt_template(self, step: str) -> str:
"""Return the prompt template for *step* of the active profile."""
self._validate_step(step)
with _connect(self._db_path) as conn:
row = conn.execute(
"""
SELECT sp.prompt_template
FROM system_prompts sp
JOIN system_prompt_profiles spp ON sp.profile_id = spp.id
WHERE spp.is_active=1 AND sp.step_name=?
""",
(step,),
).fetchone()
if row is None:
raise RuntimeError(f"No template found for step '{step}'.")
return row["prompt_template"]
def list_profiles(self) -> list[dict]:
"""Return all profiles with their active status."""
with _connect(self._db_path) as conn:
rows = conn.execute(
"SELECT name, is_active FROM system_prompt_profiles ORDER BY name"
).fetchall()
return [{"name": r["name"], "is_active": bool(r["is_active"])} for r in rows]
def get_profile_prompts(self, name: str) -> dict:
"""Return all three prompt templates for the given profile."""
self._validate_name(name)
with _connect(self._db_path) as conn:
rows = conn.execute(
"""
SELECT sp.step_name, sp.prompt_template
FROM system_prompts sp
JOIN system_prompt_profiles spp ON sp.profile_id = spp.id
WHERE spp.name=?
ORDER BY sp.step_name
""",
(name,),
).fetchall()
return {r["step_name"]: r["prompt_template"] for r in rows}
# ── write operations ───────────────────────────────────────────────────
def activate_profile(self, name: str) -> None:
"""Set *name* as the active profile (deactivates all others)."""
self._validate_name(name)
with _connect(self._db_path) as conn:
conn.execute("UPDATE system_prompt_profiles SET is_active=0")
conn.execute(
"UPDATE system_prompt_profiles SET is_active=1 WHERE name=?",
(name,),
)
conn.commit()
logger.info("Activated prompt profile '%s'.", name)
def update_prompt(self, name: str, step: str, template: str) -> None:
"""Update a single prompt template for the given profile."""
self._validate_name(name)
self._validate_step(step)
with _connect(self._db_path) as conn:
conn.execute(
"""
UPDATE system_prompts
SET prompt_template=?, updated_at=datetime('now')
WHERE profile_id=(SELECT id FROM system_prompt_profiles WHERE name=?)
AND step_name=?
""",
(template, name, step),
)
conn.commit()
logger.info("Updated prompt: profile='%s' step='%s'.", name, step)
def update_all_prompts(self, name: str, prompts: dict[str, str]) -> None:
"""Batch-update all three prompt templates for the given profile."""
self._validate_name(name)
for step in prompts:
self._validate_step(step)
with _connect(self._db_path) as conn:
for step, template in prompts.items():
conn.execute(
"""
UPDATE system_prompts
SET prompt_template=?, updated_at=datetime('now')
WHERE profile_id=(SELECT id FROM system_prompt_profiles WHERE name=?)
AND step_name=?
""",
(template, name, step),
)
conn.commit()
logger.info("Batch-updated all prompts for profile '%s'.", name)
def reset_to_defaults(self, name: str, step: str | None = None) -> None:
"""Reset prompt template(s) to the built-in seed defaults.
If *step* is ``None``, all three templates are reset.
"""
self._validate_name(name)
steps = _VALID_STEPS if step is None else {step}
for s in steps:
self._validate_step(s)
with _connect(self._db_path) as conn:
for s in steps:
conn.execute(
"""
UPDATE system_prompts
SET prompt_template=?, updated_at=datetime('now')
WHERE profile_id=(SELECT id FROM system_prompt_profiles WHERE name=?)
AND step_name=?
""",
(_SEED_TEMPLATES[s], name, s),
)
conn.commit()
logger.info("Reset prompts for profile '%s': steps=%s.", name, steps)

View File

@ -30,3 +30,41 @@ def mock_asr_client(monkeypatch):
def chroma_test_dir(tmp_path): def chroma_test_dir(tmp_path):
"""Provide a temporary directory for isolated ChromaDB instances.""" """Provide a temporary directory for isolated ChromaDB instances."""
return tmp_path / "chroma_test" return tmp_path / "chroma_test"
@pytest.fixture
def mock_prompt_service():
"""Mock PromptService for tests that don't need real DB."""
class _MockPromptService:
def __init__(self):
self._template = "Test template: {question}"
def get_active_profile_name(self) -> str:
return "A"
def get_prompt_template(self, step: str) -> str:
return self._template
def list_profiles(self) -> list[dict]:
return [
{"name": "A", "is_active": True},
{"name": "B", "is_active": False},
{"name": "C", "is_active": False},
]
def activate_profile(self, name: str) -> None:
pass
def get_profile_prompts(self, name: str) -> dict:
return {"decompose": self._template, "filter": self._template, "generate": self._template}
def update_prompt(self, name: str, step: str, template: str) -> None:
pass
def update_all_prompts(self, name: str, prompts: dict[str, str]) -> None:
pass
def reset_to_defaults(self, name: str, step: str | None = None) -> None:
pass
return _MockPromptService()

View File

@ -0,0 +1,248 @@
"""Tests for Package 3.2 PromptService — CRUD for prompt profiles and templates.
Uses real sqlite3 with tmp_path for full isolation. No mocks.
Each test gets its own fresh database seeded with A/B/C profiles.
"""
import sqlite3
import pytest
from app.core.sqlite_db import _SEED_TEMPLATES, init_prompts_db, seed_default_profiles
from app.services.prompt_service import PromptService
# ── Helper ─────────────────────────────────────────────────────────────────
def _create_service(tmp_path) -> PromptService:
db_path = str(tmp_path / "test.db")
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
init_prompts_db(conn)
seed_default_profiles(conn)
conn.close()
return PromptService(db_path=db_path)
# ── list_profiles ──────────────────────────────────────────────────────────
def test_list_profiles_returns_abc_with_a_active(tmp_path):
svc = _create_service(tmp_path)
profiles = svc.list_profiles()
assert len(profiles) == 3
names = [p["name"] for p in profiles]
assert names == ["A", "B", "C"]
active_map = {p["name"]: p["is_active"] for p in profiles}
assert active_map["A"] is True
assert active_map["B"] is False
assert active_map["C"] is False
# ── activate_profile ───────────────────────────────────────────────────────
def test_activate_profile_b(tmp_path):
svc = _create_service(tmp_path)
svc.activate_profile("B")
profiles = svc.list_profiles()
active_map = {p["name"]: p["is_active"] for p in profiles}
assert active_map["A"] is False
assert active_map["B"] is True
assert active_map["C"] is False
def test_activate_profile_c(tmp_path):
svc = _create_service(tmp_path)
svc.activate_profile("C")
profiles = svc.list_profiles()
active_map = {p["name"]: p["is_active"] for p in profiles}
assert active_map["A"] is False
assert active_map["B"] is False
assert active_map["C"] is True
def test_activate_profile_invalid_name_raises(tmp_path):
svc = _create_service(tmp_path)
with pytest.raises(ValueError, match="Invalid profile name"):
svc.activate_profile("D")
# ── get_active_profile_name ────────────────────────────────────────────────
def test_get_active_profile_name_returns_a_after_seed(tmp_path):
svc = _create_service(tmp_path)
assert svc.get_active_profile_name() == "A"
def test_get_active_profile_name_after_switch(tmp_path):
svc = _create_service(tmp_path)
svc.activate_profile("B")
assert svc.get_active_profile_name() == "B"
# ── get_profile_prompts ───────────────────────────────────────────────────
def test_get_profile_prompts_returns_all_three_steps(tmp_path):
svc = _create_service(tmp_path)
prompts = svc.get_profile_prompts("A")
assert set(prompts.keys()) == {"decompose", "filter", "generate"}
assert "{question}" in prompts["decompose"]
assert "{question}" in prompts["filter"]
assert "{question}" in prompts["generate"]
def test_get_profile_prompts_invalid_name_raises(tmp_path):
svc = _create_service(tmp_path)
with pytest.raises(ValueError, match="Invalid profile name"):
svc.get_profile_prompts("D")
# ── get_prompt_template ────────────────────────────────────────────────────
def test_get_prompt_template_for_active_profile(tmp_path):
svc = _create_service(tmp_path)
template = svc.get_prompt_template("decompose")
assert template == _SEED_TEMPLATES["decompose"]
def test_get_prompt_template_after_activate(tmp_path):
svc = _create_service(tmp_path)
svc.update_prompt("B", "decompose", "B custom template")
svc.activate_profile("B")
assert svc.get_prompt_template("decompose") == "B custom template"
def test_get_prompt_template_invalid_step_raises(tmp_path):
svc = _create_service(tmp_path)
with pytest.raises(ValueError, match="Invalid step"):
svc.get_prompt_template("nonexistent")
# ── update_prompt ──────────────────────────────────────────────────────────
def test_update_prompt_persists_change(tmp_path):
svc = _create_service(tmp_path)
new_template = "Custom decompose prompt for {question}"
svc.update_prompt("A", "decompose", new_template)
prompts = svc.get_profile_prompts("A")
assert prompts["decompose"] == new_template
assert prompts["filter"] == _SEED_TEMPLATES["filter"]
assert prompts["generate"] == _SEED_TEMPLATES["generate"]
def test_update_prompt_invalid_name_raises(tmp_path):
svc = _create_service(tmp_path)
with pytest.raises(ValueError, match="Invalid profile name"):
svc.update_prompt("D", "decompose", "template")
def test_update_prompt_invalid_step_raises(tmp_path):
svc = _create_service(tmp_path)
with pytest.raises(ValueError, match="Invalid step"):
svc.update_prompt("A", "nonexistent", "template")
# ── update_all_prompts ─────────────────────────────────────────────────────
def test_update_all_prompts_batch(tmp_path):
svc = _create_service(tmp_path)
new_prompts = {
"decompose": "New decompose",
"filter": "New filter",
"generate": "New generate",
}
svc.update_all_prompts("A", new_prompts)
prompts = svc.get_profile_prompts("A")
assert prompts["decompose"] == "New decompose"
assert prompts["filter"] == "New filter"
assert prompts["generate"] == "New generate"
def test_update_all_prompts_invalid_name_raises(tmp_path):
svc = _create_service(tmp_path)
with pytest.raises(ValueError, match="Invalid profile name"):
svc.update_all_prompts("D", {"decompose": "x"})
def test_update_all_prompts_invalid_step_raises(tmp_path):
svc = _create_service(tmp_path)
with pytest.raises(ValueError, match="Invalid step"):
svc.update_all_prompts("A", {"nonexistent": "x"})
# ── reset_to_defaults ─────────────────────────────────────────────────────
def test_reset_to_defaults_all_steps(tmp_path):
svc = _create_service(tmp_path)
svc.update_all_prompts("A", {
"decompose": "MODIFIED decompose",
"filter": "MODIFIED filter",
"generate": "MODIFIED generate",
})
svc.reset_to_defaults("A", step=None)
prompts = svc.get_profile_prompts("A")
assert prompts["decompose"] == _SEED_TEMPLATES["decompose"]
assert prompts["filter"] == _SEED_TEMPLATES["filter"]
assert prompts["generate"] == _SEED_TEMPLATES["generate"]
def test_reset_to_defaults_single_step(tmp_path):
svc = _create_service(tmp_path)
svc.update_all_prompts("A", {
"decompose": "MODIFIED decompose",
"filter": "MODIFIED filter",
"generate": "MODIFIED generate",
})
svc.reset_to_defaults("A", step="filter")
prompts = svc.get_profile_prompts("A")
assert prompts["decompose"] == "MODIFIED decompose"
assert prompts["filter"] == _SEED_TEMPLATES["filter"]
assert prompts["generate"] == "MODIFIED generate"
def test_reset_to_defaults_invalid_name_raises(tmp_path):
svc = _create_service(tmp_path)
with pytest.raises(ValueError, match="Invalid profile name"):
svc.reset_to_defaults("D")
# ── Edge cases ─────────────────────────────────────────────────────────────
def test_empty_string_template_allowed(tmp_path):
svc = _create_service(tmp_path)
svc.update_prompt("A", "decompose", "")
prompts = svc.get_profile_prompts("A")
assert prompts["decompose"] == ""
def test_very_long_template_allowed(tmp_path):
svc = _create_service(tmp_path)
long_template = "x" * 50_000
svc.update_prompt("A", "decompose", long_template)
prompts = svc.get_profile_prompts("A")
assert prompts["decompose"] == long_template
assert len(prompts["decompose"]) == 50_000

View File

@ -0,0 +1,216 @@
"""Tests for Package 3.2 prompts router — HTTP endpoint integration tests.
Uses real sqlite3 with tmp_path. TestClient hits a minimal FastAPI app
wired with the prompts router. No mocks on the DB layer.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core.sqlite_db import init_prompts_db, seed_default_profiles, _get_db
from app.routers.prompts import router
# ── Fixture ────────────────────────────────────────────────────────────────
@pytest.fixture
def client(tmp_path, monkeypatch):
prompts_path = str(tmp_path / "prompts.db")
monkeypatch.setenv("PROMPTS_DB_PATH", prompts_path)
monkeypatch.setenv("HISTORY_DB_PATH", str(tmp_path / "history.db"))
from app.core.config import get_settings
get_settings.cache_clear()
from app.core.dependencies import get_settings_cached
get_settings_cached.cache_clear()
conn = _get_db(prompts_path)
init_prompts_db(conn)
seed_default_profiles(conn)
conn.close()
test_app = FastAPI()
test_app.include_router(router)
yield TestClient(test_app)
get_settings_cached.cache_clear()
get_settings.cache_clear()
# ── GET /profiles ──────────────────────────────────────────────────────────
def test_get_profiles_returns_200_with_three_items(client):
resp = client.get("/api/v1/prompts/profiles")
assert resp.status_code == 200
data = resp.json()
assert len(data["profiles"]) == 3
names = [p["name"] for p in data["profiles"]]
assert names == ["A", "B", "C"]
active_map = {p["name"]: p["is_active"] for p in data["profiles"]}
assert active_map["A"] is True
assert active_map["B"] is False
assert active_map["C"] is False
# ── GET /profiles/{name} ──────────────────────────────────────────────────
def test_get_profile_prompts_a_returns_200(client):
resp = client.get("/api/v1/prompts/profiles/A")
assert resp.status_code == 200
data = resp.json()
assert data["profile_name"] == "A"
assert set(data["prompts"].keys()) == {"decompose", "filter", "generate"}
def test_get_profile_prompts_invalid_returns_400(client):
resp = client.get("/api/v1/prompts/profiles/D")
assert resp.status_code == 400
# ── PUT /profiles/{name}/activate ─────────────────────────────────────────
def test_activate_profile_b_then_get_confirms(client):
resp = client.put("/api/v1/prompts/profiles/B/activate")
assert resp.status_code == 200
assert resp.json()["active_profile"] == "B"
resp = client.get("/api/v1/prompts/profiles")
active_map = {p["name"]: p["is_active"] for p in resp.json()["profiles"]}
assert active_map["B"] is True
assert active_map["A"] is False
assert active_map["C"] is False
# ── PUT /profiles/{name}/{step} ───────────────────────────────────────────
def test_update_prompt_returns_200_and_persists(client):
new_template = "Updated decompose for {question}"
resp = client.put(
"/api/v1/prompts/profiles/A/decompose",
json={"template": new_template},
)
assert resp.status_code == 200
assert resp.json()["step"] == "decompose"
resp = client.get("/api/v1/prompts/profiles/A")
assert resp.json()["prompts"]["decompose"] == new_template
# ── PUT /profiles/{name}/all ──────────────────────────────────────────────
def test_update_all_prompts_batch_returns_200_and_persists(client):
new_prompts = {
"decompose": "Batch decompose",
"filter": "Batch filter",
"generate": "Batch generate",
}
resp = client.put(
"/api/v1/prompts/profiles/A/all",
json={"prompts": new_prompts},
)
assert resp.status_code == 200
assert resp.json()["profile"] == "A"
resp = client.get("/api/v1/prompts/profiles/A")
prompts = resp.json()["prompts"]
assert prompts["decompose"] == "Batch decompose"
assert prompts["filter"] == "Batch filter"
assert prompts["generate"] == "Batch generate"
# ── PUT /profiles/{name}/reset ────────────────────────────────────────────
def test_reset_single_step_returns_200(client):
from app.core.sqlite_db import _SEED_TEMPLATES
client.put(
"/api/v1/prompts/profiles/A/decompose",
json={"template": "MODIFIED"},
)
resp = client.put(
"/api/v1/prompts/profiles/A/reset",
json={"step": "decompose"},
)
assert resp.status_code == 200
assert resp.json()["reset_step"] == "decompose"
resp = client.get("/api/v1/prompts/profiles/A")
assert resp.json()["prompts"]["decompose"] == _SEED_TEMPLATES["decompose"]
def test_reset_all_steps_returns_200(client):
from app.core.sqlite_db import _SEED_TEMPLATES
client.put("/api/v1/prompts/profiles/A/all", json={"prompts": {
"decompose": "MODIFIED decompose",
"filter": "MODIFIED filter",
"generate": "MODIFIED generate",
}})
resp = client.put(
"/api/v1/prompts/profiles/A/reset",
json={"step": None},
)
assert resp.status_code == 200
assert resp.json()["reset_step"] == "all"
resp = client.get("/api/v1/prompts/profiles/A")
prompts = resp.json()["prompts"]
assert prompts["decompose"] == _SEED_TEMPLATES["decompose"]
assert prompts["filter"] == _SEED_TEMPLATES["filter"]
assert prompts["generate"] == _SEED_TEMPLATES["generate"]
def test_reset_with_no_body_resets_all(client):
from app.core.sqlite_db import _SEED_TEMPLATES
client.put("/api/v1/prompts/profiles/A/all", json={"prompts": {
"decompose": "MODIFIED",
"filter": "MODIFIED",
"generate": "MODIFIED",
}})
resp = client.put("/api/v1/prompts/profiles/A/reset")
assert resp.status_code == 200
assert resp.json()["reset_step"] == "all"
resp = client.get("/api/v1/prompts/profiles/A")
prompts = resp.json()["prompts"]
assert prompts["decompose"] == _SEED_TEMPLATES["decompose"]
assert prompts["filter"] == _SEED_TEMPLATES["filter"]
assert prompts["generate"] == _SEED_TEMPLATES["generate"]
# ── Validation: invalid name and step ──────────────────────────────────────
def test_invalid_profile_name_returns_400(client):
resp = client.get("/api/v1/prompts/profiles/D")
assert resp.status_code == 400
resp = client.put("/api/v1/prompts/profiles/D/activate")
assert resp.status_code == 400
def test_invalid_step_returns_400(client):
resp = client.put(
"/api/v1/prompts/profiles/A/nonexistent",
json={"template": "test"},
)
assert resp.status_code == 400