feat: close ai quality improvement loop
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# V2 AI 质量改进动作闭环记录 - 2026-06-23
|
||||
|
||||
## 本轮目标
|
||||
|
||||
在已有 AI 质量看板和问题归因基础上,补齐“异常归因 -> 待处理改进项 -> 人工处理状态 -> 处理后异常率回看”的运营闭环。
|
||||
|
||||
## 已完成能力
|
||||
|
||||
1. AI Platform 后端新增 `ai_quality_improvement_actions` 持久化模型。
|
||||
2. 新增改进项创建、列表、状态更新接口:
|
||||
- `GET /api/v1/audit/quality-improvement-actions`
|
||||
- `POST /api/v1/audit/quality-improvement-actions`
|
||||
- `PATCH /api/v1/audit/quality-improvement-actions/{action_id}`
|
||||
3. 质量看板响应新增 `improvement_actions`,可直接展示待推进改进队列。
|
||||
4. 改进项记录维度包括模型、Prompt、知识库、标签类型,并保存来源标签、根因提示、负责人、优先级、处理说明。
|
||||
5. 改进项自动记录基线数据,并在看板中计算当前影响调用数、待处理标签数、质量分和异常率变化。
|
||||
6. Python SDK 补齐对应 DTO 和客户端方法,合同系统后续可通过正式 SDK 调用 AI 中台能力。
|
||||
7. OpenAPI 契约同步新增改进项接口和 schema。
|
||||
8. 合同系统后台 AI 质量运营页新增:
|
||||
- 归因卡片一键“转改进项”。
|
||||
- 改进动作队列。
|
||||
- 开始处理、标记完成、误报关闭。
|
||||
- 基线/当前异常率、待处理标签、质量分对比。
|
||||
9. 合同系统新增 Next API 代理,浏览器端仍通过合同系统访问 AI Platform。
|
||||
|
||||
## 闭环使用方式
|
||||
|
||||
1. 管理员进入 `AI 质量运营` 页面。
|
||||
2. 查看模型、Prompt、知识库、标签归因。
|
||||
3. 对高频异常点击“转改进项”。
|
||||
4. 在改进动作队列中推进状态:待处理、处理中、已完成、误报关闭。
|
||||
5. 后续 AI 调用产生新数据后,看板会持续回看当前异常率是否低于创建时基线。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```powershell
|
||||
python -m pytest yuqei-ai-platform/services/ai-platform-api/tests yuqei-ai-sdk-python/tests -q
|
||||
python -c "import yaml; yaml.safe_load(open('yuqei-ai-platform/packages/ai-openapi/openapi.yaml', encoding='utf-8')); print('openapi ok')"
|
||||
npm run web:typecheck
|
||||
npm run web:build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 后续建议
|
||||
|
||||
下一步可以继续做 AI 质量改进项的运营增强:支持按负责人、优先级、状态筛选;支持关联具体 Prompt 版本、知识库导入批次、模型配置变更记录;支持改进完成后自动生成一份效果复盘。
|
||||
@@ -469,6 +469,73 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AiQualityDashboard"
|
||||
/api/v1/audit/quality-improvement-actions:
|
||||
get:
|
||||
operationId: listQualityImprovementActions
|
||||
summary: List AI quality improvement actions
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [open, in_progress, resolved, false_positive, closed]
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
responses:
|
||||
"200":
|
||||
description: AI quality improvement actions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AiQualityImprovementAction"
|
||||
post:
|
||||
operationId: createQualityImprovementAction
|
||||
summary: Create an AI quality improvement action from attribution
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AiQualityImprovementActionCreate"
|
||||
responses:
|
||||
"200":
|
||||
description: Created AI quality improvement action
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AiQualityImprovementAction"
|
||||
/api/v1/audit/quality-improvement-actions/{action_id}:
|
||||
patch:
|
||||
operationId: updateQualityImprovementAction
|
||||
summary: Update an AI quality improvement action status
|
||||
parameters:
|
||||
- name: action_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AiQualityImprovementActionUpdate"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated AI quality improvement action
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AiQualityImprovementAction"
|
||||
/api/v1/audit/ai-runs/{run_id}/quality-labels/{label_code}:
|
||||
patch:
|
||||
operationId: updateAiRunQualityLabel
|
||||
@@ -1452,6 +1519,136 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AiQualityAttributionItem"
|
||||
AiQualityImprovementActionCreate:
|
||||
type: object
|
||||
required:
|
||||
- dimension
|
||||
- key
|
||||
- label
|
||||
properties:
|
||||
dimension:
|
||||
type: string
|
||||
enum: [model, prompt_template, knowledge_base, label_code]
|
||||
key:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 256
|
||||
label:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 256
|
||||
action_type:
|
||||
type: string
|
||||
enum: [investigate, tune_prompt, improve_knowledge, tune_model, mark_false_positive]
|
||||
default: investigate
|
||||
title:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 256
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
owner:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 128
|
||||
priority:
|
||||
type: string
|
||||
enum: [low, medium, high, critical]
|
||||
default: medium
|
||||
source_label_codes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
source_run_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
root_cause_hint:
|
||||
type: string
|
||||
nullable: true
|
||||
evaluation_days:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 90
|
||||
default: 14
|
||||
AiQualityImprovementActionUpdate:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [open, in_progress, resolved, false_positive, closed]
|
||||
owner:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 128
|
||||
priority:
|
||||
type: string
|
||||
enum: [low, medium, high, critical]
|
||||
nullable: true
|
||||
resolution_note:
|
||||
type: string
|
||||
nullable: true
|
||||
AiQualityImprovementAction:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/AiQualityImprovementActionCreate"
|
||||
- type: object
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
- status
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [open, in_progress, resolved, false_positive, closed]
|
||||
default: open
|
||||
baseline_total_runs:
|
||||
type: integer
|
||||
baseline_affected_runs:
|
||||
type: integer
|
||||
baseline_pending_labels:
|
||||
type: integer
|
||||
baseline_quality_score:
|
||||
type: number
|
||||
format: float
|
||||
current_total_runs:
|
||||
type: integer
|
||||
current_affected_runs:
|
||||
type: integer
|
||||
current_pending_labels:
|
||||
type: integer
|
||||
current_quality_score:
|
||||
type: number
|
||||
format: float
|
||||
affected_rate_delta:
|
||||
type: number
|
||||
format: float
|
||||
pending_labels_delta:
|
||||
type: integer
|
||||
quality_score_delta:
|
||||
type: number
|
||||
format: float
|
||||
resolution_note:
|
||||
type: string
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
resolved_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
AiQualityDashboard:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1467,6 +1664,10 @@ components:
|
||||
$ref: "#/components/schemas/AiQualityPendingLabel"
|
||||
attribution:
|
||||
$ref: "#/components/schemas/AiQualityAttribution"
|
||||
improvement_actions:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AiQualityImprovementAction"
|
||||
AiRun:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -16,6 +16,9 @@ from yuqei_ai_platform_api.repository import (
|
||||
AiQualityLabel,
|
||||
AiQualityLabelReviewUpdate,
|
||||
AiQualityDashboard,
|
||||
AiQualityImprovementAction,
|
||||
AiQualityImprovementActionCreate,
|
||||
AiQualityImprovementActionUpdate,
|
||||
AiQualityRuleConfig,
|
||||
AiQualityRuleConfigCreate,
|
||||
AiRunAudit,
|
||||
@@ -246,6 +249,41 @@ def create_app(
|
||||
) -> AiQualityDashboard:
|
||||
return store.get_quality_dashboard(days=days, pending_limit=pending_limit)
|
||||
|
||||
@app.get(
|
||||
f"{resolved_settings.api_prefix}/audit/quality-improvement-actions",
|
||||
response_model=list[AiQualityImprovementAction],
|
||||
tags=["audit"],
|
||||
)
|
||||
def list_quality_improvement_actions(
|
||||
status: str | None = Query(None, pattern="^(open|in_progress|resolved|false_positive|closed)$"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> list[AiQualityImprovementAction]:
|
||||
return store.list_quality_improvement_actions(status=status, limit=limit)
|
||||
|
||||
@app.post(
|
||||
f"{resolved_settings.api_prefix}/audit/quality-improvement-actions",
|
||||
response_model=AiQualityImprovementAction,
|
||||
tags=["audit"],
|
||||
)
|
||||
def create_quality_improvement_action(
|
||||
payload: AiQualityImprovementActionCreate,
|
||||
) -> AiQualityImprovementAction:
|
||||
return store.create_quality_improvement_action(payload)
|
||||
|
||||
@app.patch(
|
||||
f"{resolved_settings.api_prefix}/audit/quality-improvement-actions/{{action_id}}",
|
||||
response_model=AiQualityImprovementAction,
|
||||
tags=["audit"],
|
||||
)
|
||||
def update_quality_improvement_action(
|
||||
action_id: str,
|
||||
payload: AiQualityImprovementActionUpdate,
|
||||
) -> AiQualityImprovementAction:
|
||||
try:
|
||||
return store.update_quality_improvement_action(action_id, payload)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=404, detail="AI quality improvement action not found.") from error
|
||||
|
||||
@app.patch(
|
||||
f"{resolved_settings.api_prefix}/audit/ai-runs/{{run_id}}/quality-labels/{{label_code}}",
|
||||
response_model=AiRunAudit,
|
||||
|
||||
@@ -151,3 +151,38 @@ class AiRunAuditModel(Base):
|
||||
default=lambda: datetime.now(UTC),
|
||||
index=True,
|
||||
)
|
||||
|
||||
|
||||
class AiQualityImprovementActionModel(Base):
|
||||
__tablename__ = "ai_quality_improvement_actions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
dimension: Mapped[str] = mapped_column(String(64), index=True)
|
||||
key: Mapped[str] = mapped_column(String(256), index=True)
|
||||
label: Mapped[str] = mapped_column(String(256))
|
||||
action_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
owner: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
priority: Mapped[str] = mapped_column(String(32), default="medium", index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="open", index=True)
|
||||
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)
|
||||
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)
|
||||
baseline_pending_labels: Mapped[int] = mapped_column(Integer, default=0)
|
||||
baseline_quality_score: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
index=True,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
index=True,
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
+419
-4
@@ -5,7 +5,7 @@ import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from threading import Lock
|
||||
from typing import Protocol
|
||||
from typing import Iterable, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -24,6 +24,7 @@ from yuqei_ai_platform_api.embeddings import (
|
||||
from yuqei_ai_platform_api.legal_qa import LegalCitation
|
||||
from yuqei_ai_platform_api.models import (
|
||||
AiProviderConfigModel,
|
||||
AiQualityImprovementActionModel,
|
||||
AiQualityRuleConfigModel,
|
||||
AiRunAuditModel,
|
||||
Base,
|
||||
@@ -307,6 +308,52 @@ class AiQualityAttribution(BaseModel):
|
||||
by_label_code: list[AiQualityAttributionItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AiQualityImprovementActionCreate(BaseModel):
|
||||
dimension: str = Field(pattern="^(model|prompt_template|knowledge_base|label_code)$")
|
||||
key: str = Field(min_length=1, max_length=256)
|
||||
label: str = Field(min_length=1, max_length=256)
|
||||
action_type: str = Field(
|
||||
default="investigate",
|
||||
pattern="^(investigate|tune_prompt|improve_knowledge|tune_model|mark_false_positive)$",
|
||||
)
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
description: str | None = None
|
||||
owner: str | None = Field(default=None, max_length=128)
|
||||
priority: str = Field(default="medium", pattern="^(low|medium|high|critical)$")
|
||||
source_label_codes: list[str] = Field(default_factory=list)
|
||||
source_run_ids: list[str] = Field(default_factory=list)
|
||||
root_cause_hint: str | None = None
|
||||
evaluation_days: int = Field(default=14, ge=1, le=90)
|
||||
|
||||
|
||||
class AiQualityImprovementActionUpdate(BaseModel):
|
||||
status: str = Field(pattern="^(open|in_progress|resolved|false_positive|closed)$")
|
||||
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
|
||||
|
||||
|
||||
class AiQualityImprovementAction(AiQualityImprovementActionCreate):
|
||||
id: str
|
||||
title: str
|
||||
status: str = "open"
|
||||
baseline_total_runs: int = 0
|
||||
baseline_affected_runs: int = 0
|
||||
baseline_pending_labels: int = 0
|
||||
baseline_quality_score: float = 0.0
|
||||
current_total_runs: int = 0
|
||||
current_affected_runs: int = 0
|
||||
current_pending_labels: int = 0
|
||||
current_quality_score: float = 0.0
|
||||
affected_rate_delta: float = 0.0
|
||||
pending_labels_delta: int = 0
|
||||
quality_score_delta: float = 0.0
|
||||
resolution_note: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
resolved_at: datetime | None = None
|
||||
|
||||
|
||||
class AiQualityDashboardSummary(BaseModel):
|
||||
days: int
|
||||
total_runs: int = 0
|
||||
@@ -329,6 +376,7 @@ class AiQualityDashboard(BaseModel):
|
||||
daily_metrics: list[AiQualityDailyMetric] = Field(default_factory=list)
|
||||
pending_labels: list[AiQualityPendingLabel] = Field(default_factory=list)
|
||||
attribution: AiQualityAttribution = Field(default_factory=AiQualityAttribution)
|
||||
improvement_actions: list[AiQualityImprovementAction] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AiPlatformRepository(Protocol):
|
||||
@@ -396,6 +444,24 @@ class AiPlatformRepository(Protocol):
|
||||
|
||||
def get_quality_dashboard(self, *, days: int = 14, pending_limit: int = 20) -> AiQualityDashboard: ...
|
||||
|
||||
def list_quality_improvement_actions(
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[AiQualityImprovementAction]: ...
|
||||
|
||||
def create_quality_improvement_action(
|
||||
self,
|
||||
payload: AiQualityImprovementActionCreate,
|
||||
) -> AiQualityImprovementAction: ...
|
||||
|
||||
def update_quality_improvement_action(
|
||||
self,
|
||||
action_id: str,
|
||||
payload: AiQualityImprovementActionUpdate,
|
||||
) -> AiQualityImprovementAction: ...
|
||||
|
||||
def update_ai_run_quality_label(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -414,6 +480,7 @@ class InMemoryAiPlatformRepository:
|
||||
knowledge_documents: dict[str, KnowledgeDocument] = field(default_factory=dict)
|
||||
knowledge_chunks: dict[str, KnowledgeChunk] = field(default_factory=dict)
|
||||
ai_run_audits: list[AiRunAudit] = field(default_factory=list)
|
||||
quality_improvement_actions: dict[str, AiQualityImprovementAction] = field(default_factory=dict)
|
||||
quality_rule_config: AiQualityRuleConfig = field(
|
||||
default_factory=lambda: AiQualityRuleConfig(
|
||||
id="default",
|
||||
@@ -683,7 +750,75 @@ class InMemoryAiPlatformRepository:
|
||||
for audit in self.ai_run_audits
|
||||
if _ensure_utc(audit.created_at) >= cutoff.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
]
|
||||
return _build_quality_dashboard(runs, days=days, pending_limit=pending_limit)
|
||||
actions = _materialize_improvement_actions(
|
||||
self.quality_improvement_actions.values(),
|
||||
self.ai_run_audits,
|
||||
)
|
||||
return _build_quality_dashboard(runs, days=days, pending_limit=pending_limit, improvement_actions=actions)
|
||||
|
||||
def list_quality_improvement_actions(
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[AiQualityImprovementAction]:
|
||||
with self._lock:
|
||||
actions = _materialize_improvement_actions(
|
||||
self.quality_improvement_actions.values(),
|
||||
self.ai_run_audits,
|
||||
)
|
||||
return _filter_improvement_actions(actions, status=status, limit=limit)
|
||||
|
||||
def create_quality_improvement_action(
|
||||
self,
|
||||
payload: AiQualityImprovementActionCreate,
|
||||
) -> AiQualityImprovementAction:
|
||||
now = datetime.now(UTC)
|
||||
with self._lock:
|
||||
baseline = _calculate_improvement_action_stats(payload, self.ai_run_audits)
|
||||
action = AiQualityImprovementAction(
|
||||
id=f"qa-action-{uuid4().hex[:12]}",
|
||||
title=payload.title or _default_improvement_action_title(payload),
|
||||
status="open",
|
||||
baseline_total_runs=baseline["total_runs"],
|
||||
baseline_affected_runs=baseline["affected_runs"],
|
||||
baseline_pending_labels=baseline["pending_labels"],
|
||||
baseline_quality_score=baseline["quality_score"],
|
||||
current_total_runs=baseline["total_runs"],
|
||||
current_affected_runs=baseline["affected_runs"],
|
||||
current_pending_labels=baseline["pending_labels"],
|
||||
current_quality_score=baseline["quality_score"],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
**payload.model_dump(exclude={"title"}),
|
||||
)
|
||||
self.quality_improvement_actions[action.id] = action
|
||||
return action
|
||||
|
||||
def update_quality_improvement_action(
|
||||
self,
|
||||
action_id: str,
|
||||
payload: AiQualityImprovementActionUpdate,
|
||||
) -> AiQualityImprovementAction:
|
||||
now = datetime.now(UTC)
|
||||
with self._lock:
|
||||
action = self.quality_improvement_actions.get(action_id)
|
||||
if action is None:
|
||||
raise KeyError(action_id)
|
||||
update: dict[str, object] = {
|
||||
"status": payload.status,
|
||||
"updated_at": now,
|
||||
"resolved_at": now if payload.status in {"resolved", "false_positive", "closed"} else None,
|
||||
}
|
||||
if payload.owner is not None:
|
||||
update["owner"] = payload.owner
|
||||
if payload.priority is not None:
|
||||
update["priority"] = payload.priority
|
||||
if payload.resolution_note is not None:
|
||||
update["resolution_note"] = payload.resolution_note
|
||||
updated = action.model_copy(update=update)
|
||||
self.quality_improvement_actions[action_id] = updated
|
||||
return _materialize_improvement_action(updated, self.ai_run_audits)
|
||||
|
||||
def update_ai_run_quality_label(
|
||||
self,
|
||||
@@ -1187,13 +1322,133 @@ class SqlAlchemyAiPlatformRepository:
|
||||
cutoff = datetime.now(UTC) - timedelta(days=max(days - 1, 0))
|
||||
cutoff = cutoff.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
with self._session_factory() as session:
|
||||
action_rows = session.scalars(
|
||||
select(AiQualityImprovementActionModel)
|
||||
.order_by(AiQualityImprovementActionModel.updated_at.desc())
|
||||
.limit(20)
|
||||
).all()
|
||||
action_days = [row.evaluation_days for row in action_rows]
|
||||
stats_cutoff = datetime.now(UTC) - timedelta(days=max([days, *action_days], default=days) - 1)
|
||||
stats_cutoff = stats_cutoff.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
rows = session.scalars(
|
||||
select(AiRunAuditModel)
|
||||
.where(AiRunAuditModel.created_at >= stats_cutoff)
|
||||
.order_by(AiRunAuditModel.created_at.desc())
|
||||
).all()
|
||||
all_runs = [_ai_run_audit_from_model(row) for row in rows]
|
||||
dashboard_runs = [
|
||||
run for run in all_runs if _ensure_utc(run.created_at) >= cutoff
|
||||
]
|
||||
actions = _materialize_improvement_actions(
|
||||
[_quality_improvement_action_from_model(row) for row in action_rows],
|
||||
all_runs,
|
||||
)
|
||||
return _build_quality_dashboard(
|
||||
dashboard_runs,
|
||||
days=days,
|
||||
pending_limit=pending_limit,
|
||||
improvement_actions=_filter_improvement_actions(actions, limit=20),
|
||||
)
|
||||
|
||||
def list_quality_improvement_actions(
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[AiQualityImprovementAction]:
|
||||
with self._session_factory() as session:
|
||||
statement = select(AiQualityImprovementActionModel).order_by(
|
||||
AiQualityImprovementActionModel.updated_at.desc()
|
||||
)
|
||||
if status:
|
||||
statement = statement.where(AiQualityImprovementActionModel.status == status)
|
||||
rows = session.scalars(statement.limit(limit)).all()
|
||||
max_days = max((row.evaluation_days for row in rows), default=14)
|
||||
cutoff = datetime.now(UTC) - timedelta(days=max(max_days - 1, 0))
|
||||
cutoff = cutoff.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
audit_rows = session.scalars(
|
||||
select(AiRunAuditModel)
|
||||
.where(AiRunAuditModel.created_at >= cutoff)
|
||||
.order_by(AiRunAuditModel.created_at.desc())
|
||||
).all()
|
||||
runs = [_ai_run_audit_from_model(row) for row in rows]
|
||||
return _build_quality_dashboard(runs, days=days, pending_limit=pending_limit)
|
||||
runs = [_ai_run_audit_from_model(row) for row in audit_rows]
|
||||
actions = _materialize_improvement_actions(
|
||||
[_quality_improvement_action_from_model(row) for row in rows],
|
||||
runs,
|
||||
)
|
||||
return _filter_improvement_actions(actions, status=status, limit=limit)
|
||||
|
||||
def create_quality_improvement_action(
|
||||
self,
|
||||
payload: AiQualityImprovementActionCreate,
|
||||
) -> AiQualityImprovementAction:
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(days=max(payload.evaluation_days - 1, 0))
|
||||
cutoff = cutoff.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
with self._session_factory.begin() as session:
|
||||
audit_rows = session.scalars(
|
||||
select(AiRunAuditModel)
|
||||
.where(AiRunAuditModel.created_at >= cutoff)
|
||||
.order_by(AiRunAuditModel.created_at.desc())
|
||||
).all()
|
||||
runs = [_ai_run_audit_from_model(row) for row in audit_rows]
|
||||
baseline = _calculate_improvement_action_stats(payload, runs)
|
||||
row = AiQualityImprovementActionModel(
|
||||
id=f"qa-action-{uuid4().hex[:12]}",
|
||||
dimension=payload.dimension,
|
||||
key=payload.key,
|
||||
label=payload.label,
|
||||
action_type=payload.action_type,
|
||||
title=payload.title or _default_improvement_action_title(payload),
|
||||
description=payload.description,
|
||||
owner=payload.owner,
|
||||
priority=payload.priority,
|
||||
status="open",
|
||||
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,
|
||||
evaluation_days=payload.evaluation_days,
|
||||
baseline_total_runs=baseline["total_runs"],
|
||||
baseline_affected_runs=baseline["affected_runs"],
|
||||
baseline_pending_labels=baseline["pending_labels"],
|
||||
baseline_quality_score=baseline["quality_score"],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return _materialize_improvement_action(_quality_improvement_action_from_model(row), runs)
|
||||
|
||||
def update_quality_improvement_action(
|
||||
self,
|
||||
action_id: str,
|
||||
payload: AiQualityImprovementActionUpdate,
|
||||
) -> AiQualityImprovementAction:
|
||||
now = datetime.now(UTC)
|
||||
with self._session_factory.begin() as session:
|
||||
row = session.get(AiQualityImprovementActionModel, action_id)
|
||||
if row is None:
|
||||
raise KeyError(action_id)
|
||||
row.status = payload.status
|
||||
if payload.owner is not None:
|
||||
row.owner = payload.owner
|
||||
if payload.priority is not None:
|
||||
row.priority = payload.priority
|
||||
if payload.resolution_note is not None:
|
||||
row.resolution_note = payload.resolution_note
|
||||
row.updated_at = now
|
||||
row.resolved_at = now if payload.status in {"resolved", "false_positive", "closed"} else None
|
||||
|
||||
cutoff = now - timedelta(days=max(row.evaluation_days - 1, 0))
|
||||
cutoff = cutoff.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
audit_rows = session.scalars(
|
||||
select(AiRunAuditModel)
|
||||
.where(AiRunAuditModel.created_at >= cutoff)
|
||||
.order_by(AiRunAuditModel.created_at.desc())
|
||||
).all()
|
||||
runs = [_ai_run_audit_from_model(row) for row in audit_rows]
|
||||
session.flush()
|
||||
return _materialize_improvement_action(_quality_improvement_action_from_model(row), runs)
|
||||
|
||||
def update_ai_run_quality_label(
|
||||
self,
|
||||
@@ -1453,6 +1708,33 @@ def _ai_run_audit_from_model(row: AiRunAuditModel) -> AiRunAudit:
|
||||
)
|
||||
|
||||
|
||||
def _quality_improvement_action_from_model(row: AiQualityImprovementActionModel) -> AiQualityImprovementAction:
|
||||
return AiQualityImprovementAction(
|
||||
id=row.id,
|
||||
dimension=row.dimension,
|
||||
key=row.key,
|
||||
label=row.label,
|
||||
action_type=row.action_type,
|
||||
title=row.title,
|
||||
description=row.description,
|
||||
owner=row.owner,
|
||||
priority=row.priority,
|
||||
status=row.status,
|
||||
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,
|
||||
evaluation_days=row.evaluation_days,
|
||||
baseline_total_runs=row.baseline_total_runs,
|
||||
baseline_affected_runs=row.baseline_affected_runs,
|
||||
baseline_pending_labels=row.baseline_pending_labels,
|
||||
baseline_quality_score=row.baseline_quality_score,
|
||||
resolution_note=row.resolution_note,
|
||||
created_at=_ensure_utc(row.created_at),
|
||||
updated_at=_ensure_utc(row.updated_at),
|
||||
resolved_at=_ensure_utc(row.resolved_at) if row.resolved_at else None,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
@@ -1464,6 +1746,7 @@ def _build_quality_dashboard(
|
||||
*,
|
||||
days: int,
|
||||
pending_limit: int,
|
||||
improvement_actions: list[AiQualityImprovementAction] | None = None,
|
||||
) -> AiQualityDashboard:
|
||||
normalized_days = max(days, 1)
|
||||
today = datetime.now(UTC).date()
|
||||
@@ -1541,6 +1824,7 @@ def _build_quality_dashboard(
|
||||
daily_metrics=ordered_daily,
|
||||
pending_labels=pending_labels,
|
||||
attribution=attribution,
|
||||
improvement_actions=improvement_actions or [],
|
||||
)
|
||||
|
||||
|
||||
@@ -1754,6 +2038,137 @@ def _finalize_attribution_groups(groups: dict[str, dict[str, object]]) -> list[A
|
||||
)[:5]
|
||||
|
||||
|
||||
def _materialize_improvement_actions(
|
||||
actions: Iterable[AiQualityImprovementAction],
|
||||
runs: list[AiRunAudit],
|
||||
) -> list[AiQualityImprovementAction]:
|
||||
return [_materialize_improvement_action(action, runs) for action in actions]
|
||||
|
||||
|
||||
def _materialize_improvement_action(
|
||||
action: AiQualityImprovementAction,
|
||||
runs: list[AiRunAudit],
|
||||
) -> AiQualityImprovementAction:
|
||||
current = _calculate_improvement_action_stats(action, runs)
|
||||
baseline_rate = _safe_rate(action.baseline_affected_runs, action.baseline_total_runs)
|
||||
current_rate = _safe_rate(current["affected_runs"], current["total_runs"])
|
||||
return action.model_copy(
|
||||
update={
|
||||
"current_total_runs": current["total_runs"],
|
||||
"current_affected_runs": current["affected_runs"],
|
||||
"current_pending_labels": current["pending_labels"],
|
||||
"current_quality_score": current["quality_score"],
|
||||
"affected_rate_delta": round(current_rate - baseline_rate, 4),
|
||||
"pending_labels_delta": current["pending_labels"] - action.baseline_pending_labels,
|
||||
"quality_score_delta": round(current["quality_score"] - action.baseline_quality_score, 2),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _filter_improvement_actions(
|
||||
actions: list[AiQualityImprovementAction],
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[AiQualityImprovementAction]:
|
||||
filtered = [action for action in actions if status is None or action.status == status]
|
||||
status_order = {"open": 0, "in_progress": 1, "resolved": 2, "false_positive": 3, "closed": 4}
|
||||
priority_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
||||
return sorted(
|
||||
filtered,
|
||||
key=lambda action: (
|
||||
status_order.get(action.status, 9),
|
||||
priority_order.get(action.priority, 9),
|
||||
-_ensure_utc(action.updated_at).timestamp(),
|
||||
),
|
||||
)[: max(limit, 0)]
|
||||
|
||||
|
||||
def _calculate_improvement_action_stats(
|
||||
action: AiQualityImprovementActionCreate | AiQualityImprovementAction,
|
||||
runs: list[AiRunAudit],
|
||||
) -> dict[str, int | float]:
|
||||
cutoff = datetime.now(UTC) - timedelta(days=max(action.evaluation_days - 1, 0))
|
||||
cutoff = cutoff.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
matched_runs: list[AiRunAudit] = []
|
||||
affected_runs: set[str] = set()
|
||||
pending_labels = 0
|
||||
score_sum = 0
|
||||
|
||||
for run in runs:
|
||||
if _ensure_utc(run.created_at) < cutoff:
|
||||
continue
|
||||
if not _run_matches_improvement_dimension(run, action):
|
||||
continue
|
||||
matched_runs.append(run)
|
||||
score_sum += run.quality_score
|
||||
issue_labels = _matching_improvement_issue_labels(run, action)
|
||||
if issue_labels:
|
||||
affected_runs.add(run.id)
|
||||
pending_labels += sum(1 for label in issue_labels if label.review_status == "pending")
|
||||
|
||||
total_runs = len(matched_runs)
|
||||
return {
|
||||
"total_runs": total_runs,
|
||||
"affected_runs": len(affected_runs),
|
||||
"pending_labels": pending_labels,
|
||||
"quality_score": round(score_sum / total_runs, 2) if total_runs else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _run_matches_improvement_dimension(
|
||||
run: AiRunAudit,
|
||||
action: AiQualityImprovementActionCreate | AiQualityImprovementAction,
|
||||
) -> bool:
|
||||
if action.dimension == "model":
|
||||
return f"{run.provider or 'unknown'} / {run.model or 'unknown'}" == action.key
|
||||
if action.dimension == "prompt_template":
|
||||
return (run.prompt_template_id or "unknown") == action.key
|
||||
if action.dimension == "knowledge_base":
|
||||
knowledge_base_ids = {
|
||||
source.knowledge_base_id for source in run.retrieved_sources if source.knowledge_base_id
|
||||
}
|
||||
if action.key in knowledge_base_ids:
|
||||
return True
|
||||
return action.key == "no_knowledge_source" and not knowledge_base_ids and _has_source_issue(run)
|
||||
if action.dimension == "label_code":
|
||||
return any(label.code == action.key for label in run.quality_labels)
|
||||
return False
|
||||
|
||||
|
||||
def _matching_improvement_issue_labels(
|
||||
run: AiRunAudit,
|
||||
action: AiQualityImprovementActionCreate | AiQualityImprovementAction,
|
||||
) -> list[AiQualityLabel]:
|
||||
issue_labels = [label for label in run.quality_labels if label.severity in {"warning", "error"}]
|
||||
if action.dimension == "label_code":
|
||||
return [label for label in issue_labels if label.code == action.key]
|
||||
source_label_codes = set(action.source_label_codes)
|
||||
if source_label_codes:
|
||||
return [label for label in issue_labels if label.code in source_label_codes]
|
||||
return issue_labels
|
||||
|
||||
|
||||
def _has_source_issue(run: AiRunAudit) -> bool:
|
||||
return any(label.code in {"source_insufficient", "source_limited"} for label in run.quality_labels)
|
||||
|
||||
|
||||
def _safe_rate(numerator: int | float, denominator: int | float) -> float:
|
||||
return round(float(numerator) / float(denominator), 4) if denominator else 0.0
|
||||
|
||||
|
||||
def _default_improvement_action_title(action: AiQualityImprovementActionCreate) -> str:
|
||||
action_names = {
|
||||
"investigate": "Investigate",
|
||||
"tune_prompt": "Tune prompt",
|
||||
"improve_knowledge": "Improve knowledge base",
|
||||
"tune_model": "Tune model config",
|
||||
"mark_false_positive": "Review false positive",
|
||||
}
|
||||
prefix = action_names.get(action.action_type, "Improve")
|
||||
return f"{prefix}: {action.label}"
|
||||
|
||||
|
||||
def _label_title(label: AiQualityLabel) -> str:
|
||||
return label.title or label.code
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ from yuqei_ai_platform_api.config import AiPlatformSettings
|
||||
from yuqei_ai_platform_api.embeddings import EmbeddingVector
|
||||
from yuqei_ai_platform_api.main import create_app
|
||||
from yuqei_ai_platform_api.repository import (
|
||||
AiQualityImprovementActionCreate,
|
||||
AiQualityImprovementActionUpdate,
|
||||
AiQualityLabelReviewUpdate,
|
||||
AiQualityRuleConfigCreate,
|
||||
AiRunAudit,
|
||||
@@ -199,6 +201,67 @@ def test_quality_dashboard_summarizes_daily_metrics_and_pending_labels() -> None
|
||||
assert updated_dashboard["summary"]["pending_labels"] == dashboard["summary"]["pending_labels"] - 1
|
||||
|
||||
|
||||
def test_quality_attribution_can_create_and_resolve_improvement_action() -> None:
|
||||
client = make_client()
|
||||
|
||||
response = client.post("/api/v1/legal/qa", json={"question": "How should probation be agreed?"})
|
||||
assert response.status_code == 200
|
||||
|
||||
dashboard_response = client.get("/api/v1/audit/quality-dashboard", params={"days": 7, "pending_limit": 10})
|
||||
assert dashboard_response.status_code == 200
|
||||
dashboard = dashboard_response.json()
|
||||
model_attribution = dashboard["attribution"]["by_model"][0]
|
||||
|
||||
create_response = client.post(
|
||||
"/api/v1/audit/quality-improvement-actions",
|
||||
json={
|
||||
"dimension": model_attribution["dimension"],
|
||||
"key": model_attribution["key"],
|
||||
"label": model_attribution["label"],
|
||||
"action_type": "tune_model",
|
||||
"description": "Check provider timeout and model configuration.",
|
||||
"owner": "admin",
|
||||
"priority": "high",
|
||||
"source_label_codes": model_attribution["top_label_codes"],
|
||||
"root_cause_hint": model_attribution["root_cause_hint"],
|
||||
"evaluation_days": 7,
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()
|
||||
assert created["status"] == "open"
|
||||
assert created["baseline_total_runs"] == 1
|
||||
assert created["baseline_affected_runs"] == 1
|
||||
assert created["current_affected_runs"] == 1
|
||||
|
||||
list_response = client.get(
|
||||
"/api/v1/audit/quality-improvement-actions",
|
||||
params={"status": "open", "limit": 10},
|
||||
)
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()[0]["id"] == created["id"]
|
||||
|
||||
progress_response = client.patch(
|
||||
f"/api/v1/audit/quality-improvement-actions/{created['id']}",
|
||||
json={"status": "in_progress", "owner": "ops", "priority": "critical"},
|
||||
)
|
||||
assert progress_response.status_code == 200
|
||||
assert progress_response.json()["status"] == "in_progress"
|
||||
assert progress_response.json()["owner"] == "ops"
|
||||
|
||||
resolved_response = client.patch(
|
||||
f"/api/v1/audit/quality-improvement-actions/{created['id']}",
|
||||
json={"status": "resolved", "resolution_note": "Provider config was tuned."},
|
||||
)
|
||||
assert resolved_response.status_code == 200
|
||||
resolved = resolved_response.json()
|
||||
assert resolved["status"] == "resolved"
|
||||
assert resolved["resolved_at"]
|
||||
|
||||
updated_dashboard = client.get("/api/v1/audit/quality-dashboard", params={"days": 7}).json()
|
||||
assert any(action["id"] == created["id"] for action in updated_dashboard["improvement_actions"])
|
||||
|
||||
|
||||
def test_knowledge_document_can_be_added_and_searched() -> None:
|
||||
client = make_client()
|
||||
|
||||
@@ -545,6 +608,12 @@ def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None:
|
||||
"title": "Source covered",
|
||||
"detail": "Sufficient retrieved sources were recorded.",
|
||||
},
|
||||
{
|
||||
"code": "source_limited",
|
||||
"severity": "warning",
|
||||
"title": "Source limited",
|
||||
"detail": "Only one source was used.",
|
||||
},
|
||||
],
|
||||
quality_summary="passed: 0 error(s), 0 warning(s), score 100.",
|
||||
status="succeeded",
|
||||
@@ -596,3 +665,22 @@ def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None:
|
||||
assert updated_audit.quality_labels[0].review_status == "resolved"
|
||||
assert updated_audit.quality_labels[0].review_note == "已确认依据有效。"
|
||||
assert updated_audit.quality_labels[0].reviewed_at is not None
|
||||
action = reloaded.create_quality_improvement_action(
|
||||
AiQualityImprovementActionCreate(
|
||||
dimension="model",
|
||||
key="deepseek / deepseek-chat",
|
||||
label="deepseek / deepseek-chat",
|
||||
action_type="tune_model",
|
||||
source_label_codes=["source_limited"],
|
||||
evaluation_days=14,
|
||||
)
|
||||
)
|
||||
assert action.baseline_total_runs == 1
|
||||
assert action.baseline_affected_runs == 1
|
||||
assert reloaded.list_quality_improvement_actions()[0].id == action.id
|
||||
updated_action = reloaded.update_quality_improvement_action(
|
||||
action.id,
|
||||
AiQualityImprovementActionUpdate(status="resolved", resolution_note="done"),
|
||||
)
|
||||
assert updated_action.status == "resolved"
|
||||
assert updated_action.resolved_at is not None
|
||||
|
||||
@@ -5,6 +5,9 @@ from yuqei_sdk.ai_platform import (
|
||||
AiQualityDashboard,
|
||||
AiQualityDashboardSummary,
|
||||
AiQualityDailyMetric,
|
||||
AiQualityImprovementAction,
|
||||
AiQualityImprovementActionCreate,
|
||||
AiQualityImprovementActionUpdate,
|
||||
AiQualityLabel,
|
||||
AiQualityLabelReviewUpdate,
|
||||
AiQualityPendingLabel,
|
||||
@@ -39,6 +42,9 @@ __all__ = [
|
||||
"AiQualityDashboard",
|
||||
"AiQualityDashboardSummary",
|
||||
"AiQualityDailyMetric",
|
||||
"AiQualityImprovementAction",
|
||||
"AiQualityImprovementActionCreate",
|
||||
"AiQualityImprovementActionUpdate",
|
||||
"AiQualityLabel",
|
||||
"AiQualityLabelReviewUpdate",
|
||||
"AiQualityPendingLabel",
|
||||
|
||||
@@ -321,11 +321,55 @@ class AiQualityAttribution(BaseModel):
|
||||
by_label_code: list[AiQualityAttributionItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AiQualityImprovementActionCreate(BaseModel):
|
||||
dimension: str
|
||||
key: str = Field(min_length=1, max_length=256)
|
||||
label: str = Field(min_length=1, max_length=256)
|
||||
action_type: str = "investigate"
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
description: str | None = None
|
||||
owner: str | None = Field(default=None, max_length=128)
|
||||
priority: str = "medium"
|
||||
source_label_codes: list[str] = Field(default_factory=list)
|
||||
source_run_ids: list[str] = Field(default_factory=list)
|
||||
root_cause_hint: str | None = None
|
||||
evaluation_days: int = 14
|
||||
|
||||
|
||||
class AiQualityImprovementActionUpdate(BaseModel):
|
||||
status: str
|
||||
owner: str | None = Field(default=None, max_length=128)
|
||||
priority: str | None = None
|
||||
resolution_note: str | None = None
|
||||
|
||||
|
||||
class AiQualityImprovementAction(AiQualityImprovementActionCreate):
|
||||
id: str
|
||||
title: str
|
||||
status: str = "open"
|
||||
baseline_total_runs: int = 0
|
||||
baseline_affected_runs: int = 0
|
||||
baseline_pending_labels: int = 0
|
||||
baseline_quality_score: float = 0.0
|
||||
current_total_runs: int = 0
|
||||
current_affected_runs: int = 0
|
||||
current_pending_labels: int = 0
|
||||
current_quality_score: float = 0.0
|
||||
affected_rate_delta: float = 0.0
|
||||
pending_labels_delta: int = 0
|
||||
quality_score_delta: float = 0.0
|
||||
resolution_note: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
resolved_at: str | None = None
|
||||
|
||||
|
||||
class AiQualityDashboard(BaseModel):
|
||||
summary: AiQualityDashboardSummary
|
||||
daily_metrics: list[AiQualityDailyMetric] = Field(default_factory=list)
|
||||
pending_labels: list[AiQualityPendingLabel] = Field(default_factory=list)
|
||||
attribution: AiQualityAttribution = Field(default_factory=AiQualityAttribution)
|
||||
improvement_actions: list[AiQualityImprovementAction] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AiPlatformClient:
|
||||
@@ -605,6 +649,53 @@ class AiPlatformClient:
|
||||
response.raise_for_status()
|
||||
return AiQualityDashboard.model_validate(response.json())
|
||||
|
||||
def list_quality_improvement_actions(
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int = 20,
|
||||
context: RequestContext | None = None,
|
||||
) -> list[AiQualityImprovementAction]:
|
||||
params: dict[str, Any] = {"limit": limit}
|
||||
if status:
|
||||
params["status"] = status
|
||||
response = self._client.get(
|
||||
f"{self._api_prefix}/audit/quality-improvement-actions",
|
||||
params=params,
|
||||
headers=(context or RequestContext()).to_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return [AiQualityImprovementAction.model_validate(item) for item in response.json()]
|
||||
|
||||
def create_quality_improvement_action(
|
||||
self,
|
||||
action: AiQualityImprovementActionCreate,
|
||||
*,
|
||||
context: RequestContext | None = None,
|
||||
) -> AiQualityImprovementAction:
|
||||
response = self._client.post(
|
||||
f"{self._api_prefix}/audit/quality-improvement-actions",
|
||||
json=action.model_dump(exclude_none=True),
|
||||
headers=(context or RequestContext()).to_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return AiQualityImprovementAction.model_validate(response.json())
|
||||
|
||||
def update_quality_improvement_action(
|
||||
self,
|
||||
action_id: str,
|
||||
update: AiQualityImprovementActionUpdate,
|
||||
*,
|
||||
context: RequestContext | None = None,
|
||||
) -> AiQualityImprovementAction:
|
||||
response = self._client.patch(
|
||||
f"{self._api_prefix}/audit/quality-improvement-actions/{action_id}",
|
||||
json=update.model_dump(exclude_none=True),
|
||||
headers=(context or RequestContext()).to_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return AiQualityImprovementAction.model_validate(response.json())
|
||||
|
||||
def update_ai_run_quality_label(
|
||||
self,
|
||||
run_id: str,
|
||||
|
||||
@@ -5,6 +5,8 @@ import httpx
|
||||
|
||||
from yuqei_sdk import (
|
||||
AiPlatformClient,
|
||||
AiQualityImprovementActionCreate,
|
||||
AiQualityImprovementActionUpdate,
|
||||
AiQualityLabelReviewUpdate,
|
||||
AiQualityRuleConfigUpdate,
|
||||
KnowledgeDocumentCreate,
|
||||
@@ -91,6 +93,44 @@ def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None:
|
||||
def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
seen_paths: list[str] = []
|
||||
|
||||
def quality_action_payload(
|
||||
*,
|
||||
status: str = "open",
|
||||
resolution_note: str | None = None,
|
||||
resolved_at: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"id": "qa-action-1",
|
||||
"dimension": "model",
|
||||
"key": "deepseek / deepseek-chat",
|
||||
"label": "deepseek / deepseek-chat",
|
||||
"action_type": "tune_model",
|
||||
"title": "Tune model config: deepseek / deepseek-chat",
|
||||
"description": "Check provider timeout and model configuration.",
|
||||
"owner": "admin",
|
||||
"priority": "high",
|
||||
"status": status,
|
||||
"source_label_codes": ["source_limited"],
|
||||
"source_run_ids": ["run-1"],
|
||||
"root_cause_hint": "Model quality issues are concentrated.",
|
||||
"evaluation_days": 7,
|
||||
"baseline_total_runs": 1,
|
||||
"baseline_affected_runs": 1,
|
||||
"baseline_pending_labels": 1,
|
||||
"baseline_quality_score": 90.0,
|
||||
"current_total_runs": 1,
|
||||
"current_affected_runs": 1,
|
||||
"current_pending_labels": 1,
|
||||
"current_quality_score": 90.0,
|
||||
"affected_rate_delta": 0.0,
|
||||
"pending_labels_delta": 0,
|
||||
"quality_score_delta": 0.0,
|
||||
"resolution_note": resolution_note,
|
||||
"created_at": "2026-06-22T00:00:00Z",
|
||||
"updated_at": "2026-06-22T00:02:00Z",
|
||||
"resolved_at": resolved_at,
|
||||
}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_paths.append(request.url.path)
|
||||
if request.url.path == "/api/v1/model-configs" and request.method == "POST":
|
||||
@@ -529,8 +569,33 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
}
|
||||
],
|
||||
},
|
||||
"improvement_actions": [quality_action_payload()],
|
||||
},
|
||||
)
|
||||
if request.url.path == "/api/v1/audit/quality-improvement-actions" and request.method == "POST":
|
||||
payload = json.loads(request.read().decode("utf-8"))
|
||||
assert payload["dimension"] == "model"
|
||||
assert payload["action_type"] == "tune_model"
|
||||
assert payload["source_label_codes"] == ["source_limited"]
|
||||
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())
|
||||
assert query["status"] == ["open"]
|
||||
assert query["limit"] == ["5"]
|
||||
return httpx.Response(200, json=[quality_action_payload()])
|
||||
if request.url.path == "/api/v1/audit/quality-improvement-actions/qa-action-1":
|
||||
payload = json.loads(request.read().decode("utf-8"))
|
||||
assert request.method == "PATCH"
|
||||
assert payload["status"] == "resolved"
|
||||
assert payload["resolution_note"] == "Provider config was tuned."
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=quality_action_payload(
|
||||
status="resolved",
|
||||
resolution_note="Provider config was tuned.",
|
||||
resolved_at="2026-06-22T00:05:00Z",
|
||||
),
|
||||
)
|
||||
if request.url.path == "/api/v1/audit/ai-runs/run-1/quality-labels/citation_present":
|
||||
payload = json.loads(request.read().decode("utf-8"))
|
||||
assert request.method == "PATCH"
|
||||
@@ -667,6 +732,34 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
assert dashboard.attribution.by_model[0].key == "deepseek / deepseek-chat"
|
||||
assert dashboard.attribution.by_label_code[0].top_label_codes == ["source_limited"]
|
||||
assert dashboard.attribution.by_label_code[0].root_cause_hint is not None
|
||||
assert dashboard.improvement_actions[0].id == "qa-action-1"
|
||||
created_action = client.create_quality_improvement_action(
|
||||
AiQualityImprovementActionCreate(
|
||||
dimension="model",
|
||||
key="deepseek / deepseek-chat",
|
||||
label="deepseek / deepseek-chat",
|
||||
action_type="tune_model",
|
||||
description="Check provider timeout and model configuration.",
|
||||
owner="admin",
|
||||
priority="high",
|
||||
source_label_codes=["source_limited"],
|
||||
source_run_ids=["run-1"],
|
||||
evaluation_days=7,
|
||||
)
|
||||
)
|
||||
assert created_action.status == "open"
|
||||
assert created_action.baseline_affected_runs == 1
|
||||
listed_actions = client.list_quality_improvement_actions(status="open", limit=5)
|
||||
assert listed_actions[0].id == "qa-action-1"
|
||||
updated_action = client.update_quality_improvement_action(
|
||||
"qa-action-1",
|
||||
AiQualityImprovementActionUpdate(
|
||||
status="resolved",
|
||||
resolution_note="Provider config was tuned.",
|
||||
),
|
||||
)
|
||||
assert updated_action.status == "resolved"
|
||||
assert updated_action.resolved_at == "2026-06-22T00:05:00Z"
|
||||
updated_audit = client.update_ai_run_quality_label(
|
||||
"run-1",
|
||||
"citation_present",
|
||||
@@ -680,4 +773,5 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
assert updated_audit.quality_labels[0].reviewed_at == "2026-06-22T00:01:00Z"
|
||||
assert "/api/v1/audit/ai-runs" in seen_paths
|
||||
assert "/api/v1/audit/quality-dashboard" in seen_paths
|
||||
assert "/api/v1/audit/quality-improvement-actions" in seen_paths
|
||||
assert "/api/v1/quality-rules/default" in seen_paths
|
||||
|
||||
+352
-25
@@ -3,7 +3,18 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertTriangle, Clock3, Database, RefreshCcw, ShieldCheck, Undo2 } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Database,
|
||||
PlayCircle,
|
||||
RefreshCcw,
|
||||
ShieldCheck,
|
||||
Undo2,
|
||||
Wrench,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
|
||||
type QualityDailyMetric = {
|
||||
date: string;
|
||||
@@ -59,11 +70,44 @@ type QualityAttribution = {
|
||||
by_label_code: QualityAttributionItem[];
|
||||
};
|
||||
|
||||
type QualityImprovementAction = {
|
||||
id: string;
|
||||
dimension: string;
|
||||
key: string;
|
||||
label: string;
|
||||
action_type: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
owner?: string | null;
|
||||
priority: string;
|
||||
status: string;
|
||||
source_label_codes: string[];
|
||||
source_run_ids: string[];
|
||||
root_cause_hint?: string | null;
|
||||
evaluation_days: number;
|
||||
baseline_total_runs: number;
|
||||
baseline_affected_runs: number;
|
||||
baseline_pending_labels: number;
|
||||
baseline_quality_score: number;
|
||||
current_total_runs: number;
|
||||
current_affected_runs: number;
|
||||
current_pending_labels: number;
|
||||
current_quality_score: number;
|
||||
affected_rate_delta: number;
|
||||
pending_labels_delta: number;
|
||||
quality_score_delta: number;
|
||||
resolution_note?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
resolved_at?: string | null;
|
||||
};
|
||||
|
||||
type QualityDashboard = {
|
||||
summary: QualityDailyMetric & { days: number };
|
||||
daily_metrics: QualityDailyMetric[];
|
||||
pending_labels: QualityPendingLabel[];
|
||||
attribution?: QualityAttribution;
|
||||
improvement_actions?: QualityImprovementAction[];
|
||||
};
|
||||
|
||||
type LoadState =
|
||||
@@ -77,9 +121,42 @@ const severityTone: Record<string, string> = {
|
||||
error: "tag-danger"
|
||||
};
|
||||
|
||||
const statusTone: Record<string, string> = {
|
||||
open: "tag-warning",
|
||||
in_progress: "tag-primary",
|
||||
resolved: "tag-success",
|
||||
false_positive: "",
|
||||
closed: ""
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
open: "待处理",
|
||||
in_progress: "处理中",
|
||||
resolved: "已完成",
|
||||
false_positive: "误报关闭",
|
||||
closed: "已关闭"
|
||||
};
|
||||
|
||||
const actionTypeLabels: Record<string, string> = {
|
||||
investigate: "排查原因",
|
||||
tune_prompt: "调整 Prompt",
|
||||
improve_knowledge: "补知识库",
|
||||
tune_model: "调模型参数",
|
||||
mark_false_positive: "处理误报"
|
||||
};
|
||||
|
||||
const dimensionLabels: Record<string, string> = {
|
||||
model: "模型",
|
||||
prompt_template: "Prompt",
|
||||
knowledge_base: "知识库",
|
||||
label_code: "标签"
|
||||
};
|
||||
|
||||
export default function AiQualityDashboardPage() {
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
const [days, setDays] = useState(14);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [actionBusyKey, setActionBusyKey] = useState<string | null>(null);
|
||||
|
||||
async function loadDashboard(nextDays = days) {
|
||||
setState({ status: "loading" });
|
||||
@@ -107,18 +184,83 @@ export default function AiQualityDashboardPage() {
|
||||
}, []);
|
||||
|
||||
const dashboard = state.status === "ready" ? state.dashboard : null;
|
||||
const improvementActions = dashboard?.improvement_actions ?? [];
|
||||
const openActionCount = improvementActions.filter((action) => ["open", "in_progress"].includes(action.status)).length;
|
||||
|
||||
function changeDays(value: string) {
|
||||
const nextDays = Number(value);
|
||||
setDays(nextDays);
|
||||
setActionMessage(null);
|
||||
void loadDashboard(nextDays);
|
||||
}
|
||||
|
||||
async function createActionFromAttribution(item: QualityAttributionItem) {
|
||||
const busyKey = attributionBusyKey(item);
|
||||
setActionBusyKey(busyKey);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
const response = await fetch("/api/ai-platform/audit/quality-improvement-actions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dimension: item.dimension,
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
action_type: inferActionType(item),
|
||||
description: item.root_cause_hint || `来自 ${dimensionLabels[item.dimension] ?? item.dimension} 归因的质量改进项。`,
|
||||
owner: "admin",
|
||||
priority: inferPriority(item),
|
||||
source_label_codes: item.top_label_codes,
|
||||
root_cause_hint: item.root_cause_hint,
|
||||
evaluation_days: days
|
||||
})
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
setActionMessage("已生成改进项,可以在下方队列继续处理。");
|
||||
await loadDashboard(days);
|
||||
} catch (error) {
|
||||
setActionMessage(error instanceof Error ? error.message : "生成改进项失败");
|
||||
} finally {
|
||||
setActionBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAction(action: QualityImprovementAction, status: string) {
|
||||
const busyKey = actionBusyKeyFor(action, status);
|
||||
setActionBusyKey(busyKey);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
const response = await fetch(`/api/ai-platform/audit/quality-improvement-actions/${encodeURIComponent(action.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
owner: "admin",
|
||||
resolution_note: defaultResolutionNote(status)
|
||||
})
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
setActionMessage("改进项状态已更新,指标会随新的 AI 调用继续回看。");
|
||||
await loadDashboard(days);
|
||||
} catch (error) {
|
||||
setActionMessage(error instanceof Error ? error.message : "更新改进项失败");
|
||||
} finally {
|
||||
setActionBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<h1>AI 质量运营</h1>
|
||||
<p>按日期查看质量通过率、fallback、来源不足、耗时异常和待处理标签。</p>
|
||||
<p>按日期跟踪质量通过率、fallback、来源不足、耗时异常,并把高频问题转成可处理的改进项。</p>
|
||||
</div>
|
||||
<div className="toolbar toolbar-inline">
|
||||
<select className="search-input select-input" value={days} onChange={(event) => changeDays(event.target.value)}>
|
||||
@@ -127,7 +269,7 @@ export default function AiQualityDashboardPage() {
|
||||
<option value={30}>最近 30 天</option>
|
||||
<option value={90}>最近 90 天</option>
|
||||
</select>
|
||||
<button className="button button-soft" onClick={() => void loadDashboard()}>
|
||||
<button className="button button-soft" onClick={() => void loadDashboard()} type="button">
|
||||
<RefreshCcw size={16} />
|
||||
刷新
|
||||
</button>
|
||||
@@ -151,13 +293,61 @@ export default function AiQualityDashboardPage() {
|
||||
<Metric icon={<AlertTriangle size={18} />} label="待处理标签" value={String(dashboard.summary.pending_labels)} />
|
||||
</div>
|
||||
|
||||
{actionMessage && <div className="inline-message">{actionMessage}</div>}
|
||||
|
||||
<div className="attribution-grid">
|
||||
<AttributionPanel title="模型归因" items={dashboard.attribution?.by_model ?? []} />
|
||||
<AttributionPanel title="Prompt 归因" items={dashboard.attribution?.by_prompt_template ?? []} />
|
||||
<AttributionPanel title="知识库归因" items={dashboard.attribution?.by_knowledge_base ?? []} />
|
||||
<AttributionPanel title="标签归因" items={dashboard.attribution?.by_label_code ?? []} />
|
||||
<AttributionPanel
|
||||
title="模型归因"
|
||||
items={dashboard.attribution?.by_model ?? []}
|
||||
busyKey={actionBusyKey}
|
||||
onCreateAction={createActionFromAttribution}
|
||||
/>
|
||||
<AttributionPanel
|
||||
title="Prompt 归因"
|
||||
items={dashboard.attribution?.by_prompt_template ?? []}
|
||||
busyKey={actionBusyKey}
|
||||
onCreateAction={createActionFromAttribution}
|
||||
/>
|
||||
<AttributionPanel
|
||||
title="知识库归因"
|
||||
items={dashboard.attribution?.by_knowledge_base ?? []}
|
||||
busyKey={actionBusyKey}
|
||||
onCreateAction={createActionFromAttribution}
|
||||
/>
|
||||
<AttributionPanel
|
||||
title="标签归因"
|
||||
items={dashboard.attribution?.by_label_code ?? []}
|
||||
busyKey={actionBusyKey}
|
||||
onCreateAction={createActionFromAttribution}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="panel improvement-panel">
|
||||
<div className="panel-header">
|
||||
<h2 className="panel-title">
|
||||
<Wrench size={18} />
|
||||
改进动作闭环
|
||||
</h2>
|
||||
<span className="tag tag-primary">待推进 {openActionCount}</span>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{improvementActions.length === 0 ? (
|
||||
<div className="empty-state empty-state-compact">暂无改进项,可先从上方归因卡片生成。</div>
|
||||
) : (
|
||||
<div className="quality-action-list">
|
||||
{improvementActions.map((action) => (
|
||||
<QualityActionItem
|
||||
action={action}
|
||||
busyKey={actionBusyKey}
|
||||
key={action.id}
|
||||
onUpdate={updateAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
@@ -243,7 +433,17 @@ function Metric({ icon, label, value }: { icon: ReactNode; label: string; value:
|
||||
);
|
||||
}
|
||||
|
||||
function AttributionPanel({ title, items }: { title: string; items: QualityAttributionItem[] }) {
|
||||
function AttributionPanel({
|
||||
title,
|
||||
items,
|
||||
busyKey,
|
||||
onCreateAction
|
||||
}: {
|
||||
title: string;
|
||||
items: QualityAttributionItem[];
|
||||
busyKey: string | null;
|
||||
onCreateAction: (item: QualityAttributionItem) => void | Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
@@ -254,34 +454,161 @@ function AttributionPanel({ title, items }: { title: string; items: QualityAttri
|
||||
{items.length === 0 ? (
|
||||
<div className="empty-state empty-state-compact">暂无异常归因。</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div className="attribution-item" key={`${item.dimension}-${item.key}`}>
|
||||
<div className="list-item-header">
|
||||
<strong>{item.label}</strong>
|
||||
<span className="tag tag-warning">{item.affected_runs}/{item.total_runs}</span>
|
||||
items.map((item) => {
|
||||
const busy = busyKey === attributionBusyKey(item);
|
||||
return (
|
||||
<div className="attribution-item" key={`${item.dimension}-${item.key}`}>
|
||||
<div className="list-item-header">
|
||||
<strong>{item.label}</strong>
|
||||
<span className="tag tag-warning">{item.affected_runs}/{item.total_runs}</span>
|
||||
</div>
|
||||
<div className="trace-meta">
|
||||
<span>待处理 {item.pending_labels}</span>
|
||||
<span>warning {item.warning_labels}</span>
|
||||
<span>error {item.error_labels}</span>
|
||||
<span>均分 {item.average_quality_score}</span>
|
||||
</div>
|
||||
{item.top_label_codes.length > 0 && (
|
||||
<span className="item-meta">常见标签:{item.top_label_codes.join("、")}</span>
|
||||
)}
|
||||
{item.root_cause_hint && <span className="item-meta">{item.root_cause_hint}</span>}
|
||||
<div className="action-row">
|
||||
<button className="button button-soft" disabled={busy} onClick={() => void onCreateAction(item)} type="button">
|
||||
<Wrench size={16} />
|
||||
{busy ? "生成中" : "转改进项"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="trace-meta">
|
||||
<span>待处理 {item.pending_labels}</span>
|
||||
<span>warning {item.warning_labels}</span>
|
||||
<span>error {item.error_labels}</span>
|
||||
<span>均分 {item.average_quality_score}</span>
|
||||
</div>
|
||||
{item.top_label_codes.length > 0 && (
|
||||
<span className="item-meta">常见标签:{item.top_label_codes.join("、")}</span>
|
||||
)}
|
||||
{item.root_cause_hint && <span className="item-meta">{item.root_cause_hint}</span>}
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QualityActionItem({
|
||||
action,
|
||||
busyKey,
|
||||
onUpdate
|
||||
}: {
|
||||
action: QualityImprovementAction;
|
||||
busyKey: string | null;
|
||||
onUpdate: (action: QualityImprovementAction, status: string) => void | Promise<void>;
|
||||
}) {
|
||||
const busy = busyKey?.startsWith(`${action.id}:`) ?? false;
|
||||
const baselineRate = safeRate(action.baseline_affected_runs, action.baseline_total_runs);
|
||||
const currentRate = safeRate(action.current_affected_runs, action.current_total_runs);
|
||||
|
||||
return (
|
||||
<div className="quality-action-item">
|
||||
<div className="list-item-header">
|
||||
<div>
|
||||
<strong>{action.title}</strong>
|
||||
<div className="trace-meta">
|
||||
<span>{dimensionLabels[action.dimension] ?? action.dimension}</span>
|
||||
<span>{actionTypeLabels[action.action_type] ?? action.action_type}</span>
|
||||
<span>优先级 {action.priority}</span>
|
||||
<span>{action.owner || "未分配"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`tag ${statusTone[action.status] ?? ""}`}>{statusLabels[action.status] ?? action.status}</span>
|
||||
</div>
|
||||
{(action.description || action.root_cause_hint) && (
|
||||
<span className="item-meta">{action.description || action.root_cause_hint}</span>
|
||||
)}
|
||||
<div className="quality-action-stats">
|
||||
<span>异常率 {formatPercent(baselineRate)} → {formatPercent(currentRate)}</span>
|
||||
<span>待处理 {action.baseline_pending_labels} → {action.current_pending_labels}</span>
|
||||
<span>质量分 {action.baseline_quality_score} → {action.current_quality_score}</span>
|
||||
<span>变化 {formatSignedPercent(action.affected_rate_delta)}</span>
|
||||
</div>
|
||||
{action.resolution_note && <span className="item-meta">处理说明:{action.resolution_note}</span>}
|
||||
<div className="action-row">
|
||||
{action.status === "open" && (
|
||||
<button className="button button-soft" disabled={busy} onClick={() => void onUpdate(action, "in_progress")} type="button">
|
||||
<PlayCircle size={16} />
|
||||
开始处理
|
||||
</button>
|
||||
)}
|
||||
{["open", "in_progress"].includes(action.status) && (
|
||||
<>
|
||||
<button className="button button-primary" disabled={busy} onClick={() => void onUpdate(action, "resolved")} type="button">
|
||||
<CheckCircle2 size={16} />
|
||||
标记完成
|
||||
</button>
|
||||
<button className="button button-soft" disabled={busy} onClick={() => void onUpdate(action, "false_positive")} type="button">
|
||||
<XCircle size={16} />
|
||||
误报关闭
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function inferActionType(item: QualityAttributionItem) {
|
||||
if (item.dimension === "prompt_template") {
|
||||
return "tune_prompt";
|
||||
}
|
||||
if (item.dimension === "knowledge_base") {
|
||||
return "improve_knowledge";
|
||||
}
|
||||
if (item.dimension === "model") {
|
||||
return "tune_model";
|
||||
}
|
||||
const codes = [item.key, ...item.top_label_codes].join(" ");
|
||||
if (codes.includes("source")) {
|
||||
return "improve_knowledge";
|
||||
}
|
||||
if (codes.includes("fallback") || codes.includes("token") || codes.includes("latency") || codes.includes("failed")) {
|
||||
return "tune_model";
|
||||
}
|
||||
return "investigate";
|
||||
}
|
||||
|
||||
function inferPriority(item: QualityAttributionItem) {
|
||||
if (item.error_labels > 0 || item.fallback_runs > 0) {
|
||||
return "high";
|
||||
}
|
||||
if (safeRate(item.affected_runs, item.total_runs) >= 0.5 || item.pending_labels >= 10) {
|
||||
return "high";
|
||||
}
|
||||
return "medium";
|
||||
}
|
||||
|
||||
function defaultResolutionNote(status: string) {
|
||||
if (status === "resolved") {
|
||||
return "已处理,等待后续 AI 调用数据验证异常率是否下降。";
|
||||
}
|
||||
if (status === "false_positive") {
|
||||
return "已确认为误报,后续会结合质量规则继续校准。";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function attributionBusyKey(item: QualityAttributionItem) {
|
||||
return `create:${item.dimension}:${item.key}`;
|
||||
}
|
||||
|
||||
function actionBusyKeyFor(action: QualityImprovementAction, status: string) {
|
||||
return `${action.id}:${status}`;
|
||||
}
|
||||
|
||||
function safeRate(numerator: number, denominator: number) {
|
||||
return denominator ? numerator / denominator : 0;
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function formatSignedPercent(value: number) {
|
||||
const percent = Math.round(value * 100);
|
||||
return `${percent > 0 ? "+" : ""}${percent}%`;
|
||||
}
|
||||
|
||||
function formatShortDate(value: string) {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
|
||||
+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 PATCH(
|
||||
request: Request,
|
||||
context: { params: Promise<{ actionId: string }> }
|
||||
) {
|
||||
const { actionId } = await context.params;
|
||||
const targetUrl = new URL(
|
||||
`/api/v1/audit/quality-improvement-actions/${encodeURIComponent(actionId)}`,
|
||||
aiPlatformBaseUrl
|
||||
);
|
||||
const body: unknown = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: "PATCH",
|
||||
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 quality improvement action 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 quality improvement action service is unavailable.",
|
||||
baseUrl: aiPlatformBaseUrl
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
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 { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get("status");
|
||||
const limit = searchParams.get("limit") ?? "20";
|
||||
const targetUrl = new URL("/api/v1/audit/quality-improvement-actions", aiPlatformBaseUrl);
|
||||
targetUrl.searchParams.set("limit", limit);
|
||||
if (status) {
|
||||
targetUrl.searchParams.set("status", status);
|
||||
}
|
||||
|
||||
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 quality improvement action 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 quality improvement action service is unavailable.",
|
||||
baseUrl: aiPlatformBaseUrl
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const targetUrl = new URL("/api/v1/audit/quality-improvement-actions", 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 quality improvement action 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 quality improvement action service is unavailable.",
|
||||
baseUrl: aiPlatformBaseUrl
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -722,6 +722,52 @@ select {
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.improvement-panel {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.quality-action-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quality-action-item {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.quality-action-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.quality-action-stats span {
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.trend-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
Reference in New Issue
Block a user