feat: add prompt history management
This commit is contained in:
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
Reference in New Issue
Block a user