feat: add ai run quality evaluation
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# V2 AI 质量评测 MVP 闭环记录
|
||||
|
||||
日期:2026-06-23
|
||||
|
||||
## 本轮目标
|
||||
|
||||
给每次 AI 调用增加可复盘的质量标签,让管理员在 AI Trace 页直接看到本次回答是否引用依据、是否走 fallback、token/耗时是否异常、知识来源是否不足。
|
||||
|
||||
## 已落地内容
|
||||
|
||||
1. AI run 审计表新增质量字段。
|
||||
- `quality_status`
|
||||
- `quality_score`
|
||||
- `quality_labels_json`
|
||||
- `quality_summary`
|
||||
- 新增迁移 `20260623_0007_add_ai_run_quality_fields.py`
|
||||
|
||||
2. 法律问答主链路增加质量评测。
|
||||
- 有引用依据:`citation_present`
|
||||
- 缺少引用依据:`missing_citation`
|
||||
- 知识来源不足:`source_insufficient`
|
||||
- 知识来源偏少:`source_limited`
|
||||
- 知识来源覆盖:`source_covered`
|
||||
- 使用 fallback:`fallback_used`
|
||||
- provider 失败:`provider_failed`
|
||||
- token 缺失或过高:`token_missing` / `token_high`
|
||||
- 耗时偏高或严重:`latency_high` / `latency_critical`
|
||||
|
||||
3. 质量判定输出。
|
||||
- `passed`:没有 error/warning。
|
||||
- `needs_review`:存在 warning,需要人工复核。
|
||||
- `failed`:存在 error,需要优先处理。
|
||||
- `quality_score` MVP 规则:100 分起,error 每项扣 30 分,warning 每项扣 10 分。
|
||||
|
||||
4. 契约和 SDK 同步。
|
||||
- OpenAPI `AiRun` 新增质量字段。
|
||||
- Python SDK `AiRunAudit` 新增质量字段。
|
||||
- 新增 `AiQualityLabel` SDK 模型。
|
||||
|
||||
5. AI Trace 后台展示。
|
||||
- `/admin/ai-traces` 新增“质量待复核”指标。
|
||||
- 调用列表新增质量状态和质量分。
|
||||
- 支持按质量状态筛选:全部、质量通过、需要复核、质量失败、未评测。
|
||||
- 展开详情新增“质量评测”区块,展示质量摘要和标签明细。
|
||||
|
||||
## 验收重点
|
||||
|
||||
- 执行一次法律问答后,`GET /api/v1/audit/ai-runs` 返回质量字段。
|
||||
- fallback 回答应标记 `needs_review`,并包含 `fallback_used`。
|
||||
- 缺少引用依据时应标记 `failed`。
|
||||
- AI Trace 页能按质量状态筛选,并能展开查看每个质量标签的原因。
|
||||
|
||||
## 建议下一步
|
||||
|
||||
进入 AI 质量评测规则后台化:
|
||||
|
||||
- 把 token 阈值、耗时阈值、来源数量阈值做成后台可配置。
|
||||
- 给质量标签增加人工确认状态,例如“已复核”“误报”“已处理”。
|
||||
- 给管理首页增加 AI 质量异常趋势,形成运营级监控。
|
||||
@@ -1148,6 +1148,23 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
AiQualityLabel:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
- title
|
||||
- detail
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
enum: [info, warning, error]
|
||||
default: info
|
||||
title:
|
||||
type: string
|
||||
detail:
|
||||
type: string
|
||||
AiRun:
|
||||
type: object
|
||||
required:
|
||||
@@ -1214,6 +1231,18 @@ components:
|
||||
format: float
|
||||
latency_ms:
|
||||
type: integer
|
||||
quality_status:
|
||||
type: string
|
||||
enum: [unknown, passed, needs_review, failed]
|
||||
quality_score:
|
||||
type: integer
|
||||
quality_labels:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AiQualityLabel"
|
||||
quality_summary:
|
||||
type: string
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, running, succeeded, fallback, skipped, failed, cancelled]
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
"""add ai run quality fields
|
||||
|
||||
Revision ID: 20260623_0007
|
||||
Revises: 20260623_0006
|
||||
Create Date: 2026-06-23
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260623_0007"
|
||||
down_revision: Union[str, None] = "20260623_0006"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("ai_run_audits", sa.Column("quality_status", sa.String(length=32), nullable=False, server_default="unknown"))
|
||||
op.add_column("ai_run_audits", sa.Column("quality_score", sa.Integer(), nullable=False, server_default="0"))
|
||||
op.add_column("ai_run_audits", sa.Column("quality_labels_json", sa.Text(), nullable=False, server_default="[]"))
|
||||
op.add_column("ai_run_audits", sa.Column("quality_summary", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ai_run_audits", "quality_summary")
|
||||
op.drop_column("ai_run_audits", "quality_labels_json")
|
||||
op.drop_column("ai_run_audits", "quality_score")
|
||||
op.drop_column("ai_run_audits", "quality_status")
|
||||
@@ -13,6 +13,7 @@ from yuqei_ai_platform_api.prompts import render_legal_qa_prompt
|
||||
from yuqei_ai_platform_api.providers import ProviderRequest, build_provider_adapter
|
||||
from yuqei_ai_platform_api.repository import (
|
||||
AiPlatformRepository,
|
||||
AiQualityLabel,
|
||||
AiRunAudit,
|
||||
KnowledgeChunk,
|
||||
KnowledgeDocument,
|
||||
@@ -249,6 +250,16 @@ def create_app(
|
||||
confidence=0.0,
|
||||
)
|
||||
audit_status = "failed"
|
||||
latency_ms = max(0, int((perf_counter() - started_at) * 1000))
|
||||
quality_status, quality_score, quality_labels, quality_summary = _evaluate_ai_run_quality(
|
||||
status=audit_status,
|
||||
provider_status=provider_response.status,
|
||||
provider_error=provider_response.error_message,
|
||||
citations_count=len(response.citations),
|
||||
retrieved_sources_count=len(retrieval_sources),
|
||||
total_tokens=provider_response.total_tokens,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
store.add_ai_run_audit(
|
||||
AiRunAudit(
|
||||
id=f"run-{uuid4().hex[:12]}",
|
||||
@@ -271,7 +282,11 @@ def create_app(
|
||||
completion_tokens=provider_response.completion_tokens,
|
||||
total_tokens=provider_response.total_tokens,
|
||||
estimated_cost=provider_response.estimated_cost,
|
||||
latency_ms=max(0, int((perf_counter() - started_at) * 1000)),
|
||||
latency_ms=latency_ms,
|
||||
quality_status=quality_status,
|
||||
quality_score=quality_score,
|
||||
quality_labels=quality_labels,
|
||||
quality_summary=quality_summary,
|
||||
status=audit_status,
|
||||
)
|
||||
)
|
||||
@@ -289,6 +304,175 @@ def _preview_text(value: str, *, limit: int = 4000) -> str:
|
||||
return f"{value[:limit]}..."
|
||||
|
||||
|
||||
def _evaluate_ai_run_quality(
|
||||
*,
|
||||
status: str,
|
||||
provider_status: str | None,
|
||||
provider_error: str | None,
|
||||
citations_count: int,
|
||||
retrieved_sources_count: int,
|
||||
total_tokens: int,
|
||||
latency_ms: int,
|
||||
) -> tuple[str, int, list[AiQualityLabel], str]:
|
||||
labels: list[AiQualityLabel] = []
|
||||
|
||||
if citations_count <= 0:
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="missing_citation",
|
||||
severity="error",
|
||||
title="Missing citation",
|
||||
detail="The answer did not return any legal citation or evidence source.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="citation_present",
|
||||
severity="info",
|
||||
title="Citation present",
|
||||
detail=f"The answer returned {citations_count} citation(s).",
|
||||
)
|
||||
)
|
||||
|
||||
if retrieved_sources_count <= 0:
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="source_insufficient",
|
||||
severity="warning",
|
||||
title="Source insufficient",
|
||||
detail="No retrieved knowledge chunk source was recorded for this run.",
|
||||
)
|
||||
)
|
||||
elif retrieved_sources_count < 2:
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="source_limited",
|
||||
severity="warning",
|
||||
title="Source limited",
|
||||
detail="Only one retrieved source was recorded; reviewer may need to verify coverage.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="source_covered",
|
||||
severity="info",
|
||||
title="Source covered",
|
||||
detail=f"{retrieved_sources_count} retrieved sources were recorded.",
|
||||
)
|
||||
)
|
||||
|
||||
if status == "failed":
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="run_failed",
|
||||
severity="error",
|
||||
title="Run failed",
|
||||
detail="The AI run did not complete successfully.",
|
||||
)
|
||||
)
|
||||
elif status == "fallback":
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="fallback_used",
|
||||
severity="warning",
|
||||
title="Fallback used",
|
||||
detail="The answer used the local fallback path instead of a successful provider response.",
|
||||
)
|
||||
)
|
||||
|
||||
if provider_status == "failed":
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="provider_failed",
|
||||
severity="warning",
|
||||
title="Provider failed",
|
||||
detail=provider_error or "The model provider returned a failure status.",
|
||||
)
|
||||
)
|
||||
elif provider_status == "succeeded":
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="provider_succeeded",
|
||||
severity="info",
|
||||
title="Provider succeeded",
|
||||
detail="The model provider returned a successful response.",
|
||||
)
|
||||
)
|
||||
|
||||
if total_tokens <= 0:
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="token_missing",
|
||||
severity="warning",
|
||||
title="Token usage missing",
|
||||
detail="Token usage was not reported for this run.",
|
||||
)
|
||||
)
|
||||
elif total_tokens > 8000:
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="token_high",
|
||||
severity="warning",
|
||||
title="Token usage high",
|
||||
detail=f"The run used {total_tokens} tokens, which is above the MVP review threshold.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="token_normal",
|
||||
severity="info",
|
||||
title="Token usage normal",
|
||||
detail=f"The run used {total_tokens} tokens.",
|
||||
)
|
||||
)
|
||||
|
||||
if latency_ms >= 30000:
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="latency_critical",
|
||||
severity="error",
|
||||
title="Latency critical",
|
||||
detail=f"The run took {latency_ms} ms.",
|
||||
)
|
||||
)
|
||||
elif latency_ms >= 15000:
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="latency_high",
|
||||
severity="warning",
|
||||
title="Latency high",
|
||||
detail=f"The run took {latency_ms} ms.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
labels.append(
|
||||
AiQualityLabel(
|
||||
code="latency_normal",
|
||||
severity="info",
|
||||
title="Latency normal",
|
||||
detail=f"The run took {latency_ms} ms.",
|
||||
)
|
||||
)
|
||||
|
||||
error_count = sum(1 for label in labels if label.severity == "error")
|
||||
warning_count = sum(1 for label in labels if label.severity == "warning")
|
||||
score = max(0, 100 - error_count * 30 - warning_count * 10)
|
||||
if error_count:
|
||||
quality_status = "failed"
|
||||
elif warning_count:
|
||||
quality_status = "needs_review"
|
||||
else:
|
||||
quality_status = "passed"
|
||||
summary = (
|
||||
f"{quality_status}: {error_count} error(s), "
|
||||
f"{warning_count} warning(s), score {score}."
|
||||
)
|
||||
return quality_status, score, labels, summary
|
||||
|
||||
|
||||
def _search_citation_documents(
|
||||
repository: AiPlatformRepository,
|
||||
request: LegalQaRequest,
|
||||
|
||||
@@ -124,6 +124,10 @@ class AiRunAuditModel(Base):
|
||||
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
estimated_cost: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
latency_ms: Mapped[int] = mapped_column(Integer, default=0)
|
||||
quality_status: Mapped[str] = mapped_column(String(32), default="unknown")
|
||||
quality_score: Mapped[int] = mapped_column(Integer, default=0)
|
||||
quality_labels_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
quality_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="succeeded")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
|
||||
@@ -190,6 +190,13 @@ class RetrievalSource(BaseModel):
|
||||
matched_terms: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AiQualityLabel(BaseModel):
|
||||
code: str
|
||||
severity: str = "info"
|
||||
title: str
|
||||
detail: str
|
||||
|
||||
|
||||
class AiRunAudit(BaseModel):
|
||||
id: str
|
||||
operation: str
|
||||
@@ -212,6 +219,10 @@ class AiRunAudit(BaseModel):
|
||||
total_tokens: int = 0
|
||||
estimated_cost: float = 0.0
|
||||
latency_ms: int = 0
|
||||
quality_status: str = "unknown"
|
||||
quality_score: int = 0
|
||||
quality_labels: list[AiQualityLabel] = Field(default_factory=list)
|
||||
quality_summary: str | None = None
|
||||
status: str = "succeeded"
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
@@ -962,6 +973,12 @@ class SqlAlchemyAiPlatformRepository:
|
||||
total_tokens=audit.total_tokens,
|
||||
estimated_cost=audit.estimated_cost,
|
||||
latency_ms=audit.latency_ms,
|
||||
quality_status=audit.quality_status,
|
||||
quality_score=audit.quality_score,
|
||||
quality_labels_json=_encode_json_list(
|
||||
[label.model_dump() for label in audit.quality_labels]
|
||||
),
|
||||
quality_summary=audit.quality_summary,
|
||||
status=audit.status,
|
||||
created_at=audit.created_at,
|
||||
)
|
||||
@@ -1170,23 +1187,31 @@ def _ai_run_audit_from_model(row: AiRunAuditModel) -> AiRunAudit:
|
||||
operation=row.operation,
|
||||
provider_config_id=row.provider_config_id,
|
||||
prompt_template_id=row.prompt_template_id,
|
||||
provider=row.provider,
|
||||
model=row.model,
|
||||
provider_status=row.provider_status,
|
||||
provider_error=row.provider_error,
|
||||
prompt_preview=row.prompt_preview,
|
||||
question=row.question,
|
||||
answer=row.answer,
|
||||
provider=row.provider,
|
||||
model=row.model,
|
||||
provider_status=row.provider_status,
|
||||
provider_error=row.provider_error,
|
||||
prompt_preview=row.prompt_preview,
|
||||
question=row.question,
|
||||
answer=row.answer,
|
||||
citations_count=row.citations_count,
|
||||
retrieval_query=row.retrieval_query,
|
||||
retrieval_strategy=row.retrieval_strategy,
|
||||
retrieved_chunk_ids=_decode_json_list(row.retrieved_chunk_ids_json),
|
||||
retrieved_sources=[RetrievalSource.model_validate(item) for item in _decode_json_list(row.retrieved_sources_json)],
|
||||
retrieved_sources=[
|
||||
RetrievalSource.model_validate(item) for item in _decode_json_list(row.retrieved_sources_json)
|
||||
],
|
||||
prompt_tokens=row.prompt_tokens,
|
||||
completion_tokens=row.completion_tokens,
|
||||
total_tokens=row.total_tokens,
|
||||
estimated_cost=row.estimated_cost,
|
||||
latency_ms=row.latency_ms,
|
||||
quality_status=row.quality_status,
|
||||
quality_score=row.quality_score,
|
||||
quality_labels=[
|
||||
AiQualityLabel.model_validate(item) for item in _decode_json_list(row.quality_labels_json)
|
||||
],
|
||||
quality_summary=row.quality_summary,
|
||||
status=row.status,
|
||||
created_at=_ensure_utc(row.created_at),
|
||||
)
|
||||
|
||||
@@ -327,6 +327,10 @@ def test_legal_qa_uses_chunk_search_and_records_audit() -> None:
|
||||
assert audits[0]["retrieved_sources"][0]["reference"] == "Lease-001"
|
||||
assert audits[0]["retrieved_sources"][0]["score"] > 0
|
||||
assert audits[0]["status"] == "fallback"
|
||||
assert audits[0]["quality_status"] == "needs_review"
|
||||
assert audits[0]["quality_score"] == 70
|
||||
quality_codes = {label["code"] for label in audits[0]["quality_labels"]}
|
||||
assert {"citation_present", "source_limited", "fallback_used", "token_missing"} <= quality_codes
|
||||
|
||||
|
||||
def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None:
|
||||
@@ -407,6 +411,23 @@ def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None:
|
||||
total_tokens=20,
|
||||
estimated_cost=0.00028,
|
||||
latency_ms=12,
|
||||
quality_status="passed",
|
||||
quality_score=100,
|
||||
quality_labels=[
|
||||
{
|
||||
"code": "citation_present",
|
||||
"severity": "info",
|
||||
"title": "Citation present",
|
||||
"detail": "The answer returned 1 citation.",
|
||||
},
|
||||
{
|
||||
"code": "source_covered",
|
||||
"severity": "info",
|
||||
"title": "Source covered",
|
||||
"detail": "Sufficient retrieved sources were recorded.",
|
||||
},
|
||||
],
|
||||
quality_summary="passed: 0 error(s), 0 warning(s), score 100.",
|
||||
status="succeeded",
|
||||
)
|
||||
)
|
||||
@@ -438,3 +459,7 @@ def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None:
|
||||
assert audit.estimated_cost == 0.00028
|
||||
assert audit.retrieved_chunk_ids == ["chunk-test-001"]
|
||||
assert audit.retrieved_sources[0].reference == "Article 703"
|
||||
assert audit.quality_status == "passed"
|
||||
assert audit.quality_score == 100
|
||||
assert audit.quality_labels[0].code == "citation_present"
|
||||
assert audit.quality_summary == "passed: 0 error(s), 0 warning(s), score 100."
|
||||
|
||||
@@ -78,6 +78,10 @@ def test_legal_qa_uses_provider_answer_when_enabled(monkeypatch) -> None:
|
||||
assert audit["completion_tokens"] == 50
|
||||
assert audit["total_tokens"] == 150
|
||||
assert audit["estimated_cost"] == 0.0002
|
||||
assert audit["quality_status"] == "needs_review"
|
||||
assert audit["quality_score"] == 90
|
||||
quality_codes = {label["code"] for label in audit["quality_labels"]}
|
||||
assert {"citation_present", "source_limited", "provider_succeeded", "token_normal", "latency_normal"} <= quality_codes
|
||||
|
||||
|
||||
def test_provider_failure_falls_back_to_rule_answer_and_audit_status() -> None:
|
||||
@@ -116,6 +120,10 @@ def test_provider_failure_falls_back_to_rule_answer_and_audit_status() -> None:
|
||||
assert audit["provider_error"] == "Missing provider API key."
|
||||
assert "试用期如何约定" in audit["prompt_preview"]
|
||||
assert audit["total_tokens"] == 0
|
||||
assert audit["quality_status"] == "needs_review"
|
||||
assert audit["quality_score"] == 60
|
||||
quality_codes = {label["code"] for label in audit["quality_labels"]}
|
||||
assert {"citation_present", "fallback_used", "provider_failed", "token_missing"} <= quality_codes
|
||||
|
||||
|
||||
def test_openai_compatible_provider_extracts_chat_answer() -> None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from yuqei_sdk.ai_platform import (
|
||||
AiPlatformClient,
|
||||
AiQualityLabel,
|
||||
AiRunAudit,
|
||||
KnowledgeChunk,
|
||||
KnowledgeDocument,
|
||||
@@ -24,6 +25,7 @@ from yuqei_sdk.context import RequestContext
|
||||
|
||||
__all__ = [
|
||||
"AiPlatformClient",
|
||||
"AiQualityLabel",
|
||||
"AiRunAudit",
|
||||
"KnowledgeChunk",
|
||||
"KnowledgeDocument",
|
||||
|
||||
@@ -188,6 +188,13 @@ class RetrievalSource(BaseModel):
|
||||
matched_terms: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AiQualityLabel(BaseModel):
|
||||
code: str
|
||||
severity: str = "info"
|
||||
title: str
|
||||
detail: str
|
||||
|
||||
|
||||
class AiRunAudit(BaseModel):
|
||||
id: str
|
||||
operation: str
|
||||
@@ -210,6 +217,10 @@ class AiRunAudit(BaseModel):
|
||||
total_tokens: int = 0
|
||||
estimated_cost: float = 0.0
|
||||
latency_ms: int = 0
|
||||
quality_status: str = "unknown"
|
||||
quality_score: int = 0
|
||||
quality_labels: list[AiQualityLabel] = Field(default_factory=list)
|
||||
quality_summary: str | None = None
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@@ -382,6 +382,17 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
"total_tokens": 20,
|
||||
"estimated_cost": 0.00028,
|
||||
"latency_ms": 12,
|
||||
"quality_status": "passed",
|
||||
"quality_score": 100,
|
||||
"quality_labels": [
|
||||
{
|
||||
"code": "citation_present",
|
||||
"severity": "info",
|
||||
"title": "Citation present",
|
||||
"detail": "The answer returned 1 citation.",
|
||||
}
|
||||
],
|
||||
"quality_summary": "passed: 0 error(s), 0 warning(s), score 100.",
|
||||
"status": "succeeded",
|
||||
"created_at": "2026-06-22T00:00:00Z",
|
||||
}
|
||||
@@ -452,4 +463,8 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
|
||||
assert audit.estimated_cost == 0.00028
|
||||
assert audit.retrieved_chunk_ids == ["chunk-1"]
|
||||
assert audit.retrieved_sources[0].reference == "Article 703"
|
||||
assert audit.quality_status == "passed"
|
||||
assert audit.quality_score == 100
|
||||
assert audit.quality_labels[0].code == "citation_present"
|
||||
assert audit.quality_summary == "passed: 0 error(s), 0 warning(s), score 100."
|
||||
assert "/api/v1/audit/ai-runs" in seen_paths
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FileText,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Sigma
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -29,6 +30,13 @@ type RetrievalSource = {
|
||||
matched_terms?: string[];
|
||||
};
|
||||
|
||||
type AiQualityLabel = {
|
||||
code: string;
|
||||
severity?: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type AiRunAudit = {
|
||||
id: string;
|
||||
operation: string;
|
||||
@@ -51,6 +59,10 @@ type AiRunAudit = {
|
||||
total_tokens?: number;
|
||||
estimated_cost?: number;
|
||||
latency_ms?: number;
|
||||
quality_status?: string;
|
||||
quality_score?: number;
|
||||
quality_labels?: AiQualityLabel[];
|
||||
quality_summary?: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
@@ -80,9 +92,48 @@ const statusTone: Record<string, string> = {
|
||||
cancelled: "tag-danger"
|
||||
};
|
||||
|
||||
const qualityStatusLabels: Record<string, string> = {
|
||||
passed: "质量通过",
|
||||
needs_review: "需要复核",
|
||||
failed: "质量失败",
|
||||
unknown: "未评测"
|
||||
};
|
||||
|
||||
const qualityStatusTone: Record<string, string> = {
|
||||
passed: "tag-success",
|
||||
needs_review: "tag-warning",
|
||||
failed: "tag-danger",
|
||||
unknown: ""
|
||||
};
|
||||
|
||||
const qualityLabelText: Record<string, { title: string; detail: string }> = {
|
||||
citation_present: { title: "已引用依据", detail: "回答已返回法条或证据来源。" },
|
||||
missing_citation: { title: "缺少依据", detail: "回答未返回法条或证据来源,需要复核。" },
|
||||
source_covered: { title: "来源覆盖", detail: "本次回答记录了多个知识命中来源。" },
|
||||
source_limited: { title: "来源偏少", detail: "本次回答仅记录一个知识命中来源,建议复核覆盖面。" },
|
||||
source_insufficient: { title: "来源不足", detail: "本次回答没有记录知识命中来源。" },
|
||||
fallback_used: { title: "使用回退", detail: "本次回答走了本地回退链路。" },
|
||||
run_failed: { title: "调用失败", detail: "本次 AI 调用未成功完成。" },
|
||||
provider_succeeded: { title: "模型成功", detail: "模型服务返回成功响应。" },
|
||||
provider_failed: { title: "模型失败", detail: "模型服务返回失败状态。" },
|
||||
token_normal: { title: "Token 正常", detail: "本次调用 token 用量在阈值内。" },
|
||||
token_missing: { title: "Token 缺失", detail: "本次调用没有返回 token 用量。" },
|
||||
token_high: { title: "Token 偏高", detail: "本次调用 token 用量超过 MVP 复核阈值。" },
|
||||
latency_normal: { title: "耗时正常", detail: "本次调用耗时在阈值内。" },
|
||||
latency_high: { title: "耗时偏高", detail: "本次调用耗时超过 15 秒。" },
|
||||
latency_critical: { title: "耗时严重", detail: "本次调用耗时超过 30 秒。" }
|
||||
};
|
||||
|
||||
const severityTone: Record<string, string> = {
|
||||
info: "tag-primary",
|
||||
warning: "tag-warning",
|
||||
error: "tag-danger"
|
||||
};
|
||||
|
||||
export default function AiTracesPage() {
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [qualityFilter, setQualityFilter] = useState("all");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
async function loadRuns() {
|
||||
@@ -111,6 +162,8 @@ export default function AiTracesPage() {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
return runs.filter((run) => {
|
||||
const matchesStatus = statusFilter === "all" || run.status === statusFilter;
|
||||
const qualityStatus = run.quality_status ?? "unknown";
|
||||
const matchesQuality = qualityFilter === "all" || qualityStatus === qualityFilter;
|
||||
const haystack = [
|
||||
run.id,
|
||||
run.operation,
|
||||
@@ -120,20 +173,27 @@ export default function AiTracesPage() {
|
||||
run.answer,
|
||||
run.provider_error,
|
||||
run.retrieval_query,
|
||||
run.quality_status,
|
||||
run.quality_summary,
|
||||
...(run.quality_labels ?? []).map((label) => `${label.code} ${label.title} ${label.detail}`),
|
||||
...(run.retrieved_sources ?? []).map((source) => `${source.title} ${source.reference}`)
|
||||
].join("\n").toLowerCase();
|
||||
return matchesStatus && (!normalizedKeyword || haystack.includes(normalizedKeyword));
|
||||
return matchesStatus && matchesQuality && (!normalizedKeyword || haystack.includes(normalizedKeyword));
|
||||
});
|
||||
}, [keyword, runs, statusFilter]);
|
||||
}, [keyword, qualityFilter, runs, statusFilter]);
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
const totalTokens = runs.reduce((sum, run) => sum + (run.total_tokens ?? 0), 0);
|
||||
const estimatedCost = runs.reduce((sum, run) => sum + (run.estimated_cost ?? 0), 0);
|
||||
const failedCount = runs.filter((run) => run.status === "failed" || run.provider_status === "failed").length;
|
||||
const qualityReviewCount = runs.filter((run) => {
|
||||
const qualityStatus = run.quality_status ?? "unknown";
|
||||
return qualityStatus === "needs_review" || qualityStatus === "failed";
|
||||
}).length;
|
||||
const averageLatency = runs.length
|
||||
? Math.round(runs.reduce((sum, run) => sum + (run.latency_ms ?? 0), 0) / runs.length)
|
||||
: 0;
|
||||
return { totalTokens, estimatedCost, failedCount, averageLatency };
|
||||
return { totalTokens, estimatedCost, failedCount, qualityReviewCount, averageLatency };
|
||||
}, [runs]);
|
||||
|
||||
return (
|
||||
@@ -153,6 +213,7 @@ export default function AiTracesPage() {
|
||||
<Metric icon={<Bot size={18} />} label="最近调用" value={String(runs.length)} />
|
||||
<Metric icon={<Sigma size={18} />} label="总 token" value={formatInteger(metrics.totalTokens)} />
|
||||
<Metric icon={<Clock3 size={18} />} label="平均耗时" value={`${metrics.averageLatency} ms`} />
|
||||
<Metric icon={<ShieldCheck size={18} />} label="质量待复核" value={String(metrics.qualityReviewCount)} />
|
||||
<Metric icon={<AlertTriangle size={18} />} label="异常调用" value={String(metrics.failedCount)} />
|
||||
</div>
|
||||
|
||||
@@ -182,6 +243,17 @@ export default function AiTracesPage() {
|
||||
<option value="fallback">已回退</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
<select
|
||||
className="search-input select-input"
|
||||
value={qualityFilter}
|
||||
onChange={(event) => setQualityFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">全部质量</option>
|
||||
<option value="passed">质量通过</option>
|
||||
<option value="needs_review">需要复核</option>
|
||||
<option value="failed">质量失败</option>
|
||||
<option value="unknown">未评测</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && <div className="empty-state">正在加载 AI 调用审计...</div>}
|
||||
@@ -220,6 +292,9 @@ function Metric({ icon, label, value }: { icon: ReactNode; label: string; value:
|
||||
function TraceItem({ run }: { run: AiRunAudit }) {
|
||||
const statusClass = statusTone[run.status] ?? "";
|
||||
const providerStatusClass = statusTone[run.provider_status ?? ""] ?? "";
|
||||
const qualityStatus = run.quality_status ?? "unknown";
|
||||
const qualityClass = qualityStatusTone[qualityStatus] ?? "";
|
||||
const qualityLabels = run.quality_labels ?? [];
|
||||
|
||||
return (
|
||||
<details className="trace-item">
|
||||
@@ -227,7 +302,12 @@ function TraceItem({ run }: { run: AiRunAudit }) {
|
||||
<div className="trace-summary-main">
|
||||
<div className="list-item-header">
|
||||
<strong>{run.question || run.operation}</strong>
|
||||
<span className={`tag ${statusClass}`}>{statusLabels[run.status] ?? run.status}</span>
|
||||
<div className="trace-summary-badges">
|
||||
<span className={`tag ${qualityClass}`}>
|
||||
{qualityStatusLabels[qualityStatus] ?? qualityStatus} · {run.quality_score ?? 0}
|
||||
</span>
|
||||
<span className={`tag ${statusClass}`}>{statusLabels[run.status] ?? run.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="trace-meta">
|
||||
<span>{formatDate(run.created_at)}</span>
|
||||
@@ -271,6 +351,30 @@ function TraceItem({ run }: { run: AiRunAudit }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="trace-block">
|
||||
<h3><ShieldCheck size={16} /> 质量评测</h3>
|
||||
<div className="trace-facts">
|
||||
<span>质量状态</span><strong className={`tag ${qualityClass}`}>{qualityStatusLabels[qualityStatus] ?? qualityStatus}</strong>
|
||||
<span>质量分</span><strong>{run.quality_score ?? 0}</strong>
|
||||
<span>摘要</span><strong>{run.quality_summary || "暂无质量摘要"}</strong>
|
||||
</div>
|
||||
{qualityLabels.length === 0 ? (
|
||||
<p className="muted">暂无质量标签。</p>
|
||||
) : (
|
||||
<div className="quality-labels">
|
||||
{qualityLabels.map((label) => {
|
||||
const text = qualityLabelText[label.code];
|
||||
const severityClass = severityTone[label.severity ?? "info"] ?? "";
|
||||
return (
|
||||
<div className="quality-label" key={label.code}>
|
||||
<span className={`tag ${severityClass}`}>{text?.title ?? label.title}</span>
|
||||
<span>{text?.detail ?? label.detail}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="trace-block">
|
||||
<h3><AlertTriangle size={16} /> 运行状态</h3>
|
||||
<div className="trace-facts">
|
||||
|
||||
@@ -237,7 +237,7 @@ select {
|
||||
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -534,6 +534,13 @@ select {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.trace-summary-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.trace-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -603,6 +610,21 @@ select {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.quality-labels {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quality-label {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 8px 10px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.compact-list-item {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user