Commit 5cb07f5a authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): 数据明细「!」一键问 LLM · 违规原因 + 规则 vs 代码一致性审查 + 多 tab 并行

动机
用户在结果表看到红 ! 标记的不合规行时,只能从 tooltip 看违反哪条规则,
但不知道为什么错、这条规则的代码是否真的实现了规则描述。需要点 !
直接问 LLM,让模型同时审计数据违规原因和 规则 vs 代码 一致性。
同一字段常违反多条规则(如「11 位数字」+「号段分配」),需要开多个 tab 并行诊断。

改动
backend
- core/ai_explain.py (新): explain_issue() 拼 prompt 调 LLM,解析 JSON,
  失败时附带上次原文 + 修复提示自动重试,最多 3 次;JSON 解析失败不抛异常,
  降级返回原文 + consistency='unknown'
- routers/ai.py: 新增 POST /api/ai/explain_issue 端点
  (ExplainIssueRequest/ExplainIssueResponse Pydantic)
- routers/queries.py: _evaluate_row() 的 issues 字典追加 rule_id
- core/session_manager.py: CompiledFieldRules.rules 快照元组 6 元→7 元
  (追加 rule_id),让 _evaluate_row 能拿到规则 id

frontend
- components/ExplainIssueDialog.vue (新): Teleport + 自定义 .modal 模式
  · ctx-strip 顶部展示字段名 + 原始值(所有 tab 共享)
  · 多 tab 行:每条违规规则一个 tab,打开即 Promise.allSettled 并行调 LLM
  · 每个 tab 独立 loading/result/error 状态(互不干扰)
  · tab 标题旁小圆点:加载中蓝色脉冲 / 失败红 ⚠ /
    完成按 consistency 染色(绿✓ / 红! / 黄? / 灰·)
  · 单 issue 时隐藏 tab 行
  · 「重新分析」只重跑当前 active tab
- components/ResultTable.vue: .err-flag 加 cursor:pointer + @click.stop,
  emitExplainIssue() 把该字段所有命中的 issue 都 enrich 后塞进 issues[]
  一起 emit(之前只取第一条,第二条违规原因被忽略)
- views/DataQualityView.vue: 监听 explain-issue → 打开 ExplainIssueDialog
- api/ai.js: explainIssue(payload) 导出

LLM Prompt 关键设计
- 系统提示强调「先想清楚再用严格 JSON 输出」
- 用户提示拆任务 1(违规原因)+ 任务 2(一致性审查),
  加 ASCII 双引号避坑(历史踩坑:value 里用裸 " 会让 json.loads 中断)
- 一致性三档:match / code_bug / rule_ambiguous

关键决策
- Promise.allSettled 而非 Promise.all:单条 LLM 失败不应拖垮其他 tab
- LLMUnavailable 不重试(无 key / 网络挂 / 限流):立即降级返回
- 重试时附上次原文:让 LLM 看到自己刚才错在哪,比纯说「修一下 JSON」有效
- 「重新分析当前规则」而非「全部重试」:定位精准,省 token

E2E 验证
- regex 类型:consistency='code_bug'(LLM 正确识别硬性限制年份的 bug)
- number 类型:consistency='rule_ambiguous'(LLM 指出「0-150 是否合理」需业务再斟酌)
- npx vite build → ✓ built in 10.92s(1638 modules transformed,无报错)

注
- work-logs/2026-08-24.md 已记录三轮迭代详情 + 踩坑
- 另有 4 个 web3/tests/*.png modified 未提交(与本功能无关)
parent f3a3ab93
This diff is collapsed.
......@@ -43,11 +43,13 @@ class CompiledFieldRules:
字段大小写已归一为小写(与列名对齐)。
"""
field_key: str
# 每条规则:(rule_type, regex_or_None, code_or_None, desc, skip_null, name)
# 每条规则:(rule_type, regex_or_None, code_or_None, desc, skip_null, name, rule_id)
# 2026-08-21:追加 skip_null(值为空时是否跳过)
# 2026-08-24:追加 name(规则名称)→ 给 _evaluate_row 写到 issues 里,
# 前端「数据明细」按字段+规则名展示「违反哪条规则」
rules: list[tuple[str, Optional[str], Optional[str], str, bool, str]] = field(default_factory=list)
# 2026-08-24:再追加 rule_id(Rule ORM 主键)→ 前端「点 ! 问 LLM」时用来
# 在 col.ruleList[] 里精确定位原规则(含 code/regex/rule_type)
rules: list[tuple[str, Optional[str], Optional[str], str, bool, str, int]] = field(default_factory=list)
@dataclass
......
"""AI 相关 API(web3)
端点:
POST /api/ai/rule body: {desc, rule_type} → {ok, code, note}
POST /api/rule/test body: {rule_type, code, value} → {ok, pass_, error}
POST /api/ai/rule body: {desc, rule_type} → {ok, code, note}
POST /api/rule/test body: {rule_type, code, value} → {ok, pass_, error}
POST /api/ai/explain_issue body: {field_key, field_comment, value,
rule_id, rule_name, rule_desc,
rule_type, code, regex} → {ok, violation_reason,
consistency,
consistency_detail,
raw_text, note}
兼容老端点(不破坏 2026-08-20 接入的 AI 正则流程):
POST /api/ai/regex body: {desc} → {ok, regex, note} (rule_type 默认为 regex)
......@@ -15,11 +21,13 @@
from __future__ import annotations
import time
from typing import Any, Optional
from fastapi import APIRouter
from pydantic import BaseModel, ConfigDict, Field
from web3.backend.core.ai_rule import gen_rule, test_rule
from web3.backend.core.ai_explain import explain_issue as _explain_issue
from web3.backend.core.ai_regex import test_regex as _legacy_test_regex
from web3.backend.models.rule import DEFAULT_RULE_TYPE, RULE_TYPES
from web3.backend._logging import get_logger
......@@ -140,4 +148,78 @@ class TestRegexResponse(BaseModel):
@router.post("/regex/test", response_model=TestRegexResponse, summary="[兼容] 用 Python re 校验 value")
async def ai_test_regex(req: TestRegexRequest):
"""老端点,转发到 _legacy_test_regex(保持 capture groups 输出)。"""
return TestRegexResponse(**_legacy_test_regex(req.regex, req.value))
\ No newline at end of file
return TestRegexResponse(**_legacy_test_regex(req.regex, req.value))
# ── /ai/explain_issue ─────────────────────────────────────
class ExplainIssueRequest(BaseModel):
"""数据明细「!」号一键问 LLM —— 解释违规原因 + 审查规则 vs 代码一致性"""
field_key: str = Field("", description="数据库列名(小写归一后)")
field_comment: str = Field("", description="数据库列注释(中文标签)")
value: Any = Field(None, description="原始数据值(str / int / float / None)")
rule_id: int = Field(0, description="规则 ORM id(仅用于日志/审计,前端用 rule_id+rule_list 定位原规则)")
rule_name: str = Field("", description="规则名称(前端展示用)")
rule_desc: str = Field("", description="规则自然语言描述(给 LLM 看)")
rule_type: str = Field(DEFAULT_RULE_TYPE, description=f"规则类型:{ ' / '.join(RULE_TYPES) }")
code: str = Field("", description="Python 函数源码(number/date/string 类型用)")
regex: Optional[str] = Field(None, description="正则字符串(regex 类型用)")
class ExplainIssueResponse(BaseModel):
ok: bool
# 自然语言违规原因(LLM 解释数据为什么不合规)
violation_reason: str = ""
# 规则与代码一致性结论:
# match 代码正确实现规则
# code_bug 规则描述没问题,但代码实现有 bug
# rule_ambiguous 代码没明显 bug,但规则描述有歧义
# unknown LLM 未明确给出 / JSON 解析失败
consistency: str = "unknown"
consistency_detail: str = ""
# LLM 原文(前端调试 / 解析失败时回显用)
raw_text: str = ""
# 提示 / 失败原因(前端 banner 用)
note: str = ""
# 2026-08-24:实际调 LLM 几次(1~3;前端可展示「第 N 次重试成功」之类的提示)
attempts: int = 0
@router.post(
"/ai/explain_issue",
response_model=ExplainIssueResponse,
summary="数据明细「!」一键问 LLM:解释违规原因 + 审查规则 vs 代码一致性",
)
async def ai_explain_issue(req: ExplainIssueRequest):
"""点结果表里的红 `!` 时调用。
把字段元信息 + 原始数据值 + 规则(name/desc/code/regex)一次性塞给 LLM,
让它做两件事:
1. 用自然语言解释「这条数据为什么被判为不合规」
2. 审查「规则描述」与「规则代码」是否一致 —— 规则没问题但代码有 bug 的情况
也属于合法输出(前端用户能据此判断到底改规则还是改代码)
"""
logger.info("─" * 60)
logger.info(
f"POST /api/ai/explain_issue field={req.field_key!r} "
f"rule_type={req.rule_type!r} rule_id={req.rule_id} rule_name={req.rule_name!r}"
)
started = time.monotonic()
result = _explain_issue(
field_key=req.field_key,
field_comment=req.field_comment,
value=req.value,
rule_type=req.rule_type,
rule_name=req.rule_name,
rule_desc=req.rule_desc,
code=req.code,
regex=req.regex,
)
elapsed_ms = (time.monotonic() - started) * 1000
logger.info(
f"{'✅' if result.get('ok') else '❌'} "
f"explain_issue 完成(耗时 {elapsed_ms:.0f}ms)"
f"consistency={result.get('consistency')!r} note={result.get('note')!r}"
)
logger.info("─" * 60)
return ExplainIssueResponse(**result)
\ No newline at end of file
......@@ -167,22 +167,23 @@ def _load_task_compiled(db: Session, task_id: int):
# 2026-08-24:snapshot 再追加 name(规则名称)→ _evaluate_row 写到 issues → 数据明细展示
compiled_fields: list[CompiledFieldRules] = []
for f, rules in fields_with_rules:
snapshots: list[tuple[str, Optional[str], Optional[str], str, bool, str]] = []
snapshots: list[tuple[str, Optional[str], Optional[str], str, bool, str, int]] = []
for r in rules:
rt = str(r.rule_type or "regex")
desc = str(r.desc or "")
skip_null = bool(r.skip_null)
name = str(r.name or "")
rule_id = int(r.id or 0)
if rt == "regex":
regex_src = str(r.regex or "").strip() or r"^.+$"
try:
compiled_pat = re.compile(regex_src)
snapshots.append((rt, compiled_pat.pattern, None, desc, skip_null, name))
snapshots.append((rt, compiled_pat.pattern, None, desc, skip_null, name, rule_id))
except re.error as e:
logger.warning(f"[queries] 规则 id={r.id} regex 编译失败:{e}")
snapshots.append((rt, None, None, desc, skip_null, name)) # None 标记"坏规则"
snapshots.append((rt, None, None, desc, skip_null, name, rule_id)) # None 标记"坏规则"
else:
snapshots.append((rt, None, str(r.code or ""), desc, skip_null, name))
snapshots.append((rt, None, str(r.code or ""), desc, skip_null, name, rule_id))
compiled_fields.append(CompiledFieldRules(field_key=f.field_key.lower(), rules=snapshots))
# SELECT 列名:按 ord 升序的去重小写列表
......@@ -235,7 +236,7 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
# 2026-08-21:字段级「跳过空值」开关(per-field,非 per-rule)
# —— 检测引擎目前是 per-rule;如果未来要 per-field,把 skip_null 上提到 Field 模型
# 现在是 per-rule:跑每条规则时单独判断
for rule_type, compiled_regex, code, desc, skip_null, name in cf.rules:
for rule_type, compiled_regex, code, desc, skip_null, name, rule_id in cf.rules:
passed: Optional[bool] = None
fail_desc: Optional[str] = None
if rule_type == "regex":
......@@ -261,7 +262,15 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
passed = False
fail_desc = f"规则执行失败:{e}"
if passed is False:
issues.append({"field": cf.field_key, "name": name, "desc": fail_desc})
# 2026-08-24:issues 追加 rule_id,方便前端「点 ! 问 LLM」时定位到
# 原始规则(含 code/regex)—— 字段+name+desc 三个字段都可能撞车,
# 用 rule ORM 主键定位最稳
issues.append({
"field": cf.field_key,
"name": name,
"desc": fail_desc,
"rule_id": rule_id,
})
if cf.field_key not in error_cells:
error_cells.append(cf.field_key)
return error_cells, issues
......
......@@ -11,6 +11,8 @@
* - date: code = 同 number
* - string: code = 同 number(字符串校验:长度/前缀/中文/包含等)
*
* 2026-08-24:新增 explainIssue —— 数据明细「!」一键问 LLM
*
* 老端点 genRegex / testRegex 保留为薄壳(转发到新接口),兼容 2026-08-20 的 AI 正则流程。
*/
......@@ -45,6 +47,32 @@ export function testRule(ruleType, code, value, { skipNull = false } = {}) {
return http.post('/rule/test', { rule_type: ruleType, code, value, skip_null: skipNull })
}
/**
* 2026-08-24:点结果表红「!」问 LLM —— 解释违规原因 + 审查规则 vs 代码一致性
*
* @param {object} payload
* @param {string} payload.field_key 数据库列名(小写)
* @param {string} [payload.field_comment] 数据库列注释(中文标签)
* @param {*} payload.value 原始数据值(str / int / float / null)
* @param {number} [payload.rule_id] 规则 ORM id(仅用于日志审计)
* @param {string} payload.rule_name 规则名称
* @param {string} payload.rule_desc 规则自然语言描述
* @param {string} payload.rule_type 'regex' | 'number' | 'date' | 'string'
* @param {string} [payload.code] Python 函数源码(非 regex 用)
* @param {string} [payload.regex] 正则字符串(regex 用)
* @returns {Promise<{
* ok: boolean,
* violation_reason: string,
* consistency: 'match' | 'code_bug' | 'rule_ambiguous' | 'unknown',
* consistency_detail: string,
* raw_text: string,
* note: string,
* }>}
*/
export function explainIssue(payload) {
return http.post('/ai/explain_issue', payload)
}
/**
* [兼容] AI 生成正则 —— 转发到 genRule('regex')
*/
......
This diff is collapsed.
......@@ -86,6 +86,8 @@
</template>
<!-- 错误单元格:红底 + tooltip + ! 标记 -->
<!-- 2026-08-24:「!」改成可点击 → 触发 explain-issue 事件,
父组件打开 ExplainIssueDialog 调 LLM 解释违规原因 + 审查规则 vs 代码 -->
<template v-else-if="isErrorCell(row, col.key)">
<el-tooltip
:content="errTip(row, col.key)"
......@@ -96,7 +98,13 @@
<span :class="{ 'null-val': isNullLike(row[col.key]) }">
{{ formatCell(row[col.key]) }}
</span>
<span class="err-flag">!</span>
<span
v-if="hasExplainableIssues(row, col.key)"
class="err-flag err-flag-clickable"
title="点我问 AI:这条数据为什么不合规?"
@click.stop="emitExplainIssue(row, col)"
>!</span>
<span v-else class="err-flag">!</span>
</span>
</el-tooltip>
</template>
......@@ -152,7 +160,7 @@ const props = defineProps({
// null 时回退到「按 column.showDefault 过滤」(保持向后兼容 + 老调用方不传)。
visibleFields: { type: Array, default: null },
})
const emit = defineEmits(['page-change'])
const emit = defineEmits(['page-change', 'explain-issue'])
// ── 列宽拖拽(2026-08-21,参照 web2 el-table resizable 自实现)──
// 每列的宽度按 col.key 存;拖动时实时更新;卸载时摘掉 window 监听
......@@ -265,6 +273,55 @@ function formatCell(v) {
function isErrorCell(row, key) {
return Array.isArray(row.errorCells) && row.errorCells.includes(key)
}
// 2026-08-24:点「!」问 LLM —— 从 row.issues[] 里筛出当前字段的所有违规项
// 然后从 col.ruleList[] 用 rule_id 找回原规则(含 code/regex/rule_type)
// —— 拼成完整的「{field, value, rule_name, rule_desc, rule_type, code, regex}」emit 出去
// 兜底:issue 上没有 rule_id(老数据 / 其它来源)时回退按 desc 匹配
function hasExplainableIssues(row, key) {
const issues = (row.issues || []).filter((it) => it.field === key)
if (issues.length === 0) return false
return issues.some((it) => it.rule_id || it.name || it.desc)
}
function emitExplainIssue(row, col) {
const issues = (row.issues || []).filter((it) => it.field === col.key)
if (issues.length === 0) return
// 同一字段上可能有多条规则同时失败(典型场景:「11 位数字」+「号段分配」)。
// 全部塞给弹框 → 弹框开多个 tab,每个 tab 独立调 LLM、并行发起
// issues[i] 里只有 name/desc/rule_id,要去 col.ruleList 里补 code/regex/rule_type
const ruleList = Array.isArray(col.ruleList) ? col.ruleList : []
function _enrich(issue) {
let rule = null
if (issue.rule_id) {
rule = ruleList.find((r) => r.id === issue.rule_id) || null
}
// 兜底:老数据没 rule_id 时按 (name || desc) 匹配
if (!rule) {
const key1 = issue.name || ''
const key2 = issue.desc || ''
rule = ruleList.find((r) => (r.name || '') === key1 || (r.desc || '') === key2) || null
}
return {
field: col.key,
rule_id: issue.rule_id || (rule ? rule.id : 0),
name: issue.name || (rule ? rule.name : ''),
desc: issue.desc || (rule ? rule.desc : ''),
rule_type: (rule && rule.rule_type) || 'regex',
code: (rule && rule.code) || '',
regex: (rule && rule.regex) || '',
}
}
emit('explain-issue', {
row,
col,
issues: issues.map(_enrich), // 2026-08-24:多条 issue 一起传(弹框开多 tab)
// 兼容老调用方:保留单数 issue 字段取第一条
issue: _enrich(issues[0]),
})
}
// 错误描述:把 col.key(小写)对应的所有 issue 都列出来(一个字段可配多条规则,
// 可能同时违反多条 → 之前用 .find() 只取第一条,2026-08-24 改为 .filter() + 「;」连接)
// 注:后端 issues[].field 是小写 cf.field_key(db_adapter 归一过),
......@@ -315,6 +372,15 @@ function ruleHint(col) {
gap: 4px;
cursor: help;
}
/* 2026-08-24:可点问 LLM 的「!」 —— 鼠标变小手 + hover 高亮,提示用户能点 */
.err-flag-clickable {
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.err-flag-clickable:hover {
transform: scale(1.15);
box-shadow: 0 0 0 3px rgba(245, 108, 108, 0.18);
}
/* el-pagination 居中 + 顶部间距(让分页器跟表格拉开距离) */
.result-table__pagination {
display: flex;
......
......@@ -85,6 +85,7 @@
:append-mode="true"
:row-key="rowKeyFn"
@page-change="onPageChange"
@explain-issue="onExplainIssue"
/>
</div>
......@@ -95,6 +96,12 @@
:visible-fields="visibleFields"
@save="onSaveFields"
/>
<!-- 2026-08-24:点「!」一键问 LLM —— 解释违规原因 + 审查规则 vs 代码一致性 -->
<ExplainIssueDialog
v-model:open="explainDialogOpen"
:context="explainContext"
/>
</template>
<script setup>
......@@ -107,6 +114,7 @@ import { startQuery, fetchPage, cancelQuery } from '@/api/queries'
import { exportToExcel } from '@/utils/excel'
import FieldConfigDrawer from '@/components/FieldConfigDrawer.vue'
import ResultTable from '@/components/ResultTable.vue'
import ExplainIssueDialog from '@/components/ExplainIssueDialog.vue'
const route = useRoute()
const router = useRouter()
......@@ -498,6 +506,16 @@ function onPageChange({ page, pageSize }) {
console.debug('[DataQualityView] page change', { page, pageSize })
}
// ── 2026-08-24:点「!」一键问 LLM ──
// ResultTable 把 {row, col, issue} 传过来,issue 里已拼好 rule_id/name/desc/rule_type/code/regex
// 直接塞进 explainContext → 打开 ExplainIssueDialog 自动调 LLM
const explainDialogOpen = ref(false)
const explainContext = ref(null)
function onExplainIssue(payload) {
explainContext.value = payload
explainDialogOpen.value = true
}
// ── 导出 Excel ──
// 字段范围:优先用 FieldConfigDrawer 选定的 visibleFields;
// 为空(用户没保存过 / 默认全勾)时退化为 resultFields 全量
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment