keygraph 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keygraph/__init__.py +8 -0
- keygraph/__main__.py +3 -0
- keygraph/adapters/__init__.py +33 -0
- keygraph/adapters/base.py +57 -0
- keygraph/adapters/clickhouse.py +67 -0
- keygraph/adapters/dump.py +169 -0
- keygraph/adapters/fetch.py +93 -0
- keygraph/adapters/live.py +50 -0
- keygraph/adapters/metabase.py +78 -0
- keygraph/adapters/mongo.py +93 -0
- keygraph/adapters/mysql.py +30 -0
- keygraph/adapters/postgres.py +68 -0
- keygraph/adapters/sqlite.py +24 -0
- keygraph/adapters/sqlserver.py +65 -0
- keygraph/areas.py +225 -0
- keygraph/cli.py +497 -0
- keygraph/context.py +387 -0
- keygraph/docs_out.py +199 -0
- keygraph/explorer.py +128 -0
- keygraph/graph.py +163 -0
- keygraph/hubs.py +48 -0
- keygraph/infer.py +344 -0
- keygraph/mcp_server.py +147 -0
- keygraph/mirrors.py +109 -0
- keygraph/model.py +149 -0
- keygraph/notes.py +114 -0
- keygraph/overrides.py +112 -0
- keygraph/probe.py +119 -0
- keygraph/profile.py +262 -0
- keygraph/query.py +376 -0
- keygraph/report.py +92 -0
- keygraph/search.py +175 -0
- keygraph/static/DAGRE_LICENSE +19 -0
- keygraph/static/OFL-Inter.txt +93 -0
- keygraph/static/OFL-JetBrainsMono.txt +93 -0
- keygraph/static/dagre-1.1.4.min.js +801 -0
- keygraph/static/explorer.html +69 -0
- keygraph/static/explorer.js +1076 -0
- keygraph/static/favicon.svg +1 -0
- keygraph/static/keygraph-logo.svg +1 -0
- keygraph/static/keygraph-mark.svg +1 -0
- keygraph/static/keygraph.inline.css +843 -0
- keygraph/static/kg-diagram.js +267 -0
- keygraph/static/kg-theme.js +269 -0
- keygraph/summary.py +60 -0
- keygraph-0.2.0.dist-info/METADATA +334 -0
- keygraph-0.2.0.dist-info/RECORD +50 -0
- keygraph-0.2.0.dist-info/WHEEL +4 -0
- keygraph-0.2.0.dist-info/entry_points.txt +2 -0
- keygraph-0.2.0.dist-info/licenses/LICENSE +21 -0
keygraph/context.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Business context stored relationally in SQLite (with FTS5 search): concepts, links, relations,
|
|
2
|
+
column semantics, metrics and table notes. Every row records its source, a verified flag and a timestamp."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_DIGITS = {**{0x0660 + i: 0x30 + i for i in range(10)}, **{0x06F0 + i: 0x30 + i for i in range(10)}}
|
|
13
|
+
_MAP = {0x064A: 0x06CC, 0x0649: 0x06CC, 0x0643: 0x06A9, 0x200C: 0x20, 0x0640: None, 0x06C0: 0x0647, 0x0629: 0x0647}
|
|
14
|
+
for _c in range(0x064B, 0x0660):
|
|
15
|
+
_MAP[_c] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize(text: str) -> str:
|
|
19
|
+
"""Lowercase, unify Persian/Arabic digits, ye/kaf variants, drop diacritics, split snake/camel/dotted names."""
|
|
20
|
+
text = str(text or "")
|
|
21
|
+
text = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", text)
|
|
22
|
+
text = text.translate(_DIGITS).translate(_MAP).lower()
|
|
23
|
+
return re.sub(r"[\s_./\\:;,|()\[\]{}\-]+", " ", text).strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
STOPWORDS = frozenset("a an and are as at be by for from in into is of on or per the to with".split())
|
|
27
|
+
PHRASE_BOOST = 2.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def tokens(text: str) -> list[str]:
|
|
31
|
+
return re.findall(r"\w+", normalize(text))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SCHEMA = """
|
|
35
|
+
CREATE TABLE IF NOT EXISTS concepts(name TEXT PRIMARY KEY, definition TEXT, domain TEXT, owner TEXT, synonyms TEXT,
|
|
36
|
+
source TEXT, verified INTEGER, updated_at TEXT);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS concept_links(concept TEXT, table_id TEXT, col TEXT, role TEXT, note TEXT, source TEXT,
|
|
38
|
+
verified INTEGER, updated_at TEXT, PRIMARY KEY(concept, table_id, col, role));
|
|
39
|
+
CREATE TABLE IF NOT EXISTS concept_relations(from_concept TEXT, rel TEXT, to_concept TEXT, edge_id TEXT, note TEXT,
|
|
40
|
+
source TEXT, verified INTEGER, updated_at TEXT, PRIMARY KEY(from_concept, rel, to_concept));
|
|
41
|
+
CREATE TABLE IF NOT EXISTS column_semantics(table_id TEXT, col TEXT, meaning TEXT, unit TEXT, codes TEXT, null_meaning TEXT,
|
|
42
|
+
pii INTEGER, typical_filters TEXT, index_hint TEXT, source TEXT, verified INTEGER, updated_at TEXT,
|
|
43
|
+
PRIMARY KEY(table_id, col));
|
|
44
|
+
CREATE TABLE IF NOT EXISTS metrics(name TEXT PRIMARY KEY, definition TEXT, expression TEXT, grain TEXT, tables TEXT,
|
|
45
|
+
filters TEXT, caveats TEXT, source TEXT, verified INTEGER, updated_at TEXT);
|
|
46
|
+
CREATE TABLE IF NOT EXISTS table_notes(table_id TEXT PRIMARY KEY, grain TEXT, freshness TEXT, soft_delete TEXT,
|
|
47
|
+
date_column TEXT, recommended_indexes TEXT, pitfalls TEXT, summary TEXT, source TEXT, verified INTEGER, updated_at TEXT);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS column_profile(table_id TEXT, col TEXT, profile TEXT, profiled_at TEXT, sample_size INTEGER, method TEXT,
|
|
49
|
+
PRIMARY KEY(table_id, col));
|
|
50
|
+
CREATE TABLE IF NOT EXISTS column_details(table_id TEXT, col TEXT, detail TEXT, PRIMARY KEY(table_id, col));
|
|
51
|
+
CREATE TABLE IF NOT EXISTS table_info(table_id TEXT PRIMARY KEY, info TEXT);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS curation(kind TEXT, body JSON, source TEXT);
|
|
53
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(kind, ref, title, body, tokenize='unicode61');
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
KINDS = {"concept", "relation", "link", "column", "metric", "table"}
|
|
57
|
+
ROLES = ("primary-record", "attribute", "foreign-reference", "measure", "filter")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _j(v) -> str:
|
|
61
|
+
return json.dumps(v, ensure_ascii=False) if v not in (None, "") else ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _l(v) -> list:
|
|
65
|
+
if v in (None, ""):
|
|
66
|
+
return []
|
|
67
|
+
return v if isinstance(v, list) else [x.strip() for x in str(v).split(",") if x.strip()]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def safe_source(src) -> str:
|
|
71
|
+
"""A context source as it may appear in outputs: never an absolute local path (keep the bare file name)."""
|
|
72
|
+
s = str(src or "").strip()
|
|
73
|
+
if s.startswith(("/", "~", "\\")) or re.match(r"^[A-Za-z]:[\\/]", s):
|
|
74
|
+
return re.split(r"[\\/]", s.rstrip("/\\"))[-1]
|
|
75
|
+
return s
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ContextDB:
|
|
79
|
+
def __init__(self, path: str | Path, graph=None, readonly: bool = False):
|
|
80
|
+
"""readonly: open an existing file without writing to it; a missing file gives an empty in-memory context."""
|
|
81
|
+
self.path = Path(path)
|
|
82
|
+
if readonly and self.path.exists():
|
|
83
|
+
self.db = sqlite3.connect(f"{self.path.resolve().as_uri()}?mode=ro", uri=True)
|
|
84
|
+
elif readonly:
|
|
85
|
+
self.db = sqlite3.connect(":memory:")
|
|
86
|
+
else:
|
|
87
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
self.db = sqlite3.connect(self.path)
|
|
89
|
+
self.db.row_factory = sqlite3.Row
|
|
90
|
+
if not (readonly and self.path.exists()):
|
|
91
|
+
self.db.executescript(SCHEMA)
|
|
92
|
+
else: # a file from an older keygraph may lack newer tables: read them as empty (temp tables, file untouched)
|
|
93
|
+
have = {r[0] for r in self.db.execute("SELECT name FROM sqlite_master")}
|
|
94
|
+
for stmt in SCHEMA.split(";"):
|
|
95
|
+
m = re.search(r"CREATE (?:VIRTUAL )?TABLE IF NOT EXISTS (\w+)", stmt)
|
|
96
|
+
if m and m.group(1) not in have:
|
|
97
|
+
self.db.execute(stmt.replace("IF NOT EXISTS ", "IF NOT EXISTS temp.", 1))
|
|
98
|
+
self.graph = graph
|
|
99
|
+
self.warnings: list[str] = []
|
|
100
|
+
|
|
101
|
+
def close(self) -> None:
|
|
102
|
+
try:
|
|
103
|
+
self.db.commit()
|
|
104
|
+
finally:
|
|
105
|
+
self.db.close()
|
|
106
|
+
|
|
107
|
+
def __enter__(self) -> "ContextDB":
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def __exit__(self, exc_type, *_) -> None:
|
|
111
|
+
if exc_type is None:
|
|
112
|
+
self.close()
|
|
113
|
+
else: # leave half-done writes uncommitted, but always release the handle
|
|
114
|
+
self.db.close()
|
|
115
|
+
|
|
116
|
+
# --- reference checks --------------------------------------------------------
|
|
117
|
+
def _tid(self, ref: str | None) -> str:
|
|
118
|
+
if not ref or not self.graph:
|
|
119
|
+
return ref or ""
|
|
120
|
+
try:
|
|
121
|
+
return self.graph.resolve(ref)
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
self.warnings.append(f"unknown table '{ref}': {exc}")
|
|
124
|
+
return ref
|
|
125
|
+
|
|
126
|
+
def _check_col(self, tid: str, col: str | None) -> None:
|
|
127
|
+
if col and self.graph and tid in self.graph.nodes and col not in {c["name"] for c in self.graph.nodes[tid]["columns"]}:
|
|
128
|
+
self.warnings.append(f"unknown column {tid}.{col}")
|
|
129
|
+
|
|
130
|
+
# --- writes ------------------------------------------------------------------
|
|
131
|
+
def _meta(self, d: dict, default_source: str, default_verified) -> tuple:
|
|
132
|
+
v = d.get("verified", default_verified)
|
|
133
|
+
return (safe_source(d.get("source") or default_source) or "unknown", 1 if v in (True, 1, "true", "yes") else 0,
|
|
134
|
+
time.strftime("%Y-%m-%dT%H:%M:%S"))
|
|
135
|
+
|
|
136
|
+
def upsert(self, kind: str, d: dict, source: str = "", verified=False) -> None:
|
|
137
|
+
if kind not in KINDS:
|
|
138
|
+
raise ValueError(f"unknown kind '{kind}'; use one of {sorted(KINDS)}")
|
|
139
|
+
src = self._meta(d, source, verified)
|
|
140
|
+
x = self.db
|
|
141
|
+
if kind == "concept":
|
|
142
|
+
name = str(d["name"]).strip()
|
|
143
|
+
x.execute("INSERT OR REPLACE INTO concepts VALUES(?,?,?,?,?,?,?,?)",
|
|
144
|
+
(name, d.get("definition", ""), d.get("domain", ""), d.get("owner", ""), _j(_l(d.get("synonyms"))), *src))
|
|
145
|
+
for ln in d.get("links") or []:
|
|
146
|
+
self.upsert("link", {**ln, "concept": name, "source": ln.get("source") or d.get("source"),
|
|
147
|
+
"verified": ln.get("verified", d.get("verified"))}, source, verified)
|
|
148
|
+
elif kind == "link":
|
|
149
|
+
tid = self._tid(d["table"])
|
|
150
|
+
col = d.get("column") or ""
|
|
151
|
+
self._check_col(tid, col)
|
|
152
|
+
role = d.get("role") or ("primary-record" if not col else "attribute")
|
|
153
|
+
if role not in ROLES:
|
|
154
|
+
self.warnings.append(f"link role '{role}' is not one of {ROLES}")
|
|
155
|
+
x.execute("INSERT OR REPLACE INTO concept_links VALUES(?,?,?,?,?,?,?,?)",
|
|
156
|
+
(d["concept"], tid, col, role, d.get("note", ""), *src))
|
|
157
|
+
elif kind == "relation":
|
|
158
|
+
x.execute("INSERT OR REPLACE INTO concept_relations VALUES(?,?,?,?,?,?,?,?)",
|
|
159
|
+
(d["from"], d.get("rel", "related-to"), d["to"], d.get("edge", ""), d.get("note", ""), *src))
|
|
160
|
+
elif kind == "column":
|
|
161
|
+
tid = self._tid(d["table"])
|
|
162
|
+
self._check_col(tid, d["column"])
|
|
163
|
+
x.execute("INSERT OR REPLACE INTO column_semantics VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
164
|
+
(tid, d["column"], d.get("meaning", ""), d.get("unit", ""), _j(d.get("codes") or {}),
|
|
165
|
+
d.get("null_meaning", ""), 1 if d.get("pii") in (True, 1, "true", "yes") else 0,
|
|
166
|
+
_j(d.get("typical_filters", "")), d.get("index_hint", ""), *src))
|
|
167
|
+
elif kind == "metric":
|
|
168
|
+
tbls = [self._tid(t) for t in _l(d.get("tables"))]
|
|
169
|
+
x.execute("INSERT OR REPLACE INTO metrics VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
170
|
+
(d["name"], d.get("definition", ""), d.get("expression", ""), d.get("grain", ""), _j(tbls),
|
|
171
|
+
d.get("filters", ""), d.get("caveats", ""), *src))
|
|
172
|
+
elif kind == "table":
|
|
173
|
+
tid = self._tid(d["table"])
|
|
174
|
+
self._check_col(tid, d.get("date_column"))
|
|
175
|
+
x.execute("INSERT OR REPLACE INTO table_notes VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
|
176
|
+
(tid, d.get("grain", ""), d.get("freshness", ""), d.get("soft_delete", ""), d.get("date_column", ""),
|
|
177
|
+
_j(_l(d.get("recommended_indexes"))), d.get("pitfalls", ""), d.get("summary", ""), *src))
|
|
178
|
+
x.commit()
|
|
179
|
+
|
|
180
|
+
def import_path(self, path: str | Path, default_verified=False) -> dict:
|
|
181
|
+
p = Path(path)
|
|
182
|
+
files = sorted(f for f in ([p] if p.is_file() else p.rglob("*")) if f.suffix.lower() in (".yaml", ".yml", ".json"))
|
|
183
|
+
counts = {k: 0 for k in ("concepts", "relations", "columns", "metrics", "tables")}
|
|
184
|
+
for f in files:
|
|
185
|
+
text = f.read_text(encoding="utf-8")
|
|
186
|
+
if f.suffix.lower() == ".json":
|
|
187
|
+
raw = json.loads(text)
|
|
188
|
+
else:
|
|
189
|
+
import yaml
|
|
190
|
+
raw = yaml.safe_load(text) or {}
|
|
191
|
+
rel = f.relative_to(p).as_posix() if p.is_dir() else f.name
|
|
192
|
+
if not isinstance(raw, dict):
|
|
193
|
+
raise ValueError(f"context file {rel}: expected a mapping at the top level, got {type(raw).__name__}")
|
|
194
|
+
self._save_curation(rel, raw)
|
|
195
|
+
src, ver = safe_source(raw.get("source")) or rel, raw.get("verified", default_verified)
|
|
196
|
+
for key, kind in (("concepts", "concept"), ("relations", "relation"), ("columns", "column"),
|
|
197
|
+
("metrics", "metric"), ("tables", "table")):
|
|
198
|
+
for item in raw.get(key) or []:
|
|
199
|
+
self.upsert(kind, item, src, ver)
|
|
200
|
+
counts[key] += 1
|
|
201
|
+
self.reindex()
|
|
202
|
+
return counts
|
|
203
|
+
|
|
204
|
+
def clear_curation(self) -> None:
|
|
205
|
+
"""Forget all curation rules (a build that imports context starts from the files it imports now)."""
|
|
206
|
+
self.db.execute("DELETE FROM curation")
|
|
207
|
+
self.db.commit()
|
|
208
|
+
|
|
209
|
+
def _save_curation(self, source: str, raw: dict) -> None:
|
|
210
|
+
"""Replace this file's curation rows, so importing the same file again never duplicates rules."""
|
|
211
|
+
self.db.execute("DELETE FROM curation WHERE source=?", (source,))
|
|
212
|
+
for rule in raw.get("areas") or []:
|
|
213
|
+
self.db.execute("INSERT INTO curation VALUES('area',?,?)", (json.dumps(rule, ensure_ascii=False), source))
|
|
214
|
+
if raw.get("mirrors"):
|
|
215
|
+
self.db.execute("INSERT INTO curation VALUES('mirrors',?,?)", (json.dumps(raw["mirrors"], ensure_ascii=False), source))
|
|
216
|
+
if raw.get("hubs"):
|
|
217
|
+
self.db.execute("INSERT INTO curation VALUES('hubs',?,?)", (json.dumps(raw["hubs"], ensure_ascii=False), source))
|
|
218
|
+
self.db.commit()
|
|
219
|
+
|
|
220
|
+
# --- reads -------------------------------------------------------------------
|
|
221
|
+
def rows(self, table: str, where: str = "", args: tuple = ()) -> list[dict]:
|
|
222
|
+
out = [dict(r) for r in self.db.execute(f"SELECT * FROM {table} {where}", args)]
|
|
223
|
+
for r in out: # rows written before sources were made relative may still hold absolute paths
|
|
224
|
+
if r.get("source"):
|
|
225
|
+
r["source"] = safe_source(r["source"])
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
def curation(self) -> dict:
|
|
229
|
+
"""Area rules, hub and mirror overrides from the imported files. Malformed entries (user-written YAML)
|
|
230
|
+
are skipped with a line in out["warnings"], never raised."""
|
|
231
|
+
out: dict = {"areas": [], "hubs": {"include": [], "exclude": []}, "mirrors": {}, "warnings": []}
|
|
232
|
+
warn = out["warnings"].append
|
|
233
|
+
for r in self.db.execute("SELECT * FROM curation ORDER BY source, rowid"):
|
|
234
|
+
body, src = json.loads(r["body"]), safe_source(r["source"])
|
|
235
|
+
if r["kind"] == "area":
|
|
236
|
+
if not isinstance(body, dict):
|
|
237
|
+
warn(f"{src}: area rule {body!r} ignored: expected a mapping with name, match or merge")
|
|
238
|
+
elif not any(body.get(k) for k in ("name", "match", "merge")):
|
|
239
|
+
warn(f"{src}: area rule {body!r} ignored: it needs a name, a match or a merge")
|
|
240
|
+
else:
|
|
241
|
+
rule = dict(body)
|
|
242
|
+
for k in ("match", "pin", "exclude", "merge"):
|
|
243
|
+
if k in rule:
|
|
244
|
+
rule[k] = [str(x) for x in _l(rule[k]) if x is not None]
|
|
245
|
+
if "name" in rule:
|
|
246
|
+
rule["name"] = str(rule["name"])
|
|
247
|
+
out["areas"].append(rule)
|
|
248
|
+
elif r["kind"] == "mirrors": # {mirror schema: original schema, or false for "never a mirror"}
|
|
249
|
+
if isinstance(body, dict):
|
|
250
|
+
out["mirrors"].update({str(k): v for k, v in body.items()})
|
|
251
|
+
else:
|
|
252
|
+
warn(f"{src}: mirrors ignored: expected a mapping of mirror schema to original schema")
|
|
253
|
+
elif isinstance(body, dict):
|
|
254
|
+
for k in ("include", "exclude"):
|
|
255
|
+
out["hubs"][k] += [str(t) for t in _l(body.get(k)) if str(t) not in out["hubs"][k]]
|
|
256
|
+
else:
|
|
257
|
+
warn(f"{src}: hubs ignored: expected a mapping with include and/or exclude lists")
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
def counts(self) -> dict:
|
|
261
|
+
return {t: self.db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
|
|
262
|
+
for t in ("concepts", "concept_links", "concept_relations", "column_semantics", "metrics", "table_notes")}
|
|
263
|
+
|
|
264
|
+
def table_context(self, tid: str) -> dict:
|
|
265
|
+
n = self.rows("table_notes", "WHERE table_id=?", (tid,))
|
|
266
|
+
links = self.rows("concept_links", "WHERE table_id=?", (tid,))
|
|
267
|
+
cols = self.rows("column_semantics", "WHERE table_id=? ORDER BY col", (tid,))
|
|
268
|
+
for c in cols:
|
|
269
|
+
c["codes"] = json.loads(c["codes"]) if c["codes"] else {}
|
|
270
|
+
metrics = [m for m in self.rows("metrics") if tid in json.loads(m["tables"] or "[]")]
|
|
271
|
+
return {"notes": n[0] if n else None, "concepts": links, "columns": cols, "metrics": metrics}
|
|
272
|
+
|
|
273
|
+
def dump(self) -> dict:
|
|
274
|
+
out = {"concepts": [], "relations": [], "columns": [], "metrics": [], "tables": []}
|
|
275
|
+
for c in self.rows("concepts", "ORDER BY name"):
|
|
276
|
+
c["synonyms"] = json.loads(c["synonyms"] or "[]")
|
|
277
|
+
c["links"] = [{"table": l["table_id"], "column": l["col"], "role": l["role"], "note": l["note"],
|
|
278
|
+
"source": safe_source(l["source"]), "verified": bool(l["verified"])}
|
|
279
|
+
for l in self.rows("concept_links", "WHERE concept=?", (c["name"],))]
|
|
280
|
+
c["verified"] = bool(c["verified"])
|
|
281
|
+
out["concepts"].append(c)
|
|
282
|
+
for r in self.rows("concept_relations", "ORDER BY from_concept,rel,to_concept"):
|
|
283
|
+
out["relations"].append({"from": r["from_concept"], "rel": r["rel"], "to": r["to_concept"], "edge": r["edge_id"],
|
|
284
|
+
"note": r["note"], "source": safe_source(r["source"]), "verified": bool(r["verified"])})
|
|
285
|
+
for r in self.rows("column_semantics", "ORDER BY table_id,col"):
|
|
286
|
+
r["codes"] = json.loads(r["codes"]) if r["codes"] else {}
|
|
287
|
+
r["verified"], r["pii"] = bool(r["verified"]), bool(r["pii"])
|
|
288
|
+
r["table"], r["column"] = r.pop("table_id"), r.pop("col")
|
|
289
|
+
out["columns"].append(r)
|
|
290
|
+
for r in self.rows("metrics", "ORDER BY name"):
|
|
291
|
+
r["tables"] = json.loads(r["tables"] or "[]")
|
|
292
|
+
r["verified"] = bool(r["verified"])
|
|
293
|
+
out["metrics"].append(r)
|
|
294
|
+
for r in self.rows("table_notes", "ORDER BY table_id"):
|
|
295
|
+
r["recommended_indexes"] = json.loads(r["recommended_indexes"] or "[]")
|
|
296
|
+
r["verified"] = bool(r["verified"])
|
|
297
|
+
r["table"] = r.pop("table_id")
|
|
298
|
+
out["tables"].append(r)
|
|
299
|
+
return out
|
|
300
|
+
|
|
301
|
+
# --- full-text index ---------------------------------------------------------
|
|
302
|
+
def index_graph(self, graph) -> None:
|
|
303
|
+
self.graph = graph
|
|
304
|
+
self.db.execute("DELETE FROM search_fts WHERE kind IN ('table','column')")
|
|
305
|
+
rows = []
|
|
306
|
+
self.db.execute("DELETE FROM column_details")
|
|
307
|
+
self.db.execute("DELETE FROM table_info")
|
|
308
|
+
det, tinfo = [], []
|
|
309
|
+
for n in graph.nodes.values():
|
|
310
|
+
rows.append(("table", n["id"], n["id"], normalize(n["id"] + " " + str((n.get("info") or {}).get("comment", "")))))
|
|
311
|
+
if n.get("info"):
|
|
312
|
+
tinfo.append((n["id"], json.dumps(n["info"], ensure_ascii=False, default=str)))
|
|
313
|
+
for c in n["columns"]:
|
|
314
|
+
extra = " ".join([c.get("comment", ""), " ".join(c.get("values", []))])
|
|
315
|
+
rows.append(("column", f"{n['id']}.{c['name']}", c["name"], normalize(n["table"] + " " + c["name"] + " " + extra)))
|
|
316
|
+
if len(c) > 5:
|
|
317
|
+
det.append((n["id"], c["name"], json.dumps({k: v for k, v in c.items() if k not in ("name", "family")}, ensure_ascii=False)))
|
|
318
|
+
self.db.executemany("INSERT INTO column_details VALUES(?,?,?)", det)
|
|
319
|
+
self.db.executemany("INSERT OR REPLACE INTO table_info VALUES(?,?)", tinfo)
|
|
320
|
+
self.db.executemany("INSERT INTO search_fts(kind,ref,title,body) VALUES(?,?,?,?)", rows)
|
|
321
|
+
self.db.commit()
|
|
322
|
+
|
|
323
|
+
def save_profiles(self, profiles: dict) -> None:
|
|
324
|
+
"""profiles: {"schema.table.col": profile dict}. PII profiles never carry raw values (see profile.py)."""
|
|
325
|
+
for key, p in profiles.items():
|
|
326
|
+
tid, col = key.rsplit(".", 1)
|
|
327
|
+
self.db.execute("INSERT OR REPLACE INTO column_profile VALUES(?,?,?,?,?,?)",
|
|
328
|
+
(tid, col, json.dumps(p, ensure_ascii=False, default=str), p.get("profiled_at"), p.get("sample_size"), p.get("method")))
|
|
329
|
+
self.db.commit()
|
|
330
|
+
|
|
331
|
+
def load_profiles(self) -> dict:
|
|
332
|
+
return {f"{r['table_id']}.{r['col']}": json.loads(r["profile"]) for r in self.db.execute("SELECT * FROM column_profile")}
|
|
333
|
+
|
|
334
|
+
def reindex(self) -> None:
|
|
335
|
+
self.db.execute("DELETE FROM search_fts WHERE kind NOT IN ('table','column')")
|
|
336
|
+
rows = []
|
|
337
|
+
for r in self.db.execute("SELECT * FROM column_profile"):
|
|
338
|
+
p = json.loads(r["profile"])
|
|
339
|
+
if p.get("pii") or not p.get("top_values"):
|
|
340
|
+
continue
|
|
341
|
+
rows.append(("column_value", f"{r['table_id']}.{r['col']}", r["col"], normalize(r["col"] + " " + " ".join(str(k) for k, _ in p["top_values"]))))
|
|
342
|
+
for c in self.rows("concepts"):
|
|
343
|
+
syn = " ".join(json.loads(c["synonyms"] or "[]"))
|
|
344
|
+
rows.append(("concept", c["name"], c["name"], normalize(" ".join([c["name"], syn, c["definition"] or "", c["domain"] or ""]))))
|
|
345
|
+
for m in self.rows("metrics"):
|
|
346
|
+
rows.append(("metric", m["name"], m["name"], normalize(" ".join([m["name"], m["definition"] or "", m["caveats"] or ""]))))
|
|
347
|
+
for t in self.rows("table_notes"):
|
|
348
|
+
rows.append(("table_note", t["table_id"], t["table_id"], normalize(" ".join(
|
|
349
|
+
[t["grain"] or "", t["summary"] or "", t["pitfalls"] or "", t["freshness"] or ""]))))
|
|
350
|
+
for c in self.rows("column_semantics"):
|
|
351
|
+
codes = " ".join(f"{k} {v}" for k, v in json.loads(c["codes"] or "{}").items())
|
|
352
|
+
rows.append(("column_ctx", f"{c['table_id']}.{c['col']}", c["col"], normalize(" ".join(
|
|
353
|
+
[c["col"], c["meaning"] or "", c["unit"] or "", codes, c["null_meaning"] or ""]))))
|
|
354
|
+
self.db.executemany("INSERT INTO search_fts(kind,ref,title,body) VALUES(?,?,?,?)", rows)
|
|
355
|
+
self.db.commit()
|
|
356
|
+
|
|
357
|
+
def fts(self, query: str, limit: int = 300) -> list[tuple[str, str, float]]:
|
|
358
|
+
"""Prefix OR over the meaningful words, plus a boost for rows holding the whole query as a phrase.
|
|
359
|
+
|
|
360
|
+
Words shorter than 3 characters and common stopwords are left out of the prefix OR ("in"* would match
|
|
361
|
+
inventory, invoices and every int column), unless the query has nothing else.
|
|
362
|
+
"""
|
|
363
|
+
toks = tokens(query)
|
|
364
|
+
if not toks:
|
|
365
|
+
return []
|
|
366
|
+
words = [t for t in toks if len(t) >= 3 and t not in STOPWORDS]
|
|
367
|
+
q = " OR ".join(f'"{t}"*' for t in words) if words else " OR ".join(f'"{t}"' for t in toks)
|
|
368
|
+
scores: dict[tuple[str, str], float] = {}
|
|
369
|
+
order: list[tuple[str, str]] = []
|
|
370
|
+
|
|
371
|
+
def run(match: str, boost: float) -> None:
|
|
372
|
+
try:
|
|
373
|
+
cur = self.db.execute(
|
|
374
|
+
"SELECT kind, ref, bm25(search_fts, 0.0, 0.0, 3.0, 1.0) AS s FROM search_fts WHERE search_fts MATCH ? "
|
|
375
|
+
"ORDER BY s LIMIT ?", (match, limit))
|
|
376
|
+
except sqlite3.OperationalError:
|
|
377
|
+
return
|
|
378
|
+
for r in cur:
|
|
379
|
+
k = (r["kind"], r["ref"])
|
|
380
|
+
if k not in scores:
|
|
381
|
+
order.append(k)
|
|
382
|
+
scores[k] = scores.get(k, 0.0) + boost * -r["s"]
|
|
383
|
+
|
|
384
|
+
run(q, 1.0)
|
|
385
|
+
if len(toks) > 1:
|
|
386
|
+
run('"' + " ".join(toks) + '"', PHRASE_BOOST)
|
|
387
|
+
return sorted(((k, r, scores[(k, r)]) for k, r in order), key=lambda x: -x[2])[:limit]
|
keygraph/docs_out.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Markdown + Mermaid pages per area and per table, for agents and docs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from itertools import combinations
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from keygraph.areas import area_of
|
|
10
|
+
from keygraph.graph import Graph
|
|
11
|
+
from keygraph.query import find_paths
|
|
12
|
+
from keygraph.summary import relation_summary
|
|
13
|
+
|
|
14
|
+
MERMAID_LIMIT = 25 # an area's Mermaid diagram draws its first (most connected) 25 tables
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def slug(s: str) -> str:
|
|
18
|
+
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def table_file(t: str) -> str:
|
|
22
|
+
return re.sub(r"[^\w.\-]", "_", t) + ".md"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ident(text: str, fallback: str = "t") -> str:
|
|
26
|
+
"""A Mermaid-safe identifier: ASCII letters, digits and underscores, never starting with a digit."""
|
|
27
|
+
n = re.sub(r"[^A-Za-z0-9_]", "_", str(text or ""))
|
|
28
|
+
if not re.search(r"[A-Za-z0-9]", n):
|
|
29
|
+
n = fallback
|
|
30
|
+
return "t_" + n if n[0].isdigit() else n
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _names(graph: Graph, tables: list[str]) -> dict[str, str]:
|
|
34
|
+
"""Unique Mermaid entity names: bare table name, or schema_table when names collide."""
|
|
35
|
+
bare = {t: _ident(graph.nodes[t]["table"]) for t in tables}
|
|
36
|
+
count: dict[str, int] = {}
|
|
37
|
+
for n in bare.values():
|
|
38
|
+
count[n] = count.get(n, 0) + 1
|
|
39
|
+
names: dict[str, str] = {}
|
|
40
|
+
used: set[str] = set()
|
|
41
|
+
for t in tables:
|
|
42
|
+
n = bare[t]
|
|
43
|
+
if count[n] > 1:
|
|
44
|
+
n = _ident(graph.nodes[t]["schema"] + "_" + n)
|
|
45
|
+
base, i = n, 2
|
|
46
|
+
while n in used:
|
|
47
|
+
n, i = f"{base}_{i}", i + 1
|
|
48
|
+
used.add(n)
|
|
49
|
+
names[t] = n
|
|
50
|
+
return names
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _edge_index(graph: Graph) -> dict[str, list[dict]]:
|
|
54
|
+
"""table id -> the edges that start at it (built once per docs run)."""
|
|
55
|
+
idx: dict[str, list[dict]] = {}
|
|
56
|
+
for e in graph.edges:
|
|
57
|
+
idx.setdefault(e["from"], []).append(e)
|
|
58
|
+
return idx
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def mermaid_area(graph: Graph, area: dict, limit: int = MERMAID_LIMIT, out_edges: dict[str, list[dict]] | None = None) -> str:
|
|
62
|
+
tables = area["tables"][:limit]
|
|
63
|
+
inside = set(tables)
|
|
64
|
+
ent = _names(graph, tables)
|
|
65
|
+
out_edges = out_edges if out_edges is not None else _edge_index(graph)
|
|
66
|
+
lines = ["erDiagram"]
|
|
67
|
+
for t in tables:
|
|
68
|
+
n = graph.nodes[t]
|
|
69
|
+
mine = out_edges.get(t, [])
|
|
70
|
+
rel = [e for e in mine if not (e.get("hub") or e.get("role"))]
|
|
71
|
+
keys = set(n.get("primary_key", [])) | {c for e in rel for c in e["from_columns"]}
|
|
72
|
+
if ent[t] != n["table"]:
|
|
73
|
+
lines.append(f" %% {ent[t]} is {t}")
|
|
74
|
+
lines.append(f" {ent[t]} {{")
|
|
75
|
+
for c in n.get("columns", []):
|
|
76
|
+
if c["name"] in keys:
|
|
77
|
+
typ = _ident((c["type"] or "col").split("(")[0], "col")
|
|
78
|
+
tag = " PK" if c["name"] in n.get("primary_key", []) else " FK"
|
|
79
|
+
lines.append(f" {typ} {_ident(c['name'], 'col')}{tag}")
|
|
80
|
+
lines.append(" }")
|
|
81
|
+
for e in mine:
|
|
82
|
+
if e.get("hub") or e.get("role"):
|
|
83
|
+
kind = "hub" if e.get("hub") else "role"
|
|
84
|
+
lines.append(f" %% {ent[t]} {kind} ref to {e['to']} via {', '.join(e['from_columns'])}")
|
|
85
|
+
for t in tables:
|
|
86
|
+
for e in out_edges.get(t, []):
|
|
87
|
+
if e.get("hub") or e.get("role") or e["to"] not in inside or e["from"] == e["to"]:
|
|
88
|
+
continue
|
|
89
|
+
left = "||" if e.get("cardinality") == "one-to-one" else "}o"
|
|
90
|
+
col = ", ".join(e["from_columns"]).replace('"', "'")
|
|
91
|
+
lines.append(f' {ent[e["from"]]} {left}--|| {ent[e["to"]]} : "{col}"')
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _plural(n: int, word: str) -> str:
|
|
96
|
+
return f"{n} {word}{'' if n == 1 else 's'}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _table_md(graph: Graph, t: str, areas: list[dict], name_of: dict, ctx_notes: dict,
|
|
100
|
+
touching: dict[str, list[dict]] | None = None, of: dict[str, str] | None = None) -> str:
|
|
101
|
+
n, s = graph.nodes[t], relation_summary(graph, t, areas, edges=None if touching is None else touching.get(t, []), of=of)
|
|
102
|
+
note = ctx_notes.get(t, {})
|
|
103
|
+
out = [f"# {t}", "", f"Area: {name_of.get(s['area'], '-')}", ""]
|
|
104
|
+
if note.get("grain"):
|
|
105
|
+
out += [f"One row: {note['grain']}", ""]
|
|
106
|
+
out += ["## Keys and indexes", "", f"- primary key: {', '.join(n['primary_key']) or '(none)'}"]
|
|
107
|
+
out += [f"- {ix['name']}{' (unique)' if ix.get('unique') else ''}: {', '.join(ix['columns'])}" for ix in n["indexes"]]
|
|
108
|
+
out += ["", "## Columns", "", "| column | type | nullable |", "|---|---|---|"]
|
|
109
|
+
out += [f"| {c['name']} | {c['type']} | {'yes' if c.get('nullable', True) else 'no'} |" for c in n["columns"]]
|
|
110
|
+
for title, groups in (("Points to", s["out"]), ("Pointed to by", s["in"])):
|
|
111
|
+
out += ["", f"## {title}", ""]
|
|
112
|
+
for g in groups:
|
|
113
|
+
more = f" (+{g['count'] - 8} more)" if g["count"] > 8 else ""
|
|
114
|
+
out.append(f"- via `{g['column']}`: {', '.join(g['tables'][:8])}{more}")
|
|
115
|
+
if not groups:
|
|
116
|
+
out.append("- (none)")
|
|
117
|
+
if s["hub_labels"]:
|
|
118
|
+
out += ["", "Hub references: " + ", ".join(f"{h['to']} via {', '.join(h['columns'])}" for h in s["hub_labels"])]
|
|
119
|
+
return "\n".join(out) + "\n"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _degrees(graph: Graph) -> dict[str, int]:
|
|
123
|
+
d: dict[str, int] = {}
|
|
124
|
+
for e in graph.edges:
|
|
125
|
+
if e.get("hub") or e.get("role") or e["confidence"] < 0.6 or e["from"] == e["to"]:
|
|
126
|
+
continue
|
|
127
|
+
d[e["from"]] = d.get(e["from"], 0) + 1
|
|
128
|
+
d[e["to"]] = d.get(e["to"], 0) + 1
|
|
129
|
+
return d
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _join_paths(graph: Graph, area: dict, deg: dict[str, int]) -> list[str]:
|
|
133
|
+
lines = []
|
|
134
|
+
top = sorted(area["tables"], key=lambda t: (-deg.get(t, 0), t))[:5]
|
|
135
|
+
for a, b in combinations(top, 2):
|
|
136
|
+
paths = find_paths(graph, a, b, 3, 0.6, 1, max_pops=20000)
|
|
137
|
+
if paths:
|
|
138
|
+
lines.append(f"- {' -> '.join(paths[0]['tables'])}")
|
|
139
|
+
return lines
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _neighbours(graph: Graph, areas: list[dict]) -> dict[str, dict[str, int]]:
|
|
143
|
+
"""Per area id: other area id -> count of non-hub, non-role edges (confidence >= 0.6) crossing in either direction."""
|
|
144
|
+
of = {t: a["id"] for a in areas for t in a["tables"]}
|
|
145
|
+
res: dict[str, dict[str, int]] = {}
|
|
146
|
+
for e in graph.edges:
|
|
147
|
+
if e.get("hub") or e.get("role") or e["confidence"] < 0.6:
|
|
148
|
+
continue
|
|
149
|
+
a, b = of.get(e["from"]), of.get(e["to"])
|
|
150
|
+
if a and b and a != b:
|
|
151
|
+
res.setdefault(a, {})[b] = res.setdefault(a, {}).get(b, 0) + 1
|
|
152
|
+
res.setdefault(b, {})[a] = res.setdefault(b, {}).get(a, 0) + 1
|
|
153
|
+
return res
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def write_docs(out: Path, graph: Graph, areas: list[dict], ctx_dump: dict | None) -> dict:
|
|
157
|
+
out = Path(out)
|
|
158
|
+
(out / "areas").mkdir(parents=True, exist_ok=True)
|
|
159
|
+
(out / "tables").mkdir(parents=True, exist_ok=True)
|
|
160
|
+
name_of = {a["id"]: a["name"] for a in areas}
|
|
161
|
+
ctx_notes = {x["table"]: x for x in (ctx_dump or {}).get("tables", [])}
|
|
162
|
+
written: list[str] = ["areas/index.md"]
|
|
163
|
+
idx = ["# Areas", "", "| area | tables | top tables |", "|---|---|---|"]
|
|
164
|
+
used: set[str] = set()
|
|
165
|
+
fname: dict[str, str] = {}
|
|
166
|
+
for a in areas:
|
|
167
|
+
fn = slug(a["name"]) or "area"
|
|
168
|
+
if fn in used or fn == "index":
|
|
169
|
+
fn = f"{fn}-{a['id']}"
|
|
170
|
+
used.add(fn)
|
|
171
|
+
fname[a["id"]] = fn
|
|
172
|
+
nb, deg = _neighbours(graph, areas), _degrees(graph)
|
|
173
|
+
out_edges, of = _edge_index(graph), area_of(areas)
|
|
174
|
+
touching: dict[str, list[dict]] = {}
|
|
175
|
+
for e in graph.edges:
|
|
176
|
+
touching.setdefault(e["from"], []).append(e)
|
|
177
|
+
if e["to"] != e["from"]:
|
|
178
|
+
touching.setdefault(e["to"], []).append(e)
|
|
179
|
+
for a in areas:
|
|
180
|
+
fn = fname[a["id"]]
|
|
181
|
+
idx.append(f"| [{a['name']}]({fn}.md) | {a['size']} | {', '.join(a['top'])} |")
|
|
182
|
+
shown = f" The diagram shows the {MERMAID_LIMIT} most connected." if a["size"] > MERMAID_LIMIT else ""
|
|
183
|
+
body = [f"# {a['name']}", "", f"{_plural(a['size'], 'table')}{' (curated)' if not a.get('auto', True) else ''}.{shown}", "",
|
|
184
|
+
"```mermaid", mermaid_area(graph, a, out_edges=out_edges), "```", ""]
|
|
185
|
+
paths = _join_paths(graph, a, deg)
|
|
186
|
+
if paths:
|
|
187
|
+
body += ["## Main join paths", ""] + paths + [""]
|
|
188
|
+
links = sorted(nb.get(a["id"], {}).items(), key=lambda kv: (-kv[1], name_of[kv[0]]))
|
|
189
|
+
body += ["## Neighbouring areas", ""]
|
|
190
|
+
body += [f"- [{name_of[o]}]({fname[o]}.md): {_plural(n, 'relation')}" for o, n in links] or ["- (none)"]
|
|
191
|
+
body += ["", "## Tables", ""]
|
|
192
|
+
body += [f"- {t}: {ctx_notes.get(t, {}).get('grain', '')}".rstrip(": ") for t in a["tables"]]
|
|
193
|
+
(out / "areas" / f"{fn}.md").write_text("\n".join(body) + "\n", encoding="utf-8")
|
|
194
|
+
written.append(f"areas/{fn}.md")
|
|
195
|
+
(out / "areas" / "index.md").write_text("\n".join(idx) + "\n", encoding="utf-8")
|
|
196
|
+
for t in graph.nodes:
|
|
197
|
+
(out / "tables" / table_file(t)).write_text(_table_md(graph, t, areas, name_of, ctx_notes, touching, of), encoding="utf-8")
|
|
198
|
+
written.append(f"tables/{table_file(t)}")
|
|
199
|
+
return {"areas": len(areas), "tables": len(graph.nodes), "files": written}
|