chore: move kb_check.py para dentro da knowledge-base
Script e Makefile agora vivem junto à base que gerenciam. Caminho anterior: GLPI11/scripts/kb_check.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2b10435643
commit
fb46ac0fea
2 changed files with 295 additions and 0 deletions
7
Makefile
Normal file
7
Makefile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.PHONY: kb-check kb-fix
|
||||||
|
|
||||||
|
kb-check:
|
||||||
|
python3 kb_check.py
|
||||||
|
|
||||||
|
kb-fix:
|
||||||
|
python3 kb_check.py --fix
|
||||||
288
kb_check.py
Normal file
288
kb_check.py
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Knowledge base consistency checker/fixer for AI-oriented records."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
KB_ROOT = Path(__file__).resolve().parent
|
||||||
|
INDEX_PATH = KB_ROOT / "index.json"
|
||||||
|
SCHEMA_PATH = KB_ROOT / "schemas" / "knowledge-record.schema.json"
|
||||||
|
RECORDS_ROOT = KB_ROOT / "records"
|
||||||
|
|
||||||
|
|
||||||
|
def fail(errors: list[str]) -> int:
|
||||||
|
print("KB CHECK: FAILED")
|
||||||
|
for err in errors:
|
||||||
|
print(f"- {err}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def ok(prefix: str = "KB CHECK") -> int:
|
||||||
|
print(f"{prefix}: OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_scalar(value: str) -> Any:
|
||||||
|
value = value.strip()
|
||||||
|
if value == "[]":
|
||||||
|
return []
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_front_matter(markdown_text: str) -> dict[str, Any]:
|
||||||
|
lines = markdown_text.splitlines()
|
||||||
|
if len(lines) < 3 or lines[0].strip() != "---":
|
||||||
|
raise ValueError("front matter ausente ou invalido (linha inicial '---').")
|
||||||
|
|
||||||
|
end_idx = None
|
||||||
|
for i in range(1, len(lines)):
|
||||||
|
if lines[i].strip() == "---":
|
||||||
|
end_idx = i
|
||||||
|
break
|
||||||
|
if end_idx is None:
|
||||||
|
raise ValueError("front matter sem delimitador final '---'.")
|
||||||
|
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
current_key: str | None = None
|
||||||
|
|
||||||
|
for raw in lines[1:end_idx]:
|
||||||
|
if not raw.strip():
|
||||||
|
continue
|
||||||
|
if raw.startswith(" - ") and current_key:
|
||||||
|
if not isinstance(data.get(current_key), list):
|
||||||
|
data[current_key] = []
|
||||||
|
data[current_key].append(raw[4:].strip())
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ":" not in raw:
|
||||||
|
raise ValueError(f"linha de metadado invalida: '{raw}'")
|
||||||
|
|
||||||
|
key, val = raw.split(":", 1)
|
||||||
|
key = key.strip()
|
||||||
|
val = val.strip()
|
||||||
|
|
||||||
|
if val == "":
|
||||||
|
data[key] = []
|
||||||
|
current_key = key
|
||||||
|
else:
|
||||||
|
data[key] = parse_scalar(val)
|
||||||
|
current_key = key
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def validate_metadata(
|
||||||
|
metadata: dict[str, Any], schema: dict[str, Any], record_path: Path
|
||||||
|
) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
required = schema.get("required", [])
|
||||||
|
props = schema.get("properties", {})
|
||||||
|
|
||||||
|
for key in required:
|
||||||
|
if key not in metadata:
|
||||||
|
errors.append(f"{record_path}: campo obrigatorio ausente '{key}'")
|
||||||
|
|
||||||
|
for key, rule in props.items():
|
||||||
|
if key not in metadata:
|
||||||
|
continue
|
||||||
|
value = metadata[key]
|
||||||
|
expected_type = rule.get("type")
|
||||||
|
|
||||||
|
if expected_type == "array" and not isinstance(value, list):
|
||||||
|
errors.append(f"{record_path}: '{key}' deve ser array")
|
||||||
|
continue
|
||||||
|
if expected_type == "string" and not isinstance(value, str):
|
||||||
|
errors.append(f"{record_path}: '{key}' deve ser string")
|
||||||
|
continue
|
||||||
|
|
||||||
|
enum = rule.get("enum")
|
||||||
|
if enum and value not in enum:
|
||||||
|
errors.append(f"{record_path}: '{key}' fora do enum permitido")
|
||||||
|
|
||||||
|
pattern = rule.get("pattern")
|
||||||
|
if pattern and isinstance(value, str):
|
||||||
|
if not re.match(pattern, value):
|
||||||
|
errors.append(f"{record_path}: '{key}' nao corresponde ao padrao esperado")
|
||||||
|
|
||||||
|
if key in {"created_at", "updated_at"} and isinstance(value, str):
|
||||||
|
if not re.match(r"^\d{4}-\d{2}-\d{2}$", value):
|
||||||
|
errors.append(f"{record_path}: '{key}' deve estar em formato YYYY-MM-DD")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path) -> dict[str, Any]:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary(text: str, title: str) -> str:
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or line.startswith("---"):
|
||||||
|
continue
|
||||||
|
return line[:180]
|
||||||
|
return title[:180]
|
||||||
|
|
||||||
|
|
||||||
|
def collect_records(schema: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]:
|
||||||
|
errors: list[str] = []
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
record_files = sorted(RECORDS_ROOT.rglob("*.md"))
|
||||||
|
if not record_files:
|
||||||
|
errors.append("nenhum registro encontrado em knowledge-base/records")
|
||||||
|
return records, errors
|
||||||
|
|
||||||
|
for file_path in record_files:
|
||||||
|
rel_path = str(file_path.relative_to(KB_ROOT))
|
||||||
|
text = file_path.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
metadata = parse_front_matter(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
errors.append(f"{file_path}: {exc}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
validation_errors = validate_metadata(metadata, schema, file_path)
|
||||||
|
if validation_errors:
|
||||||
|
errors.extend(validation_errors)
|
||||||
|
continue
|
||||||
|
|
||||||
|
rec_id = metadata.get("id")
|
||||||
|
if not isinstance(rec_id, str):
|
||||||
|
errors.append(f"{file_path}: id invalido")
|
||||||
|
continue
|
||||||
|
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"id": rec_id,
|
||||||
|
"title": metadata["title"],
|
||||||
|
"domain": metadata["domain"],
|
||||||
|
"tags": metadata["tags"],
|
||||||
|
"status": metadata["status"],
|
||||||
|
"severity": metadata["severity"],
|
||||||
|
"path": rel_path,
|
||||||
|
"summary": build_summary(text, metadata["title"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return records, errors
|
||||||
|
|
||||||
|
|
||||||
|
def check_index_against_records(index: dict[str, Any], records: list[dict[str, Any]]) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
indexed_records = index.get("records", [])
|
||||||
|
|
||||||
|
index_by_id: dict[str, dict[str, Any]] = {}
|
||||||
|
index_by_path: dict[str, dict[str, Any]] = {}
|
||||||
|
for rec in indexed_records:
|
||||||
|
rec_id = rec.get("id")
|
||||||
|
rec_path = rec.get("path")
|
||||||
|
if not rec_id or not rec_path:
|
||||||
|
errors.append("index.json: cada registro deve conter 'id' e 'path'")
|
||||||
|
continue
|
||||||
|
if rec_id in index_by_id:
|
||||||
|
errors.append(f"index.json: id duplicado '{rec_id}'")
|
||||||
|
if rec_path in index_by_path:
|
||||||
|
errors.append(f"index.json: path duplicado '{rec_path}'")
|
||||||
|
index_by_id[rec_id] = rec
|
||||||
|
index_by_path[rec_path] = rec
|
||||||
|
|
||||||
|
records_by_id = {rec["id"]: rec for rec in records}
|
||||||
|
|
||||||
|
for rec in records:
|
||||||
|
indexed = index_by_id.get(rec["id"])
|
||||||
|
if not indexed:
|
||||||
|
errors.append(f"{rec['path']}: id '{rec['id']}' nao encontrado em index.json")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if indexed.get("path") != rec["path"]:
|
||||||
|
errors.append(
|
||||||
|
f"{rec['path']}: path em index.json diverge "
|
||||||
|
f"(index='{indexed.get('path')}', file='{rec['path']}')"
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in ("title", "domain", "status", "severity"):
|
||||||
|
if indexed.get(key) != rec[key]:
|
||||||
|
errors.append(
|
||||||
|
f"{rec['path']}: campo '{key}' divergente entre registro e index.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
for rec in indexed_records:
|
||||||
|
rec_id = rec.get("id")
|
||||||
|
rec_path = rec.get("path")
|
||||||
|
if not isinstance(rec_path, str):
|
||||||
|
continue
|
||||||
|
abs_path = KB_ROOT / rec_path
|
||||||
|
if not abs_path.exists():
|
||||||
|
errors.append(f"index.json: arquivo referenciado inexistente '{rec_path}'")
|
||||||
|
if isinstance(rec_id, str) and rec_id not in records_by_id:
|
||||||
|
errors.append(f"index.json: id '{rec_id}' sem arquivo correspondente em records/")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def write_fixed_index(old_index: dict[str, Any], records: list[dict[str, Any]]) -> None:
|
||||||
|
new_index = {
|
||||||
|
"version": old_index.get("version", "1.0.0"),
|
||||||
|
"last_updated": date.today().isoformat(),
|
||||||
|
"records": sorted(records, key=lambda r: r["id"]),
|
||||||
|
}
|
||||||
|
with INDEX_PATH.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(new_index, f, ensure_ascii=False, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def run_check(fix: bool) -> int:
|
||||||
|
if not INDEX_PATH.exists():
|
||||||
|
return fail([f"index nao encontrado em {INDEX_PATH}"])
|
||||||
|
if not SCHEMA_PATH.exists():
|
||||||
|
return fail([f"schema nao encontrado em {SCHEMA_PATH}"])
|
||||||
|
if not RECORDS_ROOT.exists():
|
||||||
|
return fail([f"diretorio de registros nao encontrado em {RECORDS_ROOT}"])
|
||||||
|
|
||||||
|
schema = load_json(SCHEMA_PATH)
|
||||||
|
index = load_json(INDEX_PATH)
|
||||||
|
records, record_errors = collect_records(schema)
|
||||||
|
|
||||||
|
if record_errors and fix:
|
||||||
|
return fail(
|
||||||
|
["kb-fix nao pode continuar enquanto houver metadados invalidos em registros."]
|
||||||
|
+ record_errors
|
||||||
|
)
|
||||||
|
|
||||||
|
if fix:
|
||||||
|
write_fixed_index(index, records)
|
||||||
|
print("KB FIX: index.json sincronizado com registros validos.")
|
||||||
|
check_errors = check_index_against_records(load_json(INDEX_PATH), records)
|
||||||
|
if check_errors:
|
||||||
|
return fail(check_errors)
|
||||||
|
return ok("KB FIX")
|
||||||
|
|
||||||
|
check_errors = record_errors + check_index_against_records(index, records)
|
||||||
|
if check_errors:
|
||||||
|
return fail(check_errors)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--fix",
|
||||||
|
action="store_true",
|
||||||
|
help="corrige automaticamente index.json com base nos registros validos",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
return run_check(fix=args.fix)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in a new issue