Hi, I'm Pranav Chaturvedi.
This deck explains my final project, OmniFeedback AI, through its Python code: from raw data to a GenAI copilot.
Use ← → to move between slides. Every code panel links to the exact lines on GitHub, and every concept chip opens the theory sheet.
- From JaipurRajasthan, India
- B.Tech in Computer ScienceVIT Vellore
- Working at LumberFiBengaluru · construction-payroll software
- Took a career break
- Built OmniFeedback AI end to endData → database → ML & deep learning → GenAI → dashboard
The problem, and the solution I applied
A feedback triage platform, built so that its own metrics can be trusted.
The problem
- Too much feedback, too many channels. Twitter, email, reviews, call notes and in-app comments pile up faster than anyone can read them.
- Urgent complaints get buried. A “double charged, fix this now!” looks like any other ticket until someone reads it.
- Metrics can lie. My first model scored a perfect 1.0, but it had only memorised 9 sentences.
What I built
- Triage: an urgency score from 0 to 1 for every item.
- Themes: unsupervised clustering of what customers talk about.
- Resolution: a GenAI copilot that drafts root cause, action and a reply, plus entity extraction and executive summaries.
Same pipeline, three versions: the fix made visible
F1 and silhouette are perfect, but there are only 9 unique sentences. Memorisation.
Varied, noisy data gives honest scores. But its own 196-word vocabulary fails on real wording: separation 0.02.
A pretrained DistilBERT separates critical from positive about 13× better, on the same data.
The pipeline, and how each slide reads
Five stages. Each slide shows real code, what it does, and the concept behind it.
Generate & clean
- generate_data.py
- ingestor.py
Store safely
- database.py
- star schema
- ACID transactions
Learn
- ml_models.py
- dl_lstm.py
- transformer_regressor.py
Judge honestly
- F1, MSE, silhouette
- stress & real-data tests
Use it
- GenAI copilot, NER
- Streamlit dashboard
- tests, Docker
How to read a slide
- Code panel: real lines from the repo. The pill at the top right is a GitHub permalink with the line numbers, pinned to commit
d27a741so it never drifts. - Highlighted lines: the ones that carry the idea.
- ◆ INHERITANCE where a class inherits from a parent (the four PyTorch classes).
- ● SHARED FUNCTION a function or object used from many places (cleaner, variant registry, dispatcher).
- PYTHON FEATURE language features worth knowing: decorators, closures, comprehensions.
- Concept chips open the matching entry on the theory sheet.
Reproduce it (from the README)
The dashboard and models ship pre-built, so the live app needs no training step.
Generating data: the V1 trap and the V2 fix
There is no real customer dataset, so I wrote two generators. Same schema, very different difficulty.
| V1 naive | V2 hardened | |
|---|---|---|
| Text source | 9 fixed sentences | templates + random fills |
| Unique texts | 9 | 212 |
| Critical rate | 40% | 15% |
| Ambiguous cases | none | 15% straddling 0.5 |
| Label noise | none | 3% |
| Baseline macro-F1 | 1.00 | 0.91 |
What the code does
generate_naivepicks from a short list: easy to separate, and the model just memorises it._fillswaps each{placeholder}for a random choice. One template becomes many sentences.- Ambiguous rows mix praise and complaint, with scores in 0.4 to 0.7. Then 3% of labels are flipped on purpose.
Cleaning: one shared function, called everywhere
Regular expressions turn messy text into a consistent form. The same function cleans training data and live user input.
A real warehouse row
clean: love the new checkout flow update very intuitive
Replaying clean_text on a messy input (real class, real output)
- lower-case<b>soooo</b> bad!!! @support the app is down https://x.co/a #outage
- strip HTML tagssoooo bad!!! @support the app is down https://x.co/a #outage
- strip URLssoooo bad!!! @support the app is down #outage
- strip @mentionssoooo bad!!! the app is down #outage
- keep #hashtag wordsoooo bad!!! the app is down outage
- shrink repeatssoo bad!! the app is down outage
- drop punctuationsoo bad the app is down outage
- collapse spacessoo bad the app is down outage
● SHARED FUNCTION called from 7 places (outside tests)
Star schema: one fact table, small dimension tables
Press Play to watch one record travel into the warehouse and back out through a JOIN.
Dim_User (user_id)
Dim_Channel (id, name)
Dim_Product (id, aspect)
Fact_Feedback (feedback_id, user_id, channel_id, aspect_id, texts, score, …)
- Fact = the events (feedback). Dimensions = the labels (user, channel, aspect), each stored once.
- In the V2 warehouse: 5 channels, 6 aspects, 886 users for 4,000 facts.
PRIMARY KEY,FOREIGN KEY,UNIQUEandCHECKenforce the rules inside the database.
ACID: a batch either lands completely or not at all
Switch between a bad and a good batch, then press Play.
Fact_Feedback (what the dashboard can see)
Fact_Feedback (what the dashboard can see)
| Letter | Guarantee | Where in this project |
|---|---|---|
| A | all or nothing | src/database.py · L92 + src/database.py · L117 |
| C | rules always hold | src/database.py · L32 |
| I | no half-seen work | SQLite's own locking (no project code) |
| D | survives a crash | src/database.py · L115 writes to the file |
Inheritance and shared functions: how the code is organised
Four classes inherit from PyTorch; a few global functions are called from many places so that logic exists only once.
◆ INHERITANCE four classes
| Class & code | Parent | What the child gets for free |
|---|---|---|
FeedbackDatasetsrc/dl_lstm.py · L45–56 | Dataset | __len__, __getitem__ → works with DataLoader |
BiLSTMUrgencyRegressorsrc/dl_lstm.py · L59–73 | nn.Module | model(x) → forward(x), .parameters(), .train()/.eval(), .state_dict() |
UrgencyTextDatasetsrc/transformer_regressor.py · L31–44 | Dataset | same contract, returns a dict |
TransformerUrgencyRegressorsrc/transformer_regressor.py · L47–81 | nn.Module | same machinery + frozen DistilBERT inside |
super().__init__() is mandatory: without it PyTorch would not register the layers. Not inherited, by design: TextIngestor, WarehouseManager, TextVocabulary.
● SHARED FUNCTION global / shared functions
| Function | Used by | Why |
|---|---|---|
TextIngestor.clean_text | 7 files | cleans text everywhere |
VARIANTS | 11 files | single source of paths and model types |
select_variant() | 6 files | shared sidebar toggle |
load_urgency_model / score_urgency | 6 files | one entry point to either model |
WarehouseManager(...) | 6 files | all database access |
db_path and raw_csv but has its own model_dir and model_type = "transformer". The dispatcher branches on that tag, so no page had to change.score_urgency sends cleaned text to the BiLSTM and raw text to DistilBERT. Every page and script gets this for free.Classical ML: TF-IDF, K-Means and the Kneedle elbow
Text becomes numbers (TF-IDF), numbers become topics (K-Means), and the Kneedle rule picks how many topics, without being fooled.
Kneedle on the project's own test case
| k | silhouette | normalised | distance from chord |
|---|---|---|---|
| 2 | 0.10 | 0.00 | 0.00 |
| 3 | 0.30 | 0.87 | 0.38 ← max |
| 4 | 0.32 | 0.96 | 0.20 |
| 5 | 0.33 | 1.00 | 0.00 |
- Normalise both axes, draw a straight line from first to last point, pick the point farthest from it: here k = 3.
argmax(silhouette)would always pick the biggest k on templated text. Two unit tests lock the fix in.
Deep learning: the BiLSTM, written from scratch in PyTorch
This is where inheritance matters: a class that extends nn.Module gets the whole training machinery for free.
How it flows
- Vocabulary & encodecleaned text → ids, padded to 128 (196-word closed vocabulary)
- Embedding → BiLSTMread forwards and backwards; join last states (64 + 64 = 128)
- Dropout → Linear → Sigmoidone number between 0 and 1 = urgency
- MSE loss, Adam, early stoppingstopped at epoch 6 (V1) and 6 (V2)
<UNK>, so real customer sentences look alike to V2. That limit is what slide 11 addresses.V3: transfer learning with a pretrained DistilBERT
Same data, same split, same task. Only the model changes: it already knows English before it sees my data.
| V2 BiLSTM | V3 DistilBERT | |
|---|---|---|
| Vocabulary | 196 words, closed | sub-words, open |
| Input text | cleaned | raw |
| Optimiser, lr | Adam, 1e-3 | AdamW, 2e-5 |
| Frozen | nothing | embeddings + 4 of 6 layers |
| Saved checkpoint | all weights | trainable part only |
requires_grad = Falsefreezes a layer. The optimiser is given only the trainable parameters.- Mean pooling averages token vectors using the attention mask, so padding is ignored.
- Test MSE and F1 barely move versus V2 (0.0160 vs 0.0162). The gain shows up on real text, next slides.
Evaluation: macro-F1, MSE and silhouette, read honestly
The urgency score is thresholded at 0.5 to get classes, then scored with macro-F1. A perfect number is a warning, not a win.
How macro-F1 is computed (V2, all 4,000 rows, in-sample)
| predicted critical | predicted fine | |
|---|---|---|
| truly critical | 1070 | 275 |
| truly fine | 355 | 2300 |
critical: precision 0.751, recall 0.796 → F1 0.773
fine: precision 0.893, recall 0.866 → F1 0.880
macro-F1 = (0.773 + 0.880) / 2 = 0.826 ✓ matches data_insights.json
| Metric | V1 | V2 | V3 |
|---|---|---|---|
| K-Means silhouette | 1.000 | 0.328 | = V2 |
| Baseline macro-F1 (LogReg) | 1.000 | 0.914 | — |
| Urgency test MSE | 0.0137 | 0.0162 | 0.0160 |
| Urgency test macro-F1 | 1.000 | 0.821 | 0.825 |
| Critical − low separation | — | 0.022 | 0.298 |
- MSE measures how close the 0 to 1 score is. Macro-F1 measures the yes/no decision after thresholding at 0.5.
- V2 and V3 are almost tied on these synthetic test rows. The difference appears only on text neither model trained on.
Does the fix hold on real text? Separation, Mann–Whitney and Spearman
I measured it on 8 hand-written sentences, then on 150 real Yelp reviews and 150 real tweets per class.
| Real-data test | V2 | V3 |
|---|---|---|
| Yelp · Mann–Whitney p | 0.0048 | < 0.0001 |
| Tweets · Mann–Whitney p | 0.39 | < 0.0001 |
| Yelp · Spearman ρ vs stars | 0.07 | 0.68 |
- Ranking, not tiers: V3's average score is 0.55 for 1-star and 0.42 for 5-star reviews. Both sit in the mid range.
- Star rating and sentiment are proxies for urgency, and only the first 64 tokens of each text are read.
- Only 8 stress-test sentences: 3 critical, 2 low.
GenAI copilot: an LLM with guardrails and a fallback
Claude drafts the resolution when a key exists. Otherwise a deterministic local backend returns the same JSON shape, so the demo never breaks.
- Scoreurgency score from the model (slide 8 dispatcher)
- Promptsystem prompt + 2 few-shot examples;
thought_processfirst (chain of thought) - Claudemodel
claude-sonnet-4-5via the Anthropic SDK - Validatestrip fences, parse, check 5 required keys
- Fallbackany failure →
_local_backend, tagged_backend
Real output of the local backend (score 0.90)
The app, the tests, and what I would improve next
A seven-page Streamlit dashboard on top of everything above, with 31 tests and a Docker image.
What I took from it
- A perfect metric deserves suspicion; check the data before the model.
- Fix a generalisation problem with the right tool: here, pretrained knowledge.
- Build in a fallback so a demo never fails, but make the fallback visible.
What I would improve next
- One shared risk-threshold constant: the code currently uses five different cut-offs.
- Log the exception hidden by the Claude fallback, and keep the model name in one constant.
- Run
data_insights.pyon held-out rows only (it is in-sample today). - Fill
predicted_clusterin the warehouse, or remove the unused column.