374 lines
18 KiB
Python
374 lines
18 KiB
Python
"""知识库「向量 ↔ 元数据」对账(**只读**,不写 Milvus、不改 MySQL)。
|
||||
|
|
|
|||
|
|
python tools/reconcile_knowledge_vectors.py # 人读摘要
|
|||
|
|
python tools/reconcile_knowledge_vectors.py --json # 机器读(含全部 id 明细)
|
|||
|
|
python tools/reconcile_knowledge_vectors.py --out docs/evidence/xxx.json
|
|||
|
|
|
|||
|
|
## 为什么要这个工具
|
|||
|
|
|
|||
|
|
知识的**元数据在 MySQL**(`fin_knowledge_meta`),**正文与向量在 Milvus**,两侧靠
|
|||
|
|
`id`(Milvus 侧的物理字段名是 `doc_id` 或 `knowledge_id`,因环境而异,**运行时探测**)关联。
|
|||
|
|
这个关联**没有任何数据库层约束**,因此会以四种方式坏掉,而**每一种在接口层都不报错**:
|
|||
|
|
|
|||
|
|
| 分类 | 现象 | 为什么会发生 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `orphan_vectors` | 向量在 Milvus、MySQL 里没有这个 id | 知识行被物理删除过(早期脚本) |
|
|||
|
|
| `stale_vectors` | 向量在 Milvus、MySQL 行是 `expired` | 行是"别的途径"变成 expired 的,**从没投过删除事件** —— 这正是管理端口 `POST /api/v1/knowledge/{id}/vector-cleanups` 要清理的那批 |
|
|||
|
|
| `missing_vectors` | MySQL 行是 `active`、Milvus 里没有向量 | 同步事件没被消费(Worker 没跑 / 事件 `dead`),或向量集合被重建过 |
|
|||
|
|
| `duplicate_groups` | 同一 `source_file` + 集合有多条 active 行 / 正文完全相同的多行 | 导入侧幂等上线前重复入库;手工灌库 |
|
|||
|
|
|
|||
|
|
**为什么必须查这个而不是"看检索结果对不对"**:检索侧只过滤 `visibility`、**不看 `status`**,
|
|||
|
|
所以 `stale_vectors` 会**继续参与排序**并和其他副本抢答 —— 表现出来只是"客服答得不好/总转人工",
|
|||
|
|
从任何一个接口都看不出向量库里多了一堆死向量(实测:产品手册被重复入库 7 次,
|
|||
|
|
175 条历史副本把「风险等级 R1–R5」这类问题卡在"领先次优不够"的门槛下)。
|
|||
|
|
|
|||
|
|
## 口径(三条,缺一条结论就会误导人)
|
|||
|
|
|
|||
|
|
1. **非数字 id 单独一类(`unbacked_seed_vectors`)**:`faq` / `policy` 两个集合里有一批
|
|||
|
|
`FAQ-0013` 之类的**人工语义 id**,它们**本来就不该出现在 MySQL**(由
|
|||
|
|
`tools/load_knowledge_milvus.py` / 灌库脚本直接写 Milvus)。把它们算成 `orphan_vectors`
|
|||
|
|
会让人"清理孤儿"时把**唯一正确的答案**删掉 —— 本工具单独列出,且**不建议删**。
|
|||
|
|
2. **只处理三个知识集合**:长期记忆向量(`user_long_term_memory_v1`)不属于知识库,
|
|||
|
|
不在这里的统计范围内。
|
|||
|
|
3. **只读**:本工具不修任何东西。发现 `stale_vectors` 后,用管理端口的 vector-cleanups
|
|||
|
|
补投事件(见 `tools/purge_expired_knowledge_vectors.py`),由 Worker 真正删除 ——
|
|||
|
|
工具自己去删向量会绕过权限、审计与 Outbox 语义。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import hashlib
|
|||
|
|
import json
|
|||
|
|
import sys
|
|||
|
|
from collections import defaultdict
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|||
|
|
sys.path.insert(0, str(ROOT))
|
|||
|
|
|
|||
|
|
from app.core.knowledge_contracts import ALLOWED_COLLECTIONS # noqa: E402
|
|||
|
|
|
|||
|
|
#: Milvus `query` 的分页上限(各环境默认 16384);超过就分页取。
|
|||
|
|
PAGE_SIZE = 16384
|
|||
|
|
|
|||
|
|
#: 「低信息量向量」的正文长度门槛:一个向量整篇正文不到这么长,不可能回答任何问题,
|
|||
|
|
#: 却照样进排序。实测 `fin_policy_collection` 里有 20 条正文都是 19 字的
|
|||
|
|
#: 「第九条 问卷内容及评分标准:选项 分值」(灌库时把 markdown 表格切碎了)
|
|||
|
|
#: —— 它们对任何"问卷/评分"类提问都是**同分并列**,直接把客服的"领先次优 ≥0.07"卡死。
|
|||
|
|
TINY_CONTENT_LENGTH = 40
|
|||
|
|
|
|||
|
|
#: 默认的只读证据落盘位置(与人读摘要无关,摘要始终打到 stdout)。
|
|||
|
|
DEFAULT_OUT = Path("docs/evidence/knowledge-vectors-reconcile.json")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_heading_only(content: str) -> bool:
|
|||
|
|
"""整段正文只是一行 markdown 标题(`## 一、交易规则` 这种)。
|
|||
|
|
|
|||
|
|
这类向量**不可能回答任何问题**,却会在任何提到这几个字的提问上拿到不低的相似度
|
|||
|
|
—— 它把"领先次优"的差值抹平,也让真正有内容的块排不到第一。
|
|||
|
|
实测 `fin_product_collection` 里有 47 条正文不到 20 字、其中不少就是标题行,
|
|||
|
|
来源是灌库脚本按行切 markdown 时把标题单独切成了块。
|
|||
|
|
"""
|
|||
|
|
stripped = content.strip()
|
|||
|
|
if not stripped.startswith("#"):
|
|||
|
|
return False
|
|||
|
|
return len(stripped.lstrip("#").strip()) < 20
|
|||
|
|
|
|||
|
|
|
|||
|
|
def mysql_rows() -> list[dict[str, Any]]:
|
|||
|
|
"""读 `fin_knowledge_meta` 的全部行(只取对账需要的列,**不读正文**)。
|
|||
|
|
|
|||
|
|
不读 `content_text`:这份表里存着整篇正文,全表拉回来既慢又没必要 ——
|
|||
|
|
正文重复度由 Milvus 侧算(那里本来就有正文/摘要字段)。
|
|||
|
|
"""
|
|||
|
|
from urllib.parse import unquote, urlparse
|
|||
|
|
|
|||
|
|
import pymysql # type: ignore[import-untyped]
|
|||
|
|
|
|||
|
|
from app.core.config import get_settings
|
|||
|
|
|
|||
|
|
parsed = urlparse(get_settings().mysql_dsn.replace("mysql+asyncmy://", "mysql+pymysql://"))
|
|||
|
|
connection = pymysql.connect(
|
|||
|
|
host=parsed.hostname or "127.0.0.1",
|
|||
|
|
port=parsed.port or 3306,
|
|||
|
|
user=unquote(parsed.username or ""),
|
|||
|
|
password=unquote(parsed.password or ""),
|
|||
|
|
database=(parsed.path or "/").lstrip("/"),
|
|||
|
|
charset="utf8mb4",
|
|||
|
|
cursorclass=pymysql.cursors.DictCursor,
|
|||
|
|
)
|
|||
|
|
try:
|
|||
|
|
with connection.cursor() as cursor:
|
|||
|
|
cursor.execute(
|
|||
|
|
"SELECT id, knowledge_type, source_file, milvus_collection, version, status,"
|
|||
|
|
" review_status, created_at FROM fin_knowledge_meta"
|
|||
|
|
)
|
|||
|
|
return [dict(row) for row in cursor.fetchall()]
|
|||
|
|
finally:
|
|||
|
|
connection.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def milvus_vectors(collections: list[str]) -> dict[str, Any]:
|
|||
|
|
"""按集合取 (向量 id, 正文哈希) 明细,物理字段名**运行时探测**。
|
|||
|
|
|
|||
|
|
返回 `{"collections": {...}, "errors": {...}}`;单集合失败不中断其余
|
|||
|
|
(与检索侧一贯口径一致:一个集合探测不了,不该让整份报告消失)。
|
|||
|
|
"""
|
|||
|
|
from app.core.config import get_settings
|
|||
|
|
from app.core.knowledge_schema import detect_schema
|
|||
|
|
from pymilvus import MilvusClient # type: ignore[import-untyped]
|
|||
|
|
|
|||
|
|
settings = get_settings()
|
|||
|
|
client = MilvusClient(uri=settings.milvus_uri, token=settings.milvus_token or None)
|
|||
|
|
report: dict[str, Any] = {"collections": {}, "errors": {}}
|
|||
|
|
for name in collections:
|
|||
|
|
try:
|
|||
|
|
schema = detect_schema(client, name)
|
|||
|
|
if not schema.usable:
|
|||
|
|
report["errors"][name] = (
|
|||
|
|
schema.error or f"缺少必需字段:{','.join(schema.missing_required)}"
|
|||
|
|
)
|
|||
|
|
continue
|
|||
|
|
id_field = schema.resolve("doc_id")
|
|||
|
|
content_field = schema.resolve("content")
|
|||
|
|
assert id_field is not None and content_field is not None # schema.usable 已保证
|
|||
|
|
entries: list[dict[str, Any]] = []
|
|||
|
|
offset = 0
|
|||
|
|
while True:
|
|||
|
|
rows = client.query(
|
|||
|
|
collection_name=name,
|
|||
|
|
filter="",
|
|||
|
|
output_fields=[id_field, content_field],
|
|||
|
|
limit=PAGE_SIZE,
|
|||
|
|
offset=offset,
|
|||
|
|
)
|
|||
|
|
if not rows:
|
|||
|
|
break
|
|||
|
|
for row in rows:
|
|||
|
|
content = str(row.get(content_field) or "")
|
|||
|
|
entries.append({
|
|||
|
|
"vector_id": str(row.get(id_field) or ""),
|
|||
|
|
"content_sha1": hashlib.sha1(
|
|||
|
|
content.encode("utf-8")
|
|||
|
|
).hexdigest()[:12],
|
|||
|
|
"content_length": len(content),
|
|||
|
|
"heading_only": is_heading_only(content),
|
|||
|
|
})
|
|||
|
|
if len(rows) < PAGE_SIZE:
|
|||
|
|
break
|
|||
|
|
offset += len(rows)
|
|||
|
|
report["collections"][name] = {
|
|||
|
|
"id_field": id_field,
|
|||
|
|
"content_field": content_field,
|
|||
|
|
"entries": entries,
|
|||
|
|
}
|
|||
|
|
except Exception as exc: # 单集合失败不影响其余
|
|||
|
|
report["errors"][name] = f"{type(exc).__name__}: {exc}"
|
|||
|
|
return report
|
|||
|
|
|
|||
|
|
|
|||
|
|
def reconcile(
|
|||
|
|
rows: list[dict[str, Any]], vectors: dict[str, Any]
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""把两侧数据算成一份对账结论(纯函数:不连库、不连 Milvus,便于单测)。
|
|||
|
|
|
|||
|
|
`rows` 是 `fin_knowledge_meta` 的行;`vectors` 是 `milvus_vectors()` 的返回。
|
|||
|
|
"""
|
|||
|
|
by_collection: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|||
|
|
for row in rows:
|
|||
|
|
by_collection[str(row.get("milvus_collection") or "")].append(row)
|
|||
|
|
|
|||
|
|
result: dict[str, Any] = {"collections": {}, "errors": dict(vectors.get("errors", {}))}
|
|||
|
|
for name, payload in vectors.get("collections", {}).items():
|
|||
|
|
entries = payload["entries"]
|
|||
|
|
mysql_for_collection = by_collection.get(name, [])
|
|||
|
|
status_by_id = {int(row["id"]): str(row.get("status") or "") for row in mysql_for_collection}
|
|||
|
|
vector_ids = [entry["vector_id"] for entry in entries]
|
|||
|
|
|
|||
|
|
numeric_ids = [vid for vid in vector_ids if vid.isdigit()]
|
|||
|
|
unbacked = [vid for vid in vector_ids if not vid.isdigit()]
|
|||
|
|
orphan = [vid for vid in numeric_ids if int(vid) not in status_by_id]
|
|||
|
|
stale = [
|
|||
|
|
vid for vid in numeric_ids
|
|||
|
|
if status_by_id.get(int(vid), "") not in {"", "active"}
|
|||
|
|
]
|
|||
|
|
active_ids = {
|
|||
|
|
int(row["id"]) for row in mysql_for_collection
|
|||
|
|
if str(row.get("status") or "") == "active"
|
|||
|
|
}
|
|||
|
|
missing = sorted(active_ids - {int(vid) for vid in numeric_ids})
|
|||
|
|
|
|||
|
|
# 正文完全相同的向量(同一份内容被灌了多次)——**不管状态**:检索侧不看 status,
|
|||
|
|
# 所以一条 expired 行的向量照样参与排序,和别人的副本抢答。
|
|||
|
|
sha_groups: dict[str, list[str]] = defaultdict(list)
|
|||
|
|
for entry in entries:
|
|||
|
|
sha_groups[entry["content_sha1"]].append(entry["vector_id"])
|
|||
|
|
duplicate_content = {
|
|||
|
|
sha: ids for sha, ids in sha_groups.items() if len(ids) > 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# **同一份文档还有几个 active 版本**:只看行数会误判 —— 一份文档正常会切成十几个 chunk
|
|||
|
|
# (实测 `场内基金产品手册.md` 一个版本就是 24 行)。真正的重复信号是
|
|||
|
|
# **该文件的 active 行里出现了正文完全相同的两块**:那只能是同一份文档被入库了多次。
|
|||
|
|
sha_by_vector = {entry["vector_id"]: entry["content_sha1"] for entry in entries}
|
|||
|
|
active_files: dict[str, list[str]] = defaultdict(list)
|
|||
|
|
for row in mysql_for_collection:
|
|||
|
|
if str(row.get("status") or "") != "active":
|
|||
|
|
continue
|
|||
|
|
active_files[str(row.get("source_file") or "")].append(
|
|||
|
|
sha_by_vector.get(str(int(row["id"])), "")
|
|||
|
|
)
|
|||
|
|
duplicated_active_files = {
|
|||
|
|
file: sorted({sha for sha in shas if sha and shas.count(sha) > 1})
|
|||
|
|
for file, shas in active_files.items()
|
|||
|
|
if any(sha and shas.count(sha) > 1 for sha in shas)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 低信息量向量:正文短到不可能回答任何问题,却照样参与排序(见 TINY_CONTENT_LENGTH)。
|
|||
|
|
tiny = [
|
|||
|
|
entry["vector_id"] for entry in entries
|
|||
|
|
if entry["content_length"] < TINY_CONTENT_LENGTH
|
|||
|
|
]
|
|||
|
|
heading_only = [
|
|||
|
|
entry["vector_id"] for entry in entries if entry["heading_only"]
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
result["collections"][name] = {
|
|||
|
|
"milvus_vectors": len(entries),
|
|||
|
|
"mysql_rows": len(mysql_for_collection),
|
|||
|
|
"mysql_active": len(active_ids),
|
|||
|
|
"active_files": len(active_files),
|
|||
|
|
"orphan_vectors": len(orphan),
|
|||
|
|
"stale_vectors": len(stale),
|
|||
|
|
"missing_vectors": len(missing),
|
|||
|
|
"unbacked_seed_vectors": len(unbacked),
|
|||
|
|
"duplicate_content_groups": len(duplicate_content),
|
|||
|
|
"duplicate_content_vectors": sum(len(ids) for ids in duplicate_content.values()),
|
|||
|
|
"tiny_vectors": len(tiny),
|
|||
|
|
"heading_only_vectors": len(heading_only),
|
|||
|
|
"duplicated_active_files": len(duplicated_active_files),
|
|||
|
|
"detail": {
|
|||
|
|
"orphan_vectors": orphan[:200],
|
|||
|
|
"stale_vectors_ids": stale[:200],
|
|||
|
|
"missing_vectors": missing[:200],
|
|||
|
|
"unbacked_seed_vectors": unbacked[:200],
|
|||
|
|
"tiny_vectors": tiny[:100],
|
|||
|
|
"heading_only_vectors": heading_only[:100],
|
|||
|
|
"duplicated_active_files": {
|
|||
|
|
file: shas[:20] for file, shas in list(duplicated_active_files.items())[:50]
|
|||
|
|
},
|
|||
|
|
"duplicate_content_groups": {
|
|||
|
|
sha: ids[:20] for sha, ids in list(duplicate_content.items())[:50]
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
def summarize(report: dict[str, Any]) -> str:
|
|||
|
|
"""人读摘要。每条都带"下一步该做什么",避免只给一堆数字。"""
|
|||
|
|
lines: list[str] = ["知识库「向量 ↔ 元数据」对账(只读)", ""]
|
|||
|
|
header = (
|
|||
|
|
f"{'集合':<24}{'向量':>7}{'MySQL行':>9}{'active':>8}{'文档数':>8}"
|
|||
|
|
f"{'孤儿':>7}{'死向量':>8}{'缺向量':>8}{'无元数据种子':>13}{'重复正文':>9}{'碎片':>7}{'纯标题':>7}"
|
|||
|
|
)
|
|||
|
|
lines.append(header)
|
|||
|
|
lines.append("-" * len(header))
|
|||
|
|
for name, item in report.get("collections", {}).items():
|
|||
|
|
lines.append(
|
|||
|
|
f"{name:<24}{item['milvus_vectors']:>7}{item['mysql_rows']:>9}"
|
|||
|
|
f"{item['mysql_active']:>8}{item.get('active_files', 0):>8}"
|
|||
|
|
f"{item['orphan_vectors']:>7}"
|
|||
|
|
f"{item['stale_vectors']:>8}{item['missing_vectors']:>8}"
|
|||
|
|
f"{item['unbacked_seed_vectors']:>13}"
|
|||
|
|
f"{item.get('duplicate_content_vectors', 0):>9}{item.get('tiny_vectors', 0):>7}"
|
|||
|
|
f"{item.get('heading_only_vectors', 0):>7}"
|
|||
|
|
)
|
|||
|
|
for name, error in report.get("errors", {}).items():
|
|||
|
|
lines.append(f"⚠️ {name} 探测失败:{error}")
|
|||
|
|
|
|||
|
|
lines.append("")
|
|||
|
|
totals = report.get("collections", {}).values()
|
|||
|
|
stale_total = sum(item["stale_vectors"] for item in totals)
|
|||
|
|
orphan_total = sum(item["orphan_vectors"] for item in totals)
|
|||
|
|
missing_total = sum(item["missing_vectors"] for item in totals)
|
|||
|
|
dup_files = sum(item.get("duplicated_active_files", 0) for item in totals)
|
|||
|
|
dup_vectors = sum(item.get("duplicate_content_vectors", 0) for item in totals)
|
|||
|
|
tiny_total = sum(item.get("tiny_vectors", 0) for item in totals)
|
|||
|
|
heading_total = sum(item.get("heading_only_vectors", 0) for item in totals)
|
|||
|
|
lines.append("怎么读这几列:")
|
|||
|
|
lines.append(
|
|||
|
|
f" · 死向量 {stale_total} 条:MySQL 里已是 expired,向量却还在,**会继续参与检索排序**。"
|
|||
|
|
)
|
|||
|
|
lines.append(" 处理:python tools/purge_expired_knowledge_vectors.py --apply(走管理端口补投删除事件)")
|
|||
|
|
lines.append(
|
|||
|
|
f" · 孤儿向量 {orphan_total} 条:MySQL 里连行都没有(历史物理删除的残留),处理同「死向量」。"
|
|||
|
|
)
|
|||
|
|
lines.append(
|
|||
|
|
f" · 缺向量 {missing_total} 条:active 行没有向量 → 这份知识**检索永远命中不到**。"
|
|||
|
|
)
|
|||
|
|
lines.append(" 处理:确认 Worker 在跑,再重传一次该文档让同步事件重投(最省事)。")
|
|||
|
|
lines.append(
|
|||
|
|
f" · 重复正文 {dup_vectors} 条向量:正文逐字相同的多份,检索时**必然互相打平**,"
|
|||
|
|
"是「领先次优 ≥0.07」被卡死的直接来源。"
|
|||
|
|
)
|
|||
|
|
lines.append(
|
|||
|
|
f" (其中「同一份文档还有多个 active 版本」{dup_files} 份 → 重传一次该文件即可收敛)"
|
|||
|
|
)
|
|||
|
|
lines.append(
|
|||
|
|
f" · 碎片 {tiny_total} 条:整篇正文不到 {TINY_CONTENT_LENGTH} 字的向量,回答不了任何问题,"
|
|||
|
|
"却照样进排序、并和同题其它块打平(实测来源是灌库时把 markdown 表格切碎)。"
|
|||
|
|
)
|
|||
|
|
lines.append(" 处理:属**灌库脚本的切分缺陷**,要在灌库侧修;不要为此改检索层的阈值。")
|
|||
|
|
lines.append(
|
|||
|
|
f" · 纯标题 {heading_total} 条:整段正文就是一行 markdown 标题(`## 一、交易规则`)。"
|
|||
|
|
" 这类向量**不可能回答任何问题**,却会在提到这几个字的提问上拿到不低的相似度。"
|
|||
|
|
)
|
|||
|
|
lines.append(
|
|||
|
|
" 「文档数」列是一个集合里有多少份 active 文档(不是行数):一份文档正常切成十几块。"
|
|||
|
|
)
|
|||
|
|
lines.append(
|
|||
|
|
" · 无元数据种子向量:`FAQ-0013` 这类人工语义 id,**本来就不在 MySQL**,不要当孤儿清理。"
|
|||
|
|
)
|
|||
|
|
lines.append(
|
|||
|
|
" · 「向量」列来自 Milvus `query` 的**实际行数**;`get_collection_stats` 的 row_count"
|
|||
|
|
" 会把已软删、尚未 compaction 的行也算进去,故两者不等是正常的,以本列为准。"
|
|||
|
|
)
|
|||
|
|
return "\n".join(lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main(argv: list[str] | None = None) -> int:
|
|||
|
|
parser = argparse.ArgumentParser(description="知识库向量-元数据对账(只读)")
|
|||
|
|
parser.add_argument("--json", action="store_true", help="打印完整 JSON(含 id 明细)")
|
|||
|
|
parser.add_argument("--out", type=Path, default=DEFAULT_OUT, help="证据落盘路径")
|
|||
|
|
parser.add_argument("--no-write", action="store_true", help="不落盘,只打印")
|
|||
|
|
args = parser.parse_args(argv)
|
|||
|
|
|
|||
|
|
rows = mysql_rows()
|
|||
|
|
vectors = milvus_vectors(sorted(ALLOWED_COLLECTIONS))
|
|||
|
|
report = reconcile(rows, vectors)
|
|||
|
|
report["mysql_rows_total"] = len(rows)
|
|||
|
|
report["collections_checked"] = sorted(ALLOWED_COLLECTIONS)
|
|||
|
|
|
|||
|
|
if not args.no_write:
|
|||
|
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
args.out.write_text(
|
|||
|
|
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if args.json:
|
|||
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|||
|
|
else:
|
|||
|
|
print(summarize(report))
|
|||
|
|
if not args.no_write:
|
|||
|
|
print(f"\n明细已写入 {args.out}")
|
|||
|
|
|
|||
|
|
# 退出码只表达"能不能判定",不表达"有没有问题":对账发现残留是常态,不该让 CI 红。
|
|||
|
|
return 1 if report.get("errors") else 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
raise SystemExit(main())
|