feat: add ai prompt release workflow

This commit is contained in:
2026-06-23 09:06:13 +08:00
parent f145a38ee5
commit b2d60871a5
15 changed files with 1062 additions and 6 deletions
@@ -0,0 +1,64 @@
# V2 AI Platform Prompt 发布与回滚闭环记录 - 2026-06-23
## 背景
根据《AI 合同平台 V2 重构版开发方案》,AI 中台必须统一承接模型网关、知识库、RAG、Prompt、法条引用、AI 审计和质量评测。合同系统不应直接拼 Prompt 或绕过 SDK 调模型。
上一轮已经完成 AI 质量改进项运营增强,本轮继续补齐 Prompt 中心的版本发布与回滚能力,让“调整 Prompt”从运营建议变成可执行、可追溯的配置变更。
## 本轮完成内容
1. AI Platform 后端 Prompt 版本状态
- `PromptTemplateModel` 增加 `status`、`rollback_from_version`、`published_at`。
- Prompt 状态支持 `draft`、`published`、`archived`。
- 默认 Prompt seed 后自动发布,确保法律问答读取的是明确的运行版本。
2. 发布闭环
- 新增 `POST /api/v1/prompt-templates/{name}/publish`。
- 发布某个版本后,同名其他版本自动归档并禁用。
- `get_prompt_template(name)` 只返回同名已发布且启用的最新版本。
3. 回滚闭环
- 新增 `POST /api/v1/prompt-templates/{name}/rollback`。
- 回滚不会直接覆盖历史版本,而是复制来源版本内容,生成一个新的已发布版本。
- 新版本记录 `rollback_from_version`,便于审计和问题复盘。
4. OpenAPI 与 SDK
- OpenAPI 增加 publish/rollback 路径与请求 schema。
- Python SDK 增加 `PromptTemplatePublishRequest`、`PromptTemplateRollbackRequest`。
- `AiPlatformClient` 增加 `publish_prompt_template` 和 `rollback_prompt_template`。
5. 合同系统后台 Prompt 中心
- 新增 `/admin/ai-prompts`。
- 管理员可保存 Prompt 草稿、发布指定版本、从历史版本回滚发布。
- 管理首页新增“Prompt 中心”入口。
- 前端通过 `/api/ai-platform/prompt-templates` 代理调用 AI Platform,保持合同系统不直接承接 Prompt 业务逻辑。
## 验收结果
- `python -m pytest yuqei-ai-platform/services/ai-platform-api/tests yuqei-ai-sdk-python/tests -q`
- 27 passed
- `npm run web:typecheck`
- passed
- `python -c "import yaml ... openapi.yaml ..."`
- passed
## 当前闭环价值
Prompt 调整现在具备完整运营路径:
1. AI 质量运营看板发现某个 Prompt 异常集中。
2. 管理员进入 Prompt 中心创建新草稿。
3. 管理员发布新版本。
4. 后续 AI 调用审计记录 Prompt 版本与运行结果。
5. 如果新版本效果不好,管理员从历史版本回滚并形成新发布版本。
这让 V2 AI 中台从“能调模型”进一步变成“能运营、能复盘、能回滚”的生产级平台能力。
## 下一步建议
下一步自然进入“知识库导入批次与质量改进项关联”:
- 改进项类型为“补知识库”时,可关联具体知识库和导入批次。
- Prompt 改进项可关联具体 Prompt 版本。
- AI 质量看板可展示改进动作执行后,相关 Prompt/知识库的异常率变化。
@@ -139,6 +139,52 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PromptTemplate"
/api/v1/prompt-templates/{name}/publish:
post:
operationId: publishPromptTemplate
summary: Publish a prompt template version
parameters:
- name: name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PromptTemplatePublishRequest"
responses:
"200":
description: Published prompt template
content:
application/json:
schema:
$ref: "#/components/schemas/PromptTemplate"
/api/v1/prompt-templates/{name}/rollback:
post:
operationId: rollbackPromptTemplate
summary: Roll back a prompt template by publishing a new version copied from a previous version
parameters:
- name: name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PromptTemplateRollbackRequest"
responses:
"200":
description: Rolled back prompt template
content:
application/json:
schema:
$ref: "#/components/schemas/PromptTemplate"
/api/v1/knowledge-bases:
get:
operationId: listKnowledgeBases
@@ -917,16 +963,44 @@ components:
enabled:
type: boolean
default: true
PromptTemplatePublishRequest:
type: object
required:
- version
properties:
version:
type: string
PromptTemplateRollbackRequest:
type: object
required:
- from_version
properties:
from_version:
type: string
target_version:
type: string
nullable: true
PromptTemplate:
allOf:
- $ref: "#/components/schemas/PromptTemplateCreate"
- type: object
required:
- id
- status
- updated_at
properties:
id:
type: string
status:
type: string
enum: [draft, published, archived]
rollback_from_version:
type: string
nullable: true
published_at:
type: string
format: date-time
nullable: true
updated_at:
type: string
format: date-time
@@ -33,6 +33,8 @@ from yuqei_ai_platform_api.repository import (
KnowledgeSearchResult,
PromptTemplate,
PromptTemplateCreate,
PromptTemplatePublishRequest,
PromptTemplateRollbackRequest,
ProviderConfig,
ProviderConfigCreate,
chunk_to_citation,
@@ -112,6 +114,28 @@ def create_app(
def upsert_prompt_template(payload: PromptTemplateCreate) -> PromptTemplate:
return store.upsert_prompt_template(payload)
@app.post(
f"{resolved_settings.api_prefix}/prompt-templates/{{name}}/publish",
response_model=PromptTemplate,
tags=["prompts"],
)
def publish_prompt_template(name: str, payload: PromptTemplatePublishRequest) -> PromptTemplate:
try:
return store.publish_prompt_template(name, payload)
except KeyError as error:
raise HTTPException(status_code=404, detail="Prompt template version not found.") from error
@app.post(
f"{resolved_settings.api_prefix}/prompt-templates/{{name}}/rollback",
response_model=PromptTemplate,
tags=["prompts"],
)
def rollback_prompt_template(name: str, payload: PromptTemplateRollbackRequest) -> PromptTemplate:
try:
return store.rollback_prompt_template(name, payload)
except KeyError as error:
raise HTTPException(status_code=404, detail="Prompt template version not found.") from error
@app.get(
f"{resolved_settings.api_prefix}/quality-rules/default",
response_model=AiQualityRuleConfig,
@@ -34,6 +34,9 @@ class PromptTemplateModel(Base):
version: Mapped[str] = mapped_column(String(32))
content: Mapped[str] = mapped_column(Text)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
status: Mapped[str] = mapped_column(String(32), default="draft")
rollback_from_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
@@ -66,9 +66,21 @@ class PromptTemplateCreate(BaseModel):
class PromptTemplate(PromptTemplateCreate):
id: str
status: str = "draft"
rollback_from_version: str | None = None
published_at: datetime | None = None
updated_at: datetime
class PromptTemplatePublishRequest(BaseModel):
version: str = Field(min_length=1, max_length=64)
class PromptTemplateRollbackRequest(BaseModel):
from_version: str = Field(min_length=1, max_length=64)
target_version: str | None = Field(default=None, max_length=64)
class AiQualityRuleConfigCreate(BaseModel):
min_citation_count: int = Field(default=1, ge=0, le=10)
min_source_count: int = Field(default=2, ge=0, le=10)
@@ -394,6 +406,10 @@ class AiPlatformRepository(Protocol):
def get_prompt_template(self, name: str) -> PromptTemplate | None: ...
def publish_prompt_template(self, name: str, payload: PromptTemplatePublishRequest) -> PromptTemplate: ...
def rollback_prompt_template(self, name: str, payload: PromptTemplateRollbackRequest) -> PromptTemplate: ...
def get_quality_rule_config(self) -> AiQualityRuleConfig: ...
def upsert_quality_rule_config(self, payload: AiQualityRuleConfigCreate) -> AiQualityRuleConfig: ...
@@ -547,6 +563,9 @@ class InMemoryAiPlatformRepository:
template = PromptTemplate(
id=template_id,
updated_at=datetime.now(UTC),
status=existing.status if existing else "draft",
rollback_from_version=existing.rollback_from_version if existing else None,
published_at=existing.published_at if existing else None,
**payload.model_dump(),
)
self.prompt_templates[template_id] = template
@@ -561,11 +580,64 @@ class InMemoryAiPlatformRepository:
templates = [
template
for template in self.prompt_templates.values()
if template.enabled and template.name == name
if template.enabled and template.name == name and template.status == "published"
]
if not templates:
return None
return sorted(templates, key=lambda item: item.updated_at, reverse=True)[0]
return sorted(templates, key=lambda item: item.published_at or item.updated_at, reverse=True)[0]
def publish_prompt_template(self, name: str, payload: PromptTemplatePublishRequest) -> PromptTemplate:
now = datetime.now(UTC)
with self._lock:
target = self._find_prompt_template(name, payload.version)
if target is None:
raise KeyError(f"{name}:{payload.version}")
for template_id, template in list(self.prompt_templates.items()):
if template.name != name:
continue
next_status = "published" if template.id == target.id else "archived"
self.prompt_templates[template_id] = template.model_copy(
update={
"enabled": template.id == target.id,
"status": next_status,
"published_at": now if template.id == target.id else template.published_at,
"updated_at": now,
}
)
return self.prompt_templates[target.id]
def rollback_prompt_template(self, name: str, payload: PromptTemplateRollbackRequest) -> PromptTemplate:
now = datetime.now(UTC)
with self._lock:
source = self._find_prompt_template(name, payload.from_version)
if source is None:
raise KeyError(f"{name}:{payload.from_version}")
target_version = payload.target_version or f"rollback-{now.strftime('%Y%m%d%H%M%S')}"
existing = self._find_prompt_template(name, target_version)
template_id = existing.id if existing else f"prompt-{uuid4().hex[:12]}"
rolled_back = PromptTemplate(
id=template_id,
name=name,
version=target_version,
content=source.content,
enabled=False,
status="draft",
rollback_from_version=source.version,
published_at=None,
updated_at=now,
)
self.prompt_templates[template_id] = rolled_back
return self.publish_prompt_template(name, PromptTemplatePublishRequest(version=target_version))
def _find_prompt_template(self, name: str, version: str) -> PromptTemplate | None:
return next(
(
template
for template in self.prompt_templates.values()
if template.name == name and template.version == version
),
None,
)
def get_quality_rule_config(self) -> AiQualityRuleConfig:
with self._lock:
@@ -858,7 +930,11 @@ class InMemoryAiPlatformRepository:
if not self.list_provider_configs():
self.upsert_provider_config(_default_provider_config())
if not self.list_prompt_templates():
self.upsert_prompt_template(_default_prompt_template())
default_prompt = self.upsert_prompt_template(_default_prompt_template())
self.publish_prompt_template(
default_prompt.name,
PromptTemplatePublishRequest(version=default_prompt.version),
)
if not self.search_knowledge("", limit=1):
for document in _default_knowledge_documents():
self.add_knowledge_document(document)
@@ -880,6 +956,30 @@ class SqlAlchemyAiPlatformRepository:
self._vector_search_backend = vector_search_backend
if auto_create:
Base.metadata.create_all(self._engine)
self._ensure_prompt_template_columns()
def _ensure_prompt_template_columns(self) -> None:
with self._engine.begin() as connection:
dialect = self._engine.dialect.name
existing_columns: set[str]
if dialect == "sqlite":
rows = connection.execute(text("PRAGMA table_info(prompt_templates)")).mappings().all()
existing_columns = {str(row["name"]) for row in rows}
else:
rows = connection.execute(
text(
"select column_name from information_schema.columns "
"where table_name = 'prompt_templates'"
)
).mappings().all()
existing_columns = {str(row["column_name"]) for row in rows}
if "status" not in existing_columns:
connection.execute(text("ALTER TABLE prompt_templates ADD COLUMN status VARCHAR(32) DEFAULT 'draft'"))
if "rollback_from_version" not in existing_columns:
connection.execute(text("ALTER TABLE prompt_templates ADD COLUMN rollback_from_version VARCHAR(32)"))
if "published_at" not in existing_columns:
column_type = "TIMESTAMP" if dialect == "sqlite" else "TIMESTAMP WITH TIME ZONE"
connection.execute(text(f"ALTER TABLE prompt_templates ADD COLUMN published_at {column_type}"))
def upsert_provider_config(self, payload: ProviderConfigCreate) -> ProviderConfig:
now = datetime.now(UTC)
@@ -946,6 +1046,8 @@ class SqlAlchemyAiPlatformRepository:
for key, value in payload.model_dump().items():
setattr(template, key, value)
if not template.status:
template.status = "draft"
template.updated_at = now
session.flush()
return _prompt_template_from_model(template)
@@ -962,12 +1064,68 @@ class SqlAlchemyAiPlatformRepository:
.where(
PromptTemplateModel.name == name,
PromptTemplateModel.enabled.is_(True),
PromptTemplateModel.status == "published",
)
.order_by(PromptTemplateModel.updated_at.desc())
.order_by(PromptTemplateModel.published_at.desc(), PromptTemplateModel.updated_at.desc())
.limit(1)
)
return _prompt_template_from_model(template) if template else None
def publish_prompt_template(self, name: str, payload: PromptTemplatePublishRequest) -> PromptTemplate:
now = datetime.now(UTC)
with self._session_factory.begin() as session:
target = session.scalar(
select(PromptTemplateModel).where(
PromptTemplateModel.name == name,
PromptTemplateModel.version == payload.version,
)
)
if target is None:
raise KeyError(f"{name}:{payload.version}")
rows = session.scalars(
select(PromptTemplateModel).where(PromptTemplateModel.name == name)
).all()
for row in rows:
row.enabled = row.id == target.id
row.status = "published" if row.id == target.id else "archived"
row.updated_at = now
if row.id == target.id:
row.published_at = now
session.flush()
return _prompt_template_from_model(target)
def rollback_prompt_template(self, name: str, payload: PromptTemplateRollbackRequest) -> PromptTemplate:
now = datetime.now(UTC)
target_version = payload.target_version or f"rollback-{now.strftime('%Y%m%d%H%M%S')}"
with self._session_factory.begin() as session:
source = session.scalar(
select(PromptTemplateModel).where(
PromptTemplateModel.name == name,
PromptTemplateModel.version == payload.from_version,
)
)
if source is None:
raise KeyError(f"{name}:{payload.from_version}")
target = session.scalar(
select(PromptTemplateModel).where(
PromptTemplateModel.name == name,
PromptTemplateModel.version == target_version,
)
)
if target is None:
target = PromptTemplateModel(id=f"prompt-{uuid4().hex[:12]}")
session.add(target)
target.name = name
target.version = target_version
target.content = source.content
target.enabled = False
target.status = "draft"
target.rollback_from_version = source.version
target.published_at = None
target.updated_at = now
session.flush()
return self.publish_prompt_template(name, PromptTemplatePublishRequest(version=target_version))
def get_quality_rule_config(self) -> AiQualityRuleConfig:
with self._session_factory.begin() as session:
row = session.get(AiQualityRuleConfigModel, "default")
@@ -1513,7 +1671,11 @@ class SqlAlchemyAiPlatformRepository:
if not self.list_provider_configs():
self.upsert_provider_config(_default_provider_config())
if not self.list_prompt_templates():
self.upsert_prompt_template(_default_prompt_template())
default_prompt = self.upsert_prompt_template(_default_prompt_template())
self.publish_prompt_template(
default_prompt.name,
PromptTemplatePublishRequest(version=default_prompt.version),
)
if not self.search_knowledge("", limit=1):
for document in _default_knowledge_documents():
self.add_knowledge_document(document)
@@ -1624,6 +1786,9 @@ def _prompt_template_from_model(row: PromptTemplateModel) -> PromptTemplate:
version=row.version,
content=row.content,
enabled=row.enabled,
status=row.status or "draft",
rollback_from_version=row.rollback_from_version,
published_at=_ensure_utc(row.published_at) if row.published_at else None,
updated_at=_ensure_utc(row.updated_at),
)
@@ -15,6 +15,8 @@ from yuqei_ai_platform_api.repository import (
KnowledgeReindexRequest,
ProviderConfigCreate,
PromptTemplateCreate,
PromptTemplatePublishRequest,
PromptTemplateRollbackRequest,
SqlAlchemyAiPlatformRepository,
)
@@ -95,6 +97,56 @@ def test_prompt_template_can_be_hot_updated_and_listed() -> None:
assert templates[0]["version"] == "v2"
def test_prompt_template_can_be_published_and_rolled_back() -> None:
client = make_client()
client.post(
"/api/v1/prompt-templates",
json={
"name": "legal_qa.default",
"version": "v2",
"content": "Prompt v2",
"enabled": False,
},
)
client.post(
"/api/v1/prompt-templates",
json={
"name": "legal_qa.default",
"version": "v3",
"content": "Prompt v3",
"enabled": False,
},
)
publish_response = client.post(
"/api/v1/prompt-templates/legal_qa.default/publish",
json={"version": "v3"},
)
assert publish_response.status_code == 200
published = publish_response.json()
assert published["version"] == "v3"
assert published["status"] == "published"
assert published["enabled"] is True
assert published["published_at"]
rollback_response = client.post(
"/api/v1/prompt-templates/legal_qa.default/rollback",
json={"from_version": "v2", "target_version": "v2-rollback"},
)
assert rollback_response.status_code == 200
rolled_back = rollback_response.json()
assert rolled_back["version"] == "v2-rollback"
assert rolled_back["status"] == "published"
assert rolled_back["rollback_from_version"] == "v2"
assert rolled_back["content"] == "Prompt v2"
templates = client.get("/api/v1/prompt-templates").json()
by_version = {template["version"]: template for template in templates}
assert by_version["v3"]["status"] == "archived"
assert by_version["v2-rollback"]["enabled"] is True
def test_quality_rules_can_be_hot_updated_and_drive_audit_labels() -> None:
client = make_client()
@@ -636,6 +688,19 @@ def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None:
assert reloaded.get_default_provider_config().model == "deepseek-chat" # type: ignore[union-attr]
assert reloaded.list_prompt_templates()[0].version == "v2"
published_prompt = reloaded.publish_prompt_template(
"legal_qa.default",
PromptTemplatePublishRequest(version="v2"),
)
assert published_prompt.status == "published"
assert reloaded.get_prompt_template("legal_qa.default").version == "v2" # type: ignore[union-attr]
rolled_back_prompt = reloaded.rollback_prompt_template(
"legal_qa.default",
PromptTemplateRollbackRequest(from_version="v2", target_version="v2-rollback"),
)
assert rolled_back_prompt.status == "published"
assert rolled_back_prompt.rollback_from_version == "v2"
assert reloaded.get_prompt_template("legal_qa.default").version == "v2-rollback" # type: ignore[union-attr]
assert reloaded.get_quality_rule_config().warning_penalty == 7
assert reloaded.get_quality_rule_config().critical_latency_ms == 24000
assert reloaded.search_knowledge("lease", knowledge_base_ids=["laws-cn"])[0].reference == "Article 703"
@@ -29,6 +29,8 @@ from yuqei_sdk.ai_platform import (
LegalQaResponse,
PromptTemplate,
PromptTemplateCreate,
PromptTemplatePublishRequest,
PromptTemplateRollbackRequest,
ProviderConfig,
ProviderConfigCreate,
RetrievalSource,
@@ -66,6 +68,8 @@ __all__ = [
"LegalQaResponse",
"PromptTemplate",
"PromptTemplateCreate",
"PromptTemplatePublishRequest",
"PromptTemplateRollbackRequest",
"ProviderConfig",
"ProviderConfigCreate",
"RequestContext",
@@ -63,9 +63,21 @@ class PromptTemplateCreate(BaseModel):
class PromptTemplate(PromptTemplateCreate):
id: str
status: str = "draft"
rollback_from_version: str | None = None
published_at: str | None = None
updated_at: str
class PromptTemplatePublishRequest(BaseModel):
version: str = Field(min_length=1)
class PromptTemplateRollbackRequest(BaseModel):
from_version: str = Field(min_length=1)
target_version: str | None = None
class AiQualityRuleConfigUpdate(BaseModel):
min_citation_count: int = 1
min_source_count: int = 2
@@ -458,6 +470,36 @@ class AiPlatformClient:
response.raise_for_status()
return PromptTemplate.model_validate(response.json())
def publish_prompt_template(
self,
name: str,
request: PromptTemplatePublishRequest,
*,
context: RequestContext | None = None,
) -> PromptTemplate:
response = self._client.post(
f"{self._api_prefix}/prompt-templates/{name}/publish",
json=request.model_dump(exclude_none=True),
headers=(context or RequestContext()).to_headers(),
)
response.raise_for_status()
return PromptTemplate.model_validate(response.json())
def rollback_prompt_template(
self,
name: str,
request: PromptTemplateRollbackRequest,
*,
context: RequestContext | None = None,
) -> PromptTemplate:
response = self._client.post(
f"{self._api_prefix}/prompt-templates/{name}/rollback",
json=request.model_dump(exclude_none=True),
headers=(context or RequestContext()).to_headers(),
)
response.raise_for_status()
return PromptTemplate.model_validate(response.json())
def get_quality_rule_config(
self,
*,
@@ -14,6 +14,8 @@ from yuqei_sdk import (
KnowledgeReindexRequest,
LegalQaRequest,
PromptTemplateCreate,
PromptTemplatePublishRequest,
PromptTemplateRollbackRequest,
ProviderConfigCreate,
RequestContext,
)
@@ -178,9 +180,46 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
"version": "v2",
"content": "Search evidence first, then answer.",
"enabled": True,
"status": "draft",
"rollback_from_version": None,
"published_at": None,
"updated_at": "2026-06-22T00:00:00Z",
},
)
if request.url.path == "/api/v1/prompt-templates/legal_qa.default/publish":
payload = json.loads(request.read().decode("utf-8"))
assert payload["version"] == "v2"
return httpx.Response(
200,
json={
"id": "prompt-1",
"name": "legal_qa.default",
"version": "v2",
"content": "Search evidence first, then answer.",
"enabled": True,
"status": "published",
"rollback_from_version": None,
"published_at": "2026-06-22T00:01:00Z",
"updated_at": "2026-06-22T00:01:00Z",
},
)
if request.url.path == "/api/v1/prompt-templates/legal_qa.default/rollback":
payload = json.loads(request.read().decode("utf-8"))
assert payload["from_version"] == "v1"
return httpx.Response(
200,
json={
"id": "prompt-rollback-1",
"name": "legal_qa.default",
"version": "v1-rollback",
"content": "Use the stable prompt.",
"enabled": True,
"status": "published",
"rollback_from_version": "v1",
"published_at": "2026-06-22T00:02:00Z",
"updated_at": "2026-06-22T00:02:00Z",
},
)
if request.url.path == "/api/v1/prompt-templates":
return httpx.Response(
200,
@@ -191,6 +230,9 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
"version": "v2",
"content": "Search evidence first, then answer.",
"enabled": True,
"status": "published",
"rollback_from_version": None,
"published_at": "2026-06-22T00:01:00Z",
"updated_at": "2026-06-22T00:00:00Z",
}
],
@@ -667,6 +709,16 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
PromptTemplateCreate(name="legal_qa.default", version="v2", content="Search evidence first.")
).version == "v2"
assert client.list_prompt_templates()[0].name == "legal_qa.default"
assert client.publish_prompt_template(
"legal_qa.default",
PromptTemplatePublishRequest(version="v2"),
).status == "published"
rolled_back_prompt = client.rollback_prompt_template(
"legal_qa.default",
PromptTemplateRollbackRequest(from_version="v1", target_version="v1-rollback"),
)
assert rolled_back_prompt.version == "v1-rollback"
assert rolled_back_prompt.rollback_from_version == "v1"
assert client.get_quality_rule_config().min_source_count == 2
assert client.upsert_quality_rule_config(
AiQualityRuleConfigUpdate(
@@ -0,0 +1,362 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { FileText, RefreshCcw, RotateCcw, Save, Send } from "lucide-react";
type PromptTemplate = {
id: string;
name: string;
version: string;
content: string;
enabled: boolean;
status: "draft" | "published" | "archived" | string;
rollback_from_version?: string | null;
published_at?: string | null;
updated_at: string;
};
type LoadState =
| { status: "loading" }
| { status: "ready"; templates: PromptTemplate[] }
| { status: "error"; message: string };
type DraftState = {
name: string;
version: string;
content: string;
};
const defaultDraft: DraftState = {
name: "legal_qa.default",
version: "draft",
content: ""
};
const statusLabels: Record<string, string> = {
draft: "草稿",
published: "已发布",
archived: "已归档"
};
const statusTone: Record<string, string> = {
draft: "",
published: "tag-success",
archived: "tag-warning"
};
export default function AiPromptsPage() {
const [state, setState] = useState<LoadState>({ status: "loading" });
const [draft, setDraft] = useState<DraftState>(defaultDraft);
const [selectedName, setSelectedName] = useState(defaultDraft.name);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
async function loadTemplates(
options: { clearMessage?: boolean; preferName?: string } = { clearMessage: true }
) {
setState({ status: "loading" });
if (options.clearMessage !== false) {
setMessage(null);
}
try {
const response = await fetch("/api/ai-platform/prompt-templates", { cache: "no-store" });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message ?? `HTTP ${response.status}`);
}
const templates = Array.isArray(payload) ? payload : [];
setState({ status: "ready", templates });
if (templates.length > 0) {
const nextName = options.preferName ?? selectedName;
const nextTemplate =
templates.find((template) => template.name === nextName && template.status === "published") ??
templates.find((template) => template.name === nextName) ??
templates.find((template) => template.status === "published") ??
templates[0];
setDraftFromTemplate(nextTemplate);
}
} catch (error) {
setState({
status: "error",
message: error instanceof Error ? error.message : "Prompt 模板加载失败"
});
}
}
useEffect(() => {
void loadTemplates();
}, []);
const templates = state.status === "ready" ? state.templates : [];
const names = useMemo(() => Array.from(new Set(templates.map((template) => template.name))).sort(), [templates]);
const visibleTemplates = useMemo(
() =>
templates
.filter((template) => template.name === selectedName)
.sort((left, right) => compareDate(right.published_at ?? right.updated_at, left.published_at ?? left.updated_at)),
[selectedName, templates]
);
const publishedTemplate = visibleTemplates.find((template) => template.status === "published");
const validationMessage = useMemo(() => {
if (!draft.name.trim()) {
return "请填写 Prompt 名称。";
}
if (!draft.version.trim()) {
return "请填写版本号。";
}
if (!draft.content.trim()) {
return "请填写 Prompt 内容。";
}
return null;
}, [draft.content, draft.name, draft.version]);
function setDraftFromTemplate(template: PromptTemplate) {
setSelectedName(template.name);
setDraft({
name: template.name,
version: template.version,
content: template.content
});
}
async function saveDraft() {
if (validationMessage) {
setMessage(validationMessage);
return;
}
setBusyAction("save");
setMessage(null);
try {
const response = await fetch("/api/ai-platform/prompt-templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: draft.name.trim(),
version: draft.version.trim(),
content: draft.content,
enabled: false
})
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message ?? `HTTP ${response.status}`);
}
setMessage("草稿已保存,发布后才会成为运行版本。");
setSelectedName(payload.name ?? draft.name.trim());
await loadTemplates({ clearMessage: false, preferName: payload.name ?? draft.name.trim() });
} catch (error) {
setMessage(error instanceof Error ? error.message : "Prompt 草稿保存失败");
} finally {
setBusyAction(null);
}
}
async function publishTemplate(template: PromptTemplate) {
setBusyAction(`publish:${template.id}`);
setMessage(null);
try {
const response = await fetch(`/api/ai-platform/prompt-templates/${encodeURIComponent(template.name)}/publish`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version: template.version })
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message ?? `HTTP ${response.status}`);
}
setMessage(`已发布 ${template.name} / ${template.version}。`);
await loadTemplates({ clearMessage: false, preferName: template.name });
} catch (error) {
setMessage(error instanceof Error ? error.message : "Prompt 发布失败");
} finally {
setBusyAction(null);
}
}
async function rollbackTemplate(template: PromptTemplate) {
setBusyAction(`rollback:${template.id}`);
setMessage(null);
try {
const response = await fetch(`/api/ai-platform/prompt-templates/${encodeURIComponent(template.name)}/rollback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from_version: template.version })
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message ?? `HTTP ${response.status}`);
}
setMessage(`已从 ${template.version} 回滚并发布新版本。`);
await loadTemplates({ clearMessage: false, preferName: template.name });
} catch (error) {
setMessage(error instanceof Error ? error.message : "Prompt 回滚失败");
} finally {
setBusyAction(null);
}
}
return (
<>
<div className="page-heading">
<div>
<h1>Prompt 中心</h1>
<p>集中管理 AI 中台提示词草稿、发布版本和回滚记录。</p>
</div>
<div className="toolbar toolbar-inline">
<button className="button button-soft" onClick={() => void loadTemplates()}>
<RefreshCcw size={16} />
刷新
</button>
<button className="button button-primary" disabled={busyAction === "save" || Boolean(validationMessage)} onClick={() => void saveDraft()}>
<Save size={16} />
{busyAction === "save" ? "保存中" : "保存草稿"}
</button>
</div>
</div>
<div className="dashboard-grid">
<section className="panel">
<div className="panel-header">
<h2 className="panel-title">
<FileText size={16} />
草稿编辑
</h2>
{publishedTemplate && <span className="tag tag-success">当前运行:{publishedTemplate.version}</span>}
</div>
<div className="panel-body">
<div className="settings-grid">
<label className="setting-field">
<span>Prompt 名称</span>
<input
className="search-input"
value={draft.name}
onChange={(event) => {
setDraft((current) => ({ ...current, name: event.target.value }));
setSelectedName(event.target.value);
}}
placeholder="legal_qa.default"
/>
</label>
<label className="setting-field">
<span>版本号</span>
<input
className="search-input"
value={draft.version}
onChange={(event) => setDraft((current) => ({ ...current, version: event.target.value }))}
placeholder="v2026.06.23"
/>
</label>
</div>
<label className="setting-field prompt-editor-field">
<span>Prompt 内容</span>
<textarea
className="search-input prompt-textarea"
value={draft.content}
onChange={(event) => setDraft((current) => ({ ...current, content: event.target.value }))}
placeholder="写清楚任务、输出结构、引用依据和失败时的降级策略。"
/>
</label>
{(message || validationMessage) && (
<div className={`inline-message ${validationMessage ? "inline-message-error" : ""}`}>
{validationMessage || message}
</div>
)}
</div>
</section>
<section className="panel">
<div className="panel-header">
<h2 className="panel-title">版本列表</h2>
{names.length > 0 && (
<select className="search-input select-input" value={selectedName} onChange={(event) => setSelectedName(event.target.value)}>
{names.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
)}
</div>
<div className="panel-body">
{state.status === "loading" && <div className="empty-state">正在加载 Prompt 版本...</div>}
{state.status === "error" && (
<div className="empty-state empty-state-error">
<strong>无法连接 AI Platform Prompt 接口</strong>
<span>{state.message}</span>
</div>
)}
{state.status === "ready" && visibleTemplates.length === 0 && (
<div className="empty-state">暂无 Prompt 版本。可先保存一个草稿。</div>
)}
{state.status === "ready" && visibleTemplates.length > 0 && (
<div className="prompt-version-list">
{visibleTemplates.map((template) => {
const isPublished = template.status === "published";
return (
<article className="quality-action-item" key={template.id}>
<div className="list-row">
<div className="list-main">
<strong>{template.version}</strong>
<span className="item-meta">
更新:{formatDate(template.updated_at)}
{template.published_at ? ` · 发布:${formatDate(template.published_at)}` : ""}
</span>
</div>
<span className={`tag ${statusTone[template.status] ?? ""}`}>
{statusLabels[template.status] ?? template.status}
</span>
</div>
{template.rollback_from_version && (
<span className="item-meta">回滚来源:{template.rollback_from_version}</span>
)}
<pre className="prompt-preview">{template.content}</pre>
<div className="action-row">
<button className="button button-soft" onClick={() => setDraftFromTemplate(template)}>
编辑副本
</button>
<button
className="button button-primary"
disabled={isPublished || busyAction === `publish:${template.id}`}
onClick={() => void publishTemplate(template)}
>
<Send size={16} />
{busyAction === `publish:${template.id}` ? "发布中" : "发布"}
</button>
<button
className="button button-soft"
disabled={busyAction === `rollback:${template.id}`}
onClick={() => void rollbackTemplate(template)}
>
<RotateCcw size={16} />
{busyAction === `rollback:${template.id}` ? "回滚中" : "回滚发布"}
</button>
</div>
</article>
);
})}
</div>
)}
</div>
</section>
</div>
</>
);
}
function compareDate(left: string, right: string) {
return new Date(left).getTime() - new Date(right).getTime();
}
function formatDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
@@ -1,6 +1,6 @@
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import { Activity, Bot, Settings, ShieldCheck, SlidersHorizontal } from "lucide-react";
import { Activity, Bot, FileText, Settings, ShieldCheck, SlidersHorizontal } from "lucide-react";
type AdminSection = {
title: string;
@@ -21,6 +21,12 @@ const adminSections: AdminSection[] = [
icon: SlidersHorizontal,
href: "/admin/ai-quality"
},
{
title: "Prompt 中心",
description: "维护提示词草稿、发布版本、回滚和运行版本边界。",
icon: FileText,
href: "/admin/ai-prompts"
},
{
title: "AI 质量运营",
description: "质量通过率、fallback、异常趋势和待处理标签。",
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
const aiPlatformBaseUrl =
process.env.AI_PLATFORM_API_BASE_URL ??
process.env.NEXT_PUBLIC_AI_PLATFORM_API_BASE_URL ??
"http://localhost:8101";
export async function POST(
request: Request,
context: { params: Promise<{ name: string }> }
) {
const { name } = await context.params;
const targetUrl = new URL(
`/api/v1/prompt-templates/${encodeURIComponent(name)}/publish`,
aiPlatformBaseUrl
);
const body: unknown = await request.json().catch(() => null);
try {
const response = await fetch(targetUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store"
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
message: "AI Platform prompt publish service returned an error.",
status: response.status,
payload
},
{ status: response.status }
);
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "AI Platform prompt publish service is unavailable.",
baseUrl: aiPlatformBaseUrl
},
{ status: 502 }
);
}
}
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
const aiPlatformBaseUrl =
process.env.AI_PLATFORM_API_BASE_URL ??
process.env.NEXT_PUBLIC_AI_PLATFORM_API_BASE_URL ??
"http://localhost:8101";
export async function POST(
request: Request,
context: { params: Promise<{ name: string }> }
) {
const { name } = await context.params;
const targetUrl = new URL(
`/api/v1/prompt-templates/${encodeURIComponent(name)}/rollback`,
aiPlatformBaseUrl
);
const body: unknown = await request.json().catch(() => null);
try {
const response = await fetch(targetUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store"
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
message: "AI Platform prompt rollback service returned an error.",
status: response.status,
payload
},
{ status: response.status }
);
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "AI Platform prompt rollback service is unavailable.",
baseUrl: aiPlatformBaseUrl
},
{ status: 502 }
);
}
}
@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
const aiPlatformBaseUrl =
process.env.AI_PLATFORM_API_BASE_URL ??
process.env.NEXT_PUBLIC_AI_PLATFORM_API_BASE_URL ??
"http://localhost:8101";
export async function GET() {
const targetUrl = new URL("/api/v1/prompt-templates", aiPlatformBaseUrl);
try {
const response = await fetch(targetUrl, { cache: "no-store" });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
message: "AI Platform prompt template service returned an error.",
status: response.status,
payload
},
{ status: response.status }
);
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "AI Platform prompt template service is unavailable.",
baseUrl: aiPlatformBaseUrl
},
{ status: 502 }
);
}
}
export async function POST(request: Request) {
const targetUrl = new URL("/api/v1/prompt-templates", aiPlatformBaseUrl);
const body: unknown = await request.json().catch(() => null);
try {
const response = await fetch(targetUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store"
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{
message: "AI Platform prompt template service returned an error.",
status: response.status,
payload
},
{ status: response.status }
);
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "AI Platform prompt template service is unavailable.",
baseUrl: aiPlatformBaseUrl
},
{ status: 502 }
);
}
}
@@ -678,6 +678,39 @@ select {
width: 100%;
}
.prompt-editor-field {
margin-top: 14px;
}
.prompt-textarea {
width: 100%;
min-height: 260px;
padding: 12px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
line-height: 1.55;
}
.prompt-version-list {
display: grid;
gap: 12px;
}
.prompt-preview {
max-height: 220px;
margin: 0;
padding: 12px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
color: #24324a;
background: #f8fafc;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.inline-message {
margin-top: 14px;
padding: 10px 12px;