Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

🔧 CURT Inventory Assistant

A natural-language inventory assistant for the Cairo University Racing Team (CURT) — Formula Student. Team members can ask things like "how many brake pads do we have left" or "where is the ECU stored" and get an instant, accurate answer from the team's parts inventory.

The project is built in two phases, both fully implemented:

Phase Approach Natural language? External AI?
Phase 1 Rule-based regex matching Fixed patterns only ❌ None
Phase 2 LLM (Groq openai/gpt-oss-20b) + function calling Free-form questions ✅ Groq API

Both phases share the same SQLite inventory database and the same data-access layer, and both are available at the same time in one Streamlit app — a sidebar toggle switches between them without restarting.


📁 Project Structure

├── database.py        # SQLite schema, seed data, data-access layer (only place with SQL)
├── assistant.py       # Phase 1 — regex-based assistant (no AI)
├── llm_assistant.py   # Phase 2 — Groq LLM with tool calling + session memory
├── server.py          # Phase 2 — FastAPI backend (POST /chat, GET /inventory)
├── app.py             # Streamlit chat UI with Phase 1 / Phase 2 toggle
├── requirements.txt   # Python dependencies
├── .env               # Groq API key + model name (gitignored — never commit)
├── .gitignore
└── curt_inventory.db  # SQLite database (created automatically on first run)

🚀 How to Run

0. One-time setup

# Create and activate a virtual environment
python -m venv .venv
.venv\Scripts\activate            # Windows CMD/PowerShell
source .venv/Scripts/activate     # Git Bash

# Install dependencies
pip install -r requirements.txt

# Create your .env file with your Groq API key
#   GROQ_API_KEY=<your key from https://console.groq.com>
#   GROQ_MODEL=openai/gpt-oss-20b

Get a free API key at console.groq.com. The database is created and seeded automatically (14 realistic CURT parts) the first time anything runs — no manual step needed.

1. Run Phase 1 (rule-based only)

python -m streamlit run app.py

Open the browser at http://localhost:8501. The sidebar toggle starts in Phase 1 (rule-based) — no backend or API key needed.

2. Run the full stack (both phases)

# Terminal 1 — FastAPI backend (LLM mode)
python -m uvicorn server:app --port 8000

# Terminal 2 — Streamlit UI
python -m streamlit run app.py

In the sidebar, switch the radio button to Phase 2 (LLM). The sidebar shows a live backend health indicator (✅ online / ❌ offline with instructions).

3. Try the API directly

Interactive docs: http://127.0.0.1:8000/docs

# Ask a question
curl -X POST http://127.0.0.1:8000/chat \
  -H "Content-Type: application/json" \
  -d '{"session_id": "demo", "message": "How many brake pads do we have?"}'

# Get the inventory as JSON
curl http://127.0.0.1:8000/inventory

# Clear a session's conversation memory
curl -X POST http://127.0.0.1:8000/reset/demo

💬 Example Conversations

Phase 1 (rule-based):

Q: How many brake pads do we have?
A: ✅ We have 42 Brake Pads in stock. They're stored at Aisle 1, Shelf B2.

Q: where are the safety chains stored
A: 📍 Safety Chains is stored at Aisle 5, Shelf S1 (we have 50 on hand).

Q: List all items in Braking.
A: 📦 Here's what we have in Braking:
   - Brake Pads — 42 on hand (Aisle 1, Shelf B2)
   - Brake Rotors — 18 on hand (Aisle 1, Shelf B5)

Phase 2 (LLM, multi-turn with memory):

Q: How many brake pads do we have?
A: We have 42 brake pads in stock. They're located in Aisle 1, Shelf B2.

Q: Where are they stored?            ← pronoun resolved from context
A: They're stored in Aisle 1, Shelf B2.

Q: We need more ECUs, should I order more?
A: ECUs are running low — only 7 in stock...
⚠️ [SHORTAGE FLAG] ECU — only 7 left at Cabinet C1, Bin 4

🏗️ Architecture

                        ┌─────────────────────────────────────┐
                        │           Streamlit UI (app.py)     │
                        │  chat input · history · mode toggle │
                        │  live inventory table (sidebar)     │
                        └──────────────┬──────────────────────┘
                                       │
                 Phase 1 (in-process)  │  Phase 2 (HTTP)
              ┌────────────────────────┴───────────────────────┐
              ▼                                                ▼
   ┌─────────────────────┐                    ┌──────────────────────────────┐
   │  assistant.py       │                    │  FastAPI (server.py)         │
   │  regex patterns →   │                    │  POST /chat   GET /inventory │
   │  intent → handler   │                    └──────────────┬───────────────┘
   └──────────┬──────────┘                                   ▼
              │                                   ┌──────────────────────────────┐
              │                                   │  llm_assistant.py            │
              │                                   │  Groq gpt-oss-20b            │
              │                                   │  tool-calling loop           │
              │                                   │  per-session memory (dict)   │
              │                                   └──────────────┬───────────────┘
              │                                                  │ tool calls
              ▼                                                  ▼
   ┌─────────────────────────────────────────────────────────────────────┐
   │                database.py — data-access layer (dicts in/out)       │
   │    get_part · get_by_category · get_low_stock · update_quantity …   │
   └──────────────────────────────────┬──────────────────────────────────┘
                                      ▼
                        ┌───────────────────────────┐
                        │  curt_inventory.db        │
                        │  parts: id, name,         │
                        │  quantity, category,      │
                        │  location                 │
                        └───────────────────────────┘

Key design decisions:

  • Single data-access layer. Neither assistant ever writes SQL — every query goes through database.py, which returns plain dicts. Changing the DB (e.g. to PostgreSQL) would touch one file.
  • The LLM never sees SQL. It only gets three tools with JSON schemas and decides when and which to call based on the question. The backend executes the calls against the real DB.
  • Two-phase parity. Both phases answer the same questions from the same data, which makes the rule-based vs. LLM comparison easy to demo.
  • Graceful degradation. If the backend is offline, Phase 2 mode shows a friendly setup message instead of crashing.

🤖 Function Calling (Phase 2)

The model receives these tool schemas and chooses when to call them:

Tool Does Model calls it when…
check_stock(item_name) Queries quantity, category, location for one part "how many X do we have", "where is X stored"
list_by_category(category) Lists all parts in a category "list all items in Braking"
flag_shortage(item_name) Logs a low-stock alert (log/print, no real alerting) A part's stock ≤ 10 and the user asks about restocking

Tool-calling loop (llm_assistant.chat):

  1. Append the user message to the session history.
  2. Send the conversation to the model with the tool schemas.
  3. If the model requests tool calls → execute them against the DB, append results as role: "tool" messages, and loop (max 5 rounds).
  4. When the model produces a final text answer → store it in history and return it.

Conversation memory is an in-memory dict keyed by session id:

SESSIONS: dict[str, list[dict]] = {}   # session_id → message history

This is what enables follow-ups like "where are they stored?" — the full message list (including tool results) is replayed to the model every turn. History is trimmed to the last 20 messages to control token usage. POST /reset/{session_id} clears a session. No persistence is required by the task; restarting the server forgets conversations.


🗄️ Database

SQLite (curt_inventory.db), created and seeded automatically.

Schema:

CREATE TABLE IF NOT EXISTS parts (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    name        TEXT    NOT NULL UNIQUE COLLATE NOCASE,
    quantity    INTEGER NOT NULL DEFAULT 0 CHECK (quantity >= 0),
    category    TEXT    NOT NULL,
    location    TEXT    NOT NULL
);

Seed data — 14 realistic Formula Student / trailer parts across 7 categories: Braking, Electronics, Hitches, Suspension, Lighting, Electrical, Safety.

To rebuild from scratch: python -c "import database; database.reset_db()"


📚 Libraries & Why

Library Used for Why this one
sqlite3 Inventory storage Built into Python — zero setup, file-based, perfect for a demo-scale task. row_factory gives dict-like rows.
streamlit Chat UI De-facto standard for Python AI demos. st.chat_input/st.chat_message give a real chat experience in ~20 lines; st.session_state persists history; @st.cache_resource runs DB bootstrap once.
fastapi Phase 2 backend Modern async API framework with automatic OpenAPI docs (/docs), Pydantic request validation, and first-class type hints.
uvicorn ASGI server The standard server for FastAPI; --reload gives hot-reload during development.
groq LLM access Groq's API is OpenAI-compatible (same tool-calling format), offers a free tier, and runs gpt-oss-20b with extremely low latency.
python-dotenv Config Loads .env so the API key never lives in code or git history.
requests UI → backend calls Simple, ubiquitous HTTP client for the Streamlit app to talk to the FastAPI server.

Standard library only (no extra deps): re (Phase 1 regex rules), json (tool argument parsing), logging (shortage flags), uuid (session ids).


🧪 Testing

Both layers were verified with in-process test harnesses (no servers needed):

  • Backend — FastAPI TestClient: health, /inventory JSON, quantity question, context-dependent follow-up (pronoun resolution), category listing, shortage flagging, validation errors (422), session reset.
  • UI — Streamlit AppTest: app loads without exceptions, sidebar metrics correct, Phase 1 answers render, mode toggle switches to Phase 2, graceful offline-backend handling.

Run the backend checks yourself:

python -c "
from fastapi.testclient import TestClient
from server import app
c = TestClient(app)
print(c.get('/health').json())
print(c.get('/inventory').json()['count'], 'parts')
print(c.post('/chat', json={'session_id':'t','message':'How many ECU do we have?'}).json())
"

🛡️ Edge-Case Handling

The task deliberately left the behaviour for these cases up to us. Here is what we chose and why.

1. Misspelled or partially-typed item names → resolve, don't punish

Choice: Both phases resolve fuzzy input through a shared resolver, db.find_part() in database.py. It runs a 4-step pipeline: exact (case-insensitive) match → substring match in either direction ("rotor" → Brake Rotors) → difflib fuzzy spelling correction ("brak pads" → Brake Pads) → a plural-stripping retry ("brake pad" → Brake Pads). A fuzzy match is only auto-accepted when it is confidently close (similarity ≥ 0.75 or a substring hit).

Why: Team members type quickly on a phone in a workshop — rejecting "wireing harnes" would make the assistant feel brittle, and fuzzy matching is cheap, deterministic, and fully offline (stdlib difflib, no extra dependency). Auto-accepting only confident matches keeps the assistant from silently answering about the wrong part.

Result:

Q: How many brak pads do we have?      → 42 Brake Pads (Phase 1 & 2 both answer directly)
Q: where is the wireing harnes?        → Wiring Harness, Cabinet C1, Bin 9

2. Item does not exist → say so honestly, offer the closest alternatives

Choice: If no candidate is close ("turbochargers"), the assistant says the part doesn't exist and lists the categories it does know. If the name matches several parts ("hitch" → Class III Hitch, Trailer Hitch Ball), it shows a "did you mean…" list with quantities and locations instead of guessing. In Phase 2 the tool returns {found: false, reason: "ambiguous"|"not_found", candidates/hint} and the system prompt instructs the model to surface the candidates or category list — never to invent a part.

Why: A hallucinated quantity for a part we don't stock is worse than an admission of failure — someone might trust it. Listing known categories turns a dead end into a path forward, and asking for disambiguation on multi-match input is exactly what a human teammate would do.

Result:

Q: where is the hitch?                  → "hitch" could match several parts: Class III Hitch, Trailer Hitch Ball…
Q: How many turbochargers do we have?   → doesn't exist + lists the 7 real categories

3. Ambiguous / missing information → ask a targeted question

Choice: If the intent is clear but no item is named ("how many do we have?"), Phase 1 returns a targeted clarifying question with an example. Bare pronouns ("where is it?") get the same treatment in Phase 1 — it is stateless, so it cannot resolve "it". Phase 2 can resolve pronouns, because session memory keeps the earlier turns: "they" refers to whatever part was just discussed, and the system prompt tells the model to use recent context only when a part was clearly being discussed.

Why: Guessing an item would produce confident nonsense; a clarifying question costs the user one short message. Splitting the behaviour by phase also demonstrates the core difference between them: memory. The rule-based assistant literally cannot know what "it" means, while the LLM assistant handles it naturally — a nice, honest illustration of what the LLM buys us.

Result:

Phase 1:
Q: How many do we have?   → 🤔 which part are you asking about? For example: *How many brake pads…*
Q: Where is it?           → 🤔 Which part do you mean? I don't have any earlier context.

Phase 2 (multi-turn):
Q: How many brake pads do we have?   → We have 42…
Q: Where are they stored?            → They're stored in Aisle 1, Shelf B2.  ← pronoun resolved

4. Category names get the same treatment

db.find_category() mirrors the part resolver for categories: "breaks" → Braking (fuzzy), "electronic" → Electronics (partial), unknown → list the real categories. Same principle, same justifications.


🪞 Reflection

Edge-case decisions, revisited. The common thread in the choices above is "never answer with made-up confidence". Every fallback either (a) resolves the input deterministically and shows its work, (b) asks one short question, or (c) admits failure while offering the nearest useful information. The resolver threshold (0.75 similarity for auto-accept) was tuned by trial: at 0.6 the assistant occasionally guessed wrong on short queries; at 0.85 it stopped fixing obvious typos like "brak pads". The extra note field returned by check_stock ("User typed 'rotors'; interpreted as 'Brake Rotors'") keeps the LLM honest when it relays a fuzzy match.

What I'd improve or add with more time:

  1. Persistent memory and a real session store. SESSIONS is an in-memory dict; a restart wipes conversations. I'd move history into SQLite (or Redis) keyed by session id, with timestamps and TTL cleanup — the interface (_get_history/reset_session) already isolates this, so it's a contained change.
  2. A small eval suite for the LLM. Phase 2 behaviour is verified by hand-run prompts; I'd add a scripted set of ~30 questions (including the edge cases) with expected-tool-call assertions, so prompt changes can't silently regress disambiguation behaviour.
  3. Embedding-based part matching. difflib handles typos well but not synonyms ("disc pads" ≁ "Brake Pads"). With more time I'd add sentence-embedding similarity as a final resolver step (still local, e.g. a small model cached on disk).
  4. Write operations with confirmation. The tools are read-only except flag_shortage. A natural extension is update_quantity via function calling — but only with an explicit user-confirmation turn and an audit log, since a misheard "add 5" silently corrupts stock data.
  5. Streaming and better UX. Token streaming for Phase 2 replies, clickable suggestion chips when the assistant lists candidates, and a visible "interpreted as X" badge for fuzzy matches in the UI.
  6. Auth and rate limiting on the API. The backend is open by design for the demo; real deployment would need per-user API keys, request throttling, and turning the wildcard CORS off.
  7. Multi-user concurrency. SQLite in WAL mode would be the quick win; beyond that, a real DB server. Relatedly, the tool-calling loop could run tool executions concurrently when the model batches several calls.

⚠️ Notes & Limitations

  • API key hygiene. The key lives in .env (gitignored). If a key was ever shared in plain text (chat, screenshots), rotate it in the Groq console.
  • In-memory sessions. Conversation memory resets when the backend restarts — acceptable per the task spec; swap the SESSIONS dict for Redis/SQLite to persist.
  • flag_shortage is log-only. By design, per the task — the "alerting" is a log line and console print.
  • Phase 1 is intentionally literal. It matches fixed patterns ("how many X…", "where is X…"); anything outside them gets a help message. That contrast is the point of the two phases.
  • Single-user demo scope. SQLite and the in-memory session dict suit a demo/ coursework scope; a production deployment would want a real DB server and per-user auth.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages