Dev.to AI 🤖 Ai 👁 0 📖 16 min read

We Paid to Reprint 2,000 Report Cards. So We Gave Our Support Team a Memory.

Built for Graph Hacks (WeMakeDevs × FalkorDB), Company Brain track. Live at support.ednovax.com. Two thousand report cards We found out after the printing was done. One of the schools that runs our school ma

We Paid to Reprint 2,000 Report Cards. So We Gave Our Support Team a Memory.

Built for Graph Hacks (WeMakeDevs × FalkorDB), Company Brain track. Live at support.ednovax.com.

Two thousand report cards

We found out after the printing was done.

One of the schools that runs our school management system had just printed results for the whole school. More than two thousand report cards, in one go, using a bulk-print feature we had built for them alone. Every one of them was in the old format.

Not long before, the Ministry of Education had changed how results should be structured. Schools across Edo and Delta started asking us to update their result sheets, all at once, all on WhatsApp. This school asked too. We made the change. We checked the result page. It looked right.

What we didn't remember, in the middle of a dozen schools asking for the same thing, was that this school was different. For technical reasons, we had built their bulk printing as a separate function. No other school had it. The new format never reached it, because nobody connected "this school asked for the new structure" with "this school prints through a different path".

The school should have checked a sample before printing two thousand copies. We should have known our own client. We took responsibility and paid for the reprint: ₦250k+, and a dent in a relationship we had worked hard to build.

The code was not the problem. Our memory was.

Support that lives in WhatsApp

Ednovax builds software for schools in Nigeria. Several schools across Edo and Delta State run the first version of our school management system: results, broadsheets, fees, attendance, report cards.

None of our schools has ever opened a support ticket. They don't use Jira, Zendesk or a help-desk portal, and they never will. They use WhatsApp. A principal messages a developer directly. An exams officer posts in a group between "Good morning all", a prayer for the week and three birthday wishes. A request arrives late at night, or as a screenshot with no caption.

Some of it is urgent: scores that won't save the week before results. Some of it is small, like swapping the order of a student's names, something the school could do from its own dashboard. All of it lands in the same chats, mixed together, addressed to whoever on our team the school happens to know.

So we lived with three failures:

  • We lost track. Which school asked for what, and when? It was scattered across personal chats and group threads.
  • We forgot. A school would remind us of something we had promised, and we would have no memory of promising it.
  • We didn't know our clients well enough. Every school runs a slightly different setup: its own repository, its own customisations, its own exceptions. That knowledge lived in a few people's heads.

The report-card incident was all three at once. The school's request was in a chat. The exception was in someone's memory. Nothing connected them.

We didn't need another inbox. We needed a memory: one that reads the chats our schools already use and remembers what matters, and what it is connected to.

Why the memory had to be a graph

Look at the question we failed to ask before shipping the new result format:

Which schools use the result feature, how does each of them run it, and what else in the code depends on the files we're about to change?

That is not a lookup. It's a walk: from a feature, to the schools that use it, to how each school is deployed, to the code files that implement it, to the files that import those files, to the developers who own them. Support knowledge is made of relationships. A message is evidence of a problem. A problem affects a feature. A feature is implemented in code. A developer owns that code. A school is deployed as its own repository.

In tables, every one of those hops is another join, and the question you most need at 11pm is the one nobody wrote a report for. In a graph, the relationships are the data. You ask the question by drawing the path.

That is why we built on FalkorDB. It gave us four things we needed in one place:

  • Cypher queries over a property graph, so each question is a readable pattern instead of a stack of joins.
  • Vector indexes on the same nodes, so "our scores are not saving" can find the ticket titled "Scores lost on submit" without a second database.
  • Graph algorithms built in, like connected components, PageRank and shortest path, which we use for duplicates, priorities and explaining connections.
  • Many graphs in one database, which let us give every school its own isolated graph. A query on one school's graph can't reach another school's data.

We didn't want a chatbot that guesses. We wanted a memory with structure, and a model that is only allowed to answer from it.

What we built: Ednovax Support

When Graph Hacks came up, my tech lead, Blessed Oigbochie, messaged me: we should try this. It was spontaneous. We had a real wound, a short deadline, and a database built for exactly this kind of memory. The project is called Loopkeeper, and its job is to make sure no loop a school opens is ever quietly dropped. It runs as Ednovax Support at support.ednovax.com.

It sits on the WhatsApp groups our schools already use. The teachers change nothing. Here is what happens to one message.

Every message either becomes memory or is let go

1. It decides what is work. Most messages in a school group are greetings, prayers and birthdays. A fast model triages every batch of messages, in Nigerian English and light Pidgin ("e no gree save" means it won't save). Small talk is counted for the stats and never stored. Work is classified as a bug, a question, a feature request, a promise, a resolution, an update or a decision, along with how the sender sounds: calm, concerned or frustrated.

A teacher reports a problem in Pidgin; the desk links it to a ticket
A teacher reports a problem in the school's group, in Pidgin. The desk links the message to a ticket. The four teachers in the live demo are played by AI.

2. It checks its memory before it writes. This is the part we're proudest of. Before the model reads a message, FalkorDB hands it the tickets it might be about: the open tickets from the sender's school, the ones closest in meaning (vector search), the most recent, and anything resolved in the last few days. The model's first rule is reuse before you create. "Same here o" becomes a confirmation on an existing ticket, not a new one. "It's working now, thank you" closes the right ticket, and when it's ambiguous, the desk marks it for a human to confirm.

3. It connects the ticket to everything. A new ticket is linked to the message it came from, the school, the people involved and the product feature. The developer who owns most of that feature's code is assigned automatically. Then the desk searches our real code by meaning, reads the source of the closest files, and records which files and functions are most likely at fault, and why.

A ticket with its summary, owner, feature, GitHub issue, the teacher's own words and the code it touches
The same problem as a ticket: summarised in plain English, assigned to the owner of the affected code, sent to GitHub, with the teacher's original words and the files in that part of the code.

4. It remembers promises. "I'll send the updated list by Thursday" becomes a follow-up with an owner and a due date in Lagos time. When it falls due, the desk posts a gentle reminder in the group, once. Decisions ("mock exam moved to Friday") are recorded too.

5. It closes the loop. One click sends a ticket to GitHub, with the school's own words, the likely code and the owner. When the pull request merges, the ticket moves to possibly resolved, the school is told in plain language, and the teacher's confirmation in the chat is what finally resolves it.

And anyone on the team can Ask, in plain English: "What is Henson waiting on?", "Where in the code is the scores bug?", "Who promised what this week?" Every answer comes from named graph queries, cites the exact WhatsApp messages it used, and highlights the nodes it touched. If the graph doesn't know, it says so.

Ask answering what a school is waiting on
Ask answers from the graph, and says plainly what the graph doesn't hold.

Under the hood

The stack is deliberately small: a TypeScript server (Hono), a React dashboard, Claude Haiku for triage, extraction and Ask, and a local embedding model (all-MiniLM-L6-v2, 384 dimensions) so every message can be embedded without another API. FalkorDB is the only database. Everything the desk knows lives in it.

One graph per school

The desk keeps several graphs side by side in one FalkorDB instance:

Graph What it holds
lk_hq Ednovax itself: products, features, code, staff, SLA policies, the client registry and how each client is deployed
lk_client_<id> One per school: its groups, people, messages, tickets, promises, decisions and its copy of the code reference
lk_hub Our central support group, where staff forward messages from many schools

Tenant isolation isn't a WHERE school_id = ? that someone can forget. A query on one school's graph has nothing else to see. Phone numbers are hashed with a salt before they become ids, and never stored.

A school's graph

The graph behind one school: people, messages, tickets, features, code and pull requests

Every change carries its source. A ticket's status never just changes: a StatusChange node records who or what changed it, and the message, person or pull request that caused it.

The Network page: a school's graph in the browser
The Network page: run read-only Cypher against a school's graph and explore the result, the way FalkorDB stores it.

Every question is a named Cypher query

There are 182 of them, each with a stable id, and the dashboard's Database page shows them running live with FalkorDB's own execution time. A few that carry the product:

Finding the tickets a new message might be about, by meaning:

CALL db.idx.vector.queryNodes('Issue', 'embedding', 10, vecf32($embedding))
YIELD node, score
WHERE node.status IN $openStatuses
RETURN node.id AS id, score AS distance
ORDER BY distance

Assigning a new ticket to whoever owns most of the affected feature's code:

MATCH (i:Issue {id: $issueId}), (f:Feature {key: $featureKey})
MERGE (i)-[:AFFECTS]->(f)
WITH i, f
MATCH (f)-[:IMPLEMENTED_IN]->(:CodeFile)<-[:OWNS]-(dev:Person)
WITH i, dev, count(*) AS filesOwned
ORDER BY filesOwned DESC LIMIT 1
MERGE (i)-[:ASSIGNED_TO]->(dev)

The blast radius of a change: code that imports the affected files, up to three hops away:

MATCH (:Issue {id: $issueId})-[:AFFECTS]->(f:Feature)-[:IMPLEMENTED_IN]->(c:CodeFile)
MATCH (dependent:CodeFile)-[:IMPORTS*1..3]->(c)
WHERE NOT (f)-[:IMPLEMENTED_IN]->(dependent)
RETURN DISTINCT dependent.path AS file

The Database page: graphs, indexes and every query as it runs
The Database page: each graph, its range and vector indexes, and every named query as it runs, with FalkorDB's own timing.

Duplicates in three graph-native steps

Two schools rarely describe the same bug the same way. So a new ticket is linked with SIMILAR_TO to open tickets that are close in the vector index, algo.WCC groups everything connected into a candidate cluster, and only then does a model decide which are truly the same and merge them into the oldest with DUPLICATE_OF. The vector index finds candidates, the graph gives them shape, and the model only judges.

The same idea works across schools without leaking anything. Every ticket leaves an abstract Signal in HQ: the feature, severity, status and an embedding, with the school's and contacts' names scrubbed out. Signals that read alike are clustered into a Pattern, so we can see when several schools hit the same bug, while each school only ever hears about its own. A test fails the build if HQ ever holds a message or a contact's name.

Code is read, not guessed

Connecting a repository indexes every source file: imports become IMPORTS edges, CODEOWNERS becomes OWNS, folders or a features.yaml become features, and what each file defines (functions, classes, routes) is embedded into a vector index. The source itself is never stored. When a ticket opens, FalkorDB finds the closest files, the desk reads their source from GitHub, and the model names the functions involved with a one-line reason a developer can act on. It may only name files and functions it actually saw.

Other graph algorithms earn their place too: algo.pageRank ranks open tickets by how much evidence and how many links point at them, and shortestPath answers "how is this teacher connected to that developer?" in Ask.

Replaying the week that cost us ₦250k+

The question we kept asking while building was simple: if this desk had existed that week, what would have been different?

The requests stop being noise. Each school's "please update our result format" becomes a ticket in that school's own graph, linked to the results feature and to the message where they asked. Because the requests read alike across schools, their signals cluster into a single pattern in HQ. Instead of a dozen chats, we see one change requested by many schools, and a list of exactly which ones.

The school is not a name in a chat any more. It's a node with a shape. HQ knows how every client is deployed: which repository, which branch, whether the school has a codebase of its own. The school that bit us had code no other school had. Once its deployment is registered and its repository indexed, that bulk-print function is just another CodeFile that implements results, with its imports and its owner.

The ticket points at the code that matters for that school. When the school's request opens a ticket, the desk searches that school's code by meaning and reads the closest files before naming where the change has to land. The blast-radius query walks the IMPORTS edges for anything else that depends on the results code. The exception that lived in one person's head is now a path in the graph.

The fix isn't "done" until the school says so. When the pull request merges, the ticket becomes possibly resolved and the school is asked, in its own group, to confirm. Nobody on our team gets to close it from memory.

And what we know, we write down. Anyone can tell Ask: "remember that this school prints its results through a separate bulk function". It's stored against that school, and it comes back the next time someone asks about that school's results.

It doesn't make mistakes impossible. It makes them visible, before two thousand copies come off the printer.

A company brain, not a chatbot

We entered the Company Brain track, which asks for an agent that understands how a company works by connecting its people, teams, projects, documents and decisions in one graph. That is a fair description of what a support team at a small company needs, and what we didn't have. Here is how Ednovax Support maps to it:

What the track asks for How Ednovax Support does it
Knowledge from docs, tickets and chats in one graph WhatsApp messages, tickets, promises, decisions, code files, pull requests and commits are nodes in the same graph
Who owns what, and who knows what CODEOWNERS becomes OWNS; tickets are ASSIGNED_TO the owner of the affected code; Ask can find who has talked about a topic
Tracing decisions back to their source Every Decision is linked to the message that RECORDS it; every StatusChange to the message, person or pull request that CAUSED it
Keeping knowledge current Live WhatsApp and GitHub webhooks: messages update tickets as they arrive, merged pull requests move them, duplicates merge into one
Answers with clear sources Ask answers only through named Cypher queries, cites the WhatsApp messages it used, lists the queries it ran and highlights the nodes it touched

The hackathon's own rule is the best test: if the product still works without FalkorDB, the graph doesn't count. Take FalkorDB out of Ednovax Support and nothing is left. The graph decides which tickets the model sees, which code it reads, who gets assigned, which tickets are duplicates, which schools share a problem, and what Ask is allowed to say. The model reads Pidgin and writes summaries. The graph is the memory.

And when the graph doesn't know, Ask says so. For a support team, "I don't know" is a far better answer than a confident guess about a school.

What bit us

Not everything went smoothly, and the bumps taught us as much as the features.

The server's CPU was too old for an image library we never used. Our embeddings run locally with transformers.js, which loads sharp, an image-processing library, the moment it's imported. Sharp's prebuilt binaries need a newer CPU instruction set than our VPS had, so the desk crashed on the server while working fine on our laptops. We only ever embed text, so the fix (shortly after midnight on the second day) was to swap sharp for a stand-in that is never called, before transformers.js loads.

The first message of the day paid for the model download. The embedding model (about 23 MB) downloaded on first use, which meant the first WhatsApp message after a deploy waited for it. Now every deploy downloads the model, embeds a test sentence ("The scores page is not saving"), checks it gets 384 numbers back, and fails the deploy if it doesn't.

Vector search alone is not enough context. The vector index returns the nearest neighbours, and then we filter by status. Closest in meaning isn't always the ticket a message is about: "it's working now" is close to nothing. So the candidates the model sees are a union of four graph queries: the sender's school's open tickets, the nearest by meaning, the most recently active, and the recently resolved. That mix is what made "reuse before you create" actually work.

"Thank you, it's working now" is the hardest message in support. When a teacher has two open problems, which one is fixed? We stopped letting the model pretend to know. It records a low-confidence resolution against each possible ticket, and a person confirms.

What a hackathon taught us

I love building against a clock. It forces you to decide what the product actually is. A few things we learned along the way:

  • Let the graph steer the model. The single biggest jump in quality came from handing the model the right candidate tickets before it read a message. A model with no memory opens duplicates. A model with the graph behind it says "this is ticket X again".
  • The model judges; the graph decides what it gets to judge. Vector search proposes, WCC groups, the model confirms. Each step does what it's good at, and every answer stays traceable to a query.
  • Privacy is easier as structure than as discipline. Separate graphs per school, hashed phone numbers and a test that fails if HQ ever stores message text. We didn't want to rely on remembering a filter.
  • Test against the real thing. The test suite runs the whole pipeline against a real FalkorDB, with a scripted model, over a fictional three-week WhatsApp group full of greetings, prayers and Pidgin. Every push runs it before deploying.

Try it

The live desk at support.ednovax.com runs one real Ednovax school, Henson in Benin City, connected to its real GitHub repositories. Its four teachers are played by AI; everyone at Ednovax on the desk is a real person. Sign in with the demo account shown on the login page, press Go live, and watch a teacher report a problem. Reply to them, or ignore them and see who chases you. Send the ticket to GitHub, close it, and watch the teacher test the fix. One problem fails its first fix on purpose.

Then open Network to explore the graph and run read-only Cypher, or Database to watch FalkorDB answer each query as it runs.

What's next

This started as a hackathon entry. It isn't staying one. Once Graph Hacks is over, Ednovax Support becomes how we run support for our schools. Every school we serve deserves to be remembered properly: what they asked for, what we promised, and what makes them different.

We paid ₦250k+ to learn that lesson. We'd rather nobody else had to.

Links

Built by Blessed Oigbochie (tech lead) and Maro Orode (CTO), Ednovax.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.