feat: add prompt history management

This commit is contained in:
2026-07-13 14:06:45 +08:00
parent cb3b91b101
commit 44ab895149
9 changed files with 876 additions and 230 deletions

View File

@@ -0,0 +1,29 @@
"""add prompt history metadata and ordering index
Revision ID: 0010_prompt_history_management
Revises: 0009_logs_storage
"""
from alembic import op
import sqlalchemy as sa
revision = "0010_prompt_history_management"
down_revision = "0009_logs_storage"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_prompt",
sa.Column("change_type", sa.String(length=20), nullable=False, server_default="save"),
)
op.add_column("sys_prompt", sa.Column("source_prompt_id", sa.BigInteger(), nullable=True))
op.create_index("ix_sys_prompt_updated_at_id", "sys_prompt", ["updated_at", "id"])
def downgrade() -> None:
op.drop_index("ix_sys_prompt_updated_at_id", table_name="sys_prompt")
op.drop_column("sys_prompt", "source_prompt_id")
op.drop_column("sys_prompt", "change_type")

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.pagination import page_result
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
@@ -32,8 +33,13 @@ DEFAULT_PROMPT = "你是大本营答疑助手。你只能基于提供的知识
@router.get("/prompt")
def get_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
prompt = db.scalar(select(Prompt).order_by(Prompt.updated_at.desc(), Prompt.id.desc()).limit(1))
return api_success({"promptContent": prompt.prompt_content if prompt else DEFAULT_PROMPT})
row = db.execute(
select(Prompt, Admin)
.outerjoin(Admin, Admin.id == Prompt.updated_by)
.order_by(Prompt.updated_at.desc(), Prompt.id.desc())
.limit(1)
).first()
return api_success(_prompt_detail(row[0], row[1]) if row else _default_prompt_detail())
@router.put("/prompt")
@@ -42,22 +48,91 @@ def save_prompt(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
prompt = Prompt(prompt_content=payload.promptContent, updated_by=current_admin.id)
prompt = Prompt(prompt_content=payload.promptContent, change_type="save", updated_by=current_admin.id)
db.add(prompt)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="save", target_id=prompt.id)
db.commit()
return api_success({"promptContent": prompt.prompt_content})
db.refresh(prompt)
return api_success(_prompt_detail(prompt, current_admin))
@router.post("/prompt/reset")
def reset_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
prompt = Prompt(prompt_content=DEFAULT_PROMPT, updated_by=current_admin.id)
prompt = Prompt(prompt_content=DEFAULT_PROMPT, change_type="reset", updated_by=current_admin.id)
db.add(prompt)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="reset", target_id=prompt.id)
db.commit()
return api_success({"promptContent": prompt.prompt_content})
db.refresh(prompt)
return api_success(_prompt_detail(prompt, current_admin))
@router.get("/prompt/history")
def prompt_history(
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=10, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
total = db.scalar(select(func.count(Prompt.id))) or 0
rows = db.execute(
select(Prompt, Admin)
.outerjoin(Admin, Admin.id == Prompt.updated_by)
.order_by(Prompt.updated_at.desc(), Prompt.id.desc())
.offset((page - 1) * pageSize)
.limit(pageSize)
).all()
items = [
_prompt_summary(prompt, admin, is_current=index == 0 and page == 1)
for index, (prompt, admin) in enumerate(rows)
]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.get("/prompt/history/{prompt_id}")
def prompt_history_detail(
prompt_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
row = db.execute(
select(Prompt, Admin)
.outerjoin(Admin, Admin.id == Prompt.updated_by)
.where(Prompt.id == prompt_id)
).first()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提示词版本不存在")
return api_success(_prompt_detail(row[0], row[1]))
@router.post("/prompt/history/{prompt_id}/restore")
def restore_prompt(
prompt_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
source = db.get(Prompt, prompt_id)
if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提示词版本不存在")
restored = Prompt(
prompt_content=source.prompt_content,
change_type="restore",
source_prompt_id=source.id,
updated_by=current_admin.id,
)
db.add(restored)
db.flush()
OperationLogService.write(
db,
admin_id=current_admin.id,
module="prompt",
action="restore",
target_id=restored.id,
)
db.commit()
db.refresh(restored)
return api_success(_prompt_detail(restored, current_admin))
@router.post("/agent/debug")
@@ -296,6 +371,44 @@ def _model_dict(model: ModelConfig) -> dict:
}
def _default_prompt_detail() -> dict:
return {
"id": None,
"promptContent": DEFAULT_PROMPT,
"changeType": "default",
"sourcePromptId": None,
"updatedByName": "系统默认",
"updatedAt": None,
"charCount": len(DEFAULT_PROMPT),
}
def _prompt_summary(prompt: Prompt, admin: Admin | None, *, is_current: bool) -> dict:
compact = " ".join(prompt.prompt_content.split())
return {
"id": prompt.id,
"preview": compact[:140],
"charCount": len(prompt.prompt_content),
"changeType": prompt.change_type,
"sourcePromptId": prompt.source_prompt_id,
"updatedByName": admin.name if admin else "未知管理员",
"updatedAt": prompt.updated_at,
"isCurrent": is_current,
}
def _prompt_detail(prompt: Prompt, admin: Admin | None = None) -> dict:
return {
"id": prompt.id,
"promptContent": prompt.prompt_content,
"changeType": prompt.change_type,
"sourcePromptId": prompt.source_prompt_id,
"updatedByName": admin.name if admin else None,
"updatedAt": prompt.updated_at,
"charCount": len(prompt.prompt_content),
}
def _agent_knowledge_scopes(db: Session, knowledge_ids: list[int]) -> list[KnowledgeScope]:
query = select(Knowledge).where(Knowledge.status == 1)
if knowledge_ids:

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import BigInteger, DateTime, Integer, Numeric, String, Text, func
from sqlalchemy import BigInteger, DateTime, Index, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
@@ -11,9 +11,14 @@ from app.models.base import Base
class Prompt(Base):
__tablename__ = "sys_prompt"
__table_args__ = (Index("ix_sys_prompt_updated_at_id", "updated_at", "id"),)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(
BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True
)
prompt_content: Mapped[str] = mapped_column(Text, nullable=False)
change_type: Mapped[str] = mapped_column(String(20), default="save", nullable=False)
source_prompt_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -0,0 +1,98 @@
from datetime import datetime, timedelta
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.admin_settings import (
DEFAULT_PROMPT,
get_prompt,
prompt_history,
prompt_history_detail,
reset_prompt,
restore_prompt,
save_prompt,
)
from app.models import Base
from app.models.admin import Admin
from app.models.ai_config import Prompt
from app.schemas.admin import PromptSaveRequest
def _database() -> Session:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
return Session(engine)
def _admin() -> Admin:
return Admin(id=1, username="admin", password="hash", name="系统管理员", status=1)
def test_prompt_history_is_paginated_and_excludes_full_content_from_list():
with _database() as db:
admin = _admin()
db.add(admin)
started = datetime(2026, 1, 1)
db.add_all([
Prompt(
id=index,
prompt_content=f"版本 {index} " + ("x" * 500),
change_type="save",
updated_by=admin.id,
updated_at=started + timedelta(minutes=index),
)
for index in range(1, 16)
])
db.commit()
result = prompt_history(page=2, pageSize=10, db=db, current_admin=admin)["data"]
assert result["total"] == 15
assert len(result["items"]) == 5
assert result["items"][0]["id"] == 5
assert result["items"][0]["isCurrent"] is False
assert "promptContent" not in result["items"][0]
assert len(result["items"][0]["preview"]) <= 140
def test_prompt_history_detail_and_restore_create_a_new_current_version():
with _database() as db:
admin = _admin()
db.add(admin)
db.add_all([
Prompt(id=1, prompt_content="旧版主提示词", change_type="save", updated_by=admin.id),
Prompt(id=2, prompt_content="当前主提示词", change_type="save", updated_by=admin.id),
])
db.commit()
detail = prompt_history_detail(1, db=db, current_admin=admin)["data"]
restored = restore_prompt(1, db=db, current_admin=admin)["data"]
current = get_prompt(db=db, current_admin=admin)["data"]
assert detail["promptContent"] == "旧版主提示词"
assert restored["id"] not in {1, 2}
assert restored["changeType"] == "restore"
assert restored["sourcePromptId"] == 1
assert current["id"] == restored["id"]
assert current["promptContent"] == "旧版主提示词"
assert db.scalar(select(Prompt).where(Prompt.id == 2)).prompt_content == "当前主提示词"
def test_save_and_reset_each_create_an_auditable_prompt_version():
with _database() as db:
admin = _admin()
db.add(admin)
db.commit()
saved = save_prompt(PromptSaveRequest(promptContent="新主提示词"), db=db, current_admin=admin)["data"]
reset = reset_prompt(db=db, current_admin=admin)["data"]
versions = db.scalars(select(Prompt).order_by(Prompt.id)).all()
assert saved["changeType"] == "save"
assert reset["changeType"] == "reset"
assert reset["promptContent"] == DEFAULT_PROMPT
assert [(item.change_type, item.prompt_content) for item in versions] == [
("save", "新主提示词"),
("reset", DEFAULT_PROMPT),
]