feat: link ai quality actions to changes
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# V2 AI 质量改进项关联真实变更记录 - 2026-06-23
|
||||
|
||||
## 背景
|
||||
|
||||
上一轮已经完成 Prompt 发布/回滚闭环。本轮继续推进“AI 质量问题归因 -> 改进动作 -> 真实配置变更 -> 效果回看”的闭环,避免质量改进项只停留在文字状态。
|
||||
|
||||
## 本轮完成内容
|
||||
|
||||
1. 改进项增加真实关联字段
|
||||
- `linked_prompt_template_id`
|
||||
- `linked_prompt_template_version`
|
||||
- `linked_knowledge_base_id`
|
||||
- `linked_import_batch_id`
|
||||
|
||||
2. AI Platform 自动推断关联对象
|
||||
- Prompt 维度改进项会尝试关联对应 Prompt 模板和版本。
|
||||
- 知识库维度改进项会关联知识库 ID 和该知识库最新导入批次。
|
||||
- 创建接口仍支持前端或 SDK 显式传入关联字段。
|
||||
|
||||
3. 持久化兼容
|
||||
- SQLAlchemy 模型增加对应字段。
|
||||
- 自动兼容迁移会给已有 `ai_quality_improvement_actions` 表补列。
|
||||
- 内存仓库与 SQL 仓库均支持创建、更新、列表、看板返回关联字段。
|
||||
|
||||
4. OpenAPI 与 SDK 同步
|
||||
- `AiQualityImprovementActionCreate`、`AiQualityImprovementActionUpdate`、`AiQualityImprovementAction` 同步新增关联字段。
|
||||
- Python SDK 支持创建和更新改进项时传入关联字段。
|
||||
|
||||
5. 管理后台质量运营页增强
|
||||
- 页面加载 Prompt 模板和知识库导入批次候选。
|
||||
- 从 Prompt 归因生成改进项时,自动关联 Prompt 模板 ID 和版本。
|
||||
- 从知识库归因生成改进项时,自动关联知识库 ID 和最新导入批次。
|
||||
- 改进项卡片展示“关联变更”,并提供跳转 Prompt 中心入口。
|
||||
- 新增 `/api/ai-platform/knowledge-import-batches` 前端代理。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `python -m pytest yuqei-ai-platform/services/ai-platform-api/tests -q`
|
||||
- 26 passed
|
||||
- `python -m pytest yuqei-ai-sdk-python/tests -q`
|
||||
- 2 passed
|
||||
- `npm run web:typecheck`
|
||||
- passed
|
||||
- `npm run web:build`
|
||||
- passed
|
||||
- OpenAPI YAML 解析
|
||||
- passed
|
||||
|
||||
## 当前闭环价值
|
||||
|
||||
现在质量运营路径变成:
|
||||
|
||||
1. 质量看板发现某个模型、Prompt、知识库或标签异常集中。
|
||||
2. 管理员从归因卡片生成改进项。
|
||||
3. 改进项自动挂上相关 Prompt 版本或知识库导入批次。
|
||||
4. 管理员去 Prompt 中心发布新版本,或补充知识库导入批次。
|
||||
5. 后续 AI 调用继续进入质量看板,改进项显示处理后异常率、质量分、待处理标签变化。
|
||||
|
||||
这让“调整 Prompt”“补知识库”开始具备可追溯的配置变更记录。
|
||||
|
||||
## 下一步建议
|
||||
|
||||
下一步可以进入“改进动作执行页”:
|
||||
|
||||
- 对 `tune_prompt` 改进项,提供直接创建 Prompt 草稿的入口。
|
||||
- 对 `improve_knowledge` 改进项,提供上传/导入知识文档入口。
|
||||
- 改进项完成时自动要求填写“实际变更对象”,防止误标完成。
|
||||
@@ -1666,6 +1666,22 @@ components:
|
||||
root_cause_hint:
|
||||
type: string
|
||||
nullable: true
|
||||
linked_prompt_template_id:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 64
|
||||
linked_prompt_template_version:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 64
|
||||
linked_knowledge_base_id:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 64
|
||||
linked_import_batch_id:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 64
|
||||
evaluation_days:
|
||||
type: integer
|
||||
minimum: 1
|
||||
@@ -1690,6 +1706,22 @@ components:
|
||||
resolution_note:
|
||||
type: string
|
||||
nullable: true
|
||||
linked_prompt_template_id:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 64
|
||||
linked_prompt_template_version:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 64
|
||||
linked_knowledge_base_id:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 64
|
||||
linked_import_batch_id:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 64
|
||||
AiQualityImprovementAction:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/AiQualityImprovementActionCreate"
|
||||
|
||||
@@ -172,6 +172,10 @@ class AiQualityImprovementActionModel(Base):
|
||||
source_label_codes_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
source_run_ids_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
root_cause_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
linked_prompt_template_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
linked_prompt_template_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
linked_knowledge_base_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
linked_import_batch_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
evaluation_days: Mapped[int] = mapped_column(Integer, default=14)
|
||||
baseline_total_runs: Mapped[int] = mapped_column(Integer, default=0)
|
||||
baseline_affected_runs: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
+134
-8
@@ -335,6 +335,10 @@ class AiQualityImprovementActionCreate(BaseModel):
|
||||
source_label_codes: list[str] = Field(default_factory=list)
|
||||
source_run_ids: list[str] = Field(default_factory=list)
|
||||
root_cause_hint: str | None = None
|
||||
linked_prompt_template_id: str | None = Field(default=None, max_length=64)
|
||||
linked_prompt_template_version: str | None = Field(default=None, max_length=64)
|
||||
linked_knowledge_base_id: str | None = Field(default=None, max_length=64)
|
||||
linked_import_batch_id: str | None = Field(default=None, max_length=64)
|
||||
evaluation_days: int = Field(default=14, ge=1, le=90)
|
||||
|
||||
|
||||
@@ -343,6 +347,10 @@ class AiQualityImprovementActionUpdate(BaseModel):
|
||||
owner: str | None = Field(default=None, max_length=128)
|
||||
priority: str | None = Field(default=None, pattern="^(low|medium|high|critical)$")
|
||||
resolution_note: str | None = None
|
||||
linked_prompt_template_id: str | None = Field(default=None, max_length=64)
|
||||
linked_prompt_template_version: str | None = Field(default=None, max_length=64)
|
||||
linked_knowledge_base_id: str | None = Field(default=None, max_length=64)
|
||||
linked_import_batch_id: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class AiQualityImprovementAction(AiQualityImprovementActionCreate):
|
||||
@@ -866,6 +874,11 @@ class InMemoryAiPlatformRepository:
|
||||
now = datetime.now(UTC)
|
||||
with self._lock:
|
||||
baseline = _calculate_improvement_action_stats(payload, self.ai_run_audits)
|
||||
payload = _with_inferred_action_links(
|
||||
payload,
|
||||
prompt_templates=self.prompt_templates.values(),
|
||||
knowledge_import_batches=self.knowledge_import_batches.values(),
|
||||
)
|
||||
action = AiQualityImprovementAction(
|
||||
id=f"qa-action-{uuid4().hex[:12]}",
|
||||
title=payload.title or _default_improvement_action_title(payload),
|
||||
@@ -906,6 +919,14 @@ class InMemoryAiPlatformRepository:
|
||||
update["priority"] = payload.priority
|
||||
if payload.resolution_note is not None:
|
||||
update["resolution_note"] = payload.resolution_note
|
||||
if payload.linked_prompt_template_id is not None:
|
||||
update["linked_prompt_template_id"] = payload.linked_prompt_template_id
|
||||
if payload.linked_prompt_template_version is not None:
|
||||
update["linked_prompt_template_version"] = payload.linked_prompt_template_version
|
||||
if payload.linked_knowledge_base_id is not None:
|
||||
update["linked_knowledge_base_id"] = payload.linked_knowledge_base_id
|
||||
if payload.linked_import_batch_id is not None:
|
||||
update["linked_import_batch_id"] = payload.linked_import_batch_id
|
||||
updated = action.model_copy(update=update)
|
||||
self.quality_improvement_actions[action_id] = updated
|
||||
return _materialize_improvement_action(updated, self.ai_run_audits)
|
||||
@@ -956,15 +977,15 @@ class SqlAlchemyAiPlatformRepository:
|
||||
self._vector_search_backend = vector_search_backend
|
||||
if auto_create:
|
||||
Base.metadata.create_all(self._engine)
|
||||
self._ensure_prompt_template_columns()
|
||||
self._ensure_compat_columns()
|
||||
|
||||
def _ensure_prompt_template_columns(self) -> None:
|
||||
def _ensure_compat_columns(self) -> None:
|
||||
with self._engine.begin() as connection:
|
||||
dialect = self._engine.dialect.name
|
||||
existing_columns: set[str]
|
||||
prompt_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}
|
||||
prompt_columns = {str(row["name"]) for row in rows}
|
||||
else:
|
||||
rows = connection.execute(
|
||||
text(
|
||||
@@ -972,15 +993,38 @@ class SqlAlchemyAiPlatformRepository:
|
||||
"where table_name = 'prompt_templates'"
|
||||
)
|
||||
).mappings().all()
|
||||
existing_columns = {str(row["column_name"]) for row in rows}
|
||||
if "status" not in existing_columns:
|
||||
prompt_columns = {str(row["column_name"]) for row in rows}
|
||||
if "status" not in prompt_columns:
|
||||
connection.execute(text("ALTER TABLE prompt_templates ADD COLUMN status VARCHAR(32) DEFAULT 'draft'"))
|
||||
if "rollback_from_version" not in existing_columns:
|
||||
if "rollback_from_version" not in prompt_columns:
|
||||
connection.execute(text("ALTER TABLE prompt_templates ADD COLUMN rollback_from_version VARCHAR(32)"))
|
||||
if "published_at" not in existing_columns:
|
||||
if "published_at" not in prompt_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}"))
|
||||
|
||||
if dialect == "sqlite":
|
||||
rows = connection.execute(text("PRAGMA table_info(ai_quality_improvement_actions)")).mappings().all()
|
||||
action_columns = {str(row["name"]) for row in rows}
|
||||
else:
|
||||
rows = connection.execute(
|
||||
text(
|
||||
"select column_name from information_schema.columns "
|
||||
"where table_name = 'ai_quality_improvement_actions'"
|
||||
)
|
||||
).mappings().all()
|
||||
action_columns = {str(row["column_name"]) for row in rows}
|
||||
action_column_specs = {
|
||||
"linked_prompt_template_id": "VARCHAR(64)",
|
||||
"linked_prompt_template_version": "VARCHAR(64)",
|
||||
"linked_knowledge_base_id": "VARCHAR(64)",
|
||||
"linked_import_batch_id": "VARCHAR(64)",
|
||||
}
|
||||
for column_name, column_type in action_column_specs.items():
|
||||
if column_name not in action_columns:
|
||||
connection.execute(
|
||||
text(f"ALTER TABLE ai_quality_improvement_actions ADD COLUMN {column_name} {column_type}")
|
||||
)
|
||||
|
||||
def upsert_provider_config(self, payload: ProviderConfigCreate) -> ProviderConfig:
|
||||
now = datetime.now(UTC)
|
||||
with self._session_factory.begin() as session:
|
||||
@@ -1589,6 +1633,17 @@ class SqlAlchemyAiPlatformRepository:
|
||||
).all()
|
||||
runs = [_ai_run_audit_from_model(row) for row in audit_rows]
|
||||
baseline = _calculate_improvement_action_stats(payload, runs)
|
||||
payload = _with_inferred_action_links(
|
||||
payload,
|
||||
prompt_templates=[
|
||||
_prompt_template_from_model(row)
|
||||
for row in session.scalars(select(PromptTemplateModel)).all()
|
||||
],
|
||||
knowledge_import_batches=[
|
||||
_knowledge_import_batch_from_model(row)
|
||||
for row in session.scalars(select(KnowledgeImportBatchModel)).all()
|
||||
],
|
||||
)
|
||||
row = AiQualityImprovementActionModel(
|
||||
id=f"qa-action-{uuid4().hex[:12]}",
|
||||
dimension=payload.dimension,
|
||||
@@ -1603,6 +1658,10 @@ class SqlAlchemyAiPlatformRepository:
|
||||
source_label_codes_json=_encode_json_list(payload.source_label_codes),
|
||||
source_run_ids_json=_encode_json_list(payload.source_run_ids),
|
||||
root_cause_hint=payload.root_cause_hint,
|
||||
linked_prompt_template_id=payload.linked_prompt_template_id,
|
||||
linked_prompt_template_version=payload.linked_prompt_template_version,
|
||||
linked_knowledge_base_id=payload.linked_knowledge_base_id,
|
||||
linked_import_batch_id=payload.linked_import_batch_id,
|
||||
evaluation_days=payload.evaluation_days,
|
||||
baseline_total_runs=baseline["total_runs"],
|
||||
baseline_affected_runs=baseline["affected_runs"],
|
||||
@@ -1632,6 +1691,14 @@ class SqlAlchemyAiPlatformRepository:
|
||||
row.priority = payload.priority
|
||||
if payload.resolution_note is not None:
|
||||
row.resolution_note = payload.resolution_note
|
||||
if payload.linked_prompt_template_id is not None:
|
||||
row.linked_prompt_template_id = payload.linked_prompt_template_id
|
||||
if payload.linked_prompt_template_version is not None:
|
||||
row.linked_prompt_template_version = payload.linked_prompt_template_version
|
||||
if payload.linked_knowledge_base_id is not None:
|
||||
row.linked_knowledge_base_id = payload.linked_knowledge_base_id
|
||||
if payload.linked_import_batch_id is not None:
|
||||
row.linked_import_batch_id = payload.linked_import_batch_id
|
||||
row.updated_at = now
|
||||
row.resolved_at = now if payload.status in {"resolved", "false_positive", "closed"} else None
|
||||
|
||||
@@ -1926,6 +1993,10 @@ def _quality_improvement_action_from_model(row: AiQualityImprovementActionModel)
|
||||
source_label_codes=[str(item) for item in _decode_json_list(row.source_label_codes_json)],
|
||||
source_run_ids=[str(item) for item in _decode_json_list(row.source_run_ids_json)],
|
||||
root_cause_hint=row.root_cause_hint,
|
||||
linked_prompt_template_id=row.linked_prompt_template_id,
|
||||
linked_prompt_template_version=row.linked_prompt_template_version,
|
||||
linked_knowledge_base_id=row.linked_knowledge_base_id,
|
||||
linked_import_batch_id=row.linked_import_batch_id,
|
||||
evaluation_days=row.evaluation_days,
|
||||
baseline_total_runs=row.baseline_total_runs,
|
||||
baseline_affected_runs=row.baseline_affected_runs,
|
||||
@@ -1938,6 +2009,61 @@ def _quality_improvement_action_from_model(row: AiQualityImprovementActionModel)
|
||||
)
|
||||
|
||||
|
||||
def _with_inferred_action_links(
|
||||
payload: AiQualityImprovementActionCreate,
|
||||
*,
|
||||
prompt_templates: Iterable[PromptTemplate],
|
||||
knowledge_import_batches: Iterable[KnowledgeImportBatch],
|
||||
) -> AiQualityImprovementActionCreate:
|
||||
update: dict[str, str] = {}
|
||||
if payload.dimension == "prompt_template" and not payload.linked_prompt_template_id:
|
||||
prompt = _find_prompt_template_link(payload.key, prompt_templates)
|
||||
if prompt is not None:
|
||||
update["linked_prompt_template_id"] = prompt.id
|
||||
update["linked_prompt_template_version"] = prompt.version
|
||||
if payload.dimension == "knowledge_base" or payload.linked_knowledge_base_id:
|
||||
if payload.dimension == "knowledge_base" and not payload.linked_knowledge_base_id and payload.key != "no_knowledge_source":
|
||||
update["linked_knowledge_base_id"] = payload.key
|
||||
knowledge_base_id = payload.linked_knowledge_base_id or update.get("linked_knowledge_base_id")
|
||||
if knowledge_base_id and not payload.linked_import_batch_id:
|
||||
batch = _find_latest_import_batch_link(knowledge_base_id, knowledge_import_batches)
|
||||
if batch is not None:
|
||||
update["linked_import_batch_id"] = batch.id
|
||||
return payload.model_copy(update=update) if update else payload
|
||||
|
||||
|
||||
def _find_prompt_template_link(
|
||||
key: str,
|
||||
prompt_templates: Iterable[PromptTemplate],
|
||||
) -> PromptTemplate | None:
|
||||
candidates = [
|
||||
template
|
||||
for template in prompt_templates
|
||||
if template.id == key or template.name == key or f"{template.name}:{template.version}" == key
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return sorted(
|
||||
candidates,
|
||||
key=lambda item: item.published_at or item.updated_at,
|
||||
reverse=True,
|
||||
)[0]
|
||||
|
||||
|
||||
def _find_latest_import_batch_link(
|
||||
knowledge_base_id: str,
|
||||
batches: Iterable[KnowledgeImportBatch],
|
||||
) -> KnowledgeImportBatch | None:
|
||||
candidates = [
|
||||
batch
|
||||
for batch in batches
|
||||
if batch.knowledge_base_id == knowledge_base_id
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return sorted(candidates, key=lambda item: item.updated_at, reverse=True)[0]
|
||||
|
||||
|
||||
def _ensure_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
|
||||
@@ -326,6 +326,53 @@ def test_quality_attribution_can_create_and_resolve_improvement_action() -> None
|
||||
assert dashboard_action["effect_summary"]
|
||||
|
||||
|
||||
def test_quality_improvement_action_links_prompt_and_knowledge_changes() -> None:
|
||||
repository = InMemoryAiPlatformRepository()
|
||||
repository.seed_defaults()
|
||||
prompt = repository.get_prompt_template("legal_qa.default")
|
||||
assert prompt is not None
|
||||
batch = repository.create_knowledge_import_batch(
|
||||
KnowledgeImportBatchCreate(
|
||||
knowledge_base_id="laws-cn",
|
||||
source_name="civil-code-refresh",
|
||||
source_type="law_import",
|
||||
)
|
||||
)
|
||||
|
||||
prompt_action = repository.create_quality_improvement_action(
|
||||
AiQualityImprovementActionCreate(
|
||||
dimension="prompt_template",
|
||||
key=prompt.id,
|
||||
label="legal_qa.default",
|
||||
action_type="tune_prompt",
|
||||
evaluation_days=7,
|
||||
)
|
||||
)
|
||||
assert prompt_action.linked_prompt_template_id == prompt.id
|
||||
assert prompt_action.linked_prompt_template_version == prompt.version
|
||||
|
||||
knowledge_action = repository.create_quality_improvement_action(
|
||||
AiQualityImprovementActionCreate(
|
||||
dimension="knowledge_base",
|
||||
key="laws-cn",
|
||||
label="laws-cn",
|
||||
action_type="improve_knowledge",
|
||||
evaluation_days=7,
|
||||
)
|
||||
)
|
||||
assert knowledge_action.linked_knowledge_base_id == "laws-cn"
|
||||
assert knowledge_action.linked_import_batch_id == batch.id
|
||||
|
||||
updated = repository.update_quality_improvement_action(
|
||||
knowledge_action.id,
|
||||
AiQualityImprovementActionUpdate(
|
||||
status="in_progress",
|
||||
linked_import_batch_id="kb-batch-manual",
|
||||
),
|
||||
)
|
||||
assert updated.linked_import_batch_id == "kb-batch-manual"
|
||||
|
||||
|
||||
def test_knowledge_document_can_be_added_and_searched() -> None:
|
||||
client = make_client()
|
||||
|
||||
|
||||
@@ -345,6 +345,10 @@ class AiQualityImprovementActionCreate(BaseModel):
|
||||
source_label_codes: list[str] = Field(default_factory=list)
|
||||
source_run_ids: list[str] = Field(default_factory=list)
|
||||
root_cause_hint: str | None = None
|
||||
linked_prompt_template_id: str | None = None
|
||||
linked_prompt_template_version: str | None = None
|
||||
linked_knowledge_base_id: str | None = None
|
||||
linked_import_batch_id: str | None = None
|
||||
evaluation_days: int = 14
|
||||
|
||||
|
||||
@@ -353,6 +357,10 @@ class AiQualityImprovementActionUpdate(BaseModel):
|
||||
owner: str | None = Field(default=None, max_length=128)
|
||||
priority: str | None = None
|
||||
resolution_note: str | None = None
|
||||
linked_prompt_template_id: str | None = None
|
||||
linked_prompt_template_version: str | None = None
|
||||
linked_knowledge_base_id: str | None = None
|
||||
linked_import_batch_id: str | None = None
|
||||
|
||||
|
||||
class AiQualityImprovementAction(AiQualityImprovementActionCreate):
|
||||
|
||||
@@ -115,6 +115,10 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
"source_label_codes": ["source_limited"],
|
||||
"source_run_ids": ["run-1"],
|
||||
"root_cause_hint": "Model quality issues are concentrated.",
|
||||
"linked_prompt_template_id": "prompt-1",
|
||||
"linked_prompt_template_version": "v2",
|
||||
"linked_knowledge_base_id": "laws-cn",
|
||||
"linked_import_batch_id": "kb-batch-1",
|
||||
"evaluation_days": 7,
|
||||
"baseline_total_runs": 1,
|
||||
"baseline_affected_runs": 1,
|
||||
@@ -621,6 +625,7 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
assert payload["dimension"] == "model"
|
||||
assert payload["action_type"] == "tune_model"
|
||||
assert payload["source_label_codes"] == ["source_limited"]
|
||||
assert payload["linked_prompt_template_id"] == "prompt-1"
|
||||
return httpx.Response(200, json=quality_action_payload())
|
||||
if request.url.path == "/api/v1/audit/quality-improvement-actions":
|
||||
query = parse_qs(request.url.query.decode())
|
||||
@@ -636,6 +641,7 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
assert request.method == "PATCH"
|
||||
assert payload["status"] == "resolved"
|
||||
assert payload["resolution_note"] == "Provider config was tuned."
|
||||
assert payload["linked_import_batch_id"] == "kb-batch-2"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=quality_action_payload(
|
||||
@@ -802,10 +808,13 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
priority="high",
|
||||
source_label_codes=["source_limited"],
|
||||
source_run_ids=["run-1"],
|
||||
linked_prompt_template_id="prompt-1",
|
||||
evaluation_days=7,
|
||||
)
|
||||
)
|
||||
assert created_action.status == "open"
|
||||
assert created_action.linked_prompt_template_id == "prompt-1"
|
||||
assert created_action.linked_import_batch_id == "kb-batch-1"
|
||||
assert created_action.baseline_affected_runs == 1
|
||||
assert created_action.effect_status == "unchanged"
|
||||
listed_actions = client.list_quality_improvement_actions(
|
||||
@@ -823,6 +832,7 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
AiQualityImprovementActionUpdate(
|
||||
status="resolved",
|
||||
resolution_note="Provider config was tuned.",
|
||||
linked_import_batch_id="kb-batch-2",
|
||||
),
|
||||
)
|
||||
assert updated_action.status == "resolved"
|
||||
|
||||
+110
@@ -84,6 +84,10 @@ type QualityImprovementAction = {
|
||||
source_label_codes: string[];
|
||||
source_run_ids: string[];
|
||||
root_cause_hint?: string | null;
|
||||
linked_prompt_template_id?: string | null;
|
||||
linked_prompt_template_version?: string | null;
|
||||
linked_knowledge_base_id?: string | null;
|
||||
linked_import_batch_id?: string | null;
|
||||
evaluation_days: number;
|
||||
baseline_total_runs: number;
|
||||
baseline_affected_runs: number;
|
||||
@@ -112,6 +116,24 @@ type QualityDashboard = {
|
||||
improvement_actions?: QualityImprovementAction[];
|
||||
};
|
||||
|
||||
type PromptTemplateOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
status: string;
|
||||
published_at?: string | null;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type KnowledgeImportBatchOption = {
|
||||
id: string;
|
||||
knowledge_base_id: string;
|
||||
source_name: string;
|
||||
source_type: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; dashboard: QualityDashboard }
|
||||
@@ -181,6 +203,8 @@ export default function AiQualityDashboardPage() {
|
||||
const [days, setDays] = useState(14);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [actionBusyKey, setActionBusyKey] = useState<string | null>(null);
|
||||
const [promptOptions, setPromptOptions] = useState<PromptTemplateOption[]>([]);
|
||||
const [importBatchOptions, setImportBatchOptions] = useState<KnowledgeImportBatchOption[]>([]);
|
||||
const [filters, setFilters] = useState<ActionFilters>({
|
||||
status: "",
|
||||
owner: "",
|
||||
@@ -209,8 +233,24 @@ export default function AiQualityDashboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLinkOptions() {
|
||||
const [promptResponse, batchResponse] = await Promise.allSettled([
|
||||
fetch("/api/ai-platform/prompt-templates", { cache: "no-store" }),
|
||||
fetch("/api/ai-platform/knowledge-import-batches?limit=50", { cache: "no-store" })
|
||||
]);
|
||||
if (promptResponse.status === "fulfilled" && promptResponse.value.ok) {
|
||||
const payload = await promptResponse.value.json().catch(() => []);
|
||||
setPromptOptions(Array.isArray(payload) ? payload : []);
|
||||
}
|
||||
if (batchResponse.status === "fulfilled" && batchResponse.value.ok) {
|
||||
const payload = await batchResponse.value.json().catch(() => []);
|
||||
setImportBatchOptions(Array.isArray(payload) ? payload : []);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard(days);
|
||||
void loadLinkOptions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -249,6 +289,7 @@ export default function AiQualityDashboardPage() {
|
||||
priority: inferPriority(item),
|
||||
source_label_codes: item.top_label_codes,
|
||||
root_cause_hint: item.root_cause_hint,
|
||||
...inferActionLinkPayload(item, promptOptions, importBatchOptions),
|
||||
evaluation_days: days
|
||||
})
|
||||
});
|
||||
@@ -257,6 +298,7 @@ export default function AiQualityDashboardPage() {
|
||||
throw new Error(payload?.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
setActionMessage("已生成改进项,可以在下方队列继续处理。");
|
||||
await loadLinkOptions();
|
||||
await loadDashboard(days);
|
||||
} catch (error) {
|
||||
setActionMessage(error instanceof Error ? error.message : "生成改进项失败");
|
||||
@@ -600,8 +642,14 @@ function QualityActionItem({
|
||||
<span>变化 {formatSignedPercent(action.affected_rate_delta)}</span>
|
||||
</div>
|
||||
{action.resolution_note && <span className="item-meta">处理说明:{action.resolution_note}</span>}
|
||||
<LinkedChangeSummary action={action} />
|
||||
{action.effect_summary && <span className="item-meta">效果复盘:{action.effect_summary}</span>}
|
||||
<div className="action-row">
|
||||
{action.linked_prompt_template_id && (
|
||||
<Link className="button button-soft" href="/admin/ai-prompts">
|
||||
查看 Prompt
|
||||
</Link>
|
||||
)}
|
||||
{action.status === "open" && (
|
||||
<button className="button button-soft" disabled={busy} onClick={() => void onUpdate(action, "in_progress")} type="button">
|
||||
<PlayCircle size={16} />
|
||||
@@ -625,6 +673,20 @@ function QualityActionItem({
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedChangeSummary({ action }: { action: QualityImprovementAction }) {
|
||||
const links = [
|
||||
action.linked_prompt_template_id
|
||||
? `Prompt ${action.linked_prompt_template_version || action.linked_prompt_template_id}`
|
||||
: null,
|
||||
action.linked_knowledge_base_id ? `知识库 ${action.linked_knowledge_base_id}` : null,
|
||||
action.linked_import_batch_id ? `导入批次 ${action.linked_import_batch_id}` : null
|
||||
].filter(Boolean);
|
||||
if (links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return <span className="item-meta">关联变更:{links.join(" · ")}</span>;
|
||||
}
|
||||
|
||||
function filterImprovementActions(actions: QualityImprovementAction[], filters: ActionFilters) {
|
||||
return actions.filter((action) => (
|
||||
(!filters.status || action.status === filters.status)
|
||||
@@ -665,6 +727,54 @@ function inferPriority(item: QualityAttributionItem) {
|
||||
return "medium";
|
||||
}
|
||||
|
||||
function inferActionLinkPayload(
|
||||
item: QualityAttributionItem,
|
||||
promptOptions: PromptTemplateOption[],
|
||||
importBatchOptions: KnowledgeImportBatchOption[]
|
||||
) {
|
||||
if (item.dimension === "prompt_template") {
|
||||
const prompt = findPromptOption(item.key, promptOptions);
|
||||
return {
|
||||
linked_prompt_template_id: prompt?.id ?? item.key,
|
||||
linked_prompt_template_version: prompt?.version
|
||||
};
|
||||
}
|
||||
if (item.dimension === "knowledge_base" && item.key !== "no_knowledge_source") {
|
||||
const batch = findLatestImportBatchOption(item.key, importBatchOptions);
|
||||
return {
|
||||
linked_knowledge_base_id: item.key,
|
||||
linked_import_batch_id: batch?.id
|
||||
};
|
||||
}
|
||||
if (item.dimension === "label_code" && item.key.includes("source")) {
|
||||
const batch = findLatestImportBatchOption("default", importBatchOptions);
|
||||
return {
|
||||
linked_knowledge_base_id: "default",
|
||||
linked_import_batch_id: batch?.id
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function findPromptOption(key: string, options: PromptTemplateOption[]) {
|
||||
const matches = options.filter((option) => (
|
||||
option.id === key
|
||||
|| option.name === key
|
||||
|| `${option.name}:${option.version}` === key
|
||||
));
|
||||
return matches.sort((left, right) => {
|
||||
const leftTime = new Date(left.published_at || left.updated_at).getTime();
|
||||
const rightTime = new Date(right.published_at || right.updated_at).getTime();
|
||||
return rightTime - leftTime;
|
||||
})[0];
|
||||
}
|
||||
|
||||
function findLatestImportBatchOption(knowledgeBaseId: string, options: KnowledgeImportBatchOption[]) {
|
||||
return options
|
||||
.filter((option) => option.knowledge_base_id === knowledgeBaseId)
|
||||
.sort((left, right) => new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime())[0];
|
||||
}
|
||||
|
||||
function defaultResolutionNote(status: string) {
|
||||
if (status === "resolved") {
|
||||
return "已处理,等待后续 AI 调用数据验证异常率是否下降。";
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
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(request: Request) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const targetUrl = new URL("/api/v1/knowledge-import-batches", aiPlatformBaseUrl);
|
||||
requestUrl.searchParams.forEach((value, key) => {
|
||||
targetUrl.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
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 knowledge import batch 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 knowledge import batch service is unavailable.",
|
||||
baseUrl: aiPlatformBaseUrl
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user