mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 07:10:45 +08:00
Correctness (A): normalize port-map names with the template rules (snapshot v2, v1 replays unchanged); protect mappings/templates that are referenced; stop dropping edited mapping text; accept pulled-but-not-shutdown ports as migrated; validate baseline ownership on project create; one canonical key form; fix built-in template tolerances and upgrade old packaged values in place. Cross-vendor identity (B): single identity pipeline for every path, per-side and per-vendor interface rules, value maps, filters after normalization, mapping pre-flight for compare and cutover projects, SQL eligibility fixes. Data quality (V): no_data / suspect_parse / not_collected sheet states, command slice coverage, directional compare modes, value-sorted duplicate pairing, before-side raw names in results and CSV. Robustness (H): bounded auto-run history, background evaluate/finish with polling, manual route tables out of the acceptance gate, baseline row cache, scoped duplicate-key failures, merged HF tasks with a per-NE session cap, same-device cutover, explicit scope_by_port_map, read-only GETs, resolvable red tickets. Frontend: pre-flight report, mapping table view, template editor support for the new rules, data-state banners. Docs: canonical field contract. Not included: Huawei status parsers (no real samples), SQL-path route compare. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
198 lines
7.7 KiB
Python
198 lines
7.7 KiB
Python
"""Collection quality of a compare side: is a missing row a business loss or a data problem?
|
||
|
||
Compare results are only as good as the two batches behind them. This module
|
||
reads *receipts* (``BizStateBatchCommand``: counts and status, never ``raw_text``)
|
||
to tell apart:
|
||
|
||
- ``not_collected`` – a side never ran a command (or the slice) for the sheet
|
||
- ``suspect_parse`` – the device printed lines but the parser produced no rows
|
||
- ``no_data`` – both sides ran fine and are really empty
|
||
- ``ok`` – ordinary comparison
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Mapping, Sequence
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..models import BizStateBatchCommand
|
||
from .compare_rules import scalar_text
|
||
|
||
# More than this many CLI lines with zero parsed rows is not "an empty table".
|
||
SUSPECT_RAW_LINES = 5
|
||
DATA_STATES = ("ok", "no_data", "suspect_parse", "not_collected")
|
||
UNJUDGED_STATES = ("no_data", "not_collected")
|
||
|
||
|
||
def _has_receipts(db: Session, batch_id: str) -> bool:
|
||
return bool(batch_id) and db.query(BizStateBatchCommand.id).filter(
|
||
BizStateBatchCommand.batch_id == batch_id).limit(1).first() is not None
|
||
|
||
|
||
def side_receipts(db: Session, batch_id: str, metric_id: str) -> list[dict[str, Any]]:
|
||
if not batch_id or not metric_id:
|
||
return []
|
||
rows = (
|
||
db.query(
|
||
BizStateBatchCommand.profile_id, BizStateBatchCommand.parse_status,
|
||
BizStateBatchCommand.row_count, BizStateBatchCommand.raw_line_count,
|
||
BizStateBatchCommand.declared_total,
|
||
)
|
||
.filter(BizStateBatchCommand.batch_id == batch_id, BizStateBatchCommand.metric_id == metric_id)
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"profile_id": str(r[0] or ""), "parse_status": str(r[1] or ""),
|
||
"row_count": int(r[2] or 0), "raw_line_count": int(r[3] or 0),
|
||
"declared_total": int(r[4] or 0),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def collect_quality(receipts: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||
statuses: dict[str, int] = {}
|
||
for r in receipts:
|
||
key = str(r.get("parse_status") or "unknown")
|
||
statuses[key] = statuses.get(key, 0) + 1
|
||
return {
|
||
"commands": len(receipts),
|
||
"parse_status": statuses,
|
||
"row_count": sum(int(r.get("row_count") or 0) for r in receipts),
|
||
"raw_line_count": sum(int(r.get("raw_line_count") or 0) for r in receipts),
|
||
"declared_total": sum(int(r.get("declared_total") or 0) for r in receipts),
|
||
"profile_ids": sorted({str(r.get("profile_id") or "") for r in receipts if r.get("profile_id")}),
|
||
}
|
||
|
||
|
||
def sheet_slice(sheet: Mapping[str, Any]) -> dict[str, str]:
|
||
"""``{field: value}`` the sheet pins with top-level ``eq`` row filters."""
|
||
out: dict[str, str] = {}
|
||
for f in sheet.get("row_filters") or []:
|
||
if not isinstance(f, Mapping) or "any" in f or "all" in f:
|
||
continue
|
||
if str(f.get("op") or "eq").strip().lower() not in ("eq", "==", "ci_eq"):
|
||
continue
|
||
field = str(f.get("field") or "").strip()
|
||
value = scalar_text(f.get("value")).strip().lower()
|
||
if field and value:
|
||
out[field] = value
|
||
return out
|
||
|
||
|
||
def covers_slice(
|
||
receipts: Sequence[Mapping[str, Any]], wanted: Mapping[str, str], *, profile_slice: Any = None,
|
||
) -> bool:
|
||
"""True when at least one successful command could have produced ``wanted``.
|
||
|
||
A command without a declared slice (or unknown profile) covers everything.
|
||
"""
|
||
if profile_slice is None:
|
||
from .profiles import get_profile
|
||
|
||
def profile_slice(pid: str) -> dict[str, str]: # noqa: F811
|
||
prof = get_profile(pid) if pid else None
|
||
return dict(prof.slice) if prof is not None and prof.slice else {}
|
||
|
||
for r in receipts:
|
||
if str(r.get("parse_status") or "") in ("failed", "skipped_custom"):
|
||
continue
|
||
declared = profile_slice(str(r.get("profile_id") or ""))
|
||
if all(
|
||
f not in declared or str(declared[f]).strip().lower() == v
|
||
for f, v in wanted.items()
|
||
):
|
||
return True
|
||
return False
|
||
|
||
|
||
def classify_sheet(
|
||
*,
|
||
before: Sequence[Mapping[str, Any]] | None,
|
||
after: Sequence[Mapping[str, Any]] | None,
|
||
before_rows: int,
|
||
after_rows: int,
|
||
wanted_slice: Mapping[str, str] | None = None,
|
||
) -> tuple[str, list[str]]:
|
||
"""``(data_state, reasons)`` for one sheet; reasons name the offending side.
|
||
|
||
``None`` receipts mean the batch carries no receipts at all (imported or
|
||
legacy data): that side cannot be judged and is trusted as-is.
|
||
"""
|
||
wanted = dict(wanted_slice or {})
|
||
reasons: list[str] = []
|
||
for side, receipts in (("before", before), ("after", after)):
|
||
if receipts is None:
|
||
continue
|
||
if not receipts:
|
||
reasons.append(f"{side}:no_command")
|
||
elif wanted and not covers_slice(receipts, wanted):
|
||
reasons.append(f"{side}:slice_not_collected")
|
||
if reasons:
|
||
return "not_collected", reasons
|
||
for side, receipts in (("before", before), ("after", after)):
|
||
if receipts is None:
|
||
continue
|
||
q = collect_quality(receipts)
|
||
if q["row_count"] == 0 and q["raw_line_count"] > SUSPECT_RAW_LINES:
|
||
reasons.append(f"{side}:parsed_0_of_{q['raw_line_count']}_lines")
|
||
elif q["commands"] and q["parse_status"].get("failed", 0) == q["commands"]:
|
||
reasons.append(f"{side}:all_commands_failed")
|
||
if reasons:
|
||
return "suspect_parse", reasons
|
||
if before_rows == 0 and after_rows == 0:
|
||
return "no_data", ["both_sides_empty"]
|
||
return "ok", []
|
||
|
||
|
||
def assess_sheet(
|
||
db: Session, sheet: Mapping[str, Any], *, before_batch_id: str, after_batch_id: str,
|
||
before_rows: int, after_rows: int,
|
||
) -> dict[str, Any]:
|
||
"""Quality block stored on a sheet summary."""
|
||
metric_id = str(sheet.get("metric_id") or "")
|
||
before = side_receipts(db, before_batch_id, metric_id) if _has_receipts(db, before_batch_id) else None
|
||
after = side_receipts(db, after_batch_id, metric_id) if _has_receipts(db, after_batch_id) else None
|
||
state, reasons = classify_sheet(
|
||
before=before, after=after, before_rows=before_rows, after_rows=after_rows,
|
||
wanted_slice=sheet_slice(sheet),
|
||
)
|
||
return {
|
||
"data_state": state,
|
||
"data_state_reasons": reasons,
|
||
"collect_quality": {
|
||
"before": collect_quality(before or []), "after": collect_quality(after or []),
|
||
"unknown_sides": [n for n, r in (("before", before), ("after", after)) if r is None],
|
||
},
|
||
}
|
||
|
||
|
||
def command_coverage(
|
||
db: Session, *, before_batch_id: str, after_batch_id: str,
|
||
) -> dict[str, Any]:
|
||
"""Collected "metric{slice}" labels per side and their difference.
|
||
|
||
Profile ids differ per vendor (``zte.x`` vs ``huawei.x``), so the comparison
|
||
is on what a command *produces*: its metric and slice.
|
||
"""
|
||
from .profiles import get_profile
|
||
|
||
def labels(batch_id: str) -> set[str]:
|
||
if not batch_id:
|
||
return set()
|
||
rows = db.query(BizStateBatchCommand.metric_id, BizStateBatchCommand.profile_id).filter(
|
||
BizStateBatchCommand.batch_id == batch_id, BizStateBatchCommand.metric_id != "",
|
||
).distinct().all()
|
||
out: set[str] = set()
|
||
for metric_id, profile_id in rows:
|
||
prof = get_profile(str(profile_id or "")) if profile_id else None
|
||
sl = dict(prof.slice) if prof is not None and prof.slice else {}
|
||
suffix = ",".join(f"{k}={v}" for k, v in sorted(sl.items()))
|
||
out.add(f"{metric_id}{{{suffix}}}" if suffix else str(metric_id))
|
||
return out
|
||
|
||
b, a = labels(before_batch_id), labels(after_batch_id)
|
||
return {"before": sorted(b), "after": sorted(a),
|
||
"only_before": sorted(b - a), "only_after": sorted(a - b)}
|