feat: add ai prompt release workflow
This commit is contained in:
@@ -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),
|
||||
|
||||
+170
-5
@@ -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、异常趋势和待处理标签。",
|
||||
|
||||
+47
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+47
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+68
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user