<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: nightmoose</title>
    <description>The latest articles on DEV Community by nightmoose (@nightmoose).</description>
    <link>https://dev.to/nightmoose</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4191305%2F98948c69-c5f1-4a22-bf2c-fb678d29e118.jpg</url>
      <title>DEV Community: nightmoose</title>
      <link>https://dev.to/nightmoose</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nightmoose"/>
    <language>en</language>
    <item>
      <title>Ghostlog: a local séance for dead data pipelines</title>
      <dc:creator>nightmoose</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:18:05 +0000</pubDate>
      <link>https://dev.to/nightmoose/ghostlog-a-local-seance-for-dead-data-pipelines-4f8f</link>
      <guid>https://dev.to/nightmoose/ghostlog-a-local-seance-for-dead-data-pipelines-4f8f</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Every data team has a graveyard. It holds the nightly job nobody remembers writing, the &lt;code&gt;.sql&lt;/code&gt; file in &lt;code&gt;legacy/&lt;/code&gt;, and the cron log that just stops one night in March. Someone always asks what that job did, and the person who knew left two reorgs ago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ghostlog&lt;/strong&gt; holds a séance for that job. You point it at the remains, either an old cron or Airflow log or a deprecated SQL file, and it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;reads the remains: the job's name, how many nights it ran, its usual start time, every SQL statement it executed, and the error that ended it;&lt;/li&gt;
&lt;li&gt;rebuilds the job's table lineage with &lt;strong&gt;sqlglot&lt;/strong&gt; (&lt;code&gt;raw.orders -&amp;gt; stg.orders -&amp;gt; mart.daily_revenue&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;asks &lt;strong&gt;Gemma&lt;/strong&gt;, running locally through &lt;strong&gt;Ollama&lt;/strong&gt;, to let the job speak in the first person;&lt;/li&gt;
&lt;li&gt;carves an ofrenda-style &lt;strong&gt;tombstone page&lt;/strong&gt; with the job's years, an epitaph, its testimony, and its lineage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It fits Día de los Muertos: the job gets remembered properly, and the next person who inherits the warehouse gets the context it took with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Summoning a nightly ETL job that died on 2025-03-14 when an upstream column disappeared. This is real Gemma output (&lt;code&gt;gemma3:4b&lt;/code&gt; on a CPU-only Linux machine, about 8 seconds):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwfv1r6gtxkjy63rlvdp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwfv1r6gtxkjy63rlvdp.png" alt="Ghostlog in the terminal" width="800" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The tombstone it wrote:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbrbcg8imalgdjdamzhib.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbrbcg8imalgdjdamzhib.png" alt="The tombstone page" width="800" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A 60-second narrated walkthrough of a real run, summoning the job with Gemma and then opening its tombstone:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/i_2dboagSyQ" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live tombstone:&lt;/strong&gt; &lt;a href="https://nightmoose.github.io/ghostlog/sample/tombstone.html" rel="noopener noreferrer"&gt;nightmoose.github.io/ghostlog/sample/tombstone.html&lt;/a&gt;. All the samples are at &lt;a href="https://nightmoose.github.io/ghostlog/" rel="noopener noreferrer"&gt;nightmoose.github.io/ghostlog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The raw transcripts and generated pages are committed in &lt;a href="https://github.com/nightmoose/ghostlog/tree/main/docs/sample" rel="noopener noreferrer"&gt;&lt;code&gt;docs/sample/&lt;/code&gt;&lt;/a&gt;, including one run against a deprecated SQL file with a CTE and one run with &lt;code&gt;--offline&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/nightmoose" rel="noopener noreferrer"&gt;
        nightmoose
      &lt;/a&gt; / &lt;a href="https://github.com/nightmoose/ghostlog" rel="noopener noreferrer"&gt;
        ghostlog
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A local séance for dead data pipelines: Gemma via Ollama + sqlglot lineage, fully offline.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Ghostlog&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A local séance for dead data pipelines.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Point Ghostlog at the remains of a retired job (an old cron or Airflow log, or a
deprecated &lt;code&gt;.sql&lt;/code&gt; file) and it summons the job's ghost. Ghostlog reads the
remains, rebuilds the job's table lineage with &lt;a href="https://github.com/tobymao/sqlglot" rel="noopener noreferrer"&gt;sqlglot&lt;/a&gt;
and asks &lt;a href="https://ai.google.dev/gemma" rel="nofollow noopener noreferrer"&gt;Gemma&lt;/a&gt;, running locally through
&lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt;, to let the job speak for itself in the first person
Then it carves an ofrenda-style tombstone page you can open in any browser.&lt;/p&gt;
&lt;p&gt;Everything runs on your own machine. Your logs and SQL stay on your laptop, and
it keeps working with the Wi-Fi off.&lt;/p&gt;
&lt;p&gt;Built for the DEV Hacktoberfest Open-Source AI Challenge, Week 4: &lt;em&gt;Ghost in the Machine&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/nightmoose/ghostlog/docs/screenshot-cli.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnightmoose%2Fghostlog%2FHEAD%2Fdocs%2Fscreenshot-cli.png" alt="Ghostlog summoning a dead ETL job in the terminal"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/nightmoose/ghostlog/docs/screenshot-tombstone.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnightmoose%2Fghostlog%2FHEAD%2Fdocs%2Fscreenshot-tombstone.png" alt="The tombstone page Ghostlog writes"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Quick start&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;git clone https://github.com/nightmoose/ghostlog
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; ghostlog
python -m venv .venv &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="pl-c1"&gt;.&lt;/span&gt; .venv/bin/activate
pip install -e &lt;span class="pl-c1"&gt;.&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; optional, for the ghost's own voice:&lt;/span&gt;
ollama pull gemma3:4b

ghostlog summon examples/legacy/nightly_orders_etl.log
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; then open&lt;/span&gt;&lt;/pre&gt;…
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/nightmoose/ghostlog" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT licensed. It has one runtime dependency (sqlglot, MIT), 36 offline tests, and CI on Python 3.10, 3.12, and 3.13.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
ollama pull gemma3:4b
ghostlog summon examples/legacy/nightly_orders_etl.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Ghostlog is Python, split into four small modules.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;logparse&lt;/code&gt;&lt;/strong&gt; reads the log line by line. It counts distinct run nights, finds the most common start time, deduplicates SQL statements, and turns the last error into a plain cause of death. For example, &lt;code&gt;column "cust_id" does not exist&lt;/code&gt; becomes &lt;em&gt;"upstream column 'cust_id' vanished."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;lineage&lt;/code&gt;&lt;/strong&gt; gives each &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;CREATE TABLE AS&lt;/code&gt;, &lt;code&gt;MERGE&lt;/code&gt;, or &lt;code&gt;UPDATE&lt;/code&gt; to sqlglot and records a source-to-target edge for each table it reads. CTE names are skipped, so &lt;code&gt;WITH active AS (...)&lt;/code&gt; doesn't show up as a phantom table. SQL that fails to parse is reported rather than crashing the séance. The tombstone shows the longest source-to-sink chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;medium&lt;/code&gt;&lt;/strong&gt; sends the extracted facts to Gemma as JSON over Ollama's local HTTP API, using the standard library only. Ollama's JSON-schema &lt;code&gt;format&lt;/code&gt; forces a reply with exactly three lines of testimony and three lines of epitaph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tombstone&lt;/code&gt;&lt;/strong&gt; writes one self-contained HTML file with inline CSS, no scripts, no external requests, and every value HTML-escaped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I learned from the model.&lt;/strong&gt; My first runs used &lt;code&gt;gemma3:1b&lt;/code&gt;. It had plenty of atmosphere but a loose grip on facts: it once said the job ran "for fourteen days each night." So I split the work. &lt;strong&gt;Code computes the facts, and the model only supplies the voice.&lt;/strong&gt; On top of that, a grounding check rejects any reply where the ghost doesn't name its own job or says a number that isn't in the facts. When a reply fails the check, Ghostlog says why and falls back to a deterministic template. &lt;code&gt;gemma3:4b&lt;/code&gt; passed the check on the sample log, so it became the default. The fallback also covers the case where Ollama isn't installed at all, so the séance always finishes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests.&lt;/strong&gt; The suite runs fully offline. A fixture fails any test that tries a real network call, and the model is exercised through fakes for valid, malformed, ungrounded, and unreachable replies. CI also smoke-tests the CLI on a runner with no model installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Pipeline logs and SQL are some of the most sensitive text a company has. They hold table names, customer columns, and the shape of the business. With an open-weight model running locally, &lt;strong&gt;nothing leaves the laptop&lt;/strong&gt;: no API key, no upload, no usage bill. It works on a plane with the Wi-Fi off.&lt;/p&gt;

&lt;p&gt;Open weights also let me &lt;strong&gt;swap models with one flag&lt;/strong&gt; (&lt;code&gt;--model gemma3:1b&lt;/code&gt;, &lt;code&gt;--model gemma3:4b&lt;/code&gt;, or anything else Ollama serves). That's how I found the right size for the job: I tried both locally and kept the one that stayed grounded. sqlglot, also open source, does the precise work of reading SQL across dialects, so the model never has to guess at lineage.&lt;/p&gt;

&lt;p&gt;And because the whole stack is open, a tool for remembering abandoned software can't itself be abandoned by a vendor. If Ghostlog dies one day, anyone can dig it up and run it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;The first time the 4b model answered &lt;em&gt;"My existence ceased on 2025-03-14 due to the loss of cust_id,"&lt;/em&gt; the job I'd made up felt like a real colleague. Every warehouse has a few jobs like that, and they deserve a marigold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: Gemma (&lt;code&gt;gemma3:4b&lt;/code&gt;, with &lt;code&gt;gemma3:1b&lt;/code&gt; compared) runs locally through Ollama and gives each dead job its voice, constrained by a JSON schema and a grounding check.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>python</category>
    </item>
    <item>
      <title>MorningDrop: a local Gemma agent that checks last night's data drops, and gets checked itself</title>
      <dc:creator>nightmoose</dc:creator>
      <pubDate>Thu, 22 Oct 2026 00:26:29 +0000</pubDate>
      <link>https://dev.to/nightmoose/morningdrop-a-local-gemma-agent-that-checks-last-nights-data-drops-and-gets-checked-itself-29j0</link>
      <guid>https://dev.to/nightmoose/morningdrop-a-local-gemma-agent-that-checks-last-nights-data-drops-and-gets-checked-itself-29j0</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;I'm a data engineer. Most of my mornings used to start the same way: open the folder where the nightly exports landed, open each CSV, scroll, squint, and try to work out whether anything was wrong before someone downstream noticed. Usually nothing was. That's what makes it so tiring: you do the same check every day so you can catch the one day something breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MorningDrop&lt;/strong&gt; does that check overnight so I don't have to. It's a small local agent you run from cron. When it wakes up it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;finds the files in a folder that are new since last night,&lt;/li&gt;
&lt;li&gt;validates every row against a data contract with &lt;strong&gt;DataContractGate&lt;/strong&gt;, offline,&lt;/li&gt;
&lt;li&gt;hands a compact summary of the failures to &lt;strong&gt;Gemma running locally in Ollama&lt;/strong&gt;, which writes a two-line morning summary and suggests fixes,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;checks Gemma's answer with AISchemaGate&lt;/strong&gt; before I ever see it, and&lt;/li&gt;
&lt;li&gt;writes a Markdown report plus a two-line &lt;code&gt;latest.txt&lt;/code&gt; I can read before coffee.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On a quiet night where everything passed, it doesn't even wake the model. Sloths conserve energy. 🦥&lt;/p&gt;

&lt;p&gt;It's for anyone who gets data they don't control: vendor CSVs, storefront exports, CRM dumps, the "can you just drop it in the shared folder" files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh48mcjey62rwccvhytgo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh48mcjey62rwccvhytgo.gif" alt="MorningDrop demo: a messy night checked by local gemma3:4b, then a clean night" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Recorded on a CPU-only box. The ~15–30 s Gemma wait is trimmed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The sample drops in the repo have the kind of problems real exports have: a supplier that started sending &lt;code&gt;usd&lt;/code&gt; instead of &lt;code&gt;USD&lt;/code&gt; partway through the file, refunds arriving as &lt;code&gt;quantity = -1&lt;/code&gt;, a customer with &lt;code&gt;"usa"&lt;/code&gt; as a country code, and a brand-new &lt;code&gt;shipments&lt;/code&gt; file nobody wrote a contract for. This is a real run with &lt;code&gt;gemma3:4b&lt;/code&gt; on a CPU-only box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ morningdrop run
The orders_2026-10-24.csv file failed for 63 rows due to currency and quantity issues.
Prioritize fixing the currency and quantity validation rules in the orders contract.
report: reports/2026-10-25.md  (21.7s, summary by model)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The full report adds suggested fixes and the evidence behind them:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Suggested fixes&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`orders_2026-10-24.csv`&lt;/span&gt; · &lt;span class="sb"&gt;`currency`&lt;/span&gt; · fix_data: Correct currency values to USD, EUR, or GBP.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`orders_2026-10-24.csv`&lt;/span&gt; · &lt;span class="sb"&gt;`quantity`&lt;/span&gt; · fix_data: Ensure quantity values are greater than or equal to 1.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`orders_2026-10-24.csv`&lt;/span&gt; · &lt;span class="sb"&gt;`customer_id`&lt;/span&gt; · fix_data: Validate customer_id against pattern: cus_[0-9]{5}

| File                       | Contract  | Rows | Passed | Failed | Status |
|----------------------------|-----------|-----:|-------:|-------:|--------|
| orders_2026-10-24.csv      | orders    |  240 |    177 |     63 | 🔴     |
| customers_2026-10-24.jsonl | customers |  120 |    116 |      4 | 🟡     |
| shipments_2026-10-24.csv   | —         |   80 |      0 |      0 | 🟡     |

&lt;span class="gu"&gt;### shipments_2026-10-24.csv&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; No contract yet. Drafted one at contracts/_proposed/shipments.yaml; review it and move it into contracts/ to start enforcing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The next night, after the exporter is fixed, the whole report is two lines and Gemma never gets called:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 drop(s) checked overnight; every row passed.
Everything passed. Nothing to do.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/nightmoose" rel="noopener noreferrer"&gt;
        nightmoose
      &lt;/a&gt; / &lt;a href="https://github.com/nightmoose/morningdrop" rel="noopener noreferrer"&gt;
        morningdrop
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A local overnight agent: DataContractGate checks last night's data drops, local Gemma writes a two-line morning summary, AISchemaGate checks Gemma.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;MorningDrop 🦥&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Your data drops get checked overnight. You get two lines in the morning.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;MorningDrop is a small local agent for anyone who wakes up to a folder of
nightly exports: vendor CSVs, storefront dumps, CRM extracts. While you sleep it&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;finds the files that are new since last night,&lt;/li&gt;
&lt;li&gt;checks every row against a &lt;a href="https://github.com/nightmoose/contractgate" rel="noopener noreferrer"&gt;DataContractGate&lt;/a&gt; contract, fully offline,&lt;/li&gt;
&lt;li&gt;asks a &lt;strong&gt;local Gemma model&lt;/strong&gt; (via &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt;) for a two-line summary and suggested fixes,&lt;/li&gt;
&lt;li&gt;checks Gemma's answer with &lt;strong&gt;AISchemaGate&lt;/strong&gt; before it reaches you, and&lt;/li&gt;
&lt;li&gt;writes &lt;code&gt;reports/YYYY-MM-DD.md&lt;/code&gt;, &lt;code&gt;.json&lt;/code&gt; and a two-line &lt;code&gt;latest.txt&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;No rows leave your machine. No API keys. No per-call bill.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/nightmoose/morningdrop/docs/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnightmoose%2Fmorningdrop%2FHEAD%2Fdocs%2Fdemo.gif" alt="MorningDrop demo: a messy night checked by local gemma3:4b, then a clean night"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Recorded on a CPU-only box with asciinema + agg; the ~15–30 s Gemma wait is trimmed. Raw cast: &lt;a href="https://github.com/nightmoose/morningdrop/docs/demo.cast" rel="noopener noreferrer"&gt;&lt;code&gt;docs/demo.cast&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;$ morningdrop run
The orders_2026-10-24.csv file failed for 63 rows due to currency and quantity issues
Prioritize fixing the currency and quantity validation rules in&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/nightmoose/morningdrop" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT licensed, with tests (offline, using scripted fake models), CI on Python 3.9 and 3.12, and a CI job that installs Ollama and runs real Gemma end to end on a plain CPU runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The pipeline is boring on purpose.&lt;/strong&gt; Everything that has a correct answer is done in plain code. The model only does the one thing it's good at: turning &lt;code&gt;60 × enum_violation on currency&lt;/code&gt; into a sentence a tired human will act on.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Validation: DataContractGate.&lt;/strong&gt; Each dataset has a YAML contract (types, required fields, regex patterns, enums, ranges). The code imports the PyPI package named &lt;code&gt;contractgate&lt;/code&gt;, which ships DataContractGate's validator; I use it offline, so no server and no network. CSV cells are coerced to the contract's types first. A value that doesn't parse stays a string, so the validator reports an honest &lt;code&gt;type_mismatch&lt;/code&gt; instead of my code hiding it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New datasets: draft a contract.&lt;/strong&gt; When a file shows up with no contract, MorningDrop infers one (types, ranges, small enums, date patterns), compiles it with DataContractGate to prove it's valid, and saves it to &lt;code&gt;contracts/_proposed/&lt;/code&gt;. It never enforces a contract you haven't reviewed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model: Gemma via Ollama.&lt;/strong&gt; MorningDrop sends Gemma counts, field names, violation messages and the contract rule each one broke. It never sends whole rows, and passing rows never go to the model at all. The request uses Ollama's JSON-schema &lt;code&gt;format&lt;/code&gt; so the model is steered toward the right shape from the start. Any OpenAI-compatible local server (llama.cpp, vLLM, LM Studio) works too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking the model: AISchemaGate.&lt;/strong&gt; This is the part I care about most. More on it below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduling:&lt;/strong&gt; &lt;code&gt;morningdrop run&lt;/code&gt; from cron, launchd or a systemd timer (examples in the repo), or &lt;code&gt;morningdrop watch&lt;/code&gt; to poll.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The model's output gets a contract too
&lt;/h3&gt;

&lt;p&gt;Small local models are great until they aren't. While building this I watched them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gemma3:270m&lt;/code&gt;&lt;/strong&gt; repeat my instructions back as its "summary" ("one sentence, what happened overnight (10-160 chars)"),&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gemma3:1b&lt;/code&gt;&lt;/strong&gt; return perfectly valid JSON that announced &lt;em&gt;"shipments_2026-10-24.csv: 12 of 80 rows failed"&lt;/em&gt;, for a file with no contract and no failures,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gemma3:4b&lt;/code&gt;&lt;/strong&gt; suggest a quantity range of "1–1000" when the contract says 1–500. It had never been shown the contract, so I started including the rule each violation broke.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Shape-checking alone wouldn't have caught most of that. So the model's answer goes through two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;AISchemaGate&lt;/strong&gt; validates the JSON against &lt;a href="https://github.com/nightmoose/morningdrop/blob/main/morningdrop/summary_contract.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;summary_contract.yaml&lt;/code&gt;&lt;/a&gt;: &lt;code&gt;status&lt;/code&gt; must be green, yellow or red; each line must be 10–160 characters; every fix needs &lt;code&gt;file&lt;/code&gt;, &lt;code&gt;field&lt;/code&gt;, &lt;code&gt;kind&lt;/code&gt; and &lt;code&gt;suggestion&lt;/code&gt;. It's the same engine and the same contract format as the data side, pointed at LLM output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grounding checks&lt;/strong&gt; tie the answer to the facts. The status must equal the worst file's status. The two lines must name the worst file. &lt;strong&gt;Every number in the text must already appear in the facts&lt;/strong&gt;, which turned out to be the single most useful check. And every fix must point at a real file and a real failing field.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If either layer fails, the violations go back to Gemma as a repair prompt, with the temperature nudged up so a stuck model can get unstuck. If only some of the &lt;em&gt;fixes&lt;/em&gt; are wrong, those are quarantined and the rest are kept, the same way DataContractGate quarantines bad rows. If the model still can't get it right, or Ollama isn't even running, a plain template writes the report from the same facts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The result: every morning report is correct, and the model makes it nicer to read.&lt;/strong&gt; Data contracts at the edges of the pipeline, and an output contract at the edge of the model.&lt;/p&gt;

&lt;p&gt;With the final prompt, &lt;code&gt;gemma3:4b&lt;/code&gt; passes on the first try in about 20–35 seconds on a CPU-only box. The 1B and 270M models still get caught and fall back to the template, which is exactly what should happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;For this tool, open weights aren't a nice-to-have. They're the reason it can exist.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The data never leaves the machine.&lt;/strong&gt; These are customer exports: emails, order values, sometimes things covered by a DPA. "Send last night's customer file to a hosted API" is a conversation with legal. "Run Gemma on the box that already has the file" isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works at 5:30 AM with no internet and no keys.&lt;/strong&gt; Nightly jobs fail in boring ways: an expired token, a rate limit, an outage on the provider's side. A local model on a cron job has none of those failure modes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set it up once, never pay per call.&lt;/strong&gt; A job that runs every night forever should cost nothing to run every night forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can pick the model size for the hardware,&lt;/strong&gt; from a 270M model on a Raspberry Pi to 4B on a laptop, and swap Ollama for llama.cpp or vLLM by changing one line of config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The checks are open too.&lt;/strong&gt; DataContractGate and AISchemaGate are MIT-licensed, so the thing deciding whether the model's answer is trustworthy is code you can read, not another black box.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A closed API would probably write slightly nicer sentences. But it would make me ship my data somewhere else every night, and that's what I was trying to stop worrying about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;MorningDrop saves me 15–30 minutes every morning. It also checks more than I do by hand in that time: every row of every file against its contract, instead of the scroll-and-squint spot check I actually had time for, which is how 60 lowercase currency codes at the bottom of a 240-row file slip through.&lt;/p&gt;

&lt;p&gt;On a clean night, the two lines in &lt;code&gt;latest.txt&lt;/code&gt; are the whole job. On a bad night, the report names the file, the field, the row count and a first fix before I've opened anything. When a new dataset shows up, its first contract is already drafted for me to review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: Gemma (via Ollama) writes the summary and the fix suggestions, and the whole design is built around making a small open-weight model safe to trust unattended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of a .tech Domain&lt;/strong&gt;: the project page lives at &lt;a href="https://morningdrop.clankerspanker.tech" rel="noopener noreferrer"&gt;morningdrop.clankerspanker.tech&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>opensource</category>
    </item>
    <item>
      <title>"What breaks if this column changes?" Building LineageGate on FalkorDB</title>
      <dc:creator>nightmoose</dc:creator>
      <pubDate>Sun, 18 Oct 2026 01:03:26 +0000</pubDate>
      <link>https://dev.to/nightmoose/what-breaks-if-this-column-changes-building-lineagegate-on-falkordb-3cia</link>
      <guid>https://dev.to/nightmoose/what-breaks-if-this-column-changes-building-lineagegate-on-falkordb-3cia</guid>
      <description>&lt;p&gt;&lt;em&gt;By Alex Suarez · written for the Graph Hacks: Context for AI Agents hackathon (WeMakeDevs × FalkorDB)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://lineagegate.fly.dev" rel="noopener noreferrer"&gt;https://lineagegate.fly.dev&lt;/a&gt; · &lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/nightmoose/lineagegate" rel="noopener noreferrer"&gt;https://github.com/nightmoose/lineagegate&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I've been a data engineer since 1997. The tools have changed many times over: cron and shell scripts, then SSIS, then ETL suites, then Hadoop, then the cloud warehouse with dbt on top. One conversation hasn't changed in all that time:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Hey, we're changing &lt;code&gt;amount&lt;/code&gt; to cents in the orders feed. That's fine, right?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nobody can answer that with confidence. The column feeds a staging model, which feeds three intermediate models, which feed a fact table, a reconciliation mart, a customer lifetime value model and five dashboards. Two of those dashboards belong to people who've never heard of the orders feed. Somewhere in there is a data contract that says "USD dollars, not cents", written after the last time this exact thing happened.&lt;/p&gt;

&lt;p&gt;The second conversation is the 7 a.m. one: "The customer mart job failed. Why, and who do I page?" The error message names a failing test. The cause sits four hops upstream, in a CRM backfill. We've seen it before, but the write-up is in someone's old ticket and nobody remembers it.&lt;/p&gt;

&lt;p&gt;Both questions are about &lt;strong&gt;relationships&lt;/strong&gt;, not rows. That's why I built LineageGate on a graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a graph, specifically
&lt;/h2&gt;

&lt;p&gt;You &lt;em&gt;can&lt;/em&gt; answer "what's downstream of this column" with recursive SQL over an edge table. I've done it. It's painful to write, worse to maintain, and the answer comes back as a flat list of names. The flat list is the real problem: when I tell a finance lead their dashboard will break, the first thing they ask is "how?". I need the chain:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stg_orders.amount → int_order_revenue.gross_revenue → fct_orders.order_amount → fct_revenue_daily.revenue → mart_finance_revenue.revenue_usd → finance.revenue_daily&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That chain is a path, and a graph database returns paths natively. Impact analysis is a variable-length traversal. Ownership is a multi-hop walk. "Have we seen this before?" is graph distance between today's failure and old incidents. Vector similarity can't give you a path, and a path is what earns trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;LineageGate runs on a fully synthetic "Acme Analytics" warehouse: 111 nodes, 248 edges, 11 labels and 12 relationship types. It's small enough to read and big enough to be realistic. All lineage edges point &lt;strong&gt;upstream&lt;/strong&gt; ("depends on"), so impact analysis means walking incoming edges.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart RL
  Job&amp;gt;Job] --&amp;gt;|READS_FROM| Source([Source])
  Job --&amp;gt;|WRITES_TO| Table[Table]
  Job --&amp;gt;|REFRESHES| Model[Model]
  Table --&amp;gt;|HAS_COLUMN| Column[Column]
  Model --&amp;gt;|HAS_COLUMN| Column
  Model --&amp;gt;|READS_FROM| Table
  Model --&amp;gt;|DEPENDS_ON| Model
  Column --&amp;gt;|DEPENDS_ON| Column
  Dashboard[[Dashboard]] --&amp;gt;|DISPLAYS| Model
  Column --&amp;gt;|GOVERNED_BY| Contract{{Contract}}
  Test[/Test/] --&amp;gt;|GOVERNED_BY| Contract
  Test --&amp;gt;|READS_FROM| Column
  Incident((Incident)) --&amp;gt;|TRIGGERED_BY| Job
  Incident --&amp;gt;|VIOLATED| Contract
  Incident --&amp;gt;|RELATED_TO| Incident
  Model --&amp;gt;|OWNED_BY| Team[Team]
  Person[Person] --&amp;gt;|MEMBER_OF| Team&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Two design choices mattered most:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Column-level lineage is first-class.&lt;/strong&gt; &lt;code&gt;Column -[:DEPENDS_ON]-&amp;gt; Column&lt;/code&gt; edges let impact be
precise. Changing &lt;code&gt;stg_orders.amount&lt;/code&gt; doesn't flag the support dashboard just because it shares a model with &lt;code&gt;stg_orders&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incidents are nodes, not documents.&lt;/strong&gt; An incident links to the job that surfaced it, the contract
it violated, and related incidents. Memory is part of the graph, so it can be queried like the graph.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Three queries doing the real work
&lt;/h2&gt;

&lt;p&gt;Every tool the agent can call is a parameterised Cypher template. The model never writes Cypher itself. Here is the core of column impact, the path from a column to every dashboard it reaches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;c:&lt;/span&gt;&lt;span class="n"&gt;Column&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;fqn:&lt;/span&gt; &lt;span class="n"&gt;$fqn&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:DEPENDS_ON&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Column&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:HAS_COLUMN&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Model&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:DISPLAYS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;d:&lt;/span&gt;&lt;span class="n"&gt;Dashboard&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;d.id&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d.name&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hops&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
       &lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;IN&lt;/span&gt; &lt;span class="nf"&gt;nodes&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;n.id&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;label:&lt;/span&gt; &lt;span class="nf"&gt;labels&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="ss"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="ss"&gt;],&lt;/span&gt; &lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n.fqn&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n.name&lt;/span&gt;&lt;span class="ss"&gt;)}]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pnodes&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
       &lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;IN&lt;/span&gt; &lt;span class="nf"&gt;relationships&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;type:&lt;/span&gt; &lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="ss"&gt;),&lt;/span&gt; &lt;span class="py"&gt;src:&lt;/span&gt; &lt;span class="nf"&gt;startNode&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="n"&gt;.id&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;dst:&lt;/span&gt; &lt;span class="nf"&gt;endNode&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="n"&gt;.id&lt;/span&gt;&lt;span class="ss"&gt;}]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;prels&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;hops&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two list comprehensions at the end matter more than they look. They project every path into plain node and edge maps on the server, so both the LLM and the UI receive the evidence in a form they can quote or draw.&lt;/p&gt;

&lt;p&gt;Failed-job triage is one pattern from the failed job up to every ingestion job that feeds it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Job&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$id&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:REFRESHES&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Model&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:DEPENDS_ON&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Model&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:READS_FROM&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Table&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:WRITES_TO&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;uj:&lt;/span&gt;&lt;span class="n"&gt;Job&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;uj.id&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uj.status&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pnodes&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prels&lt;/span&gt;   &lt;span class="c1"&gt;// same path projection as above&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For "which model would hurt most if it broke", I didn't write any graph math. I used FalkorDB's built-in algorithms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="n"&gt;algo.pageRank&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Model'&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'DEPENDS_ON'&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;YIELD&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;node.id&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PageRank and &lt;code&gt;algo.betweenness&lt;/code&gt; both put &lt;code&gt;dim_customer&lt;/code&gt; at the top. That's the hub I planted in the seed data, and it's the model that would hurt most in real life. LineageGate then attaches the dashboard paths that make it critical, so even an algorithm result comes with evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Grok picks the graph tools
&lt;/h2&gt;

&lt;p&gt;The agent loop is deliberately small: one Python module, no agent framework. It talks to xAI's OpenAI-compatible API and hands Grok seven tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;impact_from_column&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;owners_for_node&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;paths_explain&lt;/code&gt; (k shortest paths via &lt;code&gt;algo.SPpaths&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;triage_failed_job&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;similar_incidents&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;record_incident&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;critical_assets&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each tool has a one-paragraph description. The system prompt sets a few hard rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Always call a tool before answering.&lt;/li&gt;
&lt;li&gt;Quote at least one returned path word for word.&lt;/li&gt;
&lt;li&gt;If a tool finds nothing, say the graph has no evidence and stop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I never route by keyword. Ask "a customer job failed last night, have we seen it before, who should I page?" and Grok calls &lt;code&gt;triage_failed_job&lt;/code&gt; with no arguments. That tool defaults to the most recent failure and already includes similar incidents. Ask about cents and dollars and it calls &lt;code&gt;impact_from_column(table="stg_orders", column="amount")&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Speed matters on camera. &lt;code&gt;grok-4.3&lt;/code&gt; gave great answers but took 5–26 seconds end to end. Switching the default to &lt;code&gt;grok-4.20-0309-non-reasoning&lt;/code&gt; cut that to roughly 3.6–6.4 seconds per question in my dry run against the live site. With no API key, a deterministic offline router picks the tool from the question instead, so the demo never depends on the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory that survives the session
&lt;/h2&gt;

&lt;p&gt;Hackathon Track 02 is about agent memory, and I wanted memory to be more than a chat transcript. &lt;code&gt;record_incident&lt;/code&gt; &lt;code&gt;MERGE&lt;/code&gt;s a new &lt;code&gt;Incident&lt;/code&gt; node and links it into the lineage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;TRIGGERED_BY&lt;/code&gt; to the job&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VIOLATED&lt;/code&gt; to each broken contract&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RELATED_TO&lt;/code&gt; to its two most similar past incidents&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;similar_incidents&lt;/code&gt; then ranks incidents by graph distance from whatever you're asking about, combined with keyword overlap. Ask about &lt;code&gt;stg_orders.amount&lt;/code&gt; in a brand-new session and the agent finds INC-0802 (the August cents-versus-dollars bug) through this path:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;(Column stg_orders.amount) -[:GOVERNED_BY]-&amp;gt; (Contract orders_amount) &amp;lt;-[:VIOLATED]- (Incident INC-0802)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Nobody tagged that incident with the column name. The graph connects them through the contract. That's the kind of recall I've wanted in every incident review I've ever sat through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explainable by default, and honest when it can't answer
&lt;/h2&gt;

&lt;p&gt;The UI is one HTML file served by FastAPI. Chat is on the left. The evidence panel on the right lists every tool the agent called and every path in the result, drawn left-to-right with vis-network. Click a path and it lights up. A second tab renders the &lt;strong&gt;live schema&lt;/strong&gt; straight from FalkorDB (&lt;code&gt;db.labels()&lt;/code&gt;, &lt;code&gt;db.relationshipTypes()&lt;/code&gt; and a count of each label-type-label triple).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxmosf0e2qkox44xs3x70.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxmosf0e2qkox44xs3x70.png" alt="LineageGate UI: chat on the left, tool calls and evidence paths on the right" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fev6jdq7e4dauds6qohex.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fev6jdq7e4dauds6qohex.png" alt="The Data model tab, rendered live from FalkorDB" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I spent as much time on the "no" cases as on the "yes" cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Misspelled column:&lt;/strong&gt; the agent suggests close matches taken from the graph itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ambiguous name:&lt;/strong&gt; it lists the candidates instead of picking one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty graph:&lt;/strong&gt; it tells you to seed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown column:&lt;/strong&gt; ask about &lt;code&gt;stg_orders.amount_eur&lt;/code&gt; and it says "the graph has no evidence"
and offers &lt;code&gt;stg_orders.amount&lt;/code&gt;. It doesn't invent a plausible-sounding blast radius.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An opt-in test sends an &lt;code&gt;amount_eur&lt;/code&gt; question to the live model and asserts it abstains and prints no fabricated path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;It's one Fly.io machine running FalkorDB and the app together. On boot, it seeds the graph if it's empty, so a restart heals itself. Locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'.[llm,api,dev]'&lt;/span&gt;
lineagegate seed &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; lineagegate serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd do next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real ingestion:&lt;/strong&gt; load lineage from dbt &lt;code&gt;manifest.json&lt;/code&gt; and warehouse query logs instead of synthetic CSVs. The graph model wouldn't change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-merge checks:&lt;/strong&gt; run &lt;code&gt;impact_from_column&lt;/code&gt; against a pull request's changed columns and post the paths as a review comment &lt;em&gt;before&lt;/em&gt; the change ships.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent memory:&lt;/strong&gt; add a volume and let the triage flow propose &lt;code&gt;record_incident&lt;/code&gt; when an on-call engineer confirms a root cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weighted paths:&lt;/strong&gt; score edges by query volume, so "critical" reflects actual usage, not just structure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thirty years in, my data quality questions are still the same ones. What's new is that an agent can answer them, as long as the answer is a path through a graph it can show you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built in four days with FalkorDB, Python, FastAPI and xAI Grok. AI coding assistants helped write the code; see &lt;code&gt;DISCLOSURE.md&lt;/code&gt; in the repo.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Demo video:&lt;/strong&gt; &lt;a href="https://youtu.be/ARw7zSb5Jdo" rel="noopener noreferrer"&gt;https://youtu.be/ARw7zSb5Jdo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>falkordb</category>
      <category>graphdatabase</category>
      <category>ai</category>
      <category>dataengineering</category>
    </item>
  </channel>
</rss>
