diff --git a/project-docs/V2-ai-quality-root-cause-attribution-2026-06-23.md b/project-docs/V2-ai-quality-root-cause-attribution-2026-06-23.md
new file mode 100644
index 00000000..55c25f25
--- /dev/null
+++ b/project-docs/V2-ai-quality-root-cause-attribution-2026-06-23.md
@@ -0,0 +1,61 @@
+# V2 AI 质量问题归因闭环记录
+
+日期:2026-06-23
+
+## 本轮目标
+
+在 AI 质量运营看板基础上,继续把异常按模型、Prompt 模板、知识库和标签类型聚合,帮助管理员找出最常见的质量失败来源。
+
+## 已落地内容
+
+1. 后端质量看板增加归因聚合。
+ - 返回字段:`attribution`
+ - 维度:
+ - `by_model`
+ - `by_prompt_template`
+ - `by_knowledge_base`
+ - `by_label_code`
+
+2. 每个归因项包含:
+ - `total_runs`:该来源涉及的总调用数。
+ - `affected_runs`:其中带 warning/error 质量标签的调用数。
+ - `warning_labels` / `error_labels`
+ - `pending_labels`
+ - `fallback_runs`
+ - `average_quality_score`
+ - `top_label_codes`
+ - `root_cause_hint`
+
+3. 归因提示。
+ - 模型维度:提示检查 Provider 配置、模型延迟、fallback。
+ - Prompt 维度:提示检查 token、引用约束、模板版本。
+ - 知识库维度:提示检查文档覆盖、索引质量、来源定位。
+ - 标签维度:针对 `source_limited`、`fallback_used`、`token_high`、`latency_high` 等给出处理方向。
+
+4. 契约和 SDK 同步。
+ - OpenAPI 新增 `AiQualityAttribution` 和 `AiQualityAttributionItem`。
+ - Python SDK 新增对应模型并导出。
+ - `get_quality_dashboard()` 可直接解析归因结果。
+
+5. 前端看板增强。
+ - `/admin/ai-quality-dashboard` 新增四个归因区块:
+ - 模型归因
+ - Prompt 归因
+ - 知识库归因
+ - 标签归因
+ - 每个区块展示 Top 来源、异常占比、待处理数、常见标签和建议。
+
+## 验收重点
+
+- 访问 `/admin/ai-quality-dashboard` 能看到四组归因区块。
+- 当存在 warning/error 标签时,归因区块展示 affected runs、pending labels 和 top label codes。
+- 后端 `GET /api/v1/audit/quality-dashboard` 返回 `attribution`。
+- SDK `get_quality_dashboard()` 能解析 `dashboard.attribution.by_model` 和 `dashboard.attribution.by_label_code`。
+
+## 下一步建议
+
+进入 AI 质量改进动作闭环:
+
+- 给每个归因项增加“创建改进任务”入口。
+- 将问题归因转成待办,例如“补知识库”“调整 Prompt”“切换模型”“修复 Provider 配置”。
+- 在看板中追踪改进任务完成后,对应异常率是否下降。
diff --git a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml
index 9636edb6..4048107c 100644
--- a/yuqei-ai-platform/packages/ai-openapi/openapi.yaml
+++ b/yuqei-ai-platform/packages/ai-openapi/openapi.yaml
@@ -1401,6 +1401,57 @@ components:
properties:
days:
type: integer
+ AiQualityAttributionItem:
+ type: object
+ properties:
+ dimension:
+ type: string
+ enum: [model, prompt_template, knowledge_base, label_code]
+ key:
+ type: string
+ label:
+ type: string
+ total_runs:
+ type: integer
+ affected_runs:
+ type: integer
+ warning_labels:
+ type: integer
+ error_labels:
+ type: integer
+ pending_labels:
+ type: integer
+ fallback_runs:
+ type: integer
+ average_quality_score:
+ type: number
+ format: float
+ top_label_codes:
+ type: array
+ items:
+ type: string
+ root_cause_hint:
+ type: string
+ nullable: true
+ AiQualityAttribution:
+ type: object
+ properties:
+ by_model:
+ type: array
+ items:
+ $ref: "#/components/schemas/AiQualityAttributionItem"
+ by_prompt_template:
+ type: array
+ items:
+ $ref: "#/components/schemas/AiQualityAttributionItem"
+ by_knowledge_base:
+ type: array
+ items:
+ $ref: "#/components/schemas/AiQualityAttributionItem"
+ by_label_code:
+ type: array
+ items:
+ $ref: "#/components/schemas/AiQualityAttributionItem"
AiQualityDashboard:
type: object
properties:
@@ -1414,6 +1465,8 @@ components:
type: array
items:
$ref: "#/components/schemas/AiQualityPendingLabel"
+ attribution:
+ $ref: "#/components/schemas/AiQualityAttribution"
AiRun:
type: object
required:
diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py
index e1c3d3ae..7e29532d 100644
--- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py
+++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/repository.py
@@ -285,6 +285,28 @@ class AiQualityPendingLabel(BaseModel):
quality_score: int = 0
+class AiQualityAttributionItem(BaseModel):
+ dimension: str
+ key: str
+ label: str
+ total_runs: int = 0
+ affected_runs: int = 0
+ warning_labels: int = 0
+ error_labels: int = 0
+ pending_labels: int = 0
+ fallback_runs: int = 0
+ average_quality_score: float = 0.0
+ top_label_codes: list[str] = Field(default_factory=list)
+ root_cause_hint: str | None = None
+
+
+class AiQualityAttribution(BaseModel):
+ by_model: list[AiQualityAttributionItem] = Field(default_factory=list)
+ by_prompt_template: list[AiQualityAttributionItem] = Field(default_factory=list)
+ by_knowledge_base: list[AiQualityAttributionItem] = Field(default_factory=list)
+ by_label_code: list[AiQualityAttributionItem] = Field(default_factory=list)
+
+
class AiQualityDashboardSummary(BaseModel):
days: int
total_runs: int = 0
@@ -306,6 +328,7 @@ class AiQualityDashboard(BaseModel):
summary: AiQualityDashboardSummary
daily_metrics: list[AiQualityDailyMetric] = Field(default_factory=list)
pending_labels: list[AiQualityPendingLabel] = Field(default_factory=list)
+ attribution: AiQualityAttribution = Field(default_factory=AiQualityAttribution)
class AiPlatformRepository(Protocol):
@@ -1508,6 +1531,7 @@ def _build_quality_dashboard(
for metric in daily.values():
_finalize_quality_rates(metric)
_finalize_quality_rates(summary)
+ attribution = _build_quality_attribution(runs)
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)
@@ -1516,6 +1540,7 @@ def _build_quality_dashboard(
summary=summary,
daily_metrics=ordered_daily,
pending_labels=pending_labels,
+ attribution=attribution,
)
@@ -1555,6 +1580,219 @@ def _finalize_quality_rates(metric: AiQualityDailyMetric | AiQualityDashboardSum
metric.latency_issue_rate = round(metric.latency_issue_runs / metric.total_runs, 4)
+def _build_quality_attribution(runs: list[AiRunAudit]) -> AiQualityAttribution:
+ by_model: dict[str, dict[str, object]] = {}
+ by_prompt_template: dict[str, dict[str, object]] = {}
+ by_knowledge_base: dict[str, dict[str, object]] = {}
+ by_label_code: dict[str, dict[str, object]] = {}
+
+ for run in runs:
+ issue_labels = [label for label in run.quality_labels if label.severity in {"warning", "error"}]
+ model_key = f"{run.provider or 'unknown'} / {run.model or 'unknown'}"
+ _touch_attribution_group(by_model, "model", model_key, model_key, run, issue_labels)
+
+ prompt_key = run.prompt_template_id or "unknown"
+ _touch_attribution_group(
+ by_prompt_template,
+ "prompt_template",
+ prompt_key,
+ prompt_key if prompt_key != "unknown" else "未记录 Prompt 模板",
+ run,
+ issue_labels,
+ )
+
+ knowledge_base_ids = sorted(
+ {
+ source.knowledge_base_id
+ for source in run.retrieved_sources
+ if source.knowledge_base_id
+ }
+ )
+ if not knowledge_base_ids and any(label.code in {"source_insufficient", "source_limited"} for label in issue_labels):
+ knowledge_base_ids = ["no_knowledge_source"]
+ for knowledge_base_id in knowledge_base_ids:
+ _touch_attribution_group(
+ by_knowledge_base,
+ "knowledge_base",
+ knowledge_base_id,
+ "未命中知识来源" if knowledge_base_id == "no_knowledge_source" else knowledge_base_id,
+ run,
+ issue_labels,
+ )
+
+ for label in issue_labels:
+ _touch_label_attribution_group(by_label_code, run, label)
+
+ return AiQualityAttribution(
+ by_model=_finalize_attribution_groups(by_model),
+ by_prompt_template=_finalize_attribution_groups(by_prompt_template),
+ by_knowledge_base=_finalize_attribution_groups(by_knowledge_base),
+ by_label_code=_finalize_attribution_groups(by_label_code),
+ )
+
+
+def _touch_attribution_group(
+ groups: dict[str, dict[str, object]],
+ dimension: str,
+ key: str,
+ label: str,
+ run: AiRunAudit,
+ issue_labels: list[AiQualityLabel],
+) -> None:
+ group = groups.setdefault(key, _new_attribution_group(dimension, key, label))
+ _record_attribution_run(group, run, issue_labels)
+
+
+def _touch_label_attribution_group(
+ groups: dict[str, dict[str, object]],
+ run: AiRunAudit,
+ label: AiQualityLabel,
+) -> None:
+ group = groups.setdefault(
+ label.code,
+ _new_attribution_group("label_code", label.code, _label_title(label)),
+ )
+ _record_attribution_run(group, run, [label])
+
+
+def _new_attribution_group(dimension: str, key: str, label: str) -> dict[str, object]:
+ return {
+ "dimension": dimension,
+ "key": key,
+ "label": label,
+ "run_ids": set(),
+ "affected_run_ids": set(),
+ "fallback_run_ids": set(),
+ "warning_labels": 0,
+ "error_labels": 0,
+ "pending_labels": 0,
+ "score_sum": 0,
+ "score_count": 0,
+ "label_counts": {},
+ }
+
+
+def _record_attribution_run(
+ group: dict[str, object],
+ run: AiRunAudit,
+ issue_labels: list[AiQualityLabel],
+) -> None:
+ run_ids = group["run_ids"]
+ affected_run_ids = group["affected_run_ids"]
+ fallback_run_ids = group["fallback_run_ids"]
+ label_counts = group["label_counts"]
+ assert isinstance(run_ids, set)
+ assert isinstance(affected_run_ids, set)
+ assert isinstance(fallback_run_ids, set)
+ assert isinstance(label_counts, dict)
+
+ if run.id not in run_ids:
+ run_ids.add(run.id)
+ group["score_sum"] = int(group["score_sum"]) + run.quality_score
+ group["score_count"] = int(group["score_count"]) + 1
+ if issue_labels:
+ affected_run_ids.add(run.id)
+ if run.status == "fallback" or any(label.code == "fallback_used" for label in run.quality_labels):
+ fallback_run_ids.add(run.id)
+
+ for label in issue_labels:
+ if label.severity == "error":
+ group["error_labels"] = int(group["error_labels"]) + 1
+ elif label.severity == "warning":
+ group["warning_labels"] = int(group["warning_labels"]) + 1
+ if label.review_status == "pending":
+ group["pending_labels"] = int(group["pending_labels"]) + 1
+ label_counts[label.code] = int(label_counts.get(label.code, 0)) + 1
+
+
+def _finalize_attribution_groups(groups: dict[str, dict[str, object]]) -> list[AiQualityAttributionItem]:
+ items: list[AiQualityAttributionItem] = []
+ for group in groups.values():
+ run_ids = group["run_ids"]
+ affected_run_ids = group["affected_run_ids"]
+ fallback_run_ids = group["fallback_run_ids"]
+ label_counts = group["label_counts"]
+ assert isinstance(run_ids, set)
+ assert isinstance(affected_run_ids, set)
+ assert isinstance(fallback_run_ids, set)
+ assert isinstance(label_counts, dict)
+ score_count = int(group["score_count"])
+ top_label_codes = [
+ str(code)
+ for code, _count in sorted(
+ label_counts.items(),
+ key=lambda item: (-int(item[1]), str(item[0])),
+ )[:3]
+ ]
+ item = AiQualityAttributionItem(
+ dimension=str(group["dimension"]),
+ key=str(group["key"]),
+ label=str(group["label"]),
+ total_runs=len(run_ids),
+ affected_runs=len(affected_run_ids),
+ warning_labels=int(group["warning_labels"]),
+ error_labels=int(group["error_labels"]),
+ pending_labels=int(group["pending_labels"]),
+ fallback_runs=len(fallback_run_ids),
+ average_quality_score=round(int(group["score_sum"]) / score_count, 2) if score_count else 0.0,
+ top_label_codes=top_label_codes,
+ root_cause_hint=_root_cause_hint(str(group["dimension"]), str(group["key"]), top_label_codes),
+ )
+ if item.affected_runs > 0 or item.fallback_runs > 0 or item.pending_labels > 0:
+ items.append(item)
+ return sorted(
+ items,
+ key=lambda item: (
+ item.pending_labels,
+ item.error_labels,
+ item.warning_labels,
+ item.affected_runs,
+ item.fallback_runs,
+ -item.average_quality_score,
+ ),
+ reverse=True,
+ )[:5]
+
+
+def _label_title(label: AiQualityLabel) -> str:
+ return label.title or label.code
+
+
+def _root_cause_hint(dimension: str, key: str, top_label_codes: list[str]) -> str | None:
+ top_codes = set(top_label_codes)
+ if dimension == "model":
+ if "fallback_used" in top_codes or "provider_failed" in top_codes:
+ return "模型或 Provider 配置异常集中,优先检查 API Key、模型可用性和超时。"
+ if "latency_high" in top_codes or "latency_critical" in top_codes:
+ return "该模型耗时异常集中,优先检查模型延迟、网络和 max_tokens。"
+ return "该模型相关质量异常较集中,优先对比其他模型的回答质量和成本。"
+ if dimension == "prompt_template":
+ if "token_high" in top_codes:
+ return "Prompt token 偏高,优先压缩模板、减少上下文或拆分任务。"
+ if "missing_citation" in top_codes:
+ return "Prompt 对依据引用约束不足,优先加强引用格式和来源要求。"
+ return "Prompt 模板相关异常集中,优先检查提示词结构和版本发布记录。"
+ if dimension == "knowledge_base":
+ if key == "no_knowledge_source":
+ return "检索未命中知识来源,优先补充知识库、检查切分和索引状态。"
+ return "该知识库相关异常集中,优先检查文档覆盖、索引质量和来源定位。"
+ if dimension == "label_code":
+ hints = {
+ "source_insufficient": "知识检索没有来源,优先检查知识库索引、检索词和过滤条件。",
+ "source_limited": "来源数量偏少,优先补充文档或放宽检索召回策略。",
+ "fallback_used": "模型调用走回退,优先检查模型服务配置和稳定性。",
+ "provider_failed": "Provider 返回失败,优先检查 API Key、base_url、模型名和额度。",
+ "token_high": "Token 用量偏高,优先压缩 Prompt 和上下文。",
+ "token_missing": "Provider 未返回 token,用量和费用统计可能不完整。",
+ "latency_high": "耗时偏高,优先检查模型延迟、网络和上下文长度。",
+ "latency_critical": "耗时严重异常,优先设置超时、降级或异步处理。",
+ "missing_citation": "回答缺少依据,优先加强 RAG 检索和引用约束。",
+ "run_failed": "调用失败,优先检查错误日志和 provider 状态。",
+ }
+ return hints.get(key)
+ return None
+
+
def _update_quality_label_list(
labels: list[AiQualityLabel],
label_code: str,
diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py
index dcd15981..8d2ef20f 100644
--- a/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py
+++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_platform_data.py
@@ -183,6 +183,10 @@ def test_quality_dashboard_summarizes_daily_metrics_and_pending_labels() -> None
assert len(dashboard["daily_metrics"]) == 7
assert dashboard["daily_metrics"][-1]["total_runs"] == 1
assert dashboard["pending_labels"]
+ label_attribution = dashboard["attribution"]["by_label_code"]
+ assert {item["key"] for item in label_attribution} >= {"fallback_used", "source_limited", "token_missing"}
+ assert dashboard["attribution"]["by_model"][0]["affected_runs"] == 1
+ assert dashboard["attribution"]["by_knowledge_base"]
first_pending = dashboard["pending_labels"][0]
update_response = client.patch(
diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py b/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py
index 11d4f1dc..ba0e47cb 100644
--- a/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py
+++ b/yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py
@@ -1,5 +1,7 @@
from yuqei_sdk.ai_platform import (
AiPlatformClient,
+ AiQualityAttribution,
+ AiQualityAttributionItem,
AiQualityDashboard,
AiQualityDashboardSummary,
AiQualityDailyMetric,
@@ -32,6 +34,8 @@ from yuqei_sdk.context import RequestContext
__all__ = [
"AiPlatformClient",
+ "AiQualityAttribution",
+ "AiQualityAttributionItem",
"AiQualityDashboard",
"AiQualityDashboardSummary",
"AiQualityDailyMetric",
diff --git a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py
index d19f2b43..187f409d 100644
--- a/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py
+++ b/yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py
@@ -299,10 +299,33 @@ class AiQualityPendingLabel(BaseModel):
quality_score: int = 0
+class AiQualityAttributionItem(BaseModel):
+ dimension: str
+ key: str
+ label: str
+ total_runs: int = 0
+ affected_runs: int = 0
+ warning_labels: int = 0
+ error_labels: int = 0
+ pending_labels: int = 0
+ fallback_runs: int = 0
+ average_quality_score: float = 0.0
+ top_label_codes: list[str] = Field(default_factory=list)
+ root_cause_hint: str | None = None
+
+
+class AiQualityAttribution(BaseModel):
+ by_model: list[AiQualityAttributionItem] = Field(default_factory=list)
+ by_prompt_template: list[AiQualityAttributionItem] = Field(default_factory=list)
+ by_knowledge_base: list[AiQualityAttributionItem] = Field(default_factory=list)
+ by_label_code: list[AiQualityAttributionItem] = Field(default_factory=list)
+
+
class AiQualityDashboard(BaseModel):
summary: AiQualityDashboardSummary
daily_metrics: list[AiQualityDailyMetric] = Field(default_factory=list)
pending_labels: list[AiQualityPendingLabel] = Field(default_factory=list)
+ attribution: AiQualityAttribution = Field(default_factory=AiQualityAttribution)
class AiPlatformClient:
diff --git a/yuqei-ai-sdk-python/tests/test_ai_platform.py b/yuqei-ai-sdk-python/tests/test_ai_platform.py
index 7cd5a99d..5d61b8da 100644
--- a/yuqei-ai-sdk-python/tests/test_ai_platform.py
+++ b/yuqei-ai-sdk-python/tests/test_ai_platform.py
@@ -493,6 +493,42 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
"quality_score": 100,
}
],
+ "attribution": {
+ "by_model": [
+ {
+ "dimension": "model",
+ "key": "deepseek / deepseek-chat",
+ "label": "deepseek / deepseek-chat",
+ "total_runs": 1,
+ "affected_runs": 1,
+ "warning_labels": 1,
+ "error_labels": 0,
+ "pending_labels": 1,
+ "fallback_runs": 0,
+ "average_quality_score": 90.0,
+ "top_label_codes": ["source_limited"],
+ "root_cause_hint": "该模型相关质量异常较集中,优先对比其他模型的回答质量和成本。",
+ }
+ ],
+ "by_prompt_template": [],
+ "by_knowledge_base": [],
+ "by_label_code": [
+ {
+ "dimension": "label_code",
+ "key": "source_limited",
+ "label": "Source limited",
+ "total_runs": 1,
+ "affected_runs": 1,
+ "warning_labels": 1,
+ "error_labels": 0,
+ "pending_labels": 1,
+ "fallback_runs": 0,
+ "average_quality_score": 90.0,
+ "top_label_codes": ["source_limited"],
+ "root_cause_hint": "来源数量偏少,优先补充文档或放宽检索召回策略。",
+ }
+ ],
+ },
},
)
if request.url.path == "/api/v1/audit/ai-runs/run-1/quality-labels/citation_present":
@@ -628,6 +664,9 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
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"
+ assert dashboard.attribution.by_model[0].key == "deepseek / deepseek-chat"
+ assert dashboard.attribution.by_label_code[0].top_label_codes == ["source_limited"]
+ assert dashboard.attribution.by_label_code[0].root_cause_hint is not None
updated_audit = client.update_ai_run_quality_label(
"run-1",
"citation_present",
diff --git a/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-quality-dashboard/page.tsx b/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-quality-dashboard/page.tsx
index ac6dd064..f6c476eb 100644
--- a/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-quality-dashboard/page.tsx
+++ b/yuqei-contract-platform/apps/contract-web/src/app/admin/ai-quality-dashboard/page.tsx
@@ -37,10 +37,33 @@ type QualityPendingLabel = {
quality_score: number;
};
+type QualityAttributionItem = {
+ dimension: string;
+ key: string;
+ label: string;
+ total_runs: number;
+ affected_runs: number;
+ warning_labels: number;
+ error_labels: number;
+ pending_labels: number;
+ fallback_runs: number;
+ average_quality_score: number;
+ top_label_codes: string[];
+ root_cause_hint?: string | null;
+};
+
+type QualityAttribution = {
+ by_model: QualityAttributionItem[];
+ by_prompt_template: QualityAttributionItem[];
+ by_knowledge_base: QualityAttributionItem[];
+ by_label_code: QualityAttributionItem[];
+};
+
type QualityDashboard = {
summary: QualityDailyMetric & { days: number };
daily_metrics: QualityDailyMetric[];
pending_labels: QualityPendingLabel[];
+ attribution?: QualityAttribution;
};
type LoadState =
@@ -128,6 +151,13 @@ export default function AiQualityDashboardPage() {