diff --git a/project-docs/V2-ai-platform-prompt-release-rollback-2026-06-23.md b/project-docs/V2-ai-platform-prompt-release-rollback-2026-06-23.md new file mode 100644 index 00000000..03ed120e --- /dev/null +++ b/project-docs/V2-ai-platform-prompt-release-rollback-2026-06-23.md @@ -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/知识库的异常率变化。 diff --git a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml index e0db05c9..186902be 100644 --- a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml +++ b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml @@ -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 diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py index cd4f93d6..ee3137ba 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/main.py @@ -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, diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py index 72b93f8c..8f38063b 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/models.py @@ -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), diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py index e0694c35..187e4cb2 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py @@ -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), ) diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py index 114dd7c4..018b2765 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py @@ -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" diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py b/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py index 374dc155..b00ec033 100644 --- a/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py +++ b/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py @@ -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", diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py index a187b618..9da66e56 100644 --- a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py +++ b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py @@ -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, *, diff --git a/yuqei-ai-sdk-python/tests/test_ai_platform.py b/yuqei-ai-sdk-python/tests/test_ai_platform.py index 11297e06..cacc237f 100644 --- a/yuqei-ai-sdk-python/tests/test_ai_platform.py +++ b/yuqei-ai-sdk-python/tests/test_ai_platform.py @@ -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( diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-prompts/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-prompts/page.tsx new file mode 100644 index 00000000..c860ce02 --- /dev/null +++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-prompts/page.tsx @@ -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 = { + draft: "草稿", + published: "已发布", + archived: "已归档" +}; + +const statusTone: Record = { + draft: "", + published: "tag-success", + archived: "tag-warning" +}; + +export default function AiPromptsPage() { + const [state, setState] = useState({ status: "loading" }); + const [draft, setDraft] = useState(defaultDraft); + const [selectedName, setSelectedName] = useState(defaultDraft.name); + const [busyAction, setBusyAction] = useState(null); + const [message, setMessage] = useState(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 ( + <> +
+
+

Prompt 中心

+

集中管理 AI 中台提示词草稿、发布版本和回滚记录。

+
+
+ + +
+
+ +
+
+
+

+ + 草稿编辑 +

+ {publishedTemplate && 当前运行:{publishedTemplate.version}} +
+
+
+ + +
+