feat: add ai quality operations dashboard
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# V2 AI 质量运营看板闭环记录
|
||||
|
||||
日期:2026-06-23
|
||||
|
||||
## 本轮目标
|
||||
|
||||
把单次 AI Trace 的质量标签上升为运营视角,让管理员按日期查看质量通过率、fallback 率、来源不足率、耗时异常率,并形成待处理质量标签队列。
|
||||
|
||||
## 已落地内容
|
||||
|
||||
1. AI Platform 新增质量运营聚合接口。
|
||||
- `GET /api/v1/audit/quality-dashboard`
|
||||
- 参数:
|
||||
- `days`:统计最近 N 天,范围 1-90。
|
||||
- `pending_limit`:待处理标签数量,范围 1-100。
|
||||
|
||||
2. 后端聚合指标。
|
||||
- 总调用数
|
||||
- 质量通过数和通过率
|
||||
- `needs_review` 数量
|
||||
- 质量失败数量
|
||||
- fallback 数量和 fallback 率
|
||||
- 来源不足数量和来源不足率
|
||||
- token 异常数量
|
||||
- 耗时异常数量和耗时异常率
|
||||
- 待处理 warning/error 标签数量
|
||||
|
||||
3. 日期趋势。
|
||||
- 最近 N 天自动补齐空日期。
|
||||
- 每天返回调用数、通过率、fallback 率、来源不足率、耗时异常率和待处理标签数量。
|
||||
|
||||
4. 待处理质量标签队列。
|
||||
- 只聚合 `review_status=pending` 且 severity 为 warning/error 的标签。
|
||||
- 返回 run id、标签 code、问题、模型、质量分、创建时间和标签原因。
|
||||
- 管理员可以从看板进入 AI Trace 继续处理标签。
|
||||
|
||||
5. 契约、SDK 和前端同步。
|
||||
- OpenAPI 新增 `AiQualityDashboard` 相关 schema。
|
||||
- Python SDK 新增 `get_quality_dashboard()`。
|
||||
- 合同 Web 新增同源代理:`/api/ai-platform/audit/quality-dashboard`。
|
||||
- 管理区新增页面:`/admin/ai-quality-dashboard`。
|
||||
- 管理首页新增“AI 质量运营”入口。
|
||||
|
||||
## 验收重点
|
||||
|
||||
- 访问 `/admin/ai-quality-dashboard` 能看到质量通过率、fallback 率、来源不足率、耗时异常率。
|
||||
- 最近 N 天切换后,趋势列表随之变化。
|
||||
- 有待处理 warning/error 标签时,队列中能看到对应问题、标签原因、模型和质量分。
|
||||
- 在 AI Trace 页处理标签后,刷新看板时待处理数量会减少。
|
||||
|
||||
## 下一步建议
|
||||
|
||||
进入 AI 质量问题归因:
|
||||
|
||||
- 将异常按模型、Prompt 模板、知识库、标签类型聚合。
|
||||
- 找出最常见的失败来源,例如某个模型 fallback 高、某类知识库来源不足、某个 Prompt token 过高。
|
||||
- 给看板增加“Top 问题”和“改进建议”,帮助管理员决定先调模型、补知识库还是改 Prompt。
|
||||
@@ -441,6 +441,34 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AiRun"
|
||||
/api/v1/audit/quality-dashboard:
|
||||
get:
|
||||
operationId: getQualityDashboard
|
||||
summary: Get AI quality operation dashboard
|
||||
parameters:
|
||||
- name: days
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
default: 14
|
||||
minimum: 1
|
||||
maximum: 90
|
||||
- name: pending_limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
responses:
|
||||
"200":
|
||||
description: AI quality dashboard
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AiQualityDashboard"
|
||||
/api/v1/audit/ai-runs/{run_id}/quality-labels/{label_code}:
|
||||
patch:
|
||||
operationId: updateAiRunQualityLabel
|
||||
@@ -1299,6 +1327,93 @@ components:
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
AiQualityDailyMetric:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
total_runs:
|
||||
type: integer
|
||||
passed_runs:
|
||||
type: integer
|
||||
needs_review_runs:
|
||||
type: integer
|
||||
failed_quality_runs:
|
||||
type: integer
|
||||
fallback_runs:
|
||||
type: integer
|
||||
source_issue_runs:
|
||||
type: integer
|
||||
token_issue_runs:
|
||||
type: integer
|
||||
latency_issue_runs:
|
||||
type: integer
|
||||
pending_labels:
|
||||
type: integer
|
||||
pass_rate:
|
||||
type: number
|
||||
format: float
|
||||
fallback_rate:
|
||||
type: number
|
||||
format: float
|
||||
source_issue_rate:
|
||||
type: number
|
||||
format: float
|
||||
latency_issue_rate:
|
||||
type: number
|
||||
format: float
|
||||
AiQualityPendingLabel:
|
||||
type: object
|
||||
properties:
|
||||
run_id:
|
||||
type: string
|
||||
label_code:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
detail:
|
||||
type: string
|
||||
review_status:
|
||||
type: string
|
||||
enum: [pending, reviewed, false_positive, resolved]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
question:
|
||||
type: string
|
||||
nullable: true
|
||||
provider:
|
||||
type: string
|
||||
nullable: true
|
||||
model:
|
||||
type: string
|
||||
nullable: true
|
||||
quality_status:
|
||||
type: string
|
||||
quality_score:
|
||||
type: integer
|
||||
AiQualityDashboardSummary:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/AiQualityDailyMetric"
|
||||
- type: object
|
||||
properties:
|
||||
days:
|
||||
type: integer
|
||||
AiQualityDashboard:
|
||||
type: object
|
||||
properties:
|
||||
summary:
|
||||
$ref: "#/components/schemas/AiQualityDashboardSummary"
|
||||
daily_metrics:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AiQualityDailyMetric"
|
||||
pending_labels:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AiQualityPendingLabel"
|
||||
AiRun:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -15,6 +15,7 @@ from yuqei_ai_platform_api.repository import (
|
||||
AiPlatformRepository,
|
||||
AiQualityLabel,
|
||||
AiQualityLabelReviewUpdate,
|
||||
AiQualityDashboard,
|
||||
AiQualityRuleConfig,
|
||||
AiQualityRuleConfigCreate,
|
||||
AiRunAudit,
|
||||
@@ -234,6 +235,17 @@ def create_app(
|
||||
def list_ai_runs(limit: int = Query(20, ge=1, le=100)) -> list[AiRunAudit]:
|
||||
return store.list_ai_run_audits(limit=limit)
|
||||
|
||||
@app.get(
|
||||
f"{resolved_settings.api_prefix}/audit/quality-dashboard",
|
||||
response_model=AiQualityDashboard,
|
||||
tags=["audit"],
|
||||
)
|
||||
def get_quality_dashboard(
|
||||
days: int = Query(14, ge=1, le=90),
|
||||
pending_limit: int = Query(20, ge=1, le=100),
|
||||
) -> AiQualityDashboard:
|
||||
return store.get_quality_dashboard(days=days, pending_limit=pending_limit)
|
||||
|
||||
@app.patch(
|
||||
f"{resolved_settings.api_prefix}/audit/ai-runs/{{run_id}}/quality-labels/{{label_code}}",
|
||||
response_model=AiRunAudit,
|
||||
|
||||
+199
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from threading import Lock
|
||||
from typing import Protocol
|
||||
from uuid import uuid4
|
||||
@@ -253,6 +253,61 @@ class AiRunAudit(BaseModel):
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class AiQualityDailyMetric(BaseModel):
|
||||
date: str
|
||||
total_runs: int = 0
|
||||
passed_runs: int = 0
|
||||
needs_review_runs: int = 0
|
||||
failed_quality_runs: int = 0
|
||||
fallback_runs: int = 0
|
||||
source_issue_runs: int = 0
|
||||
token_issue_runs: int = 0
|
||||
latency_issue_runs: int = 0
|
||||
pending_labels: int = 0
|
||||
pass_rate: float = 0.0
|
||||
fallback_rate: float = 0.0
|
||||
source_issue_rate: float = 0.0
|
||||
latency_issue_rate: float = 0.0
|
||||
|
||||
|
||||
class AiQualityPendingLabel(BaseModel):
|
||||
run_id: str
|
||||
label_code: str
|
||||
severity: str
|
||||
title: str
|
||||
detail: str
|
||||
review_status: str = "pending"
|
||||
created_at: datetime
|
||||
question: str | None = None
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
quality_status: str = "unknown"
|
||||
quality_score: int = 0
|
||||
|
||||
|
||||
class AiQualityDashboardSummary(BaseModel):
|
||||
days: int
|
||||
total_runs: int = 0
|
||||
passed_runs: int = 0
|
||||
needs_review_runs: int = 0
|
||||
failed_quality_runs: int = 0
|
||||
fallback_runs: int = 0
|
||||
source_issue_runs: int = 0
|
||||
token_issue_runs: int = 0
|
||||
latency_issue_runs: int = 0
|
||||
pending_labels: int = 0
|
||||
pass_rate: float = 0.0
|
||||
fallback_rate: float = 0.0
|
||||
source_issue_rate: float = 0.0
|
||||
latency_issue_rate: float = 0.0
|
||||
|
||||
|
||||
class AiQualityDashboard(BaseModel):
|
||||
summary: AiQualityDashboardSummary
|
||||
daily_metrics: list[AiQualityDailyMetric] = Field(default_factory=list)
|
||||
pending_labels: list[AiQualityPendingLabel] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AiPlatformRepository(Protocol):
|
||||
def upsert_provider_config(self, payload: ProviderConfigCreate) -> ProviderConfig: ...
|
||||
|
||||
@@ -316,6 +371,8 @@ class AiPlatformRepository(Protocol):
|
||||
|
||||
def list_ai_run_audits(self, *, limit: int = 20) -> list[AiRunAudit]: ...
|
||||
|
||||
def get_quality_dashboard(self, *, days: int = 14, pending_limit: int = 20) -> AiQualityDashboard: ...
|
||||
|
||||
def update_ai_run_quality_label(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -595,6 +652,16 @@ class InMemoryAiPlatformRepository:
|
||||
with self._lock:
|
||||
return self.ai_run_audits[:limit]
|
||||
|
||||
def get_quality_dashboard(self, *, days: int = 14, pending_limit: int = 20) -> AiQualityDashboard:
|
||||
cutoff = datetime.now(UTC) - timedelta(days=max(days - 1, 0))
|
||||
with self._lock:
|
||||
runs = [
|
||||
audit
|
||||
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)
|
||||
|
||||
def update_ai_run_quality_label(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -1093,6 +1160,18 @@ class SqlAlchemyAiPlatformRepository:
|
||||
)
|
||||
return [_ai_run_audit_from_model(row) for row in rows]
|
||||
|
||||
def get_quality_dashboard(self, *, days: int = 14, pending_limit: int = 20) -> AiQualityDashboard:
|
||||
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:
|
||||
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)
|
||||
|
||||
def update_ai_run_quality_label(
|
||||
self,
|
||||
run_id: str,
|
||||
@@ -1357,6 +1436,125 @@ def _ensure_utc(value: datetime) -> datetime:
|
||||
return value
|
||||
|
||||
|
||||
def _build_quality_dashboard(
|
||||
runs: list[AiRunAudit],
|
||||
*,
|
||||
days: int,
|
||||
pending_limit: int,
|
||||
) -> AiQualityDashboard:
|
||||
normalized_days = max(days, 1)
|
||||
today = datetime.now(UTC).date()
|
||||
dates = [
|
||||
(today - timedelta(days=offset)).isoformat()
|
||||
for offset in range(normalized_days - 1, -1, -1)
|
||||
]
|
||||
daily: dict[str, AiQualityDailyMetric] = {
|
||||
date: AiQualityDailyMetric(date=date)
|
||||
for date in dates
|
||||
}
|
||||
summary = AiQualityDashboardSummary(days=normalized_days)
|
||||
pending_labels: list[AiQualityPendingLabel] = []
|
||||
|
||||
for run in runs:
|
||||
created_at = _ensure_utc(run.created_at)
|
||||
date_key = created_at.date().isoformat()
|
||||
metric = daily.setdefault(date_key, AiQualityDailyMetric(date=date_key))
|
||||
labels = run.quality_labels
|
||||
label_codes = {label.code for label in labels}
|
||||
has_source_issue = bool(label_codes & {"source_insufficient", "source_limited"})
|
||||
has_token_issue = bool(label_codes & {"token_missing", "token_high"})
|
||||
has_latency_issue = bool(label_codes & {"latency_high", "latency_critical"})
|
||||
pending_for_run = [
|
||||
label
|
||||
for label in labels
|
||||
if label.review_status == "pending" and label.severity in {"warning", "error"}
|
||||
]
|
||||
|
||||
_add_quality_counts(
|
||||
metric,
|
||||
run=run,
|
||||
has_source_issue=has_source_issue,
|
||||
has_token_issue=has_token_issue,
|
||||
has_latency_issue=has_latency_issue,
|
||||
pending_count=len(pending_for_run),
|
||||
)
|
||||
_add_quality_counts(
|
||||
summary,
|
||||
run=run,
|
||||
has_source_issue=has_source_issue,
|
||||
has_token_issue=has_token_issue,
|
||||
has_latency_issue=has_latency_issue,
|
||||
pending_count=len(pending_for_run),
|
||||
)
|
||||
|
||||
for label in pending_for_run:
|
||||
pending_labels.append(
|
||||
AiQualityPendingLabel(
|
||||
run_id=run.id,
|
||||
label_code=label.code,
|
||||
severity=label.severity,
|
||||
title=label.title,
|
||||
detail=label.detail,
|
||||
review_status=label.review_status,
|
||||
created_at=created_at,
|
||||
question=run.question,
|
||||
provider=run.provider,
|
||||
model=run.model,
|
||||
quality_status=run.quality_status,
|
||||
quality_score=run.quality_score,
|
||||
)
|
||||
)
|
||||
|
||||
for metric in daily.values():
|
||||
_finalize_quality_rates(metric)
|
||||
_finalize_quality_rates(summary)
|
||||
pending_labels = sorted(pending_labels, key=lambda item: item.created_at, reverse=True)[:pending_limit]
|
||||
ordered_daily = [daily[date] for date in dates if date in daily]
|
||||
extra_dates = sorted((date for date in daily if date not in dates), reverse=False)
|
||||
ordered_daily.extend(daily[date] for date in extra_dates)
|
||||
return AiQualityDashboard(
|
||||
summary=summary,
|
||||
daily_metrics=ordered_daily,
|
||||
pending_labels=pending_labels,
|
||||
)
|
||||
|
||||
|
||||
def _add_quality_counts(
|
||||
metric: AiQualityDailyMetric | AiQualityDashboardSummary,
|
||||
*,
|
||||
run: AiRunAudit,
|
||||
has_source_issue: bool,
|
||||
has_token_issue: bool,
|
||||
has_latency_issue: bool,
|
||||
pending_count: int,
|
||||
) -> None:
|
||||
metric.total_runs += 1
|
||||
if run.quality_status == "passed":
|
||||
metric.passed_runs += 1
|
||||
elif run.quality_status == "failed":
|
||||
metric.failed_quality_runs += 1
|
||||
elif run.quality_status == "needs_review":
|
||||
metric.needs_review_runs += 1
|
||||
if run.status == "fallback" or any(label.code == "fallback_used" for label in run.quality_labels):
|
||||
metric.fallback_runs += 1
|
||||
if has_source_issue:
|
||||
metric.source_issue_runs += 1
|
||||
if has_token_issue:
|
||||
metric.token_issue_runs += 1
|
||||
if has_latency_issue:
|
||||
metric.latency_issue_runs += 1
|
||||
metric.pending_labels += pending_count
|
||||
|
||||
|
||||
def _finalize_quality_rates(metric: AiQualityDailyMetric | AiQualityDashboardSummary) -> None:
|
||||
if metric.total_runs <= 0:
|
||||
return
|
||||
metric.pass_rate = round(metric.passed_runs / metric.total_runs, 4)
|
||||
metric.fallback_rate = round(metric.fallback_runs / metric.total_runs, 4)
|
||||
metric.source_issue_rate = round(metric.source_issue_runs / metric.total_runs, 4)
|
||||
metric.latency_issue_rate = round(metric.latency_issue_runs / metric.total_runs, 4)
|
||||
|
||||
|
||||
def _update_quality_label_list(
|
||||
labels: list[AiQualityLabel],
|
||||
label_code: str,
|
||||
|
||||
@@ -166,6 +166,35 @@ def test_ai_run_quality_label_review_status_can_be_updated() -> None:
|
||||
assert label["reviewed_at"]
|
||||
|
||||
|
||||
def test_quality_dashboard_summarizes_daily_metrics_and_pending_labels() -> None:
|
||||
client = make_client()
|
||||
|
||||
response = client.post("/api/v1/legal/qa", json={"question": "试用期如何约定?"})
|
||||
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()
|
||||
assert dashboard["summary"]["days"] == 7
|
||||
assert dashboard["summary"]["total_runs"] == 1
|
||||
assert dashboard["summary"]["fallback_runs"] == 1
|
||||
assert dashboard["summary"]["pending_labels"] >= 1
|
||||
assert dashboard["summary"]["fallback_rate"] == 1.0
|
||||
assert len(dashboard["daily_metrics"]) == 7
|
||||
assert dashboard["daily_metrics"][-1]["total_runs"] == 1
|
||||
assert dashboard["pending_labels"]
|
||||
|
||||
first_pending = dashboard["pending_labels"][0]
|
||||
update_response = client.patch(
|
||||
f"/api/v1/audit/ai-runs/{first_pending['run_id']}/quality-labels/{first_pending['label_code']}",
|
||||
json={"review_status": "resolved", "review_note": "已处理。", "reviewed_by": "admin"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
|
||||
updated_dashboard = client.get("/api/v1/audit/quality-dashboard", params={"days": 7, "pending_limit": 10}).json()
|
||||
assert updated_dashboard["summary"]["pending_labels"] == dashboard["summary"]["pending_labels"] - 1
|
||||
|
||||
|
||||
def test_knowledge_document_can_be_added_and_searched() -> None:
|
||||
client = make_client()
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from yuqei_sdk.ai_platform import (
|
||||
AiPlatformClient,
|
||||
AiQualityDashboard,
|
||||
AiQualityDashboardSummary,
|
||||
AiQualityDailyMetric,
|
||||
AiQualityLabel,
|
||||
AiQualityLabelReviewUpdate,
|
||||
AiQualityPendingLabel,
|
||||
AiQualityRuleConfig,
|
||||
AiQualityRuleConfigUpdate,
|
||||
AiRunAudit,
|
||||
@@ -28,8 +32,12 @@ from yuqei_sdk.context import RequestContext
|
||||
|
||||
__all__ = [
|
||||
"AiPlatformClient",
|
||||
"AiQualityDashboard",
|
||||
"AiQualityDashboardSummary",
|
||||
"AiQualityDailyMetric",
|
||||
"AiQualityLabel",
|
||||
"AiQualityLabelReviewUpdate",
|
||||
"AiQualityPendingLabel",
|
||||
"AiQualityRuleConfig",
|
||||
"AiQualityRuleConfigUpdate",
|
||||
"AiRunAudit",
|
||||
|
||||
@@ -250,6 +250,61 @@ class AiRunAudit(BaseModel):
|
||||
created_at: str
|
||||
|
||||
|
||||
class AiQualityDailyMetric(BaseModel):
|
||||
date: str
|
||||
total_runs: int = 0
|
||||
passed_runs: int = 0
|
||||
needs_review_runs: int = 0
|
||||
failed_quality_runs: int = 0
|
||||
fallback_runs: int = 0
|
||||
source_issue_runs: int = 0
|
||||
token_issue_runs: int = 0
|
||||
latency_issue_runs: int = 0
|
||||
pending_labels: int = 0
|
||||
pass_rate: float = 0.0
|
||||
fallback_rate: float = 0.0
|
||||
source_issue_rate: float = 0.0
|
||||
latency_issue_rate: float = 0.0
|
||||
|
||||
|
||||
class AiQualityDashboardSummary(BaseModel):
|
||||
days: int
|
||||
total_runs: int = 0
|
||||
passed_runs: int = 0
|
||||
needs_review_runs: int = 0
|
||||
failed_quality_runs: int = 0
|
||||
fallback_runs: int = 0
|
||||
source_issue_runs: int = 0
|
||||
token_issue_runs: int = 0
|
||||
latency_issue_runs: int = 0
|
||||
pending_labels: int = 0
|
||||
pass_rate: float = 0.0
|
||||
fallback_rate: float = 0.0
|
||||
source_issue_rate: float = 0.0
|
||||
latency_issue_rate: float = 0.0
|
||||
|
||||
|
||||
class AiQualityPendingLabel(BaseModel):
|
||||
run_id: str
|
||||
label_code: str
|
||||
severity: str
|
||||
title: str
|
||||
detail: str
|
||||
review_status: str = "pending"
|
||||
created_at: str
|
||||
question: str | None = None
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
quality_status: str = "unknown"
|
||||
quality_score: int = 0
|
||||
|
||||
|
||||
class AiQualityDashboard(BaseModel):
|
||||
summary: AiQualityDashboardSummary
|
||||
daily_metrics: list[AiQualityDailyMetric] = Field(default_factory=list)
|
||||
pending_labels: list[AiQualityPendingLabel] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AiPlatformClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -512,6 +567,21 @@ class AiPlatformClient:
|
||||
response.raise_for_status()
|
||||
return [AiRunAudit.model_validate(item) for item in response.json()]
|
||||
|
||||
def get_quality_dashboard(
|
||||
self,
|
||||
*,
|
||||
days: int = 14,
|
||||
pending_limit: int = 20,
|
||||
context: RequestContext | None = None,
|
||||
) -> AiQualityDashboard:
|
||||
response = self._client.get(
|
||||
f"{self._api_prefix}/audit/quality-dashboard",
|
||||
params={"days": days, "pending_limit": pending_limit},
|
||||
headers=(context or RequestContext()).to_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return AiQualityDashboard.model_validate(response.json())
|
||||
|
||||
def update_ai_run_quality_label(
|
||||
self,
|
||||
run_id: str,
|
||||
|
||||
@@ -436,6 +436,65 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
}
|
||||
],
|
||||
)
|
||||
if request.url.path == "/api/v1/audit/quality-dashboard":
|
||||
query = parse_qs(request.url.query.decode())
|
||||
assert query["days"] == ["7"]
|
||||
assert query["pending_limit"] == ["5"]
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"summary": {
|
||||
"days": 7,
|
||||
"total_runs": 1,
|
||||
"passed_runs": 1,
|
||||
"needs_review_runs": 0,
|
||||
"failed_quality_runs": 0,
|
||||
"fallback_runs": 0,
|
||||
"source_issue_runs": 0,
|
||||
"token_issue_runs": 0,
|
||||
"latency_issue_runs": 0,
|
||||
"pending_labels": 1,
|
||||
"pass_rate": 1.0,
|
||||
"fallback_rate": 0.0,
|
||||
"source_issue_rate": 0.0,
|
||||
"latency_issue_rate": 0.0,
|
||||
},
|
||||
"daily_metrics": [
|
||||
{
|
||||
"date": "2026-06-22",
|
||||
"total_runs": 1,
|
||||
"passed_runs": 1,
|
||||
"needs_review_runs": 0,
|
||||
"failed_quality_runs": 0,
|
||||
"fallback_runs": 0,
|
||||
"source_issue_runs": 0,
|
||||
"token_issue_runs": 0,
|
||||
"latency_issue_runs": 0,
|
||||
"pending_labels": 1,
|
||||
"pass_rate": 1.0,
|
||||
"fallback_rate": 0.0,
|
||||
"source_issue_rate": 0.0,
|
||||
"latency_issue_rate": 0.0,
|
||||
}
|
||||
],
|
||||
"pending_labels": [
|
||||
{
|
||||
"run_id": "run-1",
|
||||
"label_code": "citation_present",
|
||||
"severity": "info",
|
||||
"title": "Citation present",
|
||||
"detail": "The answer returned 1 citation.",
|
||||
"review_status": "pending",
|
||||
"created_at": "2026-06-22T00:00:00Z",
|
||||
"question": "How should lease deposit refund be handled?",
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-chat",
|
||||
"quality_status": "passed",
|
||||
"quality_score": 100,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
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"
|
||||
@@ -565,6 +624,10 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
assert audit.quality_labels[0].code == "citation_present"
|
||||
assert audit.quality_labels[0].review_status == "pending"
|
||||
assert audit.quality_summary == "passed: 0 error(s), 0 warning(s), score 100."
|
||||
dashboard = client.get_quality_dashboard(days=7, pending_limit=5)
|
||||
assert dashboard.summary.pass_rate == 1.0
|
||||
assert dashboard.daily_metrics[0].date == "2026-06-22"
|
||||
assert dashboard.pending_labels[0].run_id == "run-1"
|
||||
updated_audit = client.update_ai_run_quality_label(
|
||||
"run-1",
|
||||
"citation_present",
|
||||
@@ -577,4 +640,5 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
assert updated_audit.quality_labels[0].review_status == "resolved"
|
||||
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/quality-rules/default" in seen_paths
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
type QualityDailyMetric = {
|
||||
date: string;
|
||||
total_runs: number;
|
||||
passed_runs: number;
|
||||
needs_review_runs: number;
|
||||
failed_quality_runs: number;
|
||||
fallback_runs: number;
|
||||
source_issue_runs: number;
|
||||
token_issue_runs: number;
|
||||
latency_issue_runs: number;
|
||||
pending_labels: number;
|
||||
pass_rate: number;
|
||||
fallback_rate: number;
|
||||
source_issue_rate: number;
|
||||
latency_issue_rate: number;
|
||||
};
|
||||
|
||||
type QualityPendingLabel = {
|
||||
run_id: string;
|
||||
label_code: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
review_status: string;
|
||||
created_at: string;
|
||||
question?: string | null;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
quality_status: string;
|
||||
quality_score: number;
|
||||
};
|
||||
|
||||
type QualityDashboard = {
|
||||
summary: QualityDailyMetric & { days: number };
|
||||
daily_metrics: QualityDailyMetric[];
|
||||
pending_labels: QualityPendingLabel[];
|
||||
};
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; dashboard: QualityDashboard }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
const severityTone: Record<string, string> = {
|
||||
info: "tag-primary",
|
||||
warning: "tag-warning",
|
||||
error: "tag-danger"
|
||||
};
|
||||
|
||||
export default function AiQualityDashboardPage() {
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
const [days, setDays] = useState(14);
|
||||
|
||||
async function loadDashboard(nextDays = days) {
|
||||
setState({ status: "loading" });
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/ai-platform/audit/quality-dashboard?days=${nextDays}&pending_limit=30`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
setState({ status: "ready", dashboard: payload });
|
||||
} catch (error) {
|
||||
setState({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "AI 质量运营看板加载失败"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard(days);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const dashboard = state.status === "ready" ? state.dashboard : null;
|
||||
function changeDays(value: string) {
|
||||
const nextDays = Number(value);
|
||||
setDays(nextDays);
|
||||
void loadDashboard(nextDays);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<h1>AI 质量运营</h1>
|
||||
<p>按日期查看质量通过率、fallback、来源不足、耗时异常和待处理标签。</p>
|
||||
</div>
|
||||
<div className="toolbar toolbar-inline">
|
||||
<select className="search-input select-input" value={days} onChange={(event) => changeDays(event.target.value)}>
|
||||
<option value={7}>最近 7 天</option>
|
||||
<option value={14}>最近 14 天</option>
|
||||
<option value={30}>最近 30 天</option>
|
||||
<option value={90}>最近 90 天</option>
|
||||
</select>
|
||||
<button className="button button-soft" onClick={() => void loadDashboard()}>
|
||||
<RefreshCcw size={16} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && <div className="empty-state">正在加载 AI 质量运营数据...</div>}
|
||||
{state.status === "error" && (
|
||||
<div className="empty-state empty-state-error">
|
||||
<strong>无法连接 AI Platform 质量运营接口</strong>
|
||||
<span>{state.message}</span>
|
||||
</div>
|
||||
)}
|
||||
{dashboard && (
|
||||
<>
|
||||
<div className="metric-row">
|
||||
<Metric icon={<ShieldCheck size={18} />} label="质量通过率" value={formatPercent(dashboard.summary.pass_rate)} />
|
||||
<Metric icon={<Undo2 size={18} />} label="Fallback 率" value={formatPercent(dashboard.summary.fallback_rate)} />
|
||||
<Metric icon={<Database size={18} />} label="来源不足率" value={formatPercent(dashboard.summary.source_issue_rate)} />
|
||||
<Metric icon={<Clock3 size={18} />} label="耗时异常率" value={formatPercent(dashboard.summary.latency_issue_rate)} />
|
||||
<Metric icon={<AlertTriangle size={18} />} label="待处理标签" value={String(dashboard.summary.pending_labels)} />
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2 className="panel-title">趋势</h2>
|
||||
<span className="tag">总调用 {dashboard.summary.total_runs}</span>
|
||||
</div>
|
||||
<div className="panel-body trend-list">
|
||||
{dashboard.daily_metrics.map((metric) => (
|
||||
<div className="trend-row" key={metric.date}>
|
||||
<span className="trend-date">{formatShortDate(metric.date)}</span>
|
||||
<div className="trend-bars">
|
||||
<span
|
||||
className="trend-bar trend-bar-pass"
|
||||
style={{ width: `${Math.max(metric.pass_rate * 100, metric.total_runs ? 4 : 0)}%` }}
|
||||
/>
|
||||
<span
|
||||
className="trend-bar trend-bar-fallback"
|
||||
style={{ width: `${Math.max(metric.fallback_rate * 100, metric.fallback_runs ? 4 : 0)}%` }}
|
||||
/>
|
||||
<span
|
||||
className="trend-bar trend-bar-issue"
|
||||
style={{
|
||||
width: `${Math.max(
|
||||
Math.max(metric.source_issue_rate, metric.latency_issue_rate) * 100,
|
||||
metric.source_issue_runs || metric.latency_issue_runs ? 4 : 0
|
||||
)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="trend-count">{metric.total_runs} 次</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="trend-legend">
|
||||
<span><i className="trend-dot trend-dot-pass" />通过</span>
|
||||
<span><i className="trend-dot trend-dot-fallback" />Fallback</span>
|
||||
<span><i className="trend-dot trend-dot-issue" />来源/耗时异常</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2 className="panel-title">待处理质量标签</h2>
|
||||
<Link className="button button-soft" href="/admin/ai-traces">
|
||||
打开 Trace
|
||||
</Link>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{dashboard.pending_labels.length === 0 ? (
|
||||
<div className="empty-state">当前没有待处理质量标签。</div>
|
||||
) : (
|
||||
<div className="list">
|
||||
{dashboard.pending_labels.map((label) => (
|
||||
<div className="list-item compact-list-item" key={`${label.run_id}-${label.label_code}`}>
|
||||
<div className="list-item-header">
|
||||
<strong>{label.question || label.run_id}</strong>
|
||||
<span className={`tag ${severityTone[label.severity] ?? ""}`}>{label.title}</span>
|
||||
</div>
|
||||
<span className="item-meta">{label.detail}</span>
|
||||
<span className="item-meta">
|
||||
{formatDateTime(label.created_at)} · {label.provider || "unknown"} / {label.model || "unknown"} · 质量分 {label.quality_score}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="metric">
|
||||
<div className="metric-icon">{icon}</div>
|
||||
<div className="metric-value">{value}</div>
|
||||
<div className="metric-label">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function formatShortDate(value: string) {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Bot, Settings, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
||||
import { Activity, Bot, Settings, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
||||
|
||||
type AdminSection = {
|
||||
title: string;
|
||||
@@ -21,6 +21,12 @@ const adminSections: AdminSection[] = [
|
||||
icon: SlidersHorizontal,
|
||||
href: "/admin/ai-quality"
|
||||
},
|
||||
{
|
||||
title: "AI 质量运营",
|
||||
description: "质量通过率、fallback、异常趋势和待处理标签。",
|
||||
icon: Activity,
|
||||
href: "/admin/ai-quality-dashboard"
|
||||
},
|
||||
{
|
||||
title: "AI 调用 Trace",
|
||||
description: "查看模型、Prompt、知识命中、token、费用、错误和耗时。",
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
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 days = searchParams.get("days") ?? "14";
|
||||
const pendingLimit = searchParams.get("pending_limit") ?? "20";
|
||||
const targetUrl = new URL("/api/v1/audit/quality-dashboard", aiPlatformBaseUrl);
|
||||
targetUrl.searchParams.set("days", days);
|
||||
targetUrl.searchParams.set("pending_limit", pendingLimit);
|
||||
|
||||
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 dashboard 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 dashboard service is unavailable.",
|
||||
baseUrl: aiPlatformBaseUrl
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -689,6 +689,95 @@ select {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.8fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.trend-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.trend-row {
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(0, 1fr) 54px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.trend-date,
|
||||
.trend-count {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trend-count {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.trend-bars {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-height: 32px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
display: block;
|
||||
min-height: 6px;
|
||||
max-width: 100%;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.trend-bar-pass {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.trend-bar-fallback {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.trend-bar-issue {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.trend-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
padding-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.trend-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.trend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.trend-dot-pass {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.trend-dot-fallback {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.trend-dot-issue {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.compact-list-item {
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -719,6 +808,7 @@ details.panel summary::-webkit-details-marker {
|
||||
.split-layout,
|
||||
.detail-grid,
|
||||
.grid-two,
|
||||
.dashboard-grid,
|
||||
.trace-detail-grid,
|
||||
.quality-label {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user