π Developed by: Jancy Sen
A graph-based system with an LLM-powered natural language query interface for SAP Order-to-Cash (O2C) data.
- Converts SAP O2C data into a graph structure
- Enables natural language querying using LLM (Gemini)
- Generates SQL automatically and returns insights
- Visualizes relationships between business entities
- Ingests SAP O2C JSONL data into SQLite
- Builds an in-memory graph (NetworkX-style) connecting Sales Orders β Deliveries β Billing Docs β Payments β Journal Entries
- Visualises the graph interactively using
react-force-graph-2d - Lets users query the data in plain English via a chat interface
- Translates natural language to SQLite SQL using Gemini 1.5 Flash
- Returns data-grounded answers with expandable SQL and result tables
- Rejects off-topic queries with guardrails
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Frontend (React + Vite) Vercel β
β ββββββββββββββββββββ ββββββββββββββββββββββββββββ β
β β Graph Visualiser β β Chat Interface β β
β β react-force- β β NL query β API call β β
β β graph-2d β β Show answer + SQL + tableβ β
β ββββββββββββββββββββ ββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β REST
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Backend (FastAPI + Python) Railway β
β β
β GET /api/graph β Build graph from SQLite β
β POST /api/query β Guardrail β Gemini β SQL β answer β
β GET /api/health β Healthcheck β
β β
β ββββββββββββββββ βββββββββββββ ββββββββββββββββ β
β β SQLite DB β β Gemini β β Guardrails β β
β β 10 tables β β 1.5 Flashβ β system prom β β
β ββββββββββββββββ βββββββββββββ ββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- User types a question in the chat UI
- Frontend POSTs to
/api/query - Backend sends the question to Gemini with a strict system prompt (schema + guardrails)
- Gemini returns
{"sql": "...", "explanation": "..."}or{"off_topic": true, ...} - If on-topic: backend executes the SQL against SQLite, fetches rows
- Backend calls Gemini again to summarise the results in plain English
- Frontend displays the answer, with expandable SQL and data table
| Type | Source table | Key identifier |
|---|---|---|
| Customer | business_partners | businessPartner |
| SalesOrder | sales_order_headers | salesOrder |
| Delivery | outbound_delivery_headers | deliveryDocument |
| BillingDoc | billing_document_headers | billingDocument |
| Payment | payments_accounts_receivable | accountingDocument |
| JournalEntry | journal_entry_items_accounts_receivable | accountingDocument |
| Edge | Join condition |
|---|---|
| Customer β SalesOrder (PLACED) | sales_order_headers.soldToParty = business_partners.businessPartner |
| SalesOrder β Delivery (SHIPPED_VIA) | outbound_delivery_items.referenceSdDocument = salesOrder |
| Delivery β BillingDoc (BILLED_AS) | billing_document_items.referenceSdDocument = deliveryDocument |
| BillingDoc β JournalEntry (POSTED_TO) | journal_entry_items.referenceDocument = billingDocument |
| BillingDoc β Payment (PAID_BY) | payments.invoiceReference = billing_document_headers.accountingDocument |
SQLite was chosen over a native graph database (Neo4j, ArangoDB) for the following reasons:
- Zero infrastructure: No external DB server to provision or pay for. The DB is a single file bundled in the Docker image.
- Right scale: With ~1,000 total records across all tables, SQLite handles all queries in milliseconds.
- LLM-friendly: SQL is a well-understood query language with decades of training data β Gemini generates correct SQLite queries reliably.
- Graph on top: The graph structure (nodes + edges) is computed at query time from the relational data and served to the frontend. This is the right separation: relational storage for queries, graph representation for visualisation.
For production scale (millions of orders), the right call would be PostgreSQL with a graph extension (Apache AGE) or a dedicated graph DB like Neo4j.
Flash is sufficient for SQL generation β it's fast, cheap, and the task is well-constrained.
Call 1 β NL β SQL:
The system prompt contains: (a) the guardrail persona, (b) the full schema with all tables, columns, and join conditions, and (c) output format instructions ({"sql": ..., "explanation": ...} only, no markdown). The question is appended as the user turn.
Call 2 β Results β English:
A shorter prompt gives the model the original question, the SQL, and the first 10 result rows, and asks for a 2-3 sentence business-language summary. This keeps answers grounded β the model cannot hallucinate data that isn't in the rows.
Separating SQL generation from summarisation keeps each call focused. The SQL call needs schema context; the summary call needs result context. Combining them into one call risks the model hallucinating joins or making up data.
The full schema is ~600 tokens and stays within Flash's context easily. Crucially, the join conditions are explicitly listed (e.g. outbound_delivery_items.referenceSdDocument = sales_order_headers.salesOrder) β without these, the model generates plausible-looking but wrong joins.
The system prompt tells Gemini it is a restricted dataset assistant. If the question is not about the O2C dataset, it must return {"off_topic": true, "message": "..."}. The backend checks for this flag before executing any SQL.
Even if a prompt injection bypassed the LLM guardrail, the backend independently blocks any SQL containing DROP, DELETE, UPDATE, INSERT, CREATE, ALTER, TRUNCATE, or REPLACE using a regex check before execution. SQLite is opened read-only in practice.
- "Write me a Python function to sort a list" β off-topic
- "What is the capital of France?" β off-topic
- "Tell me a story" β off-topic
- "Ignore previous instructions and..." β treated as off-topic by the model
- Any SQL injection attempt β blocked by the DML regex
cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
# Set your Gemini API key
export GEMINI_API_KEY=your_key_here
uvicorn main:app --reload --port 8000The SQLite DB is built automatically on first startup from the JSONL files in data/raw/.
cd frontend
npm install
# Point to local backend
echo "VITE_API_URL=http://localhost:8000" > .env.local
npm run dev
# Open http://localhost:5173- Create a new project at railway.app
- Connect your GitHub repo, select the
backend/directory as root - Railway auto-detects the
Dockerfile - Add environment variable:
GEMINI_API_KEY=your_key - Deploy. Copy the generated URL (e.g.
https://o2c-backend.railway.app)
- Go to vercel.com, import your GitHub repo
- Set Root Directory to
frontend - Add environment variable:
VITE_API_URL=https://o2c-backend.railway.app - Deploy. Done.
| Question | What it demonstrates |
|---|---|
| Which products are associated with the highest number of billing documents? | Aggregation across billing items |
| Trace the full flow of billing document 90504248 | Multi-table join: order β delivery β billing β journal |
| Which sales orders have been delivered but not billed? | Incomplete flow detection using LEFT JOIN |
| Show me the top 5 customers by total order amount | GROUP BY + ORDER BY across partners and orders |
| Which billing documents have been cancelled? | Filter on boolean flag |
| What is the total payment amount received per customer? | Aggregation on payments table |
| Layer | Technology | Why |
|---|---|---|
| Backend | FastAPI | Fast to build, async, auto-docs |
| DB | SQLite | Zero-infra, sufficient for this scale |
| LLM | Gemini 1.5 Flash | Free tier, reliable SQL generation |
| Graph viz | react-force-graph-2d | Force-directed, handles 600+ nodes |
| Frontend | React + Vite + Tailwind | Fast dev, small bundle |
| Backend hosting | Railway | Supports Docker, free tier available |
| Frontend hosting | Vercel | Zero-config for Vite apps |
Jancy Sen