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
"""AI 解释不合规数据(web3 / 2026-08-24)
入口 `explain_issue()`:把数据值 + 字段信息 + 规则 + 规则代码一起交给 LLM,让它
用自然语言解释「这条数据为什么不合规」,并审查「规则描述与实现代码是否一致」。
设计:
- 强制 LLM 输出 JSON(用 complete_json),失败兜底走自然语言
- JSON 字段定义:
violation_reason: 自然语言违规原因
consistency: 'match' | 'code_bug' | 'rule_ambiguous'
consistency_detail: 一致性结论的补充说明
- LLM 不可用(无 Key / 网络挂)→ 返回 ok=False + note,前端降级展示
"""
from __future__ import annotations
from typing import Any, Optional
from web3.backend.core.llm import LLMUnavailable, get_llm_client
from web3.backend.models.rule import RULE_TYPES
from web3.backend._logging import get_logger
logger = get_logger("backend.ai_explain")
_SYSTEM_PROMPT = (
"你是数据质量分析助手。用户的诉求是:当一条数据被判为不合规时,希望从两个角度得到答案——"
"1) 这条数据哪里错了(必须对照规则的【自然语言描述】来回答,方便业务人员理解);"
"2) 规则的【自然语言描述】与【实现代码】是否一致——实现代码可能写错。"
"请用中文思考与回答。先把分析想清楚,再用严格的 JSON 格式输出最终结论。"
)
_USER_PROMPT_TEMPLATE = """请分析下面这条「被判为不合规」的数据。
【字段】
- 列名(数据库列):{field_key}
- 字段说明(数据库注释,可空):{field_comment}
【数据】
- 原始值(从数据库读出来的,可能是字符串 / 数字 / NULL,请按真实类型理解):{value_repr}
【规则描述】(自然语言 —— 作者写的约束条件,**业务人员用这个理解规则**)
{rule_desc}
【规则代码】(实现该规则的代码:regex 字符串 或 Python 函数 —— **可能写错**)
{code_block}
请做两件事。
═══ 任务 1:违规原因(violation_reason)═══
- **必须基于「规则描述」(自然语言)回答,不是基于代码** —— 业务人员看不懂代码,要的是用人话讲清楚
- 指出原始值中具体哪个位置 / 子串 / 数值 / 字段违反了「规则描述」里的哪一条要求
- 例:「按规则描述要求,身份证号长度应为 18 位;而这条原始值实际为 19 位(多了一个空格),因此被判为不合规」
- 如果「规则描述」本身写得比较宽松但代码判得很严,那是代码的问题(记到任务 2),这里仍按描述如实回答
- 如果原始值是 NULL / 空,先说明「该值为空」再继续
═══ 任务 2:描述 vs 代码一致性(consistency)═══
- "match":代码严格实现了「规则描述」的全部约束 → 数据违规纯粹是数据本身的问题
- "code_bug":「规则描述」清楚明确,但代码有 bug(漏条件 / 判断反了 / 范围不对 /
边界写错 / 该用 AND 写成 OR 等)→ 在 consistency_detail 里**明确指出代码哪一行 /
哪一段不符合规则描述**,并给出修复建议(最好附修改后的代码片段)
- "rule_ambiguous":代码本身没明显 bug,但「规则描述」有歧义 / 缺失 / 前后矛盾 /
用语模糊 → 在 consistency_detail 里指出歧义点(用户未必能自己看出来)
═══ 输出格式(严格 JSON)═══
- 严格按下面的 JSON 结构输出,**不要** Markdown 代码块(不要 ```json 包裹),**不要** 任何额外文字
- 字符串值里如果需要引号,**必须用中文全角引号「」或转义 \\",绝不能用裸的 ASCII 双引号**(这是历史上最容易踩的坑)
- consistency 只能取上面三个值之一;其他值会被前端视为「未明确」
```json
{{
"violation_reason": "用一段中文自然语言回答任务 1",
"consistency": "match" | "code_bug" | "rule_ambiguous",
"consistency_detail": "针对任务 2 的补充说明(match 时可写"无")"
}}
```"""
# 重试时附加的「修复 JSON」提示 —— 短而直接,避免 LLM 二次跑飞
_JSON_FIX_PROMPT = """你上一次的输出无法被 JSON 解析器读取。常见原因:
1. 在 violation_reason / consistency_detail 里使用了未转义的 ASCII 双引号 `"`,导致 JSON 中断
→ 改用中文全角引号「」或单引号;或用 \\\" 转义
2. 在 JSON 外面包了 Markdown ```json ... ``` 代码块
→ 直接输出 JSON,不要任何包裹
3. JSON 后面跟了额外解释文字
→ JSON 结束后立即停止,不要任何尾巴
请只输出一个干净的 JSON 对象,字段保持不变:
{{"violation_reason": "...", "consistency": "match|code_bug|rule_ambiguous", "consistency_detail": "..."}}
"""
def _format_value(value: Any) -> str:
"""把原始值格式化成给 LLM 看的字符串(带类型说明)"""
if value is None:
return "NULL(Python None)"
if isinstance(value, bool):
return f"{value}(bool)"
if isinstance(value, (int, float)):
return f"{value}({type(value).__name__})"
if isinstance(value, str):
# 长字符串截断展示,避免 prompt 爆炸
if len(value) > 200:
return f"{value[:200]}...(截断,原长 {len(value)})"
return f"{value!r}(str,长度 {len(value)})"
return repr(value)
def _format_code_block(rule_type: str, code: str, regex: Optional[str]) -> str:
"""把规则的代码块格式化成 LLM 易读的格式"""
if rule_type == "regex":
return f"```\n{regex or ''}\n```"
return f"```python\n{code or ''}\n```"
def _safe_parse_llm_json(text: str) -> Optional[dict]:
"""从 LLM 返回中抽取 {violation_reason, consistency, consistency_detail}。
兼容:
- 纯 JSON
- Markdown ```json ... ``` 包裹
- JSON 前后有零散文字(用 _safe_parse_json 思路找首 { 末 })
"""
import json
import re
if not text:
return None
s = text.strip()
# 去掉 Markdown 围栏
s = re.sub(r"^```(?:json)?\s*\n?", "", s)
s = re.sub(r"\n?```\s*$", "", s)
# 找首个 { 和末个 }
start = s.find("{")
end = s.rfind("}")
if start == -1 or end == -1 or end <= start:
return None
candidate = s[start : end + 1]
try:
obj = json.loads(candidate)
except json.JSONDecodeError:
return None
if not isinstance(obj, dict):
return None
return obj
def explain_issue(
*,
field_key: str,
field_comment: str,
value: Any,
rule_type: str,
rule_name: str,
rule_desc: str,
code: str,
regex: Optional[str],
) -> dict:
"""让 LLM 解释一条不合规数据 + 审查规则 vs 代码一致性。
返回 dict 字段:
ok: LLM 是否成功返回(False 时 note 是失败原因)
violation_reason: 自然语言违规原因(成功时填)
consistency: 'match' / 'code_bug' / 'rule_ambiguous' / 'unknown'
consistency_detail: 一致性结论的补充说明
raw_text: LLM 原始返回(前端调试用)
note: 失败原因 / 解析回退说明 / 重试说明
attempts: 实际调 LLM 几次(1~3,给前端展示)
2026-08-24 重试策略:JSON 解析失败 → 附带 _JSON_FIX_PROMPT 再调一次,
最多 3 次;超过仍失败 → 降级把原文塞 violation_reason + consistency='unknown',
不抛异常、不阻断弹框。
"""
MAX_RETRIES = 3
if rule_type not in RULE_TYPES:
return {
"ok": False, "violation_reason": "", "consistency": "unknown",
"consistency_detail": "", "raw_text": "", "note": f"不支持的规则类型:{rule_type!r}",
"attempts": 0,
}
client = get_llm_client()
if not client.available:
return {
"ok": False, "violation_reason": "", "consistency": "unknown",
"consistency_detail": "", "raw_text": "",
"note": "LLM 未配置 API Key,请联系管理员配置 llm.yaml 或环境变量",
"attempts": 0,
}
code_block = _format_code_block(rule_type, code, regex)
base_prompt = _USER_PROMPT_TEMPLATE.format(
field_key=field_key or "(未提供)",
field_comment=field_comment or "(无)",
value_repr=_format_value(value),
rule_type=rule_type,
rule_name=rule_name or "(未命名)",
rule_desc=rule_desc or "(无)",
code_block=code_block,
)
logger.info(
f"[ai_explain] 字段={field_key!r} 规则类型={rule_type!r} "
f"规则 id 上下文: rule_name={rule_name!r}"
)
last_text = ""
last_err = ""
for attempt in range(1, MAX_RETRIES + 1):
# 第一次用主 prompt;之后把上一次坏 JSON + 修复提示一起塞给 LLM
if attempt == 1:
prompt = base_prompt
else:
prompt = (
base_prompt
+ "\n\n═══ 上一次输出(无法解析,原样附上)═══\n"
+ (last_text or "<空>")
+ "\n\n"
+ _JSON_FIX_PROMPT
)
try:
text = client.complete(prompt, system=_SYSTEM_PROMPT, json_mode=True)
except LLMUnavailable as e:
# 不可用(无 key / 网络挂 / 限流)—— 不再重试,立即降级
logger.warning(f"[ai_explain] LLM 调用失败(attempt={attempt}):{e}")
return {
"ok": False, "violation_reason": "", "consistency": "unknown",
"consistency_detail": "", "raw_text": "",
"note": f"LLM 调用失败:{e}",
"attempts": attempt,
}
last_text = text or ""
obj = _safe_parse_llm_json(last_text)
if obj is not None and isinstance(obj.get("violation_reason"), str):
# 拿到结构化结果 → 直接返回
consistency_raw = str(obj.get("consistency") or "").strip()
if consistency_raw not in ("match", "code_bug", "rule_ambiguous"):
consistency_raw = "unknown"
note = "" if attempt == 1 else f"第 {attempt} 次重试成功(LLM 首次未按 JSON 输出)"
return {
"ok": True,
"violation_reason": str(obj.get("violation_reason") or "").strip(),
"consistency": consistency_raw,
"consistency_detail": str(obj.get("consistency_detail") or "").strip(),
"raw_text": last_text,
"note": note,
"attempts": attempt,
}
# JSON 解析失败:记下原因,准备下一次重试(如果还有机会)
last_err = _diagnose_json_failure(last_text)
logger.warning(
f"[ai_explain] LLM 返回无法解析为 JSON(attempt={attempt}/{MAX_RETRIES})"
f"原因={last_err} 原文前 200 字:{(last_text or '')[:200]!r}"
)
# 重试 MAX_RETRIES 次仍失败:降级把原文塞进 violation_reason,前端 note 提示
logger.warning(
f"[ai_explain] 重试 {MAX_RETRIES} 次 JSON 解析均失败,最后原因:{last_err}"
)
return {
"ok": True,
"violation_reason": last_text or "(LLM 未返回内容)",
"consistency": "unknown",
"consistency_detail": "LLM 多次返回非 JSON,已按原文展示,无法做一致性判断",
"raw_text": last_text,
"note": f"LLM 未按 JSON 输出(已重试 {MAX_RETRIES} 次),已按原文展示",
"attempts": MAX_RETRIES,
}
def _diagnose_json_failure(text: str) -> str:
"""粗略定位 JSON 解析失败的原因,给日志 / 重试 prompt 提示用。"""
if not text:
return "空响应"
s = text.strip()
if not (s.startswith("{") or s.startswith("```")):
return "首字符不是 {,可能被 Markdown 或纯文本包裹"
if "```" in s:
return "包含 Markdown 围栏 ```(可能未剥净)"
# 简单检测:value 里有没有未转义的 "(很可能是导致 json.loads 中断的根因)
# 这一步比较粗糙,只做日志用;真正严谨可以 re 查
import re
bad_quote = re.search(r':\s*"[^"]*"\s*"', s)
if bad_quote:
return "疑似 value 里有未转义的 ASCII 双引号"
return "JSON 结构不合法(具体行号见日志)"
\ No newline at end of file
...@@ -43,11 +43,13 @@ class CompiledFieldRules: ...@@ -43,11 +43,13 @@ class CompiledFieldRules:
字段大小写已归一为小写(与列名对齐)。 字段大小写已归一为小写(与列名对齐)。
""" """
field_key: str 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-21:追加 skip_null(值为空时是否跳过)
# 2026-08-24:追加 name(规则名称)→ 给 _evaluate_row 写到 issues 里, # 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 @dataclass
......
...@@ -3,6 +3,12 @@ ...@@ -3,6 +3,12 @@
端点: 端点:
POST /api/ai/rule body: {desc, rule_type} → {ok, code, note} 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/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 正则流程): 兼容老端点(不破坏 2026-08-20 接入的 AI 正则流程):
POST /api/ai/regex body: {desc} → {ok, regex, note} (rule_type 默认为 regex) POST /api/ai/regex body: {desc} → {ok, regex, note} (rule_type 默认为 regex)
...@@ -15,11 +21,13 @@ ...@@ -15,11 +21,13 @@
from __future__ import annotations from __future__ import annotations
import time import time
from typing import Any, Optional
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from web3.backend.core.ai_rule import gen_rule, test_rule 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.core.ai_regex import test_regex as _legacy_test_regex
from web3.backend.models.rule import DEFAULT_RULE_TYPE, RULE_TYPES from web3.backend.models.rule import DEFAULT_RULE_TYPE, RULE_TYPES
from web3.backend._logging import get_logger from web3.backend._logging import get_logger
...@@ -141,3 +149,77 @@ class TestRegexResponse(BaseModel): ...@@ -141,3 +149,77 @@ class TestRegexResponse(BaseModel):
async def ai_test_regex(req: TestRegexRequest): async def ai_test_regex(req: TestRegexRequest):
"""老端点,转发到 _legacy_test_regex(保持 capture groups 输出)。""" """老端点,转发到 _legacy_test_regex(保持 capture groups 输出)。"""
return TestRegexResponse(**_legacy_test_regex(req.regex, req.value)) 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): ...@@ -167,22 +167,23 @@ def _load_task_compiled(db: Session, task_id: int):
# 2026-08-24:snapshot 再追加 name(规则名称)→ _evaluate_row 写到 issues → 数据明细展示 # 2026-08-24:snapshot 再追加 name(规则名称)→ _evaluate_row 写到 issues → 数据明细展示
compiled_fields: list[CompiledFieldRules] = [] compiled_fields: list[CompiledFieldRules] = []
for f, rules in fields_with_rules: 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: for r in rules:
rt = str(r.rule_type or "regex") rt = str(r.rule_type or "regex")
desc = str(r.desc or "") desc = str(r.desc or "")
skip_null = bool(r.skip_null) skip_null = bool(r.skip_null)
name = str(r.name or "") name = str(r.name or "")
rule_id = int(r.id or 0)
if rt == "regex": if rt == "regex":
regex_src = str(r.regex or "").strip() or r"^.+$" regex_src = str(r.regex or "").strip() or r"^.+$"
try: try:
compiled_pat = re.compile(regex_src) 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: except re.error as e:
logger.warning(f"[queries] 规则 id={r.id} regex 编译失败:{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: 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)) compiled_fields.append(CompiledFieldRules(field_key=f.field_key.lower(), rules=snapshots))
# SELECT 列名:按 ord 升序的去重小写列表 # SELECT 列名:按 ord 升序的去重小写列表
...@@ -235,7 +236,7 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any] ...@@ -235,7 +236,7 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
# 2026-08-21:字段级「跳过空值」开关(per-field,非 per-rule) # 2026-08-21:字段级「跳过空值」开关(per-field,非 per-rule)
# —— 检测引擎目前是 per-rule;如果未来要 per-field,把 skip_null 上提到 Field 模型 # —— 检测引擎目前是 per-rule;如果未来要 per-field,把 skip_null 上提到 Field 模型
# 现在是 per-rule:跑每条规则时单独判断 # 现在是 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 passed: Optional[bool] = None
fail_desc: Optional[str] = None fail_desc: Optional[str] = None
if rule_type == "regex": if rule_type == "regex":
...@@ -261,7 +262,15 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any] ...@@ -261,7 +262,15 @@ def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]
passed = False passed = False
fail_desc = f"规则执行失败:{e}" fail_desc = f"规则执行失败:{e}"
if passed is False: 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: if cf.field_key not in error_cells:
error_cells.append(cf.field_key) error_cells.append(cf.field_key)
return error_cells, issues return error_cells, issues
......
...@@ -11,6 +11,8 @@ ...@@ -11,6 +11,8 @@
* - date: code = 同 number * - date: code = 同 number
* - string: code = 同 number(字符串校验:长度/前缀/中文/包含等) * - string: code = 同 number(字符串校验:长度/前缀/中文/包含等)
* *
* 2026-08-24:新增 explainIssue —— 数据明细「!」一键问 LLM
*
* 老端点 genRegex / testRegex 保留为薄壳(转发到新接口),兼容 2026-08-20 的 AI 正则流程。 * 老端点 genRegex / testRegex 保留为薄壳(转发到新接口),兼容 2026-08-20 的 AI 正则流程。
*/ */
...@@ -45,6 +47,32 @@ export function testRule(ruleType, code, value, { skipNull = false } = {}) { ...@@ -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 }) 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') * [兼容] AI 生成正则 —— 转发到 genRule('regex')
*/ */
......
<!--
ExplainIssueDialog · 数据明细「!」一键问 LLM 弹框(2026-08-24 · v2)
打开即自动调 /api/ai/explain_issue。
同一个字段可能违反多条规则(典型:「11 位数字」+「号段分配」),
此时开多个 tab,每个 tab 独立调 LLM,并行发起。
入参:
open v-model —— 父组件控制开关
context {row, col, issues[]} —— 点哪个「!」传哪个:
row[col.key] 是被违反的原始值(所有 tab 共享)
col.ruleList 字段配的全部规则(用来定位每个 issue 对应的 rule)
issues[] 该字段命中的所有违规项(带 name/desc/rule_id/code/regex/rule_type)
issues.length === 1 时不显示 tab 行
-->
<template>
<Teleport to="body">
<div class="modal-mask" :class="{ show: open }" @click="$emit('update:open', false)">
<div class="modal explain-dialog" @click.stop>
<div class="modal-header">
<span class="modal-title">
<span class="title-icon">✨</span>
AI 分析:这条数据为什么不合规?
<span v-if="tabs.length > 1" class="tab-count-badge">共 {{ tabs.length }} 条规则违规</span>
</span>
<button class="modal-close" @click="$emit('update:open', false)">×</button>
</div>
<div class="modal-body">
<!-- 顶部:字段 + 原始数据(所有 tab 共享,跨 tab 不变) -->
<div class="ctx-strip">
<span class="ctx-item">
<span class="ctx-label">字段</span>
<span class="ctx-value">{{ ctx.fieldDisplay }}</span>
</span>
<span class="ctx-item ctx-item-wide">
<span class="ctx-label">原始值</span>
<span class="ctx-value ctx-value-mono">{{ ctx.valueText }}</span>
</span>
</div>
<!-- 多 tab 行:issues > 1 时显示(每条违规规则一个 tab) -->
<div v-if="tabs.length > 1" class="modal-tabs">
<div
v-for="(t, idx) in tabs"
:key="idx"
class="modal-tab"
:class="{ active: idx === activeTabIdx }"
@click="activeTabIdx = idx"
>
<span class="tab-label">{{ t.issue.name || t.issue.desc || `规则 ${idx + 1}` }}</span>
<span v-if="t.loading" class="tab-loading-dot"></span>
<span v-else-if="t.error" class="tab-status tab-status-error" title="调用失败">⚠</span>
<span v-else-if="t.result && t.result.ok" class="tab-status" :class="`tab-status-${t.result.consistency}`" :title="t.result.consistency">{{ tabGlyph(t.result.consistency) }}</span>
</div>
</div>
<!-- 当前 tab 的内容(per-tab 切换) -->
<template v-if="activeTab">
<div class="explain-grid">
<!-- 左:规则说明 + 规则代码(按 tab 切换;原始数据已在顶部固定) -->
<div class="explain-pane explain-pane-left">
<div class="pane-section">
<div class="pane-title">规则说明(自然语言)</div>
<div class="desc-block">{{ activeTab.issue.desc || '(无)' }}</div>
</div>
<div class="pane-section">
<div class="pane-title">
规则代码(实现逻辑)
<span class="pane-title-tag">{{ RULE_TYPE_LABEL[activeTab.issue.rule_type] || activeTab.issue.rule_type }}</span>
</div>
<pre v-if="activeTab.issue.rule_type === 'regex'" class="code-block">{{ activeTab.issue.regex || '(空)' }}</pre>
<pre v-else class="code-block code-python">{{ activeTab.issue.code || '(空)' }}</pre>
</div>
</div>
<!-- 右:LLM 解释(按 tab 切换;每个 tab 独立 loading/result) -->
<div class="explain-pane explain-pane-right">
<!-- 加载中 -->
<div v-if="activeTab.loading" class="state state-loading">
<div class="spinner"></div>
<div class="state-text">LLM 正在分析…</div>
<div class="state-sub">通常 2~10 秒,模型同时审计「数据违规原因」与「规则 vs 代码一致性」</div>
</div>
<!-- LLM 不可用 / 调用失败 -->
<div v-else-if="activeTab.error || (activeTab.result && !activeTab.result.ok)" class="state state-error">
<div class="state-icon">⚠</div>
<div class="state-text">LLM 未返回结果</div>
<div class="state-sub">{{ activeTab.error || activeTab.result?.note || '请稍后重试,或检查后端 llm.yaml / 环境变量配置' }}</div>
</div>
<!-- 成功 -->
<template v-else-if="activeTab.result && activeTab.result.ok">
<!-- 一致性结论条 -->
<div class="verdict" :class="`verdict-${activeTab.result.consistency}`">
<span class="verdict-label">{{ CONSISTENCY_LABEL[activeTab.result.consistency] || activeTab.result.consistency }}</span>
<span class="verdict-detail">{{ activeTab.result.consistency_detail || '(无补充说明)' }}</span>
</div>
<!-- 自然语言违规原因 -->
<div class="pane-section">
<div class="pane-title">违规原因</div>
<div class="reason-block">{{ activeTab.result.violation_reason || '(LLM 未给出原因)' }}</div>
</div>
<!-- LLM 未按 JSON 输出时的原始回显 / 重试说明 -->
<div v-if="activeTab.result.note" class="note-block">
ℹ {{ activeTab.result.note }}
</div>
</template>
<!-- 兜底:尚未发起(理论上 watcher 同步就会触发,保险) -->
<div v-else class="state state-loading">
<div class="spinner"></div>
<div class="state-text">准备发起请求…</div>
</div>
</div>
</div>
</template>
</div>
<div class="modal-footer">
<button class="btn" @click="onRetry" :disabled="!activeTab || activeTab.loading">
{{ activeTab?.loading ? '分析中…' : '🔄 重新分析当前规则' }}
</button>
<button class="btn btn-primary" @click="$emit('update:open', false)">关闭</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { explainIssue } from '@/api/ai'
const props = defineProps({
open: { type: Boolean, default: false },
// { row, col, issues[] } —— 父组件传过来的上下文
context: {
type: Object,
default: () => null,
},
})
defineEmits(['update:open'])
// 规则类型 → 中文标签
const RULE_TYPE_LABEL = {
regex: '正则(regex)',
number: '数值(Python 函数)',
date: '日期(Python 函数)',
string: '字符串(Python 函数)',
}
// 一致性结论 → 中文标签
const CONSISTENCY_LABEL = {
match: '✅ 一致:代码正确实现了规则',
code_bug: '🐞 代码有 bug:规则没问题,但代码实现错了',
rule_ambiguous: '❓ 规则有歧义:代码没明显问题,但规则描述需要斟酌',
unknown: '⚠ 未明确:LLM 未给出明确结论',
}
// 一致性结论 → tab 上的小图标
const CONSISTENCY_GLYPH = {
match: '✓',
code_bug: '!',
rule_ambiguous: '?',
unknown: '·',
}
// tabs:[{ issue, loading, result, error }, ...]
// result: { ok, violation_reason, consistency, consistency_detail, raw_text, note, attempts }
const tabs = ref([])
const activeTabIdx = ref(0)
const activeTab = computed(() => tabs.value[activeTabIdx.value] || null)
// 顶部 ctx-strip 用的字段名 + 原始值(与具体规则无关,所有 tab 共享)
const ctx = computed(() => {
const c = props.context
if (!c || !c.row || !c.col || !Array.isArray(c.issues) || c.issues.length === 0) {
return {
fieldDisplay: '(未选择)',
valueText: '(无)',
}
}
const { row, col } = c
const value = row[col.key]
const valueText =
value === null || value === undefined
? 'NULL(空)'
: typeof value === 'string'
? `"${value.length > 80 ? value.slice(0, 80) + '...' : value}"(长度 ${value.length})`
: `${value}(${typeof value})`
const fieldDisplay =
[col.cn, col.comment ? `(${col.comment})` : ''].filter(Boolean).join(' ') || col.key
return { fieldDisplay, valueText }
})
function tabGlyph(consistency) {
return CONSISTENCY_GLYPH[consistency] || '·'
}
// 监听 open:true 时初始化 tabs 并并行调 LLM
// 用 issues 的 length 作为 reset 触发条件 —— 同一字段两条不同规则违规时,issues 数组引用会变
watch(
() => [props.open, props.context?.issues?.length, props.context?.row, props.context?.col],
([isOpen, _n, _r, _c]) => {
if (!isOpen) return
const ctx = props.context
if (!ctx || !Array.isArray(ctx.issues) || ctx.issues.length === 0) return
// 初始化 tabs —— 每个 issue 一个 tab,先全部置 loading=true
tabs.value = ctx.issues.map((issue) => ({
issue,
loading: true,
result: null,
error: '',
}))
activeTabIdx.value = 0
// 并行发起所有 tab 的 LLM 调用(Promise.allSettled 防止单条失败拖垮其他 tab)
Promise.allSettled(tabs.value.map((_t, idx) => callLLM(idx)))
},
{ immediate: true },
)
async function callLLM(idx) {
const c = props.context
const tab = tabs.value[idx]
if (!c || !c.row || !c.col || !tab) return
tab.loading = true
tab.error = ''
try {
const r = await explainIssue({
field_key: c.col.key || '',
field_comment: c.col.comment || '',
value: c.row[c.col.key] === undefined ? null : c.row[c.col.key],
rule_id: tab.issue.rule_id || 0,
rule_name: tab.issue.name || '',
rule_desc: tab.issue.desc || '',
rule_type: tab.issue.rule_type || 'regex',
code: tab.issue.code || '',
regex: tab.issue.regex || null,
})
tab.result = r || { ok: false, note: '空响应' }
if (!tab.result.ok && !tab.result.note) {
tab.error = 'LLM 未返回结果'
}
} catch (e) {
tab.error = `请求失败:${e.message || e}`
tab.result = { ok: false, note: tab.error }
} finally {
tab.loading = false
}
}
// 「重新分析当前规则」只重跑当前 active tab —— 不影响其他 tab
function onRetry() {
if (activeTabIdx.value >= 0 && activeTabIdx.value < tabs.value.length) {
callLLM(activeTabIdx.value)
}
}
</script>
<style scoped>
/* 弹框尺寸:比 rule-modal 略宽,左右两栏布局 -->
宽 880px,max-width 92vw,适配小屏 */
.explain-dialog {
width: 880px;
max-width: 92vw;
max-height: 84vh;
display: flex;
flex-direction: column;
}
.title-icon {
margin-right: 6px;
}
/* 标题右侧「共 N 条规则违规」徽章 */
.tab-count-badge {
margin-left: 12px;
padding: 2px 10px;
background: #ecf5ff;
color: #409eff;
font-size: 12px;
border-radius: 10px;
font-weight: 500;
}
/* 顶部上下文条 */
.ctx-strip {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
padding: 12px 20px;
background: #f7f8fa;
border-bottom: 1px solid #ebeef5;
}
.ctx-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #303133;
min-width: 0;
}
.ctx-item-wide {
flex: 1;
min-width: 0;
}
.ctx-label {
color: #909399;
font-size: 12px;
}
.ctx-value {
color: #303133;
font-weight: 500;
}
.ctx-value-mono {
font-family: ui-monospace, 'Cascadia Code', 'Consolas', 'Menlo', monospace;
font-weight: 400;
word-break: break-all;
white-space: pre-wrap;
}
/* ── tabs 行:复用 global.css .modal-tabs 样式 ── */
.modal-tabs {
/* 全局样式已设 flex + 底部边框;这里只补必要的覆盖 */
flex-shrink: 0;
}
.modal-tab {
display: inline-flex;
align-items: center;
gap: 6px;
position: relative;
}
.tab-label {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* tab 上的状态指示 */
.tab-loading-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #409eff;
animation: explain-pulse 1s ease-in-out infinite;
}
.tab-status {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.tab-status-match {
background: #f0f9eb;
color: #67c23a;
border: 1px solid #e1f3d8;
}
.tab-status-code_bug {
background: #fef0f0;
color: #c0392b;
border: 1px solid #fbc4c4;
}
.tab-status-rule_ambiguous {
background: #fdf6ec;
color: #e6a23c;
border: 1px solid #faecd8;
}
.tab-status-unknown {
background: #f4f4f5;
color: #909399;
border: 1px solid #e9e9eb;
}
.tab-status-error {
background: #fef0f0;
color: #c0392b;
border: 1px solid #fbc4c4;
}
@keyframes explain-pulse {
0%, 100% { opacity: 0.4; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.1); }
}
/* 左右两栏 */
.explain-grid {
display: flex;
gap: 16px;
padding: 16px 20px;
min-height: 0;
flex: 1;
}
.explain-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
max-height: 60vh;
}
.explain-pane-left {
/* 左栏更紧凑 */
flex: 0 0 46%;
}
.explain-pane-right {
flex: 1;
}
.pane-section {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
}
.pane-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #909399;
font-weight: 500;
letter-spacing: 0.5px;
}
.pane-title-tag {
font-size: 11px;
padding: 1px 8px;
border-radius: 8px;
background: #ecf5ff;
color: #409eff;
font-weight: 400;
}
/* 数据 / 代码块 */
.code-block {
margin: 0;
padding: 8px 10px;
background: #fafafa;
border: 1px solid #ebeef5;
border-radius: 4px;
font-family: ui-monospace, 'Cascadia Code', 'Consolas', 'Menlo', monospace;
font-size: 12.5px;
line-height: 1.5;
color: #303133;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
.code-python {
background: #1e1e1e;
color: #d4d4d4;
}
.code-python::first-line {
color: #569cd6;
}
/* 规则说明(自然语言) */
.desc-block {
padding: 8px 10px;
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 4px;
font-size: 13px;
color: #5c4b00;
line-height: 1.6;
white-space: pre-wrap;
}
/* LLM 状态:loading / error / success */
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 16px;
gap: 10px;
text-align: center;
}
.state-icon {
font-size: 36px;
}
.state-text {
font-size: 14px;
color: #303133;
font-weight: 500;
}
.state-sub {
font-size: 12px;
color: #909399;
line-height: 1.5;
max-width: 320px;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid #e4e7ed;
border-top-color: #409eff;
border-radius: 50%;
animation: explain-spin 0.9s linear infinite;
}
@keyframes explain-spin {
to { transform: rotate(360deg); }
}
/* 一致性结论条 */
.verdict {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid;
font-size: 13px;
line-height: 1.5;
}
.verdict-label {
font-weight: 600;
font-size: 13.5px;
}
.verdict-detail {
color: #5c5c5c;
}
.verdict-match {
background: #f0f9eb;
border-color: #e1f3d8;
color: #67c23a;
}
.verdict-match .verdict-detail { color: #5c5c5c; }
.verdict-code_bug {
background: #fef0f0;
border-color: #fbc4c4;
color: #c0392b;
}
.verdict-code_bug .verdict-detail { color: #5c5c5c; }
.verdict-rule_ambiguous {
background: #fdf6ec;
border-color: #faecd8;
color: #e6a23c;
}
.verdict-rule_ambiguous .verdict-detail { color: #5c5c5c; }
.verdict-unknown {
background: #f4f4f5;
border-color: #e9e9eb;
color: #909399;
}
/* 自然语言违规原因 */
.reason-block {
padding: 10px 12px;
background: #ecf5ff;
border: 1px solid #d9ecff;
border-radius: 4px;
font-size: 13px;
line-height: 1.7;
color: #303133;
white-space: pre-wrap;
word-break: break-word;
}
/* LLM 未按 JSON 输出的回退提示 */
.note-block {
margin-top: 4px;
padding: 8px 10px;
background: #f4f4f5;
border-radius: 4px;
font-size: 12px;
color: #606266;
line-height: 1.5;
}
/* footer 跟其他 modal 对齐 */
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
\ No newline at end of file
...@@ -86,6 +86,8 @@ ...@@ -86,6 +86,8 @@
</template> </template>
<!-- 错误单元格:红底 + tooltip + ! 标记 --> <!-- 错误单元格:红底 + tooltip + ! 标记 -->
<!-- 2026-08-24:「!」改成可点击 → 触发 explain-issue 事件,
父组件打开 ExplainIssueDialog 调 LLM 解释违规原因 + 审查规则 vs 代码 -->
<template v-else-if="isErrorCell(row, col.key)"> <template v-else-if="isErrorCell(row, col.key)">
<el-tooltip <el-tooltip
:content="errTip(row, col.key)" :content="errTip(row, col.key)"
...@@ -96,7 +98,13 @@ ...@@ -96,7 +98,13 @@
<span :class="{ 'null-val': isNullLike(row[col.key]) }"> <span :class="{ 'null-val': isNullLike(row[col.key]) }">
{{ formatCell(row[col.key]) }} {{ formatCell(row[col.key]) }}
</span> </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> </span>
</el-tooltip> </el-tooltip>
</template> </template>
...@@ -152,7 +160,7 @@ const props = defineProps({ ...@@ -152,7 +160,7 @@ const props = defineProps({
// null 时回退到「按 column.showDefault 过滤」(保持向后兼容 + 老调用方不传)。 // null 时回退到「按 column.showDefault 过滤」(保持向后兼容 + 老调用方不传)。
visibleFields: { type: Array, default: null }, visibleFields: { type: Array, default: null },
}) })
const emit = defineEmits(['page-change']) const emit = defineEmits(['page-change', 'explain-issue'])
// ── 列宽拖拽(2026-08-21,参照 web2 el-table resizable 自实现)── // ── 列宽拖拽(2026-08-21,参照 web2 el-table resizable 自实现)──
// 每列的宽度按 col.key 存;拖动时实时更新;卸载时摘掉 window 监听 // 每列的宽度按 col.key 存;拖动时实时更新;卸载时摘掉 window 监听
...@@ -265,6 +273,55 @@ function formatCell(v) { ...@@ -265,6 +273,55 @@ function formatCell(v) {
function isErrorCell(row, key) { function isErrorCell(row, key) {
return Array.isArray(row.errorCells) && row.errorCells.includes(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 都列出来(一个字段可配多条规则, // 错误描述:把 col.key(小写)对应的所有 issue 都列出来(一个字段可配多条规则,
// 可能同时违反多条 → 之前用 .find() 只取第一条,2026-08-24 改为 .filter() + 「;」连接) // 可能同时违反多条 → 之前用 .find() 只取第一条,2026-08-24 改为 .filter() + 「;」连接)
// 注:后端 issues[].field 是小写 cf.field_key(db_adapter 归一过), // 注:后端 issues[].field 是小写 cf.field_key(db_adapter 归一过),
...@@ -315,6 +372,15 @@ function ruleHint(col) { ...@@ -315,6 +372,15 @@ function ruleHint(col) {
gap: 4px; gap: 4px;
cursor: help; 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 居中 + 顶部间距(让分页器跟表格拉开距离) */ /* el-pagination 居中 + 顶部间距(让分页器跟表格拉开距离) */
.result-table__pagination { .result-table__pagination {
display: flex; display: flex;
......
...@@ -85,6 +85,7 @@ ...@@ -85,6 +85,7 @@
:append-mode="true" :append-mode="true"
:row-key="rowKeyFn" :row-key="rowKeyFn"
@page-change="onPageChange" @page-change="onPageChange"
@explain-issue="onExplainIssue"
/> />
</div> </div>
...@@ -95,6 +96,12 @@ ...@@ -95,6 +96,12 @@
:visible-fields="visibleFields" :visible-fields="visibleFields"
@save="onSaveFields" @save="onSaveFields"
/> />
<!-- 2026-08-24:点「!」一键问 LLM —— 解释违规原因 + 审查规则 vs 代码一致性 -->
<ExplainIssueDialog
v-model:open="explainDialogOpen"
:context="explainContext"
/>
</template> </template>
<script setup> <script setup>
...@@ -107,6 +114,7 @@ import { startQuery, fetchPage, cancelQuery } from '@/api/queries' ...@@ -107,6 +114,7 @@ import { startQuery, fetchPage, cancelQuery } from '@/api/queries'
import { exportToExcel } from '@/utils/excel' import { exportToExcel } from '@/utils/excel'
import FieldConfigDrawer from '@/components/FieldConfigDrawer.vue' import FieldConfigDrawer from '@/components/FieldConfigDrawer.vue'
import ResultTable from '@/components/ResultTable.vue' import ResultTable from '@/components/ResultTable.vue'
import ExplainIssueDialog from '@/components/ExplainIssueDialog.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
...@@ -498,6 +506,16 @@ function onPageChange({ page, pageSize }) { ...@@ -498,6 +506,16 @@ function onPageChange({ page, pageSize }) {
console.debug('[DataQualityView] page change', { 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 ── // ── 导出 Excel ──
// 字段范围:优先用 FieldConfigDrawer 选定的 visibleFields; // 字段范围:优先用 FieldConfigDrawer 选定的 visibleFields;
// 为空(用户没保存过 / 默认全勾)时退化为 resultFields 全量 // 为空(用户没保存过 / 默认全勾)时退化为 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