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:
字段大小写已归一为小写(与列名对齐)。
"""
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')
*/
......
<!--
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 @@
</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