Files
AgentLab/backend/app/data/source_health.py
T
ThearchyHelios 8d9a712eae 业务本体与标准实体:跨源键值对齐、对账共变算子、健康证据链与实体助手
- 新增标准业务实体(CanonicalEntity)模型、确定性跨源编码翻译、后台候选实体扫描与文档驱动实体智能助手
- 新增指标本体图谱(factor / reconciles_with / upstream_driver)、DAG 防环校验以及 ontology_neighbors / source_health 内置工具
- 新增跨源对账(analysis_reconcile)与滞后共变(analysis_cochange)分析算子,打通 [[h:H1]] 数据源健康证据链、报告导出与本体消融评测
- 数据目录新增「标准业务实体」视图与候选审阅工作台,口径页集成指标本体拓扑面板与端到端浏览器检查
2026-11-12 12:57:53 +08:00

464 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""跨数据源健康与漂移快速探针(source_health,W1-b)。
用于指标异动与跨源穿透时的第 1 顺位核查(先排数,后归因):
1. 读取 `SourceChange` 待处理变化记录(`date_stalled`、`date_gap`、`volume_drop`、`rows_drop`、`codes_new`、
`codes_shift`、`coverage_drop`、`table_removed`、`column_removed`、`column_type` 等);
2. 对目标表非遮罩的时间列做有界只读鲜度与时间窗覆盖探查(`PROBE_TIMEOUT_S = 10.0s`,`guard.check + run_query`),
超时或失败优雅降级为 `unverified`,绝不拖垮主运行;
3. 严守遮罩铁律(铁律 3):绝不探查或返回任何 `mask_columns` 列的值;`restricted=True`(普通 user 角色)时自动隐去
物理表名、物理列名与底层码值细节,只输出业务数据源说明与高层健康判定。
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterable, Mapping
from datetime import date, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import artifact_store
from app.data import schema_diff as sd
from app.data import source_changes as sc
from app.data.coverage import _as_day
from app.data.engine import QueryLimits, masked_columns, run_query
from app.data.guard import check, is_write
from app.data.names import name_key
from app.db.models import DataSource, Workflow, WorkflowVersion
from app.db.models_sources import SourceChange
ARTIFACT_KIND = "source_health"
LEDGER_KIND = "source_health"
CATALOG_PREFIX = "H"
PROBE_TIMEOUT_S = 10.0
MAX_TABLES_PROBED = 6
HEALTHY = "healthy"
DEGRADED = "degraded"
ANOMALOUS = "anomalous"
UNVERIFIED = "unverified"
VERDICTS = (HEALTHY, DEGRADED, ANOMALOUS, UNVERIFIED)
_SEVERE_CHANGE_KINDS = frozenset({
sd.TABLE_REMOVED,
sd.COLUMN_REMOVED,
sd.COLUMN_TYPE,
sc.DATE_STALLED,
sc.DATE_GAP,
sc.VOLUME_DROP,
sc.ROWS_DROP,
sc.COVERAGE_DROP,
})
_WARN_CHANGE_KINDS = frozenset({
sd.TABLE_REFLECT_FAILED,
sd.TABLE_LEFT_SCOPE,
sc.CODES_NEW,
sc.CODES_SHIFT,
sc.ROWS_JUMP,
})
def _quote_ident(name: str, dialect: str) -> str:
clean = str(name)
if dialect in ("mysql", "mariadb"):
return "`" + clean.replace("`", "``") + "`"
return '"' + clean.replace('"', '""') + '"'
def _date_columns_of(table_meta: Mapping[str, Any], masked: set[str]) -> list[str]:
cols = table_meta.get("columns")
if not isinstance(cols, list):
return []
out: list[str] = []
for col in cols:
if not isinstance(col, Mapping):
continue
cname = str(col.get("name") or "").strip()
if not cname or cname.lower() in masked:
continue
ctype = str(col.get("type") or "").lower()
low = cname.lower()
if any(tok in ctype for tok in ("date", "time", "timestamp")) or any(
low == k or low.endswith(f"_{k}") or low.startswith(f"{k}_")
for k in ("date", "day", "dt", "shift_date", "production_day", "biz_date", "created_at")
):
out.append(cname)
return out[:2]
async def _resolve_caliber_targets(
session: AsyncSession,
*,
workflow_id: str,
version: int | None,
card_id: str | None,
metric_id: str | None,
) -> list[tuple[str, list[str]]]:
"""根据已发布(或指定版本)口径自动反查 (source_name, [table_names])。"""
from app.ledger.ontology import workflow_ontology_index
wf = await session.get(Workflow, workflow_id)
if wf is None:
return []
ver = version if version is not None else wf.published_version or wf.version
snap = (
await session.execute(
select(WorkflowVersion).where(
WorkflowVersion.workflow_id == workflow_id,
WorkflowVersion.version == int(ver),
)
)
).scalar_one_or_none()
graph = (snap.graph if snap is not None else wf.graph) or {}
idx = workflow_ontology_index(workflow_id=workflow.id if (workflow := wf) else workflow_id, version=int(ver), graph=graph)
by_src: dict[str, set[str]] = {}
for card in idx["cards"]:
if card_id and card["card_id"] != card_id:
continue
if metric_id:
for m in card["metrics"]:
if m["metric_id"] == metric_id:
for item in m["lineage"]:
by_src.setdefault(item["source"], set()).update(item["tables"])
else:
for src, tbls in card["tables_by_source"].items():
by_src.setdefault(src, set()).update(tbls)
return [(src, sorted(tbls)) for src, tbls in sorted(by_src.items())]
def _parse_window_day(raw: str | date | None) -> date | None:
if raw is None:
return None
if isinstance(raw, datetime):
return raw.date()
if isinstance(raw, date):
return raw
d = _as_day(str(raw).strip())
return d.day if d is not None else None
async def _probe_table_coverage(
source: DataSource,
table_name: str,
table_meta: Mapping[str, Any],
*,
masked: set[str],
start_day: date | None,
end_day: date | None,
) -> dict[str, Any]:
"""对单张表做只读行数与非遮罩日期边界探查,超时或报错返回 status='unverified'。"""
dialect = (source.kind or "sqlite").lower()
q_table = _quote_ident(table_name, dialect)
date_cols = _date_columns_of(table_meta, masked)
select_parts = ["COUNT(*) AS row_cnt"]
for idx, col in enumerate(date_cols):
qc = _quote_ident(col, dialect)
select_parts.append(f"MIN({qc}) AS min_d{idx}")
select_parts.append(f"MAX({qc}) AS max_d{idx}")
sql = f"SELECT {', '.join(select_parts)} FROM {q_table}"
if is_write(sql, dialect=dialect):
return {"table": table_name, "status": UNVERIFIED, "reason": "探查语句未通过只读校验"}
check(sql, readonly=True, source_name=source.name, dialect=dialect)
try:
res = await asyncio.wait_for(
run_query(source, sql, limits=QueryLimits(max_rows=5, timeout_seconds=PROBE_TIMEOUT_S)),
timeout=PROBE_TIMEOUT_S + 1.0,
)
except Exception: # noqa: BLE001 - 探查失败优雅降级,绝不中断主流程
return {"table": table_name, "status": UNVERIFIED, "reason": "实时探查超时或暂时不可达"}
if not res.rows:
return {"table": table_name, "status": ANOMALOUS, "issue": "empty_table", "row_count": 0}
first = res.rows[0]
row_cnt = int(first[0] or 0) if first and first[0] is not None else 0
if row_cnt == 0:
return {"table": table_name, "status": ANOMALOUS, "issue": "empty_table", "row_count": 0}
ranges: list[dict[str, Any]] = []
window_issues: list[dict[str, Any]] = []
for idx, col in enumerate(date_cols):
pos = 1 + idx * 2
raw_min = first[pos] if pos < len(first) else None
raw_max = first[pos + 1] if (pos + 1) < len(first) else None
d_min = _as_day(raw_min)
d_max = _as_day(raw_max)
ranges.append({
"column": col,
"min": d_min.day.isoformat() if d_min else (str(raw_min) if raw_min is not None else None),
"max": d_max.day.isoformat() if d_max else (str(raw_max) if raw_max is not None else None),
})
if start_day and d_max and d_max.day < start_day:
window_issues.append({
"kind": sc.DATE_STALLED,
"severity": "action",
"column": col,
"max_date": d_max.day.isoformat(),
"requested_start": start_day.isoformat(),
})
elif end_day and d_min and d_min.day >= end_day:
window_issues.append({
"kind": sc.DATE_GAP,
"severity": "action",
"column": col,
"min_date": d_min.day.isoformat(),
"requested_end": end_day.isoformat(),
})
return {
"table": table_name,
"status": ANOMALOUS if window_issues else HEALTHY,
"row_count": row_cnt,
"ranges": ranges,
"window_issues": window_issues,
}
def _render_phrase(source_title: str, verdict: str, issues: list[dict[str, Any]], *, probe_unverified: bool) -> str:
if verdict == ANOMALOUS:
labels = list(dict.fromkeys(str(i.get("label") or i.get("kind") or "数据异常") for i in issues))
shown = "、".join(labels[:3])
return f"数据源「{source_title}」检测到数据健康异常({shown}),当前异动优先归因于上游同步或主数据质量问题,不宜直接归结为业务实质变化。"
if verdict == DEGRADED:
labels = list(dict.fromkeys(str(i.get("label") or i.get("kind") or "取值变化") for i in issues))
shown = "、".join(labels[:3])
return f"数据源「{source_title}」存在待审阅的数据变化提醒({shown}),跨源对比与归因时需留意码值或分布漂移。"
if verdict == UNVERIFIED or probe_unverified:
return f"数据源「{source_title}」未发现已登记的变化告警,但实时范围探查未完成(状态记为待核实)。"
return f"数据源「{source_title}」同步状态与查询区间覆盖正常,未发现断更、缺口或未处理的结构漂移。"
async def check_source_health(
session: AsyncSession,
*,
source_name: str | None = None,
tables: Iterable[str] | None = None,
workflow_id: str | None = None,
version: int | None = None,
card_id: str | None = None,
metric_id: str | None = None,
start: str | date | None = None,
end: str | date | None = None,
restricted: bool = False,
live_probe: bool = True,
) -> dict[str, Any]:
"""检查目标数据源(或目标口径底层依赖源)的健康度与数据漂移,返回确定性结构化结论。"""
targets: list[tuple[str, list[str]]] = []
if source_name and str(source_name).strip():
explicit_tables = [str(t).strip() for t in (tables or ()) if str(t).strip()]
targets.append((str(source_name).strip(), explicit_tables))
elif workflow_id and str(workflow_id).strip():
targets = await _resolve_caliber_targets(
session,
workflow_id=str(workflow_id).strip(),
version=version,
card_id=card_id,
metric_id=metric_id,
)
if not targets:
return {
"kind": ARTIFACT_KIND,
"verdict": UNVERIFIED,
"sources": [],
"issues": [],
"phrase": "未能定位目标数据源,健康状态记为未核实。",
}
start_day = _parse_window_day(start)
end_day = _parse_window_day(end)
source_reports: list[dict[str, Any]] = []
all_issues: list[dict[str, Any]] = []
overall_rank = 0 # 0 healthy, 1 unverified, 2 degraded, 3 anomalous
rank_to_verdict = {0: HEALTHY, 1: UNVERIFIED, 2: DEGRADED, 3: ANOMALOUS}
for src_name, target_tables in targets:
src = (
await session.execute(select(DataSource).where(DataSource.name == src_name).limit(1))
).scalar_one_or_none()
if src is None or not src.enabled:
overall_rank = max(overall_rank, 3 if src is not None and not src.enabled else 1)
status = ANOMALOUS if (src is not None and not src.enabled) else UNVERIFIED
issue_label = "数据源已停用" if status == ANOMALOUS else "数据源未找到"
issue = {"kind": "source_unavailable", "severity": "action", "label": issue_label}
if not restricted:
issue["source"] = src_name
all_issues.append(issue)
source_reports.append({
"source_label": (src.description or src.name) if src else ("关联数据源" if restricted else src_name),
**({} if restricted else {"source": src_name}),
"verdict": status,
"issues": [issue],
})
continue
masked = {c.lower() for c in masked_columns(src.options)}
schema_tables = (src.schema_cache or {}).get("tables") if isinstance(src.schema_cache, Mapping) else {}
if not isinstance(schema_tables, Mapping):
schema_tables = {}
wanted_keys = {name_key(t) for t in target_tables} if target_tables else None
change_rows = list(
(
await session.execute(
select(SourceChange)
.where(SourceChange.source_id == src.id, SourceChange.status.in_(sc.PENDING))
.order_by(SourceChange.seq.desc())
)
).scalars()
)
src_issues: list[dict[str, Any]] = []
src_rank = 0
for ch in change_rows:
if wanted_keys is not None and ch.table_name and name_key(ch.table_name) not in wanted_keys:
continue
# 遮罩列上的变化不暴露列名与取值细节(铁律 3)
if ch.column_name and ch.column_name.lower() in masked:
continue
label = sc.KIND_LABEL.get(ch.kind, ch.kind)
is_severe = ch.kind in _SEVERE_CHANGE_KINDS or ch.severity == "action"
src_rank = max(src_rank, 3 if is_severe else 2)
entry: dict[str, Any] = {
"kind": ch.kind,
"severity": ch.severity,
"status": ch.status,
"label": label,
}
if not restricted:
entry["source"] = src.name
if ch.table_name:
entry["table"] = ch.table_name
if ch.column_name:
entry["column"] = ch.column_name
entry["summary"] = sc.summary(ch)
src_issues.append(entry)
probe_unverified = False
probed_tables: list[dict[str, Any]] = []
if live_probe:
candidate_names = target_tables or list(schema_tables.keys())[:MAX_TABLES_PROBED]
for tbl_name in candidate_names[:MAX_TABLES_PROBED]:
# 找到 schema_cache 里精确的表名键
matched_name = next((k for k in schema_tables if name_key(k) == name_key(tbl_name)), tbl_name)
meta = schema_tables.get(matched_name) if isinstance(schema_tables.get(matched_name), Mapping) else {}
probe = await _probe_table_coverage(
src,
matched_name,
meta,
masked=masked,
start_day=start_day,
end_day=end_day,
)
if probe["status"] == UNVERIFIED:
probe_unverified = True
if src_rank == 0:
src_rank = 1
elif probe["status"] == ANOMALOUS:
src_rank = max(src_rank, 3)
if probe.get("issue") == "empty_table":
item: dict[str, Any] = {
"kind": sc.ROWS_DROP,
"severity": "action",
"label": "目标表当前无数据(0 行)",
}
if not restricted:
item["source"] = src.name
item["table"] = matched_name
src_issues.append(item)
for wi in probe.get("window_issues") or []:
item = {
"kind": wi["kind"],
"severity": wi["severity"],
"label": sc.KIND_LABEL.get(wi["kind"], wi["kind"]),
}
if not restricted:
item["source"] = src.name
item["table"] = matched_name
item["column"] = wi["column"]
if "max_date" in wi:
item["max_date"] = wi["max_date"]
if "min_date" in wi:
item["min_date"] = wi["min_date"]
src_issues.append(item)
if not restricted:
probed_tables.append(probe)
src_verdict = rank_to_verdict[src_rank]
overall_rank = max(overall_rank, src_rank)
all_issues.extend(src_issues)
display_title = (src.description or "关联业务源") if restricted else src.name
rep: dict[str, Any] = {
"source_label": display_title,
"verdict": src_verdict,
"issue_count": len(src_issues),
"issues": src_issues,
"phrase": _render_phrase(display_title, src_verdict, src_issues, probe_unverified=probe_unverified),
}
if not restricted:
rep["source"] = src.name
rep["tables_probed"] = probed_tables
source_reports.append(rep)
overall_verdict = rank_to_verdict[overall_rank]
lead_phrase = source_reports[0]["phrase"] if len(source_reports) == 1 else (
";".join(r["phrase"] for r in source_reports)
)
out: dict[str, Any] = {
"kind": ARTIFACT_KIND,
"verdict": overall_verdict,
"sources": source_reports,
"issues": all_issues,
"phrase": lead_phrase,
}
if workflow_id:
out["caliber_ref"] = {
"workflow_id": workflow_id,
**({"version": version} if version is not None else {}),
**({"card_id": card_id} if card_id else {}),
**({"metric_id": metric_id} if metric_id else {}),
}
return out
def is_health_result(content: Any) -> bool:
return (
isinstance(content, Mapping)
and content.get("kind") == ARTIFACT_KIND
and content.get("verdict") in VERDICTS
and isinstance(content.get("sources"), list)
)
async def store_source_health(result: Mapping[str, Any], *, run_id: str | None, node_id: str | None = None) -> str:
return await artifact_store.put_json(dict(result), kind=ARTIFACT_KIND, run_id=run_id, meta={
"verdict": result.get("verdict"),
"node_id": node_id or "",
})
def ledger_entry(
result: Mapping[str, Any],
*,
node_id: str,
exec_no: int,
artifact: str,
call_id: str | None = None,
) -> dict[str, Any]:
sources = [
str(s.get("source") or s.get("source_label") or "")
for s in (result.get("sources") or [])
if isinstance(s, Mapping)
]
entry: dict[str, Any] = {
"kind": LEDGER_KIND,
"node_id": node_id,
"exec": exec_no,
"artifact": artifact,
"verdict": result.get("verdict"),
"sources": [s for s in sources if s],
"issue_count": len(result.get("issues") or []),
}
if call_id is not None:
entry["call_id"] = call_id
return entry