From 0c975a6f80e7c2c0258b7dd8332ec1c0af99b26e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 31 Mar 2026 18:05:42 +0200 Subject: [PATCH] Simplify prompts to seed-based CRUD with version tracking --- .../113_add_system_prompt_slug_to_prompts.py | 25 ++-- surfsense_backend/app/db.py | 7 +- .../app/prompts/system_defaults.py | 10 +- .../app/routes/prompts_routes.py | 121 ++---------------- surfsense_backend/app/schemas/prompts.py | 19 +-- .../contracts/types/prompts.types.ts | 22 +++- 6 files changed, 56 insertions(+), 148 deletions(-) diff --git a/surfsense_backend/alembic/versions/113_add_system_prompt_slug_to_prompts.py b/surfsense_backend/alembic/versions/113_add_system_prompt_slug_to_prompts.py index 44f042df4..9a30d4c04 100644 --- a/surfsense_backend/alembic/versions/113_add_system_prompt_slug_to_prompts.py +++ b/surfsense_backend/alembic/versions/113_add_system_prompt_slug_to_prompts.py @@ -1,4 +1,4 @@ -"""add system_prompt_slug and drop icon from prompts +"""add default_prompt_slug, version and drop icon from prompts Revision ID: 113 Revises: 112 @@ -17,23 +17,30 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: op.execute( "ALTER TABLE prompts ADD COLUMN IF NOT EXISTS" - " system_prompt_slug VARCHAR(100)" + " default_prompt_slug VARCHAR(100)" ) op.execute( - "CREATE INDEX IF NOT EXISTS ix_prompts_system_prompt_slug" - " ON prompts (system_prompt_slug)" + "CREATE INDEX IF NOT EXISTS ix_prompts_default_prompt_slug" + " ON prompts (default_prompt_slug)" ) op.execute( - "ALTER TABLE prompts ADD CONSTRAINT uq_prompt_user_system_slug" - " UNIQUE (user_id, system_prompt_slug)" + "ALTER TABLE prompts ADD CONSTRAINT uq_prompt_user_default_slug" + " UNIQUE (user_id, default_prompt_slug)" + ) + op.execute( + "ALTER TABLE prompts ADD COLUMN IF NOT EXISTS" + " version INTEGER NOT NULL DEFAULT 1" ) op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS icon") def downgrade() -> None: op.execute("ALTER TABLE prompts ADD COLUMN IF NOT EXISTS icon VARCHAR(50)") + op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS version") op.execute( - "ALTER TABLE prompts DROP CONSTRAINT IF EXISTS uq_prompt_user_system_slug" + "ALTER TABLE prompts DROP CONSTRAINT IF EXISTS uq_prompt_user_default_slug" + ) + op.execute("DROP INDEX IF EXISTS ix_prompts_default_prompt_slug") + op.execute( + "ALTER TABLE prompts DROP COLUMN IF EXISTS default_prompt_slug" ) - op.execute("DROP INDEX IF EXISTS ix_prompts_system_prompt_slug") - op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS system_prompt_slug") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index c86c7436e..e76b0d694 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1785,8 +1785,8 @@ class Prompt(BaseModel, TimestampMixin): __table_args__ = ( UniqueConstraint( "user_id", - "system_prompt_slug", - name="uq_prompt_user_system_slug", + "default_prompt_slug", + name="uq_prompt_user_default_slug", ), ) @@ -1802,10 +1802,11 @@ class Prompt(BaseModel, TimestampMixin): nullable=True, index=True, ) - system_prompt_slug = Column(String(100), nullable=True, index=True) + default_prompt_slug = Column(String(100), nullable=True, index=True) name = Column(String(200), nullable=False) prompt = Column(Text, nullable=False) mode = Column(SQLAlchemyEnum(PromptMode), nullable=False) + version = Column(Integer, nullable=False, default=1) is_public = Column(Boolean, nullable=False, default=False) user = relationship("User") diff --git a/surfsense_backend/app/prompts/system_defaults.py b/surfsense_backend/app/prompts/system_defaults.py index d3a17fc2c..aaf9b64bd 100644 --- a/surfsense_backend/app/prompts/system_defaults.py +++ b/surfsense_backend/app/prompts/system_defaults.py @@ -1,6 +1,7 @@ SYSTEM_PROMPT_DEFAULTS: list[dict] = [ { "slug": "fix-grammar", + "version": 1, "name": "Fix grammar", "prompt": ( "Fix the grammar and spelling in the following text." @@ -10,6 +11,7 @@ SYSTEM_PROMPT_DEFAULTS: list[dict] = [ }, { "slug": "make-shorter", + "version": 1, "name": "Make shorter", "prompt": ( "Make the following text more concise while preserving its meaning." @@ -19,6 +21,7 @@ SYSTEM_PROMPT_DEFAULTS: list[dict] = [ }, { "slug": "translate", + "version": 1, "name": "Translate", "prompt": ( "Translate the following text to English." @@ -29,6 +32,7 @@ SYSTEM_PROMPT_DEFAULTS: list[dict] = [ }, { "slug": "rewrite", + "version": 1, "name": "Rewrite", "prompt": ( "Rewrite the following text to improve clarity and readability." @@ -38,6 +42,7 @@ SYSTEM_PROMPT_DEFAULTS: list[dict] = [ }, { "slug": "summarize", + "version": 1, "name": "Summarize", "prompt": ( "Summarize the following text concisely." @@ -47,22 +52,23 @@ SYSTEM_PROMPT_DEFAULTS: list[dict] = [ }, { "slug": "explain", + "version": 1, "name": "Explain", "prompt": "Explain the following text in simple terms:\n\n{selection}", "mode": "explore", }, { "slug": "ask-knowledge-base", + "version": 1, "name": "Ask my knowledge base", "prompt": "Search my knowledge base for information related to:\n\n{selection}", "mode": "explore", }, { "slug": "look-up-web", + "version": 1, "name": "Look up on the web", "prompt": "Search the web for information about:\n\n{selection}", "mode": "explore", }, ] - -SYSTEM_PROMPT_SLUGS: set[str] = {p["slug"] for p in SYSTEM_PROMPT_DEFAULTS} diff --git a/surfsense_backend/app/routes/prompts_routes.py b/surfsense_backend/app/routes/prompts_routes.py index 5f9baf067..810ec2f18 100644 --- a/surfsense_backend/app/routes/prompts_routes.py +++ b/surfsense_backend/app/routes/prompts_routes.py @@ -4,35 +4,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db import Prompt, User, get_async_session -from app.prompts.system_defaults import SYSTEM_PROMPT_DEFAULTS, SYSTEM_PROMPT_SLUGS from app.schemas.prompts import ( PromptCreate, PromptRead, PromptUpdate, PublicPromptRead, - SystemPromptUpdate, ) from app.users import current_active_user router = APIRouter(tags=["Prompts"]) -def _prompt_to_read(prompt: Prompt) -> PromptRead: - source = "system" if prompt.system_prompt_slug else "custom" - return PromptRead( - id=prompt.id, - name=prompt.name, - prompt=prompt.prompt, - mode=prompt.mode.value if hasattr(prompt.mode, "value") else prompt.mode, - search_space_id=prompt.search_space_id, - is_public=prompt.is_public, - created_at=prompt.created_at, - source=source, - system_prompt_slug=prompt.system_prompt_slug, - is_modified=source == "system", - ) - - @router.get("/prompts", response_model=list[PromptRead]) async def list_prompts( search_space_id: int | None = None, @@ -42,35 +24,9 @@ async def list_prompts( query = select(Prompt).where(Prompt.user_id == user.id) if search_space_id is not None: query = query.where(Prompt.search_space_id == search_space_id) + query = query.order_by(Prompt.created_at.desc()) result = await session.execute(query) - user_prompts = result.scalars().all() - - overrides = {p.system_prompt_slug: p for p in user_prompts if p.system_prompt_slug} - custom_prompts = [p for p in user_prompts if not p.system_prompt_slug] - - merged: list[PromptRead] = [] - for default in SYSTEM_PROMPT_DEFAULTS: - slug = default["slug"] - override = overrides.get(slug) - if override: - merged.append(_prompt_to_read(override)) - else: - merged.append( - PromptRead( - id=None, - name=default["name"], - prompt=default["prompt"], - mode=default["mode"], - source="system", - system_prompt_slug=slug, - is_modified=False, - ) - ) - - for p in sorted(custom_prompts, key=lambda x: x.created_at, reverse=True): - merged.append(_prompt_to_read(p)) - - return merged + return result.scalars().all() @router.post("/prompts", response_model=PromptRead) @@ -89,7 +45,7 @@ async def create_prompt( session.add(prompt) await session.commit() await session.refresh(prompt) - return _prompt_to_read(prompt) + return prompt @router.put("/prompts/{prompt_id}", response_model=PromptRead) @@ -112,10 +68,12 @@ async def update_prompt( for field, value in body.model_dump(exclude_unset=True).items(): setattr(prompt, field, value) + prompt.version = (prompt.version or 0) + 1 + session.add(prompt) await session.commit() await session.refresh(prompt) - return _prompt_to_read(prompt) + return prompt @router.delete("/prompts/{prompt_id}") @@ -139,69 +97,6 @@ async def delete_prompt( return {"success": True} -@router.put("/prompts/system/{slug}", response_model=PromptRead) -async def update_system_prompt( - slug: str, - body: SystemPromptUpdate, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - if slug not in SYSTEM_PROMPT_SLUGS: - raise HTTPException(status_code=404, detail="System prompt not found") - - result = await session.execute( - select(Prompt).where( - Prompt.user_id == user.id, - Prompt.system_prompt_slug == slug, - ) - ) - override = result.scalar_one_or_none() - - default = next(d for d in SYSTEM_PROMPT_DEFAULTS if d["slug"] == slug) - - if override: - for field, value in body.model_dump(exclude_unset=True).items(): - setattr(override, field, value) - else: - updates = body.model_dump(exclude_unset=True) - override = Prompt( - user_id=user.id, - system_prompt_slug=slug, - name=updates.get("name", default["name"]), - prompt=updates.get("prompt", default["prompt"]), - mode=updates.get("mode", default["mode"]), - ) - - session.add(override) - await session.commit() - await session.refresh(override) - return _prompt_to_read(override) - - -@router.delete("/prompts/system/{slug}") -async def reset_system_prompt( - slug: str, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - if slug not in SYSTEM_PROMPT_SLUGS: - raise HTTPException(status_code=404, detail="System prompt not found") - - result = await session.execute( - select(Prompt).where( - Prompt.user_id == user.id, - Prompt.system_prompt_slug == slug, - ) - ) - override = result.scalar_one_or_none() - if not override: - return {"success": True} - - await session.delete(override) - await session.commit() - return {"success": True} - - @router.get("/prompts/public", response_model=list[PublicPromptRead]) async def list_public_prompts( session: AsyncSession = Depends(get_async_session), @@ -216,7 +111,7 @@ async def list_public_prompts( prompts = result.scalars().all() return [ PublicPromptRead( - **_prompt_to_read(p).model_dump(), + **PromptRead.model_validate(p).model_dump(), author_name=p.user.email if p.user else None, ) for p in prompts @@ -249,4 +144,4 @@ async def copy_public_prompt( session.add(copy) await session.commit() await session.refresh(copy) - return _prompt_to_read(copy) + return copy diff --git a/surfsense_backend/app/schemas/prompts.py b/surfsense_backend/app/schemas/prompts.py index d103eb40c..9f11520ff 100644 --- a/surfsense_backend/app/schemas/prompts.py +++ b/surfsense_backend/app/schemas/prompts.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Literal from pydantic import BaseModel, Field @@ -19,23 +18,15 @@ class PromptUpdate(BaseModel): is_public: bool | None = None -class SystemPromptUpdate(BaseModel): - name: str | None = Field(None, min_length=1, max_length=200) - prompt: str | None = Field(None, min_length=1) - mode: str | None = Field(None, pattern="^(transform|explore)$") - - class PromptRead(BaseModel): - id: int | None + id: int name: str prompt: str mode: str - search_space_id: int | None = None - is_public: bool = False - created_at: datetime | None = None - source: Literal["system", "custom"] - system_prompt_slug: str | None = None - is_modified: bool = False + search_space_id: int | None + is_public: bool + version: int + created_at: datetime class Config: from_attributes = True diff --git a/surfsense_web/contracts/types/prompts.types.ts b/surfsense_web/contracts/types/prompts.types.ts index 4d1bf3fb9..228897546 100644 --- a/surfsense_web/contracts/types/prompts.types.ts +++ b/surfsense_web/contracts/types/prompts.types.ts @@ -3,14 +3,16 @@ import { z } from "zod"; export type PromptMode = "transform" | "explore"; export const promptRead = z.object({ - id: z.number(), + id: z.number().nullable(), name: z.string(), prompt: z.string(), mode: z.enum(["transform", "explore"]), - icon: z.string().nullable(), - search_space_id: z.number().nullable(), - is_public: z.boolean(), - created_at: z.string(), + search_space_id: z.number().nullable().optional(), + is_public: z.boolean().optional(), + created_at: z.string().nullable().optional(), + source: z.enum(["system", "custom"]), + system_prompt_slug: z.string().nullable().optional(), + is_modified: z.boolean().optional(), }); export type PromptRead = z.infer; @@ -29,7 +31,6 @@ export const promptCreateRequest = z.object({ name: z.string().min(1).max(200), prompt: z.string().min(1), mode: z.enum(["transform", "explore"]), - icon: z.string().max(50).nullable().optional(), search_space_id: z.number().nullable().optional(), is_public: z.boolean().optional(), }); @@ -40,12 +41,19 @@ export const promptUpdateRequest = z.object({ name: z.string().min(1).max(200).optional(), prompt: z.string().min(1).optional(), mode: z.enum(["transform", "explore"]).optional(), - icon: z.string().max(50).nullable().optional(), is_public: z.boolean().optional(), }); export type PromptUpdateRequest = z.infer; +export const systemPromptUpdateRequest = z.object({ + name: z.string().min(1).max(200).optional(), + prompt: z.string().min(1).optional(), + mode: z.enum(["transform", "explore"]).optional(), +}); + +export type SystemPromptUpdateRequest = z.infer; + export const promptDeleteResponse = z.object({ success: z.boolean(), });