diff --git a/docs/projects/folder-knowledge-graph/_category_.json b/docs/projects/folder-knowledge-graph/_category_.json
new file mode 100644
index 0000000..6269873
--- /dev/null
+++ b/docs/projects/folder-knowledge-graph/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "FolderKnowledgeGraph",
+ "position": 27
+}
diff --git a/docs/projects/folder-knowledge-graph/index.md b/docs/projects/folder-knowledge-graph/index.md
new file mode 100644
index 0000000..8db4aca
--- /dev/null
+++ b/docs/projects/folder-knowledge-graph/index.md
@@ -0,0 +1,470 @@
+---
+id: folder-knowledge-graph
+title: "Turn a Folder of PDFs, Configs, and SQL Schemas Into a Queryable Knowledge Graph"
+sidebar_label: "Turn a Folder of PDFs, Configs, and SQL Schemas Into a Knowledge Graph"
+slug: /projects/folder-knowledge-graph
+description: "Graduate from the in-browser playground to real Python: walk a folder of mixed PDFs, config files, and SQL schemas, extract the hidden references between them with pypdf and the standard library, and build a queryable networkx graph — no API key, no LLM, no network access needed."
+---
+
+import ProjectProgressCheckbox from '@site/src/components/ProjectProgressCheckbox';
+import ProjectPublishedDate from '@site/src/components/ProjectPublishedDate';
+import ProjectGreeting from '@site/src/components/ProjectGreeting';
+import {StepChecklist, StepChecklistItem} from '@site/src/components/StepChecklist';
+
+# 🌍 Turn a Folder of PDFs, Configs, and SQL Schemas Into a Queryable Knowledge Graph
+
+
+
+
+
+You have a folder full of documents — a couple of SQL schema files, a few config files, some PDFs describing how everything fits together. You need an answer to a question the docs don't have a heading for: "which configs reference the `users` table?", "what exactly does this PDF touch?". A keyword search over the raw files fails on the second question instantly and only half-answers the first. This project builds the tool that *does* answer both: it walks a **heterogeneous folder** — PDFs via `pypdf`, config files with the standard library, SQL schemas with a couple of regexes — and turns the references hiding inside those documents into a **graph**: files, tables, and config keys as nodes; "defines", "references", and "mentions" as edges. Then you ask the graph questions, using real graph traversal, no AI required.
+
+The teaching core is the reframe: **a heterogeneous folder is a knowledge-graph problem, not a search problem.** The structure you want is already in the documents — a config value names a table, a foreign key points at another table, a PDF's prose names a config key — and it can be extracted deterministically, offline, with tools you've already met (`re`, `pathlib`, and a couple of tiny parsers). Once it's a graph, questions that need *indirect* knowledge — "which config keys end up touching the `books` table?" — become a one-liner, where they're nearly impossible to answer by searching file text.
+
+The honest framing: every relationship here comes from **hand-written extraction rules**, so the graph is only as good as its rules. Things the rules don't look for are invisible to it — that's a real limitation, and an important one to internalize before you ever bolt an LLM onto a pipeline like this. (An optional extension, mentioned in Next steps but not built here, is an LLM doing the relation extraction.)
+
+This assumes Python 101 and comfort with functions, dictionaries, and loops; a little regex helps but isn't required. Nothing from Data Analysis is required, and nothing here calls any model or web service. It's optional and ungraded; see [Real-World Projects](/docs/projects) for the full, growing list.
+
+## 🎯 What you'll do
+
+1. Install `uv` and set up a small project with `pypdf`, `networkx`, and `pyvis` — no API key, no signup, nothing to configure.
+2. Read a config file's keys with the standard library (`tomllib` for TOML, `configparser` for INI) and turn each key into an entity.
+3. Parse SQL schema files with a couple of regexes: `CREATE TABLE` names become tables, `REFERENCES` becomes edges between tables.
+4. Extract text from the PDFs with `pypdf`, using a tiny hand-written PDF writer to generate the sample ones.
+5. Build the `networkx` graph and resolve the cross-file references — config values naming tables, PDFs mentioning tables and keys — with a two-pass approach.
+6. Visualize the graph with `pyvis` and query it: "which configs reference table `users`?", "list all entities that mention `auth`".
+
+## Where to run this
+
+**Locally with `uv`** is the primary, recommended path — real Python, on your own machine, reading real files from a real folder on disk.
+
+**GitHub Codespaces** works great here too: open [the whole course repo in a free Codespace](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python, and `uv` are already installed, per the repo's `.devcontainer/devcontainer.json`) and run the exact same `uv` commands from a terminal in your browser tab — and the sample folder is already sitting there in the repo to point the tool at.
+
+**Google Colab or Kaggle Notebooks** are a genuinely easy option too, not just a fallback — this project needs no GPU, no API key, and no server, just `pip install`s and pure computation. `!pip install pypdf networkx pyvis` in a cell, and the rest of the code below works essentially unchanged (pyvis's HTML output can even be displayed inline in a notebook cell).
+
+[](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/folder-knowledge-graph/notebook.ipynb)
+[](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/folder-knowledge-graph/notebook.ipynb)
+[](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Ffolder-knowledge-graph%2Fnotebook.ipynb)
+
+A ready-made notebook with all of the code below — including the sample files and PDFs written out inline, so there's nothing to upload or clone — is at [`examples/folder-knowledge-graph/notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/folder-knowledge-graph/notebook.ipynb). Click a badge above to launch it directly.
+
+> **opencode** *(optional)* — a free, open-source AI coding agent that runs in your terminal. If you'd rather have an agent write and run this project for you than type the code yourself, install it with `curl -fsSL https://opencode.ai/install | bash` (or `npm install -g opencode-ai`) and point it at this repo with the same API key from Setup below. It's optional — this project's whole point is building it yourself, so treat it as a bonus, not a shortcut.
+
+## Setup
+
+Since there's no API key or `.env` file anywhere in this project, setup is unusually short.
+
+**Install `uv`**, a single tool that replaces the usual "install Python, then install pip, then install a virtual environment tool, then install packages" chain:
+
+**macOS / Linux** (terminal):
+
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+**Windows** (PowerShell):
+
+```powershell
+powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
+```
+
+Close and reopen your terminal, then confirm it installed:
+
+```bash
+uv --version
+```
+
+**Set up a project and install dependencies:**
+
+```bash
+uv init folder-graph
+cd folder-graph
+uv add pypdf networkx pyvis
+```
+
+`pypdf` is a free, pure-Python library for reading and writing PDFs — it extracts the text that PDFs don't otherwise give you as a plain string. `networkx` handles the actual graph data structure (nodes, edges, traversal). `pyvis` turns a `networkx` graph into an interactive HTML page you can drag around and zoom in a browser.
+
+That's the whole setup. **No API key, no `.env` file, no free-tier signup, no environment variable to configure** — every step from here on reads local files and runs local computation.
+
+:::tip[No internet access needed after installation]
+Once `uv add` finishes downloading these three packages, the entire rest of this project can run with your network disconnected. Everything else in this section of the course revolves around calling a remote model or a remote website, so it's easy to start assuming every "real" Python project needs a network call somewhere. This one is a useful counterexample — deterministic document parsing and graph theory are entirely offline.
+:::
+
+## Step 1: Read a config file's keys with the standard library
+
+Your folder's config files are the easiest win, because the standard library already ships parsers for the two most common formats. TOML (used by `pyproject.toml`, Cargo, and many modern tools) is parsed by `tomllib` — built into Python 3.11+; INI (older but everywhere) by `configparser`.
+
+```python
+# explore_config.py
+import tomllib
+from pathlib import Path
+
+with open("app.toml", "rb") as f:
+ data = tomllib.load(f)
+
+# data is a nested dict: {"database": {"port": 8080}, ...}
+for section, values in data.items():
+ print(section, "->", values)
+```
+
+For the sample project you'll build this folder for, a config file looks like this:
+
+```toml
+# app.toml
+[database]
+dbname = "bookstore"
+seed_tables = ["users", "books"]
+
+[auth]
+jwt_secret = "change-me-in-prod"
+```
+
+The key insight for the graph: each config key — `database.seed_tables`, `auth.jwt_secret` — is an *entity* in its own right, and its *value* may name other entities in the folder ("users", "books" are table names from the SQL files). That's a reference waiting to become a graph edge. So the first extraction step flattens the nested dict into dotted key paths and records each one:
+
+```python
+def flatten_toml(data, prefix=""):
+ """Flattens nested TOML into dotted key paths, e.g. {"database": {"port": 8080}}
+ becomes {"database.port": 8080}. Values keep their native types."""
+ flat = {}
+ for key, value in data.items():
+ path = f"{prefix}.{key}" if prefix else key
+ if isinstance(value, dict):
+ flat.update(flatten_toml(value, path))
+ else:
+ flat[path] = value
+ return flat
+```
+
+INI is the same idea with `configparser` — for every section, for every key inside it, the dotted path is `section.key`:
+
+```python
+import configparser
+
+parser = configparser.ConfigParser()
+parser.read_string(Path("auth.ini").read_text(encoding="utf-8"))
+keys = {f"{section}.{key}": value
+ for section in parser.sections()
+ for key, value in parser.items(section)}
+```
+
+Flattening matters because a graph node needs a *unique, stable id*: `database.seed_tables` and `auth.seed_tables` in the same file are different keys, and if you just used `seed_tables` as the node id, `networkx` would silently merge them — the same "qualified id" lesson as the codebase version of this project, where `utils.py::run` and `models.py::run` must not become the same node.
+
+:::tip[What about YAML?]
+The sample folder uses TOML and INI on purpose, because both have first-class parsers in the standard library. YAML does not — and this project deliberately avoids `PyYAML` to stay dependency-light. The companion example handles simple `key: value` YAML with a tiny regex fallback and warns that nested YAML is out of scope; if you need real YAML, that's an honest place to add a dependency.
+:::
+
+**✅ Checklist**
+
+
+`flatten_toml({"a": {"b": 1}})` returns `{"a.b": 1}` — dotted key paths, not nested dicts.
+Running the INI snippet on `auth.ini` yields keys like `auth.provider` and `auth.session_table`.
+You can explain why `database.seed_tables` is a better node id than `seed_tables`.
+
+
+**🤔 Socratic Question(s)**
+
+- `tomllib.load` requires the file to be opened in binary mode (`"rb"`); `configparser.read_string` takes a string. Why does the TOML parser care about bytes while the INI parser doesn't?
+- The flattened key path uses a dot as a separator. What would go wrong if a key itself contained a dot (e.g. `"my.key"`), and how would you disambiguate?
+
+## Step 2: Parse SQL schemas — tables and foreign keys
+
+Config keys are half the story. The SQL schema files define the *tables* that config values and PDFs keep naming — the other half. A full SQL parser is a big project; a schema parser needs almost nothing, because schemas are heavily conventional:
+
+```python
+# explore_sql.py (excerpt)
+import re
+
+def extract_sql(text):
+ """Every CREATE TABLE name in the file, plus, for each table, every table
+ its foreign keys REFERENCES."""
+ current_table = None
+ for line in text.splitlines():
+ create = re.match(r"(?i)^\s*create\s+table\s+([a-z0-9_]+)", line)
+ if create:
+ current_table = create.group(1).lower()
+ print("table:", current_table)
+ ref = re.search(r"(?i)references\s+([a-z0-9_]+)", line)
+ if ref and current_table:
+ print(f" {current_table} references {ref.group(1).lower()}")
+```
+
+```bash
+uv run python explore_sql.py < 001_users.sql
+```
+
+For the sample schema file this prints:
+
+```
+table: users
+table: sessions
+ sessions references users
+```
+
+Two relationships live in one line: `CREATE TABLE sessions` *defines* the `sessions` node, and `user_id INTEGER NOT NULL REFERENCES users(id)` *connects* `sessions` to `users`. Notice the `references` match doesn't depend on the line being a `CREATE TABLE` line — tracking `current_table` as you scan, so a `REFERENCES` clause knows which table it belongs to, is the whole trick. (Also notice it doesn't matter that `users` is defined in a *different file* — `sessions` in `001_users.sql` can reference `users` in the same file or `books` in `003_books.sql`; the graph doesn't care, and neither should your extractor.)
+
+**✅ Checklist**
+
+
+Running `extract_sql` on `001_users.sql` prints `table: users`, `table: sessions`, and `sessions references users`.
+A table referenced by a foreign key is still reported even if it's defined in a different `.sql` file.
+You can explain what `current_table` is tracking and why the scan is line-by-line rather than a single regex over the whole file.
+
+
+**🤔 Socratic Question(s)**
+
+- The regexes here are case-insensitive (`(?i)`) and anchored at line start. What schema formatting would these regexes *miss* — and is that an acceptable tradeoff for a learning project?
+- If a `REFERENCES` clause pointed at a table that appears nowhere in the folder, should the extractor crash, warn, or silently create the node? Which choice keeps the tool most robust on a real folder?
+
+## Step 3: Extract text from the PDFs with pypdf
+
+PDFs are the hard file type, because unlike SQL and configs there's no standard-library parser and no structured syntax — a PDF is a binary container, and the text inside it is only reconstructed by the viewer. `pypdf` is the friendly entry point:
+
+```python
+# explore_pdf.py
+from pypdf import PdfReader
+
+reader = PdfReader("architecture.pdf")
+for page in reader.pages:
+ print(page.extract_text())
+```
+
+That's the whole extraction: `extract_text()` returns whatever text the PDF's content streams actually draw. For a text-based PDF (created by typing, not scanning) this gives you clean, searchable prose — which is exactly what the "mentions" edges in Step 4 will scan. For a *scanned* PDF it gives you nothing, because the "text" is just an image of a page; handling that case means OCR, which is a genuinely different problem (the [Chat with PDFs](/docs/projects/chat-with-pdfs) project's indexing pipeline is where that starts to matter).
+
+Where do the sample PDFs come from? `pypdf` reads PDFs but doesn't ship a text-layout writer, so the sample folder's PDFs are generated by a small committed script, `make_pdf_data.py`, that hand-crafts minimal valid PDFs — a catalog object, a page object, two font objects, and a content stream that draws text with the classic `BT`/`ET` operators. It's a genuinely useful trick to have seen once: a valid PDF is just a few objects plus an `xref` offset table, and a pure-Python writer is ~60 lines. The generated files are tiny (a couple of KB each) and are committed to the repo so everything works out of the box; the script is there so you can inspect or regenerate them.
+
+:::tip[If a PDF won't parse, skip it — don't crash the run]
+`PdfReader` can raise on corrupt, encrypted, or otherwise unusual PDFs. The full tool in Step 4 wraps extraction in `try`/`except` and skips the file with a warning — exactly like the codebase version of this project skips a `.py` file that `ast.parse` can't handle. One bad file out of a hundred shouldn't end the run.
+:::
+
+**✅ Checklist**
+
+
+`pypdf` extracts readable prose from at least one of the sample PDFs in `data/sample/pdfs/`.
+You can run `make_pdf_data.py` and regenerate identical PDFs (same byte sizes) deterministically.
+You can explain, in one sentence, why a scanned PDF returns empty text from `extract_text()`.
+
+
+**🤔 Socratic Question(s)**
+
+- The hand-written PDF writer draws every line with the same built-in font. What would a real PDF generator like `reportlab` give you that this minimal writer doesn't — and why is "good enough to extract text back out" the right bar for sample data?
+- `extract_text()` returns text *in the order the PDF draws it*, which for a multi-column layout can be wrong. If your folder's PDFs were two-column documents, what would that do to the "mentions" edges in Step 4 — would they still be correct?
+
+## Step 4: Build the graph and resolve the references
+
+Now the parts connect. Everything so far extracted *entities*; this step turns them into a **graph** and, crucially, resolves the *references* that only become visible once the whole folder is in view. `networkx.DiGraph` (directed — "config references table" isn't the same claim as "table references config") is the data structure.
+
+```python
+# build_graph.py (excerpt -- Step 4)
+import networkx as nx
+
+def build_graph(folder):
+ graph = nx.DiGraph()
+ config_keys = [] # (config_key_node, value_as_text) -- resolved later
+ pdf_files = [] # file nodes whose text we scan for mentions later
+
+ for path in sorted(folder.rglob("*")):
+ if not path.is_file():
+ continue
+ rel = str(path.relative_to(folder))
+
+ if path.suffix == ".sql":
+ graph.add_node(rel, kind="file", doc_type="sql")
+ extract_sql(path, graph, rel) # Step 2
+ elif path.suffix in {".toml", ".ini", ".cfg"}:
+ graph.add_node(rel, kind="file", doc_type="config")
+ config_keys.extend(extract_config(path, graph, rel)) # Step 1
+ elif path.suffix == ".pdf":
+ graph.add_node(rel, kind="pdf")
+ extract_pdf_text_onto_node(path, graph, rel) # Step 3
+ pdf_files.append(rel)
+ ...
+```
+
+Each extractor does two things: adds nodes with a stable id (`table:users`, `key:config/app.toml:database.seed_tables`, or just the file's relative path) and adds the local edges it can see immediately — a file *defines* the tables and keys inside it, a table *references* the table its foreign key points at.
+
+The second pass is where the *knowledge* shows up. A config value can name a table defined in another file; a PDF can mention a config key from a third file. None of those edges are resolvable until every file has been scanned — the exact same reason the codebase version of this project resolves "calls" edges only after every function is known:
+
+```python
+# build_graph.py (excerpt -- Step 4, second pass)
+def mentions(haystack, name):
+ """Word-boundary match: `users` matches "users table" but not "user_id"
+ or "idx_sessions_user"."""
+ return re.search(rf"\b{re.escape(name)}\b", haystack.lower()) is not None
+
+# Every table in the folder is now a known node -- index them by name.
+tables = {node.removeprefix("table:"): node for node in graph.nodes
+ if node.startswith("table:")}
+
+# 1. Config values that name a table -> "references" edge.
+for key_node, value_text in config_keys:
+ for table_name, table_node in tables.items():
+ if mentions(value_text, table_name):
+ graph.add_edge(key_node, table_node, kind="references")
+
+# 2. PDF text that names a table or a config key -> "mentions" edge.
+for rel in pdf_files:
+ text = graph.nodes[rel]["text"]
+ for table_name, table_node in tables.items():
+ if mentions(text, table_name):
+ graph.add_edge(rel, table_node, kind="mentions")
+```
+
+Config keys are referable two ways — by their full dotted path (`auth.jwt_secret`) or by their leaf name (`jwt_secret`, which is how prose usually says it) — so the real script indexes both, the same "match by name, resolve by list" approach the codebase version uses for call edges. The `\b...\b` word boundaries matter more than they look like they should: without them, `users` would "mention"-match `user_id` and `idx_sessions_user`, and the graph would be full of edges that describe strings, not relationships.
+
+When this runs on the sample folder you get a graph with three kinds of nodes — files, tables, and config keys — and three kinds of edges:
+
+| Edge kind | Source | Target | Meaning |
+|---|---|---|---|
+| `defines` | a `.sql`/config file | a table / config key | "this file declares this entity" |
+| `references` | a table or config key | a table | "this foreign key / config value points at that table" |
+| `mentions` | a PDF | a table / config key | "this document talks about that entity" |
+
+**✅ Checklist**
+
+
+`build_graph` on the sample folder returns a graph with a nonzero number of `references` and `mentions` edges, not just `defines` edges.
+`table:books` has incoming edges from config keys, from PDFs, *and* from `table:order_items`'s foreign key.
+Removing the `\b` word boundaries from `mentions()` produces visible extra (wrong) edges — proving the boundaries were doing real work.
+
+
+**🤔 Socratic Question(s)**
+
+- Why must the `references`/`mentions` pass run *after* every file is scanned, not file-by-file as you go? What specific edge would a single top-to-bottom pass miss?
+- The second pass iterates every config key against every table name — `O(keys × tables)`. On a folder with thousands of files that's still fast at this scale, but can you sketch the change (indexing names by word, for example) that would make it `O(keys + tables)`?
+
+## Step 5: Visualize the graph
+
+A graph you can only print as a list of edges is hard to actually *see* — a small one like this, with ~30 nodes, is already borderline. `pyvis` wraps the `networkx` graph into a self-contained, interactive HTML page: drag nodes, zoom, hover for details, no server needed.
+
+```python
+# build_graph.py (excerpt -- Step 5)
+from pyvis.network import Network
+
+COLORS = {"file": "#3b82f6", "pdf": "#f59e0b", "table": "#10b981", "config_key": "#8b5cf6"}
+
+def visualize_pyvis(graph, output_path="graph.html"):
+ net = Network(height="800px", width="100%", directed=True, notebook=False)
+ net.barnes_hut() # a physics layout that spaces nodes apart instead of overlapping
+
+ for node, data in graph.nodes(data=True):
+ kind = data.get("kind", "file")
+ net.add_node(node, label=data.get("short_name", node),
+ title=f"{kind}: {node}", color=COLORS.get(kind, "#9ca3af"))
+ for source, target, data in graph.edges(data=True):
+ kind = data.get("kind", "")
+ net.add_edge(source, target, title=kind)
+
+ net.write_html(output_path)
+```
+
+```bash
+uv run python build_graph.py data/sample
+```
+
+Open the resulting `graph.html` in a browser. Blue nodes are schema/config files, amber are PDFs, green are tables, purple are config keys; hovering any node or edge shows its full id and relationship kind. The most interesting thing to look for: which tables have *both* a blue `defines` edge from a schema file *and* amber `mentions` edges from multiple PDFs and purple `references` edges from config keys — those are the entities the whole folder agrees are important, and that agreement is exactly the kind of signal a keyword search can't give you.
+
+**✅ Checklist**
+
+
+`graph.html` opens in a browser and shows a real, non-empty graph — not a blank page.
+Dragging a node moves it, and the connected edges follow it.
+Hovering a node shows its kind and full id in a tooltip; hovering an edge shows its kind (`defines`, `references`, or `mentions`).
+
+
+**🤔 Socratic Question(s)**
+
+- Node *labels* use the short name (`seed_tables`) while the *title tooltip* shows the full id (`key:config/app.toml:database.seed_tables`). Why not label nodes with the full id? What would the visualization look like if it did?
+- The graph is directed, and `pyvis` draws arrows. For a `mentions` edge (PDF → table) the arrow direction is obvious; what would an undirected version of this graph cost you when you get to Step 6's queries?
+
+## Step 6: Query the graph
+
+A graph you can only look at is already useful; a graph you can *ask questions of* is the whole point. `networkx` gives you real traversal, so both directions of "what's connected to this node" are a handful of lines:
+
+```python
+# build_graph.py (excerpt -- Step 6)
+def configs_for_table(graph, table_name):
+ """Which config keys reference this table? Returns (config_key_node, file)."""
+ table_node = f"table:{table_name}"
+ if table_node not in graph:
+ return []
+ return [(src, graph.nodes[src].get("file", "?"))
+ for src, _, data in graph.in_edges(table_node, data=True)
+ if data.get("kind") == "references"
+ and graph.nodes[src].get("kind") == "config_key"]
+
+def entities_mentioning(graph, keyword):
+ """Every entity whose id, label, or extracted text contains `keyword`."""
+ needle = keyword.lower()
+ return [node for node, data in graph.nodes(data=True)
+ if needle in f"{node} {data.get('short_name', '')} {data.get('text', '')}".lower()]
+```
+
+```bash
+uv run python build_graph.py data/sample --configs-for-table users
+uv run python build_graph.py data/sample --mentions auth
+```
+
+`graph.in_edges(node, data=True)` walks every edge *pointing at* a node — the exact operation "what refers to this table?" needs. `entities_mentioning` is a keyword search, but over the graph's *content*: a PDF's extracted text lives on its node, so a match is an *entity* you can then expand with its neighbors, not a raw file you'd have to re-read.
+
+The companion example runs two canonical queries by default, and supports a `--query` flag that routes a few natural-language phrasings to the right function:
+
+```bash
+uv run python build_graph.py data/sample
+
+# Q1: which configs reference table 'users'?
+# key:config/app.toml:database.migrate_tables (in config/app.toml)
+# key:config/app.toml:database.seed_tables (in config/app.toml)
+# Q2: list all entities that mention 'auth'
+# ...every config key under auth.*, both auth-related PDFs, auth.ini itself...
+```
+
+The answer to Q1 is the project's whole thesis in one output line: `app.toml`'s `seed_tables` key references the `users` table — a relationship between a config file and a schema file that exists nowhere in either file's *text* as a direct statement. It's manufactured by the graph from two documents that happen to name the same thing. That's the "indirect relationship" a keyword search can't answer.
+
+**✅ Checklist**
+
+
+`--configs-for-table users` lists `key:config/app.toml:database.seed_tables` and `key:config/app.toml:database.migrate_tables`.
+`--mentions auth` returns a non-empty list including at least one PDF and at least one config key.
+Querying a table that doesn't exist in the graph returns an empty result, not a crash.
+
+
+**🤔 Socratic Question(s)**
+
+- `configs_for_table` answers "what refers to this table?" using `in_edges`. Write the reverse — "what does this config key's value touch?" — using `out_edges`. Which one is more natural for a *PDF* node, and why?
+- The `entities_mentioning` search matches substrings in a node's full text, so `--mentions auth` also matches `authentication` and `author`. Is that a bug or a feature? How would you make it exact if the question really meant the `auth` config section?
+
+## ⚠️ Common pitfalls
+
+- **One bad file shouldn't kill the whole scan.** A corrupt or encrypted PDF, or a config file with a TOML parse error, will raise if you let it. Catch, warn, skip, keep going — exactly the discipline the codebase version of this project applies to `SyntaxError`s. Step 3's `try`/`except` and a `try` around `tomllib.loads` are there for that reason.
+- **Word boundaries or your edges lie.** Matching `users` without `\b` boundaries creates edges against `user_id`, `idx_sessions_user`, and any other string that happens to contain the name as a substring. The "references"/"mentions" edges are only as trustworthy as the matcher that made them.
+- **Config keys can share a leaf name.** Two config files can both define a `port` key. If you index config keys by leaf name only, mentions of `port` resolve to a guess — the full script maps each name to a *list* of candidate nodes and links them all, exactly like the codebase version's `by_short_name` for calls. Same tradeoff, same honest resolution.
+- **The graph is only as good as its rules.** A table name written as "the login table" in a PDF, a foreign key inside a `CONSTRAINT` on its own line, a schema with `CREATE TABLE` split across lines — the hand-written extractors here miss all of those, and that's the point: this is a rules-based tool, and understanding exactly where its blind spots are is the lesson. This is also the strongest argument for the LLM extension in the next section — but notice that the LLM then becomes a *replacement for the extractors*, not an addition to them.
+- **Scanned PDFs have no text to extract.** `extract_text()` returns empty for a PDF that's just images. Don't debug that as a pypdf bug; it's a missing OCR step, which is a different (and much heavier) kind of tool.
+
+## What you just built
+
+A tool that walks a heterogeneous folder — PDFs, config files, SQL schemas — and rebuilds the *relationships* between its documents as an honest graph data structure: files, tables, and config keys as nodes, "defines"/"references"/"mentions" as edges, all extracted deterministically with `pypdf` and the standard library. You can *see* it (interactively, with `pyvis`) and *query* it (with `networkx` traversal), and the queries answer questions — "which configs reference table `users`?", "which documents touch `books`?" — that a keyword search over raw files cannot, because the graph knows relationships that only exist *between* documents. The whole pipeline runs offline, with no API key, and nothing about it was simplified into a toy: the same three-step shape — extract entities, build a graph, query it — is how a real "internal knowledge base" over a real documentation folder would start.
+
+## Where to go from here
+
+- Point `build_graph` at your own folder of PDFs/configs/SQL (or just two file types) and see which relationships it surfaces that you didn't already know. The [codebase knowledge graph](/docs/projects/codebase-knowledge-graph) project is the sibling version of this tool for Python code — the graph-building structure transfers almost line for line.
+- Add a file type or a rule: an `INSERT INTO`/`SELECT FROM` edge in the SQL extractor, a `key: value` YAML fallback, or an "inherits from" style relationship for your own document format. Each rule is a few lines, and each one makes the graph answer a question it previously couldn't.
+- Use real graph algorithms instead of eyeballing: `nx.pagerank` or in-degree centrality on the table nodes to find the most-referenced tables (a decent proxy for "core schema"), or `nx.weakly_connected_components` to find documents that nothing else touches.
+- **The LLM upgrade (mentioned, not built):** let an LLM do the relation extraction — give it each PDF's text and the known entity names, and ask it to emit `(source, relation, target)` triples, which you then feed straight into the same `networkx` graph. You'd lose the guarantee of determinism but gain the ability to catch synonyms and prose references the hand-written rules miss. If you want a working LLM-with-retrieval pipeline to model the surrounding system on, the [RAG over your own notes](/docs/projects/rag-notes) project shows the shape.
+- Export the graph as JSON with `nx.readwrite.json_graph.node_link_data` so another tool (or a web frontend) can consume it without needing `networkx`.
+
+## Related projects
+
+- [Chat with PDFs](/docs/projects/chat-with-pdfs) — the same "PDFs are hard to get text out of" starting point, taken in the RAG direction: chunk, embed, and ask a model questions about your documents.
+- [Codebase Knowledge Graph](/docs/projects/codebase-knowledge-graph) — the sibling project this one's two-pass graph-building structure is modeled on, applied to a folder of Python files instead of mixed documents.
+- [RAG App Over Your Own Notes](/docs/projects/rag-notes) — retrieval over a document folder with local embeddings, where the extraction step of this project would slot in before the embedding step.
+- [MCP Notes Server](/docs/projects/mcp-notes-server) — expose a searchable index over a folder of documents to an LLM client, a different answer to the same "my knowledge is stuck in files" problem.
+- [Docs Q&A Bot](/docs/projects/docs-qa-bot) — wraps a document-retrieval pipeline in a Discord bot, end-to-end.
+
+## Share your project with the class
+
+Built something you're proud of? [`examples/student-projects/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/student-projects) is a gallery of projects other students have submitted — and its README has a full, beginner-friendly walkthrough for adding yours via a **pull request**, even if you've never used git before: forking the repo, making a branch, committing your files, and opening the PR, one step at a time. No prior git experience assumed.
+
+Welcome to writing Python outside the browser. 🎓
+
+
diff --git a/docs/projects/index.mdx b/docs/projects/index.mdx
index e22e291..1128a4b 100644
--- a/docs/projects/index.mdx
+++ b/docs/projects/index.mdx
@@ -17,6 +17,12 @@ They're optional and ungraded. Browse them any time — each project's intro say
+[](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/folder-knowledge-graph/notebook.ipynb)
+[](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/folder-knowledge-graph/notebook.ipynb)
+[](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Ffolder-knowledge-graph%2Fnotebook.ipynb)
+
+## What's here
+
+- `data/sample/` — a small, realistic project folder: three `.sql` schema files, three config files (`app.toml`, `auth.ini`, `reporting.toml`), and three short text-based PDFs about the same bookstore app. The PDFs are committed (a few KB each) so everything works out of the box.
+- `build_graph.py` — the main tool: walks the folder, extracts entities + edges per file type, builds the `networkx` graph, writes an interactive `graph.html` (pyvis, gitignored), and answers the built-in questions.
+- `make_pdf_data.py` — deterministically regenerates the sample PDFs with a tiny hand-rolled pure-Python PDF writer (no `reportlab` needed), so you can inspect or recreate them.
+- `notebook.ipynb` — the same walkthrough as a self-contained notebook: it writes the sample files (including the PDFs) inline, builds the graph, shows the pyvis output plus a printed adjacency summary, and runs the same queries. Click a badge above to launch it in Colab, Kaggle, or Binder with zero local setup.
+
+No API key, no signup, no network access needed after `uv add` — this is deterministic, hand-written extraction running entirely on your own machine.
+
+## How to run this
+
+```bash
+uv run python build_graph.py data/sample
+```
+
+This prints the graph summary, answers the two canonical questions ("which configs reference table `users`?", "list all entities that mention `auth`"), and writes `graph.html` — open it in a browser (drag nodes, zoom, hover for tooltips).
+
+Other things to try:
+
+```bash
+# Answer one specific question precisely
+uv run python build_graph.py data/sample --configs-for-table orders
+uv run python build_graph.py data/sample --mentions auth
+uv run python build_graph.py data/sample --neighbors "table:books"
+
+# Natural-ish query routing (handles the two canonical questions + a keyword fallback)
+uv run python build_graph.py data/sample --query "which configs reference table users"
+
+# Regenerate the sample PDFs (they're committed, so this is only needed to tweak them)
+uv run python make_pdf_data.py
+
+# Point it at your own mixed folder of PDFs/configs/SQL
+uv run python build_graph.py /path/to/some/folder --html my_graph.html
+```
+
+`uv run` reads `pyproject.toml`/`uv.lock` and creates an isolated environment for this project automatically on first run — no manual virtual environment setup needed.
+
+## Where's the AI?
+
+There isn't any, on purpose. Every relationship in this graph comes from hand-written extraction rules — regexes over SQL, `tomllib`/`configparser` for configs, `pypdf` text extraction for PDFs — and those rules are the whole point: they show how much structure you can pull out of a heterogeneous folder deterministically, with only the standard library plus `pypdf`. The tradeoff is real and explicit: the graph is only as good as its rules, and things the rules don't look for are invisible to it. An LLM doing the relation extraction is a genuinely useful upgrade, and the [lesson's Next steps](../../docs/projects/folder-knowledge-graph/index.md) point at it — but it's not needed to get a working, queryable knowledge graph out of this folder.
+
+## Built your own version?
+
+See [`examples/student-projects/`](../student-projects/) for how to share it with the class via a pull request — no git experience required, it walks through every step.
diff --git a/examples/folder-knowledge-graph/build_graph.py b/examples/folder-knowledge-graph/build_graph.py
new file mode 100644
index 0000000..f8e5636
--- /dev/null
+++ b/examples/folder-knowledge-graph/build_graph.py
@@ -0,0 +1,464 @@
+"""Turn a folder of PDFs, configs, and SQL schemas into a queryable knowledge graph.
+
+Walks a folder of mixed document types -- PDFs (via pypdf), config files
+(TOML/INI/YAML with the standard library), and SQL schema files -- and builds
+a directed networkx graph out of the *references* hidden inside them:
+a schema defines a table, a table references another table, a config value
+names a table, a PDF mentions a table or a config key. Then you can query it
+("which configs reference table 'users'?", "what does this PDF mention?") in
+ways a plain keyword search over files can't -- the graph knows *indirect*
+relationships.
+
+Honest framing: the extraction below is hand-written rules, so the graph is
+only as good as its rules. See the lesson for what that buys you and where it
+breaks. Runs entirely locally -- no API key, no LLM, no network access.
+
+Usage:
+ uv run python build_graph.py data/sample
+ uv run python build_graph.py data/sample --html graph.html
+ uv run python build_graph.py data/sample --configs-for-table users
+ uv run python build_graph.py data/sample --mentions auth
+ uv run python build_graph.py data/sample --neighbors "table:books"
+ uv run python build_graph.py data/sample --query "which configs reference table users"
+"""
+
+from __future__ import annotations
+
+import argparse
+import configparser
+import re
+import tomllib
+from pathlib import Path
+
+import networkx as nx
+
+# ---------------------------------------------------------------------------
+# Extraction: SQL schemas
+# ---------------------------------------------------------------------------
+
+
+def _table_node(graph: nx.DiGraph, name: str) -> str:
+ """Returns the node id for a table, creating the node if it's new.
+
+ A table can be referenced (by a config, a PDF, or another table's FK)
+ before it's ever *defined*, so references must be allowed to create nodes.
+ """
+ node = f"table:{name}"
+ if node not in graph:
+ graph.add_node(node, kind="table", short_name=name)
+ return node
+
+
+def extract_sql(path: Path, graph: nx.DiGraph, rel: str) -> None:
+ """Finds CREATE TABLE definitions and REFERENCES foreign keys in a .sql file.
+
+ Table nodes get a "defines" edge from the file that declares them; a
+ REFERENCES clause produces a "references" edge from the table being
+ declared to the table it points at.
+ """
+ text = path.read_text(encoding="utf-8", errors="ignore")
+ current_table: str | None = None
+ for line in text.splitlines():
+ create = re.match(r"(?i)^\s*create\s+table\s+([a-z0-9_]+)", line)
+ if create:
+ current_table = create.group(1).lower()
+ graph.add_edge(rel, _table_node(graph, current_table), kind="defines")
+ ref = re.search(r"(?i)references\s+([a-z0-9_]+)", line)
+ if ref and current_table:
+ graph.add_edge(
+ _table_node(graph, current_table),
+ _table_node(graph, ref.group(1).lower()),
+ kind="references",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Extraction: config files
+# ---------------------------------------------------------------------------
+
+
+def _flatten_toml(data: dict, prefix: str = "") -> dict[str, object]:
+ """Flattens nested TOML into dotted key paths, e.g. {"database": {"port": 8080}}
+ becomes {"database.port": 8080}. Values keep their native types.
+ """
+ flat: dict[str, object] = {}
+ for key, value in data.items():
+ path = f"{prefix}.{key}" if prefix else key
+ if isinstance(value, dict):
+ flat.update(_flatten_toml(value, path))
+ else:
+ flat[path] = value
+ return flat
+
+
+def extract_config(path: Path, graph: nx.DiGraph, rel: str) -> list[tuple[str, str]]:
+ """Parses one config file into (config_key_node, value_as_text) pairs.
+
+ TOML uses tomllib; INI uses configparser; YAML gets a simple, honest
+ line-based key extractor (see the lesson's pitfalls for its limits).
+ Each key becomes a node with a "defines" edge from its file; the returned
+ pairs are resolved into "references" edges in a later pass, once every
+ table in the folder is known.
+ """
+ suffix = path.suffix.lower()
+ text = path.read_text(encoding="utf-8", errors="ignore")
+ keys: dict[str, object] = {}
+
+ if suffix in {".toml"}:
+ keys = _flatten_toml(tomllib.loads(text))
+ elif suffix in {".ini", ".cfg"}:
+ parser = configparser.ConfigParser()
+ parser.read_string(text)
+ for section in parser.sections():
+ for key, value in parser.items(section):
+ keys[f"{section}.{key}"] = value
+ elif suffix in {".yaml", ".yml"}:
+ # Top-level `key: value` lines only -- nested YAML would need a real
+ # YAML parser (PyYAML), which this deliberately-lean project skips.
+ for line in text.splitlines():
+ stripped = line.strip()
+ if stripped.startswith(("#", ";", "-", " ")):
+ continue
+ match = re.match(r"^([A-Za-z0-9_.-]+)\s*:\s*(.*)$", stripped)
+ if match:
+ keys[match.group(1)] = match.group(2)
+ else:
+ print(f"⚠️ Skipping {path}: unsupported config extension '{suffix}'")
+ return []
+
+ parsed: list[tuple[str, str]] = []
+ for key, value in keys.items():
+ node = f"key:{rel}:{key}"
+ graph.add_node(node, kind="config_key", short_name=key, file=rel)
+ graph.add_edge(rel, node, kind="defines")
+ parsed.append((node, f"{key} {value}"))
+ return parsed
+
+
+# ---------------------------------------------------------------------------
+# Extraction: PDFs
+# ---------------------------------------------------------------------------
+
+
+def extract_pdf(path: Path, graph: nx.DiGraph, rel: str) -> str:
+ """Extracts all text from a PDF with pypdf and returns it (also stored on the node).
+
+ If pypdf can't read the file -- a scanned PDF, say -- we warn and keep
+ going, exactly like skipping a file with a syntax error in the codebase
+ version of this project.
+ """
+ try:
+ from pypdf import PdfReader
+ except ImportError:
+ print("⚠️ pypdf not installed -- run `uv add pypdf` (see README).")
+ return ""
+
+ try:
+ reader = PdfReader(str(path))
+ except Exception as exc: # noqa: BLE001 -- any PDF parse failure just skips the file
+ print(f"⚠️ Skipping {path}: could not read PDF ({exc})")
+ return ""
+
+ text = "\n".join(page.extract_text() or "" for page in reader.pages)
+ graph.add_node(rel, kind="pdf", short_name=path.name, text=text)
+ return text
+
+
+# ---------------------------------------------------------------------------
+# Graph building
+# ---------------------------------------------------------------------------
+
+
+def _mentions(haystack: str, name: str) -> bool:
+ """Word-boundary substring match -- `users` matches "users table" but not
+ "user_id" or "idx_sessions_user"."""
+ return re.search(rf"\b{re.escape(name)}\b", haystack.lower()) is not None
+
+
+def build_graph(folder: Path) -> nx.DiGraph:
+ """Walks `folder` and builds a directed graph from every document's references.
+
+ Node kinds: "file" (config/sql containers), "table", "config_key", "pdf".
+ Edge kinds: "defines" (file declares a table/key), "references"
+ (table FK or config value naming a table), "mentions" (PDF naming a table
+ or config key).
+
+ Cross-file edges ("references", "mentions") need a *second pass*: a config
+ value can name a table defined in another file, and a PDF can mention a
+ config key from yet another file, so nothing is resolvable until every
+ file has been scanned -- the same two-pass shape as the codebase version
+ of this project resolves call edges only after every file is known.
+ """
+ graph = nx.DiGraph()
+ config_keys: list[tuple[str, str]] = []
+ pdf_files: list[str] = []
+
+ for path in sorted(folder.rglob("*")):
+ if not path.is_file():
+ continue
+ if any(part.startswith(".") or part == "__pycache__" for part in path.parts):
+ continue
+ rel = str(path.relative_to(folder))
+
+ if path.suffix.lower() in {".sql"}:
+ graph.add_node(rel, kind="file", doc_type="sql", short_name=path.name)
+ extract_sql(path, graph, rel)
+ elif path.suffix.lower() in {".toml", ".ini", ".cfg", ".yaml", ".yml"}:
+ graph.add_node(rel, kind="file", doc_type="config", short_name=path.name)
+ config_keys.extend(extract_config(path, graph, rel))
+ elif path.suffix.lower() == ".pdf":
+ if extract_pdf(path, graph, rel):
+ pdf_files.append(rel)
+ else:
+ print(f"Skipping unsupported file: {path}")
+
+ # Second pass: resolve references now that every table is a known node.
+ tables = {
+ node.removeprefix("table:"): node
+ for node in graph.nodes
+ if node.startswith("table:")
+ }
+
+ # Config keys are referable two ways: by their full dotted path
+ # ("auth.jwt_secret") or by their leaf name ("jwt_secret"). Multiple keys
+ # can share a leaf name across files, so this maps each name to a list of
+ # candidate nodes -- the same "match by name, resolve by list" approach
+ # the codebase version uses for call edges.
+ keys_by_name: dict[str, list[str]] = {}
+ for node, data in graph.nodes(data=True):
+ if data.get("kind") == "config_key":
+ short = data["short_name"]
+ keys_by_name.setdefault(short, []).append(node)
+ keys_by_name.setdefault(short.rsplit(".", 1)[-1], []).append(node)
+
+ for key_node, value_text in config_keys:
+ for table_name, table_node in tables.items():
+ if _mentions(value_text, table_name):
+ graph.add_edge(key_node, table_node, kind="references")
+
+ for rel in pdf_files:
+ text = graph.nodes[rel].get("text", "")
+ for table_name, table_node in tables.items():
+ if _mentions(text, table_name):
+ graph.add_edge(rel, table_node, kind="mentions")
+ for key_short, key_nodes in keys_by_name.items():
+ if _mentions(text, key_short):
+ for key_node in key_nodes:
+ if key_node not in graph:
+ continue
+ graph.add_edge(rel, key_node, kind="mentions")
+
+ return graph
+
+
+# ---------------------------------------------------------------------------
+# Queries
+# ---------------------------------------------------------------------------
+
+
+def configs_for_table(graph: nx.DiGraph, table_name: str) -> list[tuple[str, str]]:
+ """Which config keys reference this table? Returns (config_key_node, file)."""
+ table_node = f"table:{table_name}"
+ if table_node not in graph:
+ return []
+ results = []
+ for src, _, data in graph.in_edges(table_node, data=True):
+ if data.get("kind") == "references" and graph.nodes[src].get("kind") == "config_key":
+ results.append((src, graph.nodes[src].get("file", "?")))
+ return sorted(results)
+
+
+def entities_mentioning(graph: nx.DiGraph, keyword: str) -> list[str]:
+ """Every entity whose id, label, or extracted text contains `keyword`.
+
+ This is a keyword search over the graph's *content*, not over raw files:
+ a PDF's extracted text lives on its node, so a match here is an entity in
+ the graph -- which you can then expand with neighbors().
+ """
+ needle = keyword.lower()
+ hits = []
+ for node, data in graph.nodes(data=True):
+ haystack = f"{node} {data.get('short_name', '')} {data.get('text', '')}".lower()
+ if needle in haystack:
+ hits.append(node)
+ return sorted(hits)
+
+
+def neighbors(graph: nx.DiGraph, node: str) -> tuple[list[str], list[str]]:
+ """(outgoing, incoming) neighbor labels for a node, with edge kinds."""
+ if node not in graph:
+ return [], []
+ out = sorted(f"{data.get('kind', '?')} -> {target}" for _, target, data in graph.out_edges(node, data=True))
+ inc = sorted(f"{source} -> {data.get('kind', '?')}" for source, _, data in graph.in_edges(node, data=True))
+ return out, inc
+
+
+def run_query(graph: nx.DiGraph, query: str) -> str:
+ """A small, honest query router for --query. For precise answers use the
+ dedicated flags (--configs-for-table / --mentions / --neighbors); this
+ handles the two canonical questions plus a fallback keyword search."""
+ q = query.lower()
+
+ canonical = re.search(r"configs?\s+reference\s+table\s+'?([a-z0-9_]+)'?", q)
+ if canonical:
+ table = canonical.group(1)
+ hits = configs_for_table(graph, table)
+ if not hits:
+ return f"No config key references table '{table}'."
+ lines = [f"'{table}' is referenced by:"]
+ for key_node, file in hits:
+ lines.append(f" {key_node} (in {file})")
+ return "\n".join(lines)
+
+ mention = re.search(r"mention(s|ing)?\s+'?([a-z0-9_]+)'?", q)
+ if mention:
+ keyword = mention.group(2)
+ hits = entities_mentioning(graph, keyword)
+ if not hits:
+ return f"No entity mentions '{keyword}'."
+ return f"Entities mentioning '{keyword}':\n" + "\n".join(f" {node}" for node in hits)
+
+ words = [w for w in re.findall(r"[a-z0-9_]+", q) if len(w) >= 3]
+ hits = entities_mentioning(graph, " ".join(words))
+ if not hits:
+ return f"No entity matched query: {query!r}"
+ lines = [f"Entities matching {query!r}:"]
+ for node in hits[:20]:
+ out, inc = neighbors(graph, node)
+ lines.append(f" {node}")
+ for item in out[:6]:
+ lines.append(f" references/mentions -> {item}")
+ for item in inc[:6]:
+ lines.append(f" <- {item}")
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# Visualization
+# ---------------------------------------------------------------------------
+
+_COLORS = {
+ "file": "#3b82f6", # blue -- schema/config container files
+ "pdf": "#f59e0b", # amber -- documents
+ "table": "#10b981", # green -- SQL tables
+ "config_key": "#8b5cf6", # purple -- configuration keys
+}
+
+_EDGE_COLORS = {
+ "defines": "#d1d5db",
+ "references": "#ef4444",
+ "mentions": "#f59e0b",
+}
+
+
+def visualize_pyvis(graph: nx.DiGraph, output_path: Path) -> None:
+ """Renders the graph as a self-contained, interactive HTML file with pyvis."""
+ from pyvis.network import Network
+
+ net = Network(height="800px", width="100%", directed=True, notebook=False)
+ net.barnes_hut()
+
+ for node, data in graph.nodes(data=True):
+ kind = data.get("kind", "file")
+ label = data.get("short_name", node)
+ net.add_node(node, label=label, title=f"{kind}: {node}", color=_COLORS.get(kind, "#9ca3af"))
+
+ for source, target, data in graph.edges(data=True):
+ kind = data.get("kind", "")
+ net.add_edge(source, target, title=kind, color=_EDGE_COLORS.get(kind, "#d1d5db"))
+
+ net.write_html(str(output_path))
+ print(f"Wrote interactive graph to {output_path} -- open it in a browser.")
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def summarize(graph: nx.DiGraph, folder: Path) -> None:
+ """Prints a human-readable adjacency summary of the whole folder's graph."""
+ kinds: dict[str, list[str]] = {}
+ for node, data in graph.nodes(data=True):
+ kinds.setdefault(data.get("kind", "file"), []).append(node)
+
+ for kind in ["file", "pdf", "table", "config_key"]:
+ count = len(kinds.get(kind, []))
+ if count:
+ print(f" {kind}: {count}")
+ print(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges.\n")
+
+ for node in sorted(graph.nodes):
+ if graph.nodes[node].get("kind") not in {"file", "pdf"}:
+ continue
+ out = [(target, data.get("kind")) for _, target, data in graph.out_edges(node, data=True)]
+ if out:
+ edges = ", ".join(f"{kind} {target}" for target, kind in out)
+ print(f" {node}: {edges}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("folder", type=Path, help="Path to the mixed-document folder to analyze")
+ parser.add_argument("--html", type=Path, default=None, help="Write an interactive pyvis HTML file here")
+ parser.add_argument("--query", type=str, default=None, help="Natural-ish query, e.g. \"which configs reference table users\"")
+ parser.add_argument("--configs-for-table", type=str, default=None, help="Query: which config keys reference this table?")
+ parser.add_argument("--mentions", type=str, default=None, help="Query: list all entities mentioning this keyword")
+ parser.add_argument("--neighbors", type=str, default=None, help="Query: show in/out edges of this node id")
+ args = parser.parse_args()
+
+ graph = build_graph(args.folder)
+ print(f"Parsed {args.folder} ({graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges):")
+ summarize(graph, args.folder)
+
+ if args.configs_for_table:
+ hits = configs_for_table(graph, args.configs_for_table)
+ if hits:
+ print(f"'{args.configs_for_table}' is referenced by:")
+ for key_node, file in hits:
+ print(f" {key_node} (in {file})")
+ else:
+ print(f"No config key references table '{args.configs_for_table}'.")
+
+ if args.mentions:
+ hits = entities_mentioning(graph, args.mentions)
+ if hits:
+ print(f"Entities mentioning '{args.mentions}':")
+ for node in hits:
+ print(f" {node}")
+ else:
+ print(f"No entity mentions '{args.mentions}'.")
+
+ if args.neighbors:
+ out, inc = neighbors(graph, args.neighbors)
+ print(f"Neighbors of {args.neighbors}:")
+ for item in out:
+ print(f" {item}")
+ for item in inc:
+ print(f" {item}")
+ if not out and not inc:
+ print(f" (no such node: {args.neighbors})")
+
+ if args.query:
+ print()
+ print(f"Q: {args.query}")
+ print(run_query(graph, args.query))
+
+ if not (args.configs_for_table or args.mentions or args.neighbors or args.query):
+ # No query flags given -- answer the two canonical built-in questions,
+ # exactly like the codebase version prints its built-in queries.
+ print("Q1: which configs reference table 'users'?")
+ for key_node, file in configs_for_table(graph, "users"):
+ print(f" {key_node} (in {file})")
+ print("Q2: list all entities that mention 'auth'")
+ for node in entities_mentioning(graph, "auth"):
+ print(f" {node}")
+
+ if args.html:
+ visualize_pyvis(graph, args.html)
+ elif not (args.configs_for_table or args.mentions or args.neighbors or args.query):
+ visualize_pyvis(graph, Path("graph.html"))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/folder-knowledge-graph/data/sample/config/app.toml b/examples/folder-knowledge-graph/data/sample/config/app.toml
new file mode 100644
index 0000000..d2703be
--- /dev/null
+++ b/examples/folder-knowledge-graph/data/sample/config/app.toml
@@ -0,0 +1,15 @@
+# app.toml
+# Main application configuration for the bookstore service.
+
+[database]
+dbname = "bookstore"
+seed_tables = ["users", "books"]
+migrate_tables = ["users", "sessions", "orders", "order_items", "books", "tax_rates"]
+
+[auth]
+jwt_secret = "change-me-in-prod"
+token_ttl_seconds = 3600
+
+[server]
+port = 8080
+host = "127.0.0.1"
diff --git a/examples/folder-knowledge-graph/data/sample/config/auth.ini b/examples/folder-knowledge-graph/data/sample/config/auth.ini
new file mode 100644
index 0000000..f2fd15a
--- /dev/null
+++ b/examples/folder-knowledge-graph/data/sample/config/auth.ini
@@ -0,0 +1,10 @@
+; auth.ini
+; Authentication provider settings.
+
+[auth]
+provider = local
+session_table = sessions
+password_hash_algo = sha256
+
+[database]
+connection_pool_size = 5
diff --git a/examples/folder-knowledge-graph/data/sample/config/reporting.toml b/examples/folder-knowledge-graph/data/sample/config/reporting.toml
new file mode 100644
index 0000000..aa56c46
--- /dev/null
+++ b/examples/folder-knowledge-graph/data/sample/config/reporting.toml
@@ -0,0 +1,10 @@
+# reporting.toml
+# Weekly sales report configuration.
+
+[reports.weekly]
+enabled = true
+source_tables = ["orders", "order_items"]
+currency = "USD"
+
+[exports]
+include_tax_rates = true
diff --git a/examples/folder-knowledge-graph/data/sample/pdfs/architecture.pdf b/examples/folder-knowledge-graph/data/sample/pdfs/architecture.pdf
new file mode 100644
index 0000000..12e0cde
Binary files /dev/null and b/examples/folder-knowledge-graph/data/sample/pdfs/architecture.pdf differ
diff --git a/examples/folder-knowledge-graph/data/sample/pdfs/data-model.pdf b/examples/folder-knowledge-graph/data/sample/pdfs/data-model.pdf
new file mode 100644
index 0000000..55da244
Binary files /dev/null and b/examples/folder-knowledge-graph/data/sample/pdfs/data-model.pdf differ
diff --git a/examples/folder-knowledge-graph/data/sample/pdfs/onboarding.pdf b/examples/folder-knowledge-graph/data/sample/pdfs/onboarding.pdf
new file mode 100644
index 0000000..35bd2f0
Binary files /dev/null and b/examples/folder-knowledge-graph/data/sample/pdfs/onboarding.pdf differ
diff --git a/examples/folder-knowledge-graph/data/sample/sql/001_users.sql b/examples/folder-knowledge-graph/data/sample/sql/001_users.sql
new file mode 100644
index 0000000..2b017af
--- /dev/null
+++ b/examples/folder-knowledge-graph/data/sample/sql/001_users.sql
@@ -0,0 +1,18 @@
+-- 001_users.sql
+-- Core identity tables: users and their login sessions.
+CREATE TABLE users (
+ id INTEGER PRIMARY KEY,
+ username TEXT NOT NULL UNIQUE,
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE sessions (
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ token TEXT NOT NULL UNIQUE,
+ expires_at TEXT NOT NULL
+);
+
+CREATE INDEX idx_sessions_user ON sessions(user_id);
diff --git a/examples/folder-knowledge-graph/data/sample/sql/002_orders.sql b/examples/folder-knowledge-graph/data/sample/sql/002_orders.sql
new file mode 100644
index 0000000..54ce58b
--- /dev/null
+++ b/examples/folder-knowledge-graph/data/sample/sql/002_orders.sql
@@ -0,0 +1,16 @@
+-- 002_orders.sql
+-- Sales tables: orders belong to a user, order_items belong to an order
+-- and point back at a book.
+CREATE TABLE orders (
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ total_cents INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'pending'
+);
+
+CREATE TABLE order_items (
+ id INTEGER PRIMARY KEY,
+ order_id INTEGER NOT NULL REFERENCES orders(id),
+ book_id INTEGER NOT NULL REFERENCES books(id),
+ quantity INTEGER NOT NULL DEFAULT 1
+);
diff --git a/examples/folder-knowledge-graph/data/sample/sql/003_books.sql b/examples/folder-knowledge-graph/data/sample/sql/003_books.sql
new file mode 100644
index 0000000..ea5039f
--- /dev/null
+++ b/examples/folder-knowledge-graph/data/sample/sql/003_books.sql
@@ -0,0 +1,15 @@
+-- 003_books.sql
+-- Inventory and pricing tables for the bookstore.
+CREATE TABLE books (
+ id INTEGER PRIMARY KEY,
+ title TEXT NOT NULL,
+ author TEXT NOT NULL,
+ price_cents INTEGER NOT NULL,
+ stock INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE TABLE tax_rates (
+ id INTEGER PRIMARY KEY,
+ country_code TEXT NOT NULL UNIQUE,
+ rate REAL NOT NULL
+);
diff --git a/examples/folder-knowledge-graph/make_pdf_data.py b/examples/folder-knowledge-graph/make_pdf_data.py
new file mode 100644
index 0000000..cd679df
--- /dev/null
+++ b/examples/folder-knowledge-graph/make_pdf_data.py
@@ -0,0 +1,157 @@
+"""Deterministically generates the tiny sample PDFs in data/sample/pdfs/.
+
+This project deliberately uses no LLM and no heavyweight PDF library: pypdf
+(the project's only document dependency) can *read* PDFs but doesn't ship a
+text-layout writer, so instead of pulling in reportlab we hand-craft the PDFs
+here with the same plain-byte technique anyone can use to make a minimal,
+valid PDF -- objects, a content stream with BT/ET text-drawing operators, and
+a computed xref table.
+
+Run with: uv run python make_pdf_data.py
+
+The output PDFs are small (a few KB each) and are committed to the repo so
+everything works out of the box; this script is here so you can inspect or
+regenerate them. If you regenerate, re-run build_graph.py afterwards -- the
+graph only shows what pypdf can extract from the files that actually exist.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+OUTPUT_DIR = Path("data/sample/pdfs")
+
+
+def _escape_pdf_string(text: str) -> str:
+ """Escapes a PDF literal string. Content stays ASCII so latin-1 bytes work."""
+ return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
+
+
+def _wrap(text: str, width: int = 88) -> list[str]:
+ """A tiny whitespace-aware word-wrapping loop (no textwrap import needed)."""
+ lines: list[str] = []
+ current = ""
+ for word in text.split():
+ candidate = f"{current} {word}".strip()
+ if len(candidate) <= width:
+ current = candidate
+ else:
+ if current:
+ lines.append(current)
+ current = word
+ if current:
+ lines.append(current)
+ return lines
+
+
+def make_pdf(title: str, paragraphs: list[str]) -> bytes:
+ """Builds a minimal, valid single-page PDF that draws `title` and text.
+
+ The content stream uses the classic BT/ET block with the standard
+ Helvetica fonts -- the most widely supported way to draw text in a
+ hand-made PDF. Returns raw bytes ready to be written to a .pdf file.
+ """
+ lines: list[tuple[str, str, int]] = [(title, "F2", 20)]
+ for paragraph in paragraphs:
+ for chunk in _wrap(paragraph):
+ lines.append((chunk, "F1", 11))
+ lines.append(("", "F1", 9)) # blank spacer line
+
+ y = 740
+ stream_lines: list[str] = []
+ for text, font, size in lines:
+ if y < 60: # don't draw past the bottom margin
+ break
+ if text:
+ stream_lines.append(f"BT /{font} {size} Tf 50 {y} Td ({_escape_pdf_string(text)}) Tj ET")
+ y -= size + 5
+ else:
+ y -= 12
+
+ content = "\n".join(stream_lines)
+ content_bytes = content.encode("latin-1")
+
+ objects: list[tuple[int, str]] = [
+ (1, "<< /Type /Catalog /Pages 2 0 R >>"),
+ (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"),
+ (
+ 3,
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
+ "/Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>",
+ ),
+ (4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"),
+ (5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>"),
+ (6, f"<< /Length {len(content_bytes)} >>\nstream\n{content}\nendstream"),
+ ]
+
+ out = bytearray()
+ out += b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"
+ offsets: dict[int, int] = {}
+ for obj_num, body in objects:
+ offsets[obj_num] = len(out)
+ out += f"{obj_num} 0 obj\n".encode("ascii")
+ out += body.encode("latin-1")
+ out += b"\nendobj\n"
+
+ xref_offset = len(out)
+ count = len(objects)
+ out += b"xref\n"
+ out += f"0 {count + 1}\n".encode("ascii")
+ out += b"0000000000 65535 f \n"
+ for obj_num in range(1, count + 1):
+ out += f"{offsets[obj_num]:010d} 00000 n \n".encode("ascii")
+ out += b"trailer\n"
+ out += f"<< /Size {count + 1} /Root 1 0 R >>\n".encode("ascii")
+ out += b"startxref\n"
+ out += f"{xref_offset}\n".encode("ascii")
+ out += b"%%EOF\n"
+ return bytes(out)
+
+
+def main() -> None:
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ pdfs: list[tuple[str, str, list[str]]] = [
+ (
+ "architecture.pdf",
+ "System Architecture",
+ [
+ "The bookstore app has four layers: web, auth, database, and reporting.",
+ "The auth module reads the users and sessions tables, and is configured through auth.ini.",
+ "The [database] section of app.toml sets the connection and the seed_tables list.",
+ "Orders flow from the web layer into the orders table, and each order_item links back to a book.",
+ ],
+ ),
+ (
+ "onboarding.pdf",
+ "Developer Onboarding",
+ [
+ "Welcome to the bookstore codebase. Start by reading the SQL schema in data/sample/sql.",
+ "The users table stores login credentials; sessions holds the active tokens.",
+ "Run the migrations to create orders, order_items, books, and tax_rates.",
+ "Set jwt_secret in auth.ini before the first deploy.",
+ ],
+ ),
+ (
+ "data-model.pdf",
+ "Data Model Overview",
+ [
+ "This document defines the database tables used across the service.",
+ "users: identity records. sessions: login tokens linked to a user.",
+ "orders: purchases placed by a user. order_items: line items referencing a book.",
+ "books: catalog entries. tax_rates: the VAT rate per country.",
+ "A config key like seed_tables in app.toml controls which tables are pre-filled.",
+ ],
+ ),
+ ]
+
+ for filename, title, paragraphs in pdfs:
+ data = make_pdf(title, paragraphs)
+ (OUTPUT_DIR / filename).write_bytes(data)
+ print(f"Wrote {OUTPUT_DIR / filename} ({len(data)} bytes)")
+
+ print(f"\nRegenerated {len(pdfs)} sample PDFs. Re-run build_graph.py to rebuild the graph.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/folder-knowledge-graph/notebook.ipynb b/examples/folder-knowledge-graph/notebook.ipynb
new file mode 100644
index 0000000..1f554ad
--- /dev/null
+++ b/examples/folder-knowledge-graph/notebook.ipynb
@@ -0,0 +1,542 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "intro",
+ "metadata": {},
+ "source": [
+ "# Turn a Folder of PDFs, Configs, and SQL Schemas Into a Queryable Knowledge Graph\n",
+ "\n",
+ "A runnable companion to the course project [*Turn a Folder of PDFs, Configs, and SQL Schemas Into a Queryable Knowledge Graph*](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/docs/projects/folder-knowledge-graph). This notebook walks a folder of mixed document types — PDFs (`pypdf`), config files (standard library), and SQL schemas — and builds a `networkx` graph out of the **references** hidden inside them: a schema defines a table, a config value names a table, a PDF mentions a config key. Then it queries the graph.\n",
+ "\n",
+ "**No API key, no signup, no network access needed after installing packages.** Every relationship comes from deterministic, hand-written extraction rules — the graph is only as good as its rules, and that honesty is part of the point.\n",
+ "\n",
+ "Works the same in Google Colab, Kaggle Notebooks, or Binder.\n",
+ "\n",
+ "## Setup: install dependencies\n",
+ "\n",
+ "These are the exact packages the local example project (`examples/folder-knowledge-graph/pyproject.toml`) declares."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "install",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "!pip install -q pypdf networkx pyvis\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "sample-head",
+ "metadata": {},
+ "source": [
+ "## Create a toy sample folder\n",
+ "\n",
+ "The real companion example ships a small `data/sample/` folder on disk. Since a fresh Colab/Kaggle/Binder session doesn't have it, we recreate the same files here by writing them directly — this keeps the notebook fully self-contained.\n",
+ "\n",
+ "The sample is a tiny **bookstore app** project: three SQL schemas (`users`/`sessions`, `orders`/`order_items`, `books`/`tax_rates`), three config files that name those tables, and three short PDFs that document them."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "write-sql",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "\n",
+ "ROOT = Path(\"data/sample\")\n",
+ "(ROOT / \"sql\").mkdir(parents=True, exist_ok=True)\n",
+ "(ROOT / \"config\").mkdir(parents=True, exist_ok=True)\n",
+ "(ROOT / \"pdfs\").mkdir(parents=True, exist_ok=True)\n",
+ "\n",
+ "(ROOT / \"sql/001_users.sql\").write_text('''-- Core identity tables: users and their login sessions.\n",
+ "CREATE TABLE users (\n",
+ " id INTEGER PRIMARY KEY,\n",
+ " username TEXT NOT NULL UNIQUE,\n",
+ " email TEXT NOT NULL UNIQUE,\n",
+ " password_hash TEXT NOT NULL,\n",
+ " created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP\n",
+ ");\n",
+ "\n",
+ "CREATE TABLE sessions (\n",
+ " id INTEGER PRIMARY KEY,\n",
+ " user_id INTEGER NOT NULL REFERENCES users(id),\n",
+ " token TEXT NOT NULL UNIQUE,\n",
+ " expires_at TEXT NOT NULL\n",
+ ");\n",
+ "''', encoding=\"utf-8\")\n",
+ "\n",
+ "(ROOT / \"sql/002_orders.sql\").write_text('''-- Sales tables: orders belong to a user, order_items point back at a book.\n",
+ "CREATE TABLE orders (\n",
+ " id INTEGER PRIMARY KEY,\n",
+ " user_id INTEGER NOT NULL REFERENCES users(id),\n",
+ " total_cents INTEGER NOT NULL DEFAULT 0,\n",
+ " status TEXT NOT NULL DEFAULT 'pending'\n",
+ ");\n",
+ "\n",
+ "CREATE TABLE order_items (\n",
+ " id INTEGER PRIMARY KEY,\n",
+ " order_id INTEGER NOT NULL REFERENCES orders(id),\n",
+ " book_id INTEGER NOT NULL REFERENCES books(id),\n",
+ " quantity INTEGER NOT NULL DEFAULT 1\n",
+ ");\n",
+ "''', encoding=\"utf-8\")\n",
+ "\n",
+ "(ROOT / \"sql/003_books.sql\").write_text('''-- Inventory and pricing tables for the bookstore.\n",
+ "CREATE TABLE books (\n",
+ " id INTEGER PRIMARY KEY,\n",
+ " title TEXT NOT NULL,\n",
+ " author TEXT NOT NULL,\n",
+ " price_cents INTEGER NOT NULL,\n",
+ " stock INTEGER NOT NULL DEFAULT 0\n",
+ ");\n",
+ "\n",
+ "CREATE TABLE tax_rates (\n",
+ " id INTEGER PRIMARY KEY,\n",
+ " country_code TEXT NOT NULL UNIQUE,\n",
+ " rate REAL NOT NULL\n",
+ ");\n",
+ "''', encoding=\"utf-8\")\n",
+ "\n",
+ "print(\"Wrote sql/:\", sorted(p.name for p in (ROOT / \"sql\").glob(\"*.sql\")))\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "write-config",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "(ROOT / \"config/app.toml\").write_text('''# Main application configuration for the bookstore service.\n",
+ "\n",
+ "[database]\n",
+ "dbname = \"bookstore\"\n",
+ "seed_tables = [\"users\", \"books\"]\n",
+ "migrate_tables = [\"users\", \"sessions\", \"orders\", \"order_items\", \"books\", \"tax_rates\"]\n",
+ "\n",
+ "[auth]\n",
+ "jwt_secret = \"change-me-in-prod\"\n",
+ "token_ttl_seconds = 3600\n",
+ "\n",
+ "[server]\n",
+ "port = 8080\n",
+ "host = \"127.0.0.1\"\n",
+ "''', encoding=\"utf-8\")\n",
+ "\n",
+ "(ROOT / \"config/auth.ini\").write_text('''; Authentication provider settings.\n",
+ "\n",
+ "[auth]\n",
+ "provider = local\n",
+ "session_table = sessions\n",
+ "password_hash_algo = sha256\n",
+ "\n",
+ "[database]\n",
+ "connection_pool_size = 5\n",
+ "''', encoding=\"utf-8\")\n",
+ "\n",
+ "(ROOT / \"config/reporting.toml\").write_text('''# Weekly sales report configuration.\n",
+ "\n",
+ "[reports.weekly]\n",
+ "enabled = true\n",
+ "source_tables = [\"orders\", \"order_items\"]\n",
+ "currency = \"USD\"\n",
+ "\n",
+ "[exports]\n",
+ "include_tax_rates = true\n",
+ "''', encoding=\"utf-8\")\n",
+ "\n",
+ "print(\"Wrote config/:\", sorted(p.name for p in (ROOT / \"config\").glob(\"*\")))\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "pdf-head",
+ "metadata": {},
+ "source": [
+ "## The tricky part: the sample PDFs\n",
+ "\n",
+ "`pypdf` can *read* PDFs, but it doesn't ship a text-layout writer — so the sample PDFs are hand-crafted with a tiny pure-Python PDF writer. A minimal valid PDF is just a handful of objects: a catalog, a page, two fonts, and a content stream that draws text with the classic `BT`/`ET` operators. The companion example's `make_pdf_data.py` does exactly this; here's the same generator inline."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "pdf-gen",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def _esc(s):\n",
+ " return s.replace(\"\\\\\", \"\\\\\\\\\").replace(\"(\", \"\\\\\").replace(\")\", \"\\\\)\")\n",
+ "\n",
+ "def _wrap(text, width=88):\n",
+ " lines, cur = [], \"\"\n",
+ " for word in text.split():\n",
+ " candidate = f\"{cur} {word}\".strip()\n",
+ " if len(candidate) <= width:\n",
+ " cur = candidate\n",
+ " else:\n",
+ " lines.append(cur); cur = word\n",
+ " if cur:\n",
+ " lines.append(cur)\n",
+ " return lines\n",
+ "\n",
+ "def make_pdf(title, paragraphs):\n",
+ " \"\"\"Builds a minimal, valid single-page PDF that draws `title` and text.\"\"\"\n",
+ " lines = [(title, \"F2\", 20)]\n",
+ " for para in paragraphs:\n",
+ " for chunk in _wrap(para):\n",
+ " lines.append((chunk, \"F1\", 11))\n",
+ " lines.append((\"\", \"F1\", 9))\n",
+ " y, stream = 740, []\n",
+ " for text, font, size in lines:\n",
+ " if y < 60:\n",
+ " break\n",
+ " if text:\n",
+ " stream.append(f\"BT /{font} {size} Tf 50 {y} Td ({_esc(text)}) Tj ET\")\n",
+ " y -= size + 5\n",
+ " else:\n",
+ " y -= 12\n",
+ " content = \"\\n\".join(stream)\n",
+ " objects = [\n",
+ " (1, \"<< /Type /Catalog /Pages 2 0 R >>\"),\n",
+ " (2, \"<< /Type /Pages /Kids [3 0 R] /Count 1 >>\"),\n",
+ " (3, \"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\"),\n",
+ " (4, \"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\"),\n",
+ " (5, \"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>\"),\n",
+ " (6, f\"<< /Length {len(content)} >>\\nstream\\n{content}\\nendstream\"),\n",
+ " ]\n",
+ " out = bytearray(b\"%PDF-1.4\\n%\\xe2\\xe3\\xcf\\xd3\\n\")\n",
+ " offsets = {}\n",
+ " for obj_num, body in objects:\n",
+ " offsets[obj_num] = len(out)\n",
+ " out += f\"{obj_num} 0 obj\\n\".encode() + body.encode() + b\"\\nendobj\\n\"\n",
+ " xref = len(out)\n",
+ " out += f\"xref\\n0 {len(objects)+1}\\n\".encode() + b\"0000000000 65535 f \\n\"\n",
+ " for i in range(1, len(objects) + 1):\n",
+ " out += f\"{offsets[i]:010d} 00000 n \\n\".encode()\n",
+ " out += f\"trailer\\n<< /Size {len(objects)+1} /Root 1 0 R >>\\nstartxref\\n{xref}\\n%%EOF\\n\".encode()\n",
+ " return bytes(out)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "write-pdfs",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "pdfs = [\n",
+ " (\"architecture.pdf\", \"System Architecture\", [\n",
+ " \"The bookstore app has four layers: web, auth, database, and reporting.\",\n",
+ " \"The auth module reads the users and sessions tables, and is configured through auth.ini.\",\n",
+ " \"The [database] section of app.toml sets the connection and the seed_tables list.\",\n",
+ " \"Orders flow from the web layer into the orders table, and each order_item links back to a book.\",\n",
+ " ]),\n",
+ " (\"onboarding.pdf\", \"Developer Onboarding\", [\n",
+ " \"Welcome to the bookstore codebase. Start by reading the SQL schema in data/sample/sql.\",\n",
+ " \"The users table stores login credentials; sessions holds the active tokens.\",\n",
+ " \"Run the migrations to create orders, order_items, books, and tax_rates.\",\n",
+ " \"Set jwt_secret in auth.ini before the first deploy.\",\n",
+ " ]),\n",
+ " (\"data-model.pdf\", \"Data Model Overview\", [\n",
+ " \"This document defines the database tables used across the service.\",\n",
+ " \"users: identity records. sessions: login tokens linked to a user.\",\n",
+ " \"orders: purchases placed by a user. order_items: line items referencing a book.\",\n",
+ " \"books: catalog entries. tax_rates: the VAT rate per country.\",\n",
+ " \"A config key like seed_tables in app.toml controls which tables are pre-filled.\",\n",
+ " ]),\n",
+ "]\n",
+ "\n",
+ "for filename, title, paras in pdfs:\n",
+ " (ROOT / f\"pdfs/{filename}\").write_bytes(make_pdf(title, paras))\n",
+ "\n",
+ "# Sanity check: pypdf must be able to read back the hand-crafted PDFs.\n",
+ "from pypdf import PdfReader\n",
+ "\n",
+ "for p in sorted((ROOT / \"pdfs\").glob(\"*.pdf\")):\n",
+ " reader = PdfReader(str(p))\n",
+ " first = (reader.pages[0].extract_text() or \"\").splitlines()[0]\n",
+ " print(f\"{p.name}: {len(reader.pages)} page(s), first line -> {first!r}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "build-head",
+ "metadata": {},
+ "source": [
+ "## Build the graph\n",
+ "\n",
+ "This mirrors `build_graph.py` from the companion example almost line-for-line: extract entities per file type (SQL tables + foreign keys, config keys via `tomllib`/`configparser`, PDF text via `pypdf`), then a **second pass** resolves cross-file relationships once every table and config key in the folder is known — the same two-pass shape the codebase version of this project uses for call edges."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "build",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import configparser\n",
+ "import re\n",
+ "import tomllib\n",
+ "\n",
+ "import networkx as nx\n",
+ "\n",
+ "\n",
+ "def _table_node(graph, name):\n",
+ " node = f\"table:{name}\"\n",
+ " if node not in graph:\n",
+ " graph.add_node(node, kind=\"table\", short_name=name)\n",
+ " return node\n",
+ "\n",
+ "\n",
+ "def extract_sql(path, graph, rel):\n",
+ " current = None\n",
+ " for line in path.read_text(encoding=\"utf-8\").splitlines():\n",
+ " create = re.match(r\"(?i)^\\s*create\\s+table\\s+([a-z0-9_]+)\", line)\n",
+ " if create:\n",
+ " current = create.group(1).lower()\n",
+ " graph.add_edge(rel, _table_node(graph, current), kind=\"defines\")\n",
+ " ref = re.search(r\"(?i)references\\s+([a-z0-9_]+)\", line)\n",
+ " if ref and current:\n",
+ " graph.add_edge(_table_node(graph, current), _table_node(graph, ref.group(1).lower()), kind=\"references\")\n",
+ "\n",
+ "\n",
+ "def _flatten(data, prefix=\"\"):\n",
+ " flat = {}\n",
+ " for key, value in data.items():\n",
+ " path = f\"{prefix}.{key}\" if prefix else key\n",
+ " if isinstance(value, dict):\n",
+ " flat.update(_flatten(value, path))\n",
+ " else:\n",
+ " flat[path] = value\n",
+ " return flat\n",
+ "\n",
+ "\n",
+ "def extract_config(path, graph, rel):\n",
+ " text = path.read_text(encoding=\"utf-8\")\n",
+ " suffix = path.suffix.lower()\n",
+ " if suffix == \".toml\":\n",
+ " keys = _flatten(tomllib.loads(text))\n",
+ " elif suffix in (\".ini\", \".cfg\"):\n",
+ " parser = configparser.ConfigParser()\n",
+ " parser.read_string(text)\n",
+ " keys = {f\"{s}.{k}\": v for s in parser.sections() for k, v in parser.items(s)}\n",
+ " else:\n",
+ " keys = {}\n",
+ " parsed = []\n",
+ " for key, value in keys.items():\n",
+ " node = f\"key:{rel}:{key}\"\n",
+ " graph.add_node(node, kind=\"config_key\", short_name=key, file=rel)\n",
+ " graph.add_edge(rel, node, kind=\"defines\")\n",
+ " parsed.append((node, f\"{key} {value}\"))\n",
+ " return parsed\n",
+ "\n",
+ "\n",
+ "def _mentions(haystack, name):\n",
+ " return re.search(rf\"\\b{re.escape(name)}\\b\", haystack.lower()) is not None\n",
+ "\n",
+ "\n",
+ "def build_graph(folder):\n",
+ " graph = nx.DiGraph()\n",
+ " config_keys, pdf_files = [], []\n",
+ " for path in sorted(folder.rglob(\"*\")):\n",
+ " if not path.is_file() or any(p.startswith(\".\") for p in path.parts):\n",
+ " continue\n",
+ " rel = str(path.relative_to(folder))\n",
+ " suffix = path.suffix.lower()\n",
+ " if suffix == \".sql\":\n",
+ " graph.add_node(rel, kind=\"file\", doc_type=\"sql\", short_name=path.name)\n",
+ " extract_sql(path, graph, rel)\n",
+ " elif suffix in (\".toml\", \".ini\", \".cfg\"):\n",
+ " graph.add_node(rel, kind=\"file\", doc_type=\"config\", short_name=path.name)\n",
+ " config_keys.extend(extract_config(path, graph, rel))\n",
+ " elif suffix == \".pdf\":\n",
+ " text = \"\\n\".join(page.extract_text() or \"\" for page in PdfReader(str(path)).pages)\n",
+ " graph.add_node(rel, kind=\"pdf\", short_name=path.name, text=text)\n",
+ " pdf_files.append(rel)\n",
+ " # Second pass: resolve cross-file references now that every table/key is known.\n",
+ " tables = {n.removeprefix(\"table:\"): n for n in graph.nodes if n.startswith(\"table:\")}\n",
+ " keys_by_name = {}\n",
+ " for node, data in graph.nodes(data=True):\n",
+ " if data.get(\"kind\") == \"config_key\":\n",
+ " keys_by_name.setdefault(data[\"short_name\"], []).append(node)\n",
+ " keys_by_name.setdefault(data[\"short_name\"].rsplit(\".\", 1)[-1], []).append(node)\n",
+ " for key_node, value_text in config_keys:\n",
+ " for table_name, table_node in tables.items():\n",
+ " if _mentions(value_text, table_name):\n",
+ " graph.add_edge(key_node, table_node, kind=\"references\")\n",
+ " for rel in pdf_files:\n",
+ " text = graph.nodes[rel][\"text\"]\n",
+ " for table_name, table_node in tables.items():\n",
+ " if _mentions(text, table_name):\n",
+ " graph.add_edge(rel, table_node, kind=\"mentions\")\n",
+ " for key_name, key_nodes in keys_by_name.items():\n",
+ " if _mentions(text, key_name):\n",
+ " for key_node in key_nodes:\n",
+ " if key_node in graph:\n",
+ " graph.add_edge(rel, key_node, kind=\"mentions\")\n",
+ " return graph\n",
+ "\n",
+ "\n",
+ "graph = build_graph(ROOT)\n",
+ "print(f\"{graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges\")\n",
+ "\n",
+ "# Printed adjacency summary: what every document contributed.\n",
+ "for node in sorted(graph.nodes):\n",
+ " if graph.nodes[node].get(\"kind\") not in {\"file\", \"pdf\"}:\n",
+ " continue\n",
+ " out = [f\"{data.get('kind')} {target}\" for _, target, data in graph.out_edges(node, data=True)]\n",
+ " if out:\n",
+ " print(f\" {node}: {', '.join(out)}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "viz-head",
+ "metadata": {},
+ "source": [
+ "## Visualize the graph\n",
+ "\n",
+ "`pyvis` wraps the `networkx` graph into a self-contained interactive HTML page — drag nodes, zoom, hover for details. In Google Colab it can be displayed inline with `IPython.display.HTML`; on some platforms it renders blank, in which case the printed adjacency summary above is the reliable fallback."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "viz",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "_COLORS = {\n",
+ " \"file\": \"#3b82f6\", # blue -- schema/config container files\n",
+ " \"pdf\": \"#f59e0b\", # amber -- documents\n",
+ " \"table\": \"#10b981\", # green -- SQL tables\n",
+ " \"config_key\": \"#8b5cf6\", # purple -- configuration keys\n",
+ "}\n",
+ "_EDGE_COLORS = {\"defines\": \"#d1d5db\", \"references\": \"#ef4444\", \"mentions\": \"#f59e0b\"}\n",
+ "\n",
+ "from pyvis.network import Network\n",
+ "\n",
+ "net = Network(height=\"800px\", width=\"100%\", directed=True, notebook=False)\n",
+ "net.barnes_hut()\n",
+ "for node, data in graph.nodes(data=True):\n",
+ " kind = data.get(\"kind\", \"file\")\n",
+ " net.add_node(node, label=data.get(\"short_name\", node), title=f\"{kind}: {node}\", color=_COLORS.get(kind, \"#9ca3af\"))\n",
+ "for source, target, data in graph.edges(data=True):\n",
+ " kind = data.get(\"kind\", \"\")\n",
+ " net.add_edge(source, target, title=kind, color=_EDGE_COLORS.get(kind, \"#d1d5db\"))\n",
+ "net.write_html(\"graph.html\")\n",
+ "print(\"Wrote graph.html\")\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "viz-inline",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Colab: display the interactive pyvis HTML inline.\n",
+ "# (On Kaggle/Binder this may render blank -- use the adjacency summary above instead.)\n",
+ "from IPython.display import HTML\n",
+ "\n",
+ "HTML(filename=\"graph.html\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "query-head",
+ "metadata": {},
+ "source": [
+ "## Query the graph\n",
+ "\n",
+ "The payoff: `networkx` traversal answers questions a keyword search over raw files can't — especially *indirect* ones like \"which config keys reference table `books`?\" where the connection only exists because two separate documents happen to name the same table."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "queries",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def configs_for_table(graph, table_name):\n",
+ " t = f\"table:{table_name}\"\n",
+ " if t not in graph:\n",
+ " return []\n",
+ " return sorted((src, graph.nodes[src].get(\"file\", \"?\"))\n",
+ " for src, _, data in graph.in_edges(t, data=True)\n",
+ " if data.get(\"kind\") == \"references\" and graph.nodes[src].get(\"kind\") == \"config_key\")\n",
+ "\n",
+ "def entities_mentioning(graph, keyword):\n",
+ " needle = keyword.lower()\n",
+ " return sorted(node for node, data in graph.nodes(data=True)\n",
+ " if needle in f\"{node} {data.get('short_name', '')} {data.get('text', '')}\".lower())\n",
+ "\n",
+ "def neighbors(graph, node):\n",
+ " out = sorted(f\"{data.get('kind')} -> {t}\" for _, t, data in graph.out_edges(node, data=True))\n",
+ " inc = sorted(f\"{s} -> {data.get('kind')}\" for s, _, data in graph.in_edges(node, data=True))\n",
+ " return out, inc\n",
+ "\n",
+ "# Q1: which configs reference table 'users'?\n",
+ "print(\"Q1: which configs reference table 'users'?\")\n",
+ "for key_node, file in configs_for_table(graph, \"users\"):\n",
+ " print(f\" {key_node} (in {file})\")\n",
+ "\n",
+ "print()\n",
+ "\n",
+ "# Q2: list all entities that mention 'auth'\n",
+ "print(\"Q2: list all entities that mention 'auth'\")\n",
+ "for node in entities_mentioning(graph, \"auth\"):\n",
+ " print(f\" {node}\")\n",
+ "\n",
+ "print()\n",
+ "\n",
+ "# Indirect: what touches table:books, and where did those edges come from?\n",
+ "out, inc = neighbors(graph, \"table:books\")\n",
+ "print(\"Neighbors of table:books\")\n",
+ "for item in out:\n",
+ " print(f\" {item}\")\n",
+ "for item in inc:\n",
+ " print(f\" {item}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "next",
+ "metadata": {},
+ "source": [
+ "## Next steps\n",
+ "\n",
+ "- Point `build_graph` at your own mixed folder of PDFs/configs/SQL and see what relationships it finds that you didn't know were there.\n",
+ "- Try graph metrics on top: `nx.pagerank(graph)` or in-degree centrality to find the most-referenced tables or config keys.\n",
+ "- The honest upgrade this project deliberately skips: an LLM doing the relation extraction, so relationships the hand-written rules miss (synonyms, prose like \"the login table\", cross-document concepts) start showing up — see the lesson's [Next steps](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/docs/projects/folder-knowledge-graph) for how.\n",
+ "- See the full lesson at [`docs/projects/folder-knowledge-graph/index.md`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/docs/projects/folder-knowledge-graph) and the local, `uv`-based companion script at [`examples/folder-knowledge-graph/build_graph.py`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/folder-knowledge-graph)."
+ ]
+ }
+ ],
+ "metadata": {
+ "colab": {
+ "name": "notebook.ipynb",
+ "provenance": []
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.12"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/folder-knowledge-graph/pyproject.toml b/examples/folder-knowledge-graph/pyproject.toml
new file mode 100644
index 0000000..86e5fd2
--- /dev/null
+++ b/examples/folder-knowledge-graph/pyproject.toml
@@ -0,0 +1,11 @@
+[project]
+name = "folder-knowledge-graph"
+version = "0.1.0"
+description = "Local companion to the course's Turn a Folder of PDFs, Configs, and SQL Schemas Into a Queryable Knowledge Graph project: walk a mixed-document folder with pypdf + the standard library, build a networkx graph of its hidden references, and query it."
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+ "pypdf>=5.0.0",
+ "networkx>=3.4.0",
+ "pyvis>=0.3.2",
+]
diff --git a/examples/folder-knowledge-graph/uv.lock b/examples/folder-knowledge-graph/uv.lock
new file mode 100644
index 0000000..7cf0a08
--- /dev/null
+++ b/examples/folder-knowledge-graph/uv.lock
@@ -0,0 +1,340 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+
+[[package]]
+name = "asttokens"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
+[[package]]
+name = "folder-knowledge-graph"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "networkx" },
+ { name = "pypdf" },
+ { name = "pyvis" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "networkx", specifier = ">=3.4.0" },
+ { name = "pypdf", specifier = ">=5.0.0" },
+ { name = "pyvis", specifier = ">=0.3.2" },
+]
+
+[[package]]
+name = "ipython"
+version = "9.16.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "ipython-pygments-lexers" },
+ { name = "jedi" },
+ { name = "matplotlib-inline" },
+ { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit" },
+ { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
+ { name = "pygments" },
+ { name = "stack-data" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/49/04360f83b4d110195751b4171b75dc1cd7b97ba122b18da34b5828172d59/ipython-9.16.0.tar.gz", hash = "sha256:d2f92587b1ef51d84f934dffe05fabb9255f0038ed0a21426f2ea761e39ad09a", size = 4515375, upload-time = "2026-07-31T08:02:51.977Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/82/d30656b9eb33b8ed4e421ca55c13c7fff412086f0405bbe53c39a7ee4a3b/ipython-9.16.0-py3-none-any.whl", hash = "sha256:3d02b96de2a59074d153b1ac1c3865de738df114e430e879e6e5ef100a4d470c", size = 625973, upload-time = "2026-07-31T08:02:50.114Z" },
+]
+
+[[package]]
+name = "ipython-pygments-lexers"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
+]
+
+[[package]]
+name = "jedi"
+version = "0.20.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "parso" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "jsonpickle"
+version = "4.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8d/c0/dde9b4b42cc415b9579573f967f12efbb034e427a2a37e93ad5139891d87/jsonpickle-4.1.2.tar.gz", hash = "sha256:8afed18aa189fd81e2e833b426bb4af485594921f0b1d36c2001fc5637a2f210", size = 319120, upload-time = "2026-05-28T03:50:11.892Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a1/7b/fd3c7a09649aea9da1d3587aea624d8f9b29963dfd84a1bdb2aa93b36dac/jsonpickle-4.1.2-py3-none-any.whl", hash = "sha256:7ffe34426bc797684dbf1dc84185558bd864cd25b1ff5fb01b7405e392d0a937", size = 47203, upload-time = "2026-05-28T03:50:10.605Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "matplotlib-inline"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
+]
+
+[[package]]
+name = "networkx"
+version = "3.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
+]
+
+[[package]]
+name = "parso"
+version = "0.8.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
+]
+
+[[package]]
+name = "pexpect"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ptyprocess" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
+]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.53"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
+]
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
+]
+
+[[package]]
+name = "pure-eval"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pypdf"
+version = "6.14.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182, upload-time = "2026-06-23T14:18:30.859Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514, upload-time = "2026-06-23T14:18:28.867Z" },
+]
+
+[[package]]
+name = "pyvis"
+version = "0.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ipython" },
+ { name = "jinja2" },
+ { name = "jsonpickle" },
+ { name = "networkx" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/4b/e37e4e5d5ee1179694917b445768bdbfb084f5a59ecd38089d3413d4c70f/pyvis-0.3.2-py3-none-any.whl", hash = "sha256:5720c4ca8161dc5d9ab352015723abb7a8bb8fb443edeb07f7a322db34a97555", size = 756038, upload-time = "2023-02-24T20:29:46.758Z" },
+]
+
+[[package]]
+name = "stack-data"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "executing" },
+ { name = "pure-eval" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
+]
+
+[[package]]
+name = "traitlets"
+version = "5.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/a1/d7e7d9f461575d8bb77e3c3bd78a6cdfdd2bb4a06bfbbb8a0e1f51ab7bc2/traitlets-5.16.0.tar.gz", hash = "sha256:7de0a3fabaf5971ff15c8905545f9febfa850309fb8e86e1b42bdb5b46b293ed", size = 165946, upload-time = "2026-07-31T12:23:49.785Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/bd/f8607e908605262e4926cbfd2560094bc5d04ef7f8aff1340e7fff503016/traitlets-5.16.0-py3-none-any.whl", hash = "sha256:94a9967ba45e89e837cf9934029c8d019bea9149cfffa115ed8c1900f679beba", size = 86093, upload-time = "2026-07-31T12:23:47.533Z" },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.8.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
+]
diff --git a/src/data/projects.ts b/src/data/projects.ts
index 1cad01e..ff69118 100644
--- a/src/data/projects.ts
+++ b/src/data/projects.ts
@@ -19,6 +19,12 @@ export interface ProjectMeta {
* and src/pages/index.tsx for where those get merged in.
*/
export const PROJECTS: ProjectMeta[] = [
+ {
+ id: 'folder-knowledge-graph',
+ date: '2027-08',
+ url: '/docs/projects/folder-knowledge-graph',
+ tags: ['AI Agents', 'Retrieval-Augmented Generation', 'Developer Tools'],
+ },
{
id: '2027-dependency-freshness-checker',
date: '2027-08',
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index c1ed17a..4849be4 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -190,6 +190,25 @@ function RealWorldProjects() {
+
+ Turn a Folder of PDFs, Configs, and SQL Schemas Into a Knowledge Graph
+
+ }
+ summary={
+
+ Walk a folder of mixed PDFs, config files, and SQL schemas, extract the
+ references between them with pypdf and the standard library, and build a
+ queryable knowledge graph — no API key, no LLM.
+
+ }
+ />