feat: add ai quality operations dashboard

This commit is contained in:
2026-06-23 01:24:57 +08:00
parent 803b80dc6f
commit 963b6d54b0
12 changed files with 929 additions and 2 deletions
@@ -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,
@@ -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
@@ -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、费用、错误和耗时。",
@@ -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;