folderlake 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.
@@ -0,0 +1,331 @@
1
+ """MCP server — let other AI agents query a FolderLake catalog.
2
+
3
+ The tool handlers are plain Python functions, independent of any SDK,
4
+ registered in the ``TOOLS`` dict below (name -> (description, handler)).
5
+ ``serve_stdio`` wires them up through the official ``mcp`` Python SDK,
6
+ imported lazily inside the function: the SDK is an optional dependency
7
+ (``pip install folderlake[mcp]``), never a hard one — the same pattern
8
+ the LLM layer uses with LiteLLM.
9
+
10
+ Everything is read-only: handlers reuse the viewer's read APIs
11
+ (``server.list_files`` / ``facets`` / ``file_detail``) or open the
12
+ catalog read-only themselves. Results are capped and long text fields
13
+ truncated — agent context is precious.
14
+
15
+ Run it with:
16
+
17
+ python -m folderlake.mcp_server --db /path/to/folderlake.duckdb
18
+
19
+ or set ``FOLDERLAKE_DB``. A typical MCP client config:
20
+
21
+ {"mcpServers": {"folderlake": {
22
+ "command": "python", "args": ["-m", "folderlake.mcp_server"],
23
+ "env": {"FOLDERLAKE_DB": "/path/to/folderlake.duckdb"}}}}
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import os
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Callable
33
+
34
+ import duckdb
35
+
36
+ from .server import facets, file_detail, list_files
37
+
38
+ MAX_TEXT = 500 # summaries / text previews are cut to this
39
+ MAX_DETAIL = 200 # relation details are short labels; cap anyway
40
+ DEFAULT_LIMIT = 50
41
+ MAX_LIMIT = 200
42
+ MAX_RELATED = 100
43
+ MAX_COLUMNS = 50
44
+
45
+
46
+ def _truncate(text, limit: int = MAX_TEXT) -> str:
47
+ if text is None:
48
+ return ""
49
+ text = str(text)
50
+ return text if len(text) <= limit else text[:limit] + "…"
51
+
52
+
53
+ def _file_summary(row: dict) -> dict:
54
+ """Slim a list_files row down to what an agent needs to triage."""
55
+ return {
56
+ "id": row["id"],
57
+ "name": row["name"],
58
+ "kind": row["kind"],
59
+ "ext": row["ext"],
60
+ "size_bytes": row["size_bytes"],
61
+ "title": row.get("title"),
62
+ "tags": row.get("tags", []),
63
+ }
64
+
65
+
66
+ def _paged(rows: list[dict], limit: int) -> dict:
67
+ return {
68
+ "count": len(rows),
69
+ "returned": min(len(rows), limit),
70
+ "truncated": len(rows) > limit,
71
+ "files": [_file_summary(r) for r in rows[:limit]],
72
+ }
73
+
74
+
75
+ # ----------------------------------------------------------------------
76
+ # Tool handlers — plain functions: (db_path, **args) -> JSON-able result
77
+ # ----------------------------------------------------------------------
78
+ def _tool_stats(db_path: Path) -> dict:
79
+ f = facets(db_path)
80
+ return {
81
+ "total_files": f["total_files"],
82
+ "kinds": f["kinds"],
83
+ "topic_count": len(f["topics"]),
84
+ }
85
+
86
+
87
+ def _tool_search(db_path: Path, query: str, kind: str | None = None) -> dict:
88
+ rows = list_files(db_path, kinds=[kind] if kind else None, query=query)
89
+ result = _paged(rows, MAX_LIMIT)
90
+ result["query"] = query
91
+ return result
92
+
93
+
94
+ def _tool_list_files(db_path: Path, kind: str | None = None,
95
+ topic: str | None = None,
96
+ limit: int = DEFAULT_LIMIT) -> dict:
97
+ try:
98
+ limit = int(limit)
99
+ except (TypeError, ValueError):
100
+ limit = DEFAULT_LIMIT
101
+ limit = max(1, min(limit, MAX_LIMIT))
102
+ rows = list_files(db_path, kinds=[kind] if kind else None, topic=topic)
103
+ return _paged(rows, limit)
104
+
105
+
106
+ def _tool_file_detail(db_path: Path, file_id: str) -> dict:
107
+ detail = file_detail(db_path, file_id)
108
+ if detail is None:
109
+ return {"error": f"not found: {file_id}"}
110
+ out: dict = {
111
+ "id": detail["id"],
112
+ "name": detail["name"],
113
+ "path": detail["path"],
114
+ "kind": detail["kind"],
115
+ "size_bytes": detail["size_bytes"],
116
+ "title": detail.get("title"),
117
+ "author": detail.get("author"),
118
+ "summary": _truncate(detail.get("summary")),
119
+ "tags": detail.get("tags", []),
120
+ "entities": detail.get("entities", []),
121
+ }
122
+ if "text_preview" in detail:
123
+ out["text_preview"] = _truncate(detail["text_preview"])
124
+ if "data_review" in detail:
125
+ review = detail["data_review"]
126
+ out["data_review"] = {
127
+ "columns": review.get("columns", [])[:MAX_COLUMNS],
128
+ "row_count": review.get("row_count"),
129
+ "nulls": dict(list((review.get("nulls") or {}).items())[:MAX_COLUMNS]),
130
+ "summarize": review.get("summarize", [])[:MAX_COLUMNS],
131
+ "sample_query": review.get("sample_query"),
132
+ }
133
+ return out
134
+
135
+
136
+ def _tool_related(db_path: Path, file_id: str) -> dict:
137
+ con = duckdb.connect(str(db_path), read_only=True)
138
+ try:
139
+ rows = con.execute(
140
+ """
141
+ SELECT other_id, kind, detail, name, other_kind FROM (
142
+ SELECT CASE WHEN r.file_a = ? THEN r.file_b ELSE r.file_a END
143
+ AS other_id,
144
+ r.kind AS kind, r.detail AS detail,
145
+ f.name AS name, f.kind AS other_kind
146
+ FROM relations r
147
+ JOIN files f
148
+ ON f.id = CASE WHEN r.file_a = ? THEN r.file_b ELSE r.file_a END
149
+ WHERE r.file_a = ? OR r.file_b = ?
150
+ ) ORDER BY kind, name LIMIT ?
151
+ """,
152
+ [file_id, file_id, file_id, file_id, MAX_RELATED],
153
+ ).fetchall()
154
+ finally:
155
+ con.close()
156
+ return {
157
+ "file_id": file_id,
158
+ "count": len(rows),
159
+ "related": [
160
+ {
161
+ "file_id": r[0],
162
+ "name": r[3],
163
+ "kind": r[4],
164
+ "relation_kind": r[1],
165
+ "detail": _truncate(r[2], MAX_DETAIL),
166
+ }
167
+ for r in rows
168
+ ],
169
+ }
170
+
171
+
172
+ def _topic_tree(db_path: Path) -> dict | None:
173
+ """Nested topic hierarchy from topic_nodes, or None if unavailable.
174
+
175
+ The hierarchy tables are built by a separate step and may not exist
176
+ in a catalog; any problem at all falls back to the flat topics.
177
+ """
178
+ con = duckdb.connect(str(db_path), read_only=True)
179
+ try:
180
+ exists = con.execute(
181
+ "SELECT count(*) FROM information_schema.tables "
182
+ "WHERE table_name = 'topic_nodes'"
183
+ ).fetchone()[0]
184
+ if not exists:
185
+ return None
186
+ rows = con.execute(
187
+ "SELECT id, parent_id, kind, label, file_count FROM topic_nodes"
188
+ ).fetchall()
189
+ if not rows:
190
+ return None # table exists but no hierarchy was built
191
+ except Exception:
192
+ return None
193
+ finally:
194
+ con.close()
195
+ nodes: dict[str, dict] = {
196
+ r[0]: {
197
+ "id": r[0], "label": r[3], "kind": r[2],
198
+ "file_count": r[4], "children": [],
199
+ }
200
+ for r in rows
201
+ }
202
+ roots: list[dict] = []
203
+ for r in rows:
204
+ node = nodes[r[0]]
205
+ parent = nodes.get(r[1]) if r[1] else None
206
+ if parent is None:
207
+ roots.append(node)
208
+ else:
209
+ parent["children"].append(node)
210
+ return {"mode": "hierarchy", "roots": roots}
211
+
212
+
213
+ def _tool_topics(db_path: Path) -> dict:
214
+ tree = _topic_tree(db_path)
215
+ if tree is not None:
216
+ return tree
217
+ return {"mode": "flat", "topics": facets(db_path)["topics"]}
218
+
219
+
220
+ TOOLS: dict[str, tuple[str, Callable[..., dict]]] = {
221
+ "folderlake_stats": (
222
+ "Catalog overview: total file count, counts per kind "
223
+ "(document/data/text), and the number of distinct topics.",
224
+ _tool_stats,
225
+ ),
226
+ "folderlake_search": (
227
+ "Full-text search over file names, summaries and extracted text. "
228
+ "Args: query (required), kind (optional: document, data or text).",
229
+ _tool_search,
230
+ ),
231
+ "folderlake_list_files": (
232
+ "List catalogued files, optionally filtered by kind and/or a "
233
+ "topic tag. Args: kind, topic, limit (default 50, max 200).",
234
+ _tool_list_files,
235
+ ),
236
+ "folderlake_file_detail": (
237
+ "Detail for one file by id: tags, entities, summary, a text "
238
+ "preview for documents, and a data-review block (schema, row "
239
+ "count, sample query) for data files.",
240
+ _tool_file_detail,
241
+ ),
242
+ "folderlake_related": (
243
+ "Files related to the given file id, from the catalog's "
244
+ "relations (shared topic/entity, explicit reference, same "
245
+ "folder), each with the relation kind and detail.",
246
+ _tool_related,
247
+ ),
248
+ "folderlake_topics": (
249
+ "The topic organisation of the catalog: the hierarchical topic "
250
+ "tree when one has been built, otherwise the flat topic list "
251
+ "with per-topic file counts.",
252
+ _tool_topics,
253
+ ),
254
+ }
255
+
256
+
257
+ # ----------------------------------------------------------------------
258
+ # SDK wiring (optional dependency, imported lazily)
259
+ # ----------------------------------------------------------------------
260
+ def serve_stdio(db_path: Path) -> None:
261
+ """Serve the TOOLS handlers over MCP stdio via the official SDK."""
262
+ server_cls = None
263
+ try: # mcp 1.x
264
+ from mcp.server.fastmcp import FastMCP
265
+
266
+ server_cls = FastMCP
267
+ except ImportError:
268
+ try: # mcp >= 2: FastMCP was renamed MCPServer (same shape)
269
+ from mcp.server.mcpserver import MCPServer
270
+
271
+ server_cls = MCPServer
272
+ except ImportError:
273
+ pass
274
+ if server_cls is None:
275
+ raise RuntimeError(
276
+ "The MCP SDK is not installed. Install it with: "
277
+ "pip install folderlake[mcp]"
278
+ )
279
+
280
+ server = server_cls("folderlake")
281
+
282
+ @server.tool(description=TOOLS["folderlake_stats"][0])
283
+ def folderlake_stats() -> dict:
284
+ return _tool_stats(db_path)
285
+
286
+ @server.tool(description=TOOLS["folderlake_search"][0])
287
+ def folderlake_search(query: str, kind: str | None = None) -> dict:
288
+ return _tool_search(db_path, query, kind)
289
+
290
+ @server.tool(description=TOOLS["folderlake_list_files"][0])
291
+ def folderlake_list_files(kind: str | None = None,
292
+ topic: str | None = None,
293
+ limit: int = DEFAULT_LIMIT) -> dict:
294
+ return _tool_list_files(db_path, kind, topic, limit)
295
+
296
+ @server.tool(description=TOOLS["folderlake_file_detail"][0])
297
+ def folderlake_file_detail(file_id: str) -> dict:
298
+ return _tool_file_detail(db_path, file_id)
299
+
300
+ @server.tool(description=TOOLS["folderlake_related"][0])
301
+ def folderlake_related(file_id: str) -> dict:
302
+ return _tool_related(db_path, file_id)
303
+
304
+ @server.tool(description=TOOLS["folderlake_topics"][0])
305
+ def folderlake_topics() -> dict:
306
+ return _tool_topics(db_path)
307
+
308
+ server.run() # stdio transport by default
309
+
310
+
311
+ def main(argv: list[str] | None = None) -> int:
312
+ parser = argparse.ArgumentParser(
313
+ prog="folderlake-mcp",
314
+ description="Serve a FolderLake catalog to other agents over MCP (stdio).",
315
+ )
316
+ parser.add_argument(
317
+ "--db", default=None,
318
+ help="path to the catalog (default: $FOLDERLAKE_DB or ./folderlake.duckdb)",
319
+ )
320
+ args = parser.parse_args(argv)
321
+ raw = args.db or os.environ.get("FOLDERLAKE_DB") or "folderlake.duckdb"
322
+ db_path = Path(raw)
323
+ if not db_path.exists():
324
+ print(f"catalog not found: {db_path}", file=sys.stderr)
325
+ return 2
326
+ serve_stdio(db_path)
327
+ return 0
328
+
329
+
330
+ if __name__ == "__main__":
331
+ raise SystemExit(main())
folderlake/preview.py ADDED
@@ -0,0 +1,57 @@
1
+ """Preview payload for one file.
2
+
3
+ Documents/text: summary + extracted text.
4
+ Data files get a small side feature: schema, row count, per-column nulls,
5
+ and a ready-to-run DuckDB query — a review, not an analytics product.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ import duckdb
14
+
15
+
16
+ def build_preview(db_path: Path, file_id: str) -> dict | None:
17
+ con = duckdb.connect(str(db_path), read_only=True)
18
+ try:
19
+ row = con.execute(
20
+ "SELECT path, name, kind, size_bytes, title, author, extra_json "
21
+ "FROM files WHERE id = ?",
22
+ [file_id],
23
+ ).fetchone()
24
+ if row is None:
25
+ return None
26
+ path, name, kind, size, title, author, extra_json = row
27
+ extra = json.loads(extra_json or "{}")
28
+ text_row = con.execute(
29
+ "SELECT summary, text FROM file_text WHERE file_id = ?", [file_id]
30
+ ).fetchone()
31
+ summary, text = text_row if text_row else ("", "")
32
+ tags = [
33
+ r[0]
34
+ for r in con.execute(
35
+ "SELECT tag FROM file_topics WHERE file_id = ? ORDER BY weight DESC",
36
+ [file_id],
37
+ ).fetchall()
38
+ ]
39
+ preview: dict = {
40
+ "id": file_id, "name": name, "path": path, "kind": kind,
41
+ "size_bytes": size, "title": title, "author": author,
42
+ "summary": summary, "tags": tags,
43
+ }
44
+ if kind == "data":
45
+ escaped = path.replace("'", "''")
46
+ preview["data_review"] = {
47
+ "columns": extra.get("columns", []),
48
+ "row_count": extra.get("row_count"),
49
+ "nulls": extra.get("nulls", {}),
50
+ "summarize": extra.get("summarize", []),
51
+ "sample_query": f"SELECT * FROM '{escaped}' LIMIT 100;",
52
+ }
53
+ else:
54
+ preview["text_preview"] = (text or "")[:4000]
55
+ return preview
56
+ finally:
57
+ con.close()
folderlake/registry.py ADDED
@@ -0,0 +1,157 @@
1
+ """Local registry for the FolderLake app (home page).
2
+
3
+ A small SQLite database — stdlib only — that remembers every catalog
4
+ the app has built (so the home page can list history and reopen one in
5
+ a click) and the user's settings (LLM provider/model, defaults). Each
6
+ catalog record also stores a snapshot of the settings its last scan
7
+ ran with, so the history shows *how* a catalog was generated, not just
8
+ where it lives.
9
+
10
+ Location: ``$FOLDERLAKE_HOME/registry.sqlite3``, defaulting to
11
+ ``~/.folderlake/registry.sqlite3``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import sqlite3
20
+ import time
21
+ from pathlib import Path
22
+
23
+ SETTING_KEYS = ("llm_provider", "llm_model", "llm_base_url",
24
+ "llm_api_key", "use_llm")
25
+
26
+
27
+ def registry_home() -> Path:
28
+ home = Path(os.environ.get("FOLDERLAKE_HOME",
29
+ Path.home() / ".folderlake"))
30
+ home.mkdir(parents=True, exist_ok=True)
31
+ return home
32
+
33
+
34
+ def catalog_id_for(path: Path) -> str:
35
+ return hashlib.sha1(str(path.resolve()).encode()).hexdigest()[:16]
36
+
37
+
38
+ class Registry:
39
+ def __init__(self, db_path: Path | None = None):
40
+ self.db_path = db_path or (registry_home() / "registry.sqlite3")
41
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
42
+ self._con = sqlite3.connect(str(self.db_path),
43
+ check_same_thread=False)
44
+ self._con.row_factory = sqlite3.Row
45
+ self._con.executescript(
46
+ """
47
+ CREATE TABLE IF NOT EXISTS catalogs (
48
+ id TEXT PRIMARY KEY,
49
+ path TEXT UNIQUE NOT NULL,
50
+ name TEXT NOT NULL,
51
+ created_at REAL NOT NULL,
52
+ updated_at REAL NOT NULL,
53
+ last_opened_at REAL,
54
+ file_count INTEGER DEFAULT 0,
55
+ catalog_bytes INTEGER DEFAULT 0,
56
+ settings TEXT DEFAULT '{}'
57
+ );
58
+ CREATE TABLE IF NOT EXISTS settings (
59
+ key TEXT PRIMARY KEY,
60
+ value TEXT
61
+ );
62
+ """
63
+ )
64
+ self._con.commit()
65
+
66
+ # -- catalogs ----------------------------------------------------
67
+ def list_catalogs(self) -> list[dict]:
68
+ rows = self._con.execute(
69
+ "SELECT * FROM catalogs ORDER BY updated_at DESC").fetchall()
70
+ out = []
71
+ for r in rows:
72
+ d = dict(r)
73
+ d["settings"] = json.loads(d["settings"] or "{}")
74
+ d["exists"] = Path(d["path"]).is_dir()
75
+ out.append(d)
76
+ return out
77
+
78
+ def get_catalog(self, catalog_id: str) -> dict | None:
79
+ row = self._con.execute(
80
+ "SELECT * FROM catalogs WHERE id = ?", [catalog_id]).fetchone()
81
+ if row is None:
82
+ return None
83
+ d = dict(row)
84
+ d["settings"] = json.loads(d["settings"] or "{}")
85
+ d["exists"] = Path(d["path"]).is_dir()
86
+ return d
87
+
88
+ def upsert_catalog(self, path: Path, file_count: int,
89
+ catalog_bytes: int, settings: dict) -> dict:
90
+ path = path.resolve()
91
+ cid = catalog_id_for(path)
92
+ now = time.time()
93
+ self._con.execute(
94
+ """
95
+ INSERT INTO catalogs (id, path, name, created_at, updated_at,
96
+ file_count, catalog_bytes, settings)
97
+ VALUES (?,?,?,?,?,?,?,?)
98
+ ON CONFLICT(path) DO UPDATE SET
99
+ updated_at=excluded.updated_at,
100
+ file_count=excluded.file_count,
101
+ catalog_bytes=excluded.catalog_bytes,
102
+ settings=excluded.settings
103
+ """,
104
+ [cid, str(path), path.name, now, now, file_count,
105
+ catalog_bytes, json.dumps(settings, ensure_ascii=False)],
106
+ )
107
+ self._con.commit()
108
+ return self.get_catalog(cid) # type: ignore[return-value]
109
+
110
+ def touch_opened(self, catalog_id: str) -> None:
111
+ self._con.execute(
112
+ "UPDATE catalogs SET last_opened_at = ? WHERE id = ?",
113
+ [time.time(), catalog_id])
114
+ self._con.commit()
115
+
116
+ def delete_catalog(self, catalog_id: str) -> dict | None:
117
+ rec = self.get_catalog(catalog_id)
118
+ if rec is None:
119
+ return None
120
+ self._con.execute("DELETE FROM catalogs WHERE id = ?",
121
+ [catalog_id])
122
+ self._con.commit()
123
+ return rec
124
+
125
+ # -- settings ------------------------------------------------------
126
+ def get_settings(self) -> dict:
127
+ rows = self._con.execute("SELECT key, value FROM settings").fetchall()
128
+ saved = {r["key"]: r["value"] for r in rows}
129
+ return {
130
+ "llm_provider": saved.get("llm_provider", "ollama"),
131
+ "llm_model": saved.get("llm_model", ""),
132
+ "llm_base_url": saved.get("llm_base_url", ""),
133
+ "llm_api_key_set": bool(saved.get("llm_api_key")),
134
+ "use_llm": saved.get("use_llm", "0") == "1",
135
+ }
136
+
137
+ def get_api_key(self) -> str:
138
+ row = self._con.execute(
139
+ "SELECT value FROM settings WHERE key = 'llm_api_key'"
140
+ ).fetchone()
141
+ return row["value"] if row and row["value"] else ""
142
+
143
+ def save_settings(self, values: dict) -> dict:
144
+ for key in SETTING_KEYS:
145
+ if key not in values:
146
+ continue
147
+ v = values[key]
148
+ if key == "llm_api_key" and (v is None or v == ""):
149
+ continue # blank field = keep the saved key
150
+ if key == "use_llm":
151
+ v = "1" if v else "0"
152
+ self._con.execute(
153
+ "INSERT INTO settings (key, value) VALUES (?,?) "
154
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
155
+ [key, "" if v is None else str(v)])
156
+ self._con.commit()
157
+ return self.get_settings()
@@ -0,0 +1,103 @@
1
+ """Four simple, labelled relation kinds between files. Nothing fancier.
2
+
3
+ shared_topic / shared_entity / reference / same_folder — every edge
4
+ stores its reason in `detail`, so the graph never shows a mystery link.
5
+ Implemented as plain SQL over the catalog tables.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ import duckdb
14
+
15
+ # Dataset names that are also ordinary English words: a capitalised
16
+ # mention of these is too weak to count as a subject reference.
17
+ _COMMON_STEMS = {"tips", "flights", "exercise", "attention", "data",
18
+ "file", "files", "table"}
19
+
20
+
21
+ def build_relations(db_path: Path) -> int:
22
+ con = duckdb.connect(str(db_path))
23
+ try:
24
+ con.execute("DELETE FROM relations")
25
+ con.execute(
26
+ """
27
+ INSERT INTO relations
28
+ SELECT a.file_id, b.file_id, 'shared_topic', a.tag
29
+ FROM file_topics a JOIN file_topics b
30
+ ON a.tag = b.tag AND a.file_id < b.file_id
31
+ """
32
+ )
33
+ con.execute(
34
+ """
35
+ INSERT INTO relations
36
+ SELECT a.file_id, b.file_id, 'shared_entity', a.entity
37
+ FROM file_entities a JOIN file_entities b
38
+ ON a.entity = b.entity AND a.file_id < b.file_id
39
+ """
40
+ )
41
+ # explicit reference: file A's text mentions file B's name
42
+ rows = con.execute(
43
+ """
44
+ SELECT a.file_id, b.id, b.name
45
+ FROM file_text a JOIN files b ON a.file_id <> b.id
46
+ WHERE a.text ILIKE '%' || b.name || '%'
47
+ """
48
+ ).fetchall()
49
+ ref_pairs: set[tuple[str, str]] = set()
50
+ for fa, fb, name in rows:
51
+ a, b = (fa, fb) if fa < fb else (fb, fa)
52
+ ref_pairs.add((a, b))
53
+ con.execute(
54
+ "INSERT INTO relations VALUES (?,?, 'reference', ?)", [a, b, name]
55
+ )
56
+ # subject reference: a text/document that mentions a data
57
+ # file's subject by its distinctive name links to that file —
58
+ # a Wikipedia article about the Titanic points at titanic.csv.
59
+ # The mention must be capitalised in the text ("Titanic",
60
+ # "Penguins", "MPG"), which keeps ordinary lowercase uses of
61
+ # common words from creating junk links.
62
+ data_files = con.execute(
63
+ "SELECT id, name FROM files WHERE kind = 'data'"
64
+ ).fetchall()
65
+ texts = con.execute(
66
+ """
67
+ SELECT t.file_id, t.text FROM file_text t
68
+ JOIN files f ON f.id = t.file_id
69
+ WHERE f.kind IN ('text', 'document')
70
+ """
71
+ ).fetchall()
72
+ for data_id, data_name in data_files:
73
+ stem = Path(data_name).stem.lower()
74
+ if len(stem) < 3 or stem in _COMMON_STEMS:
75
+ continue
76
+ forms = {stem.capitalize(), stem.upper(), stem.title()}
77
+ pat = re.compile(
78
+ r"\b(?:" + "|".join(re.escape(f) for f in forms) + r")\b")
79
+ for file_id, text in texts:
80
+ if file_id == data_id or not pat.search(text or ""):
81
+ continue
82
+ a, b = ((file_id, data_id) if file_id < data_id
83
+ else (data_id, file_id))
84
+ if (a, b) in ref_pairs:
85
+ continue
86
+ ref_pairs.add((a, b))
87
+ con.execute(
88
+ "INSERT INTO relations VALUES (?,?, 'reference', ?)",
89
+ [a, b, f"mentions {stem}"],
90
+ )
91
+ con.execute(
92
+ """
93
+ INSERT INTO relations
94
+ SELECT a.id, b.id, 'same_folder',
95
+ regexp_replace(a.path, '/[^/]*$', '')
96
+ FROM files a JOIN files b ON a.id < b.id
97
+ WHERE regexp_replace(a.path, '/[^/]*$', '')
98
+ = regexp_replace(b.path, '/[^/]*$', '')
99
+ """
100
+ )
101
+ return con.execute("SELECT count(*) FROM relations").fetchone()[0]
102
+ finally:
103
+ con.close()