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.
- folderlake/__init__.py +3 -0
- folderlake/appserver.py +307 -0
- folderlake/assets/echarts.min.js +45 -0
- folderlake/catalog.py +184 -0
- folderlake/cli.py +209 -0
- folderlake/cypher.py +100 -0
- folderlake/extractors.py +360 -0
- folderlake/hierarchy.py +297 -0
- folderlake/home.html +305 -0
- folderlake/llm.py +210 -0
- folderlake/mcp_server.py +331 -0
- folderlake/preview.py +57 -0
- folderlake/registry.py +157 -0
- folderlake/relations.py +103 -0
- folderlake/server.py +329 -0
- folderlake/topics.py +591 -0
- folderlake/viewer.html +397 -0
- folderlake-0.2.0.dist-info/METADATA +278 -0
- folderlake-0.2.0.dist-info/RECORD +23 -0
- folderlake-0.2.0.dist-info/WHEEL +5 -0
- folderlake-0.2.0.dist-info/entry_points.txt +3 -0
- folderlake-0.2.0.dist-info/licenses/LICENSE +21 -0
- folderlake-0.2.0.dist-info/top_level.txt +1 -0
folderlake/mcp_server.py
ADDED
|
@@ -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()
|
folderlake/relations.py
ADDED
|
@@ -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()
|