A full-stack CRM (React, Node/Express, MongoDB). On top of it sits a Python/FastAPI service that reads a lead's interaction history and answers three questions for a sales rep:
Which leads need attention, why, and what should I do next?
Each answer is explainable. The priority comes from a documented rule table, not from an AI score. Every AI-derived claim quotes the exact interaction text it comes from, and the system still produces a useful decision when the AI is unavailable.
- The problem
- The original system
- What was added
- Architecture
- How an analysis works
- Tech stack
- API
- Running locally
- Environment variables
- Testing
- Design decisions
- Limitations
- Known issues in the original code
Sales reps juggle dozens of leads. The useful signals are buried in emails, call notes and meetings. A lead asked for pricing two weeks ago and nobody replied. Another has looped in their procurement team. A third raised a price objection that was never addressed. A CRM list sorted by "created date" shows none of this.
A plain "AI lead score" doesn't help either. A number such as 87 can't be checked or explained, and the rep has no reason to trust it.
The base project was a MERN lead manager built as an SDE internship assignment:
- JWT authentication in httpOnly cookies, with bcrypt-hashed passwords
- Lead CRUD with server-side pagination, filtering and sorting
- An AG Grid lead list
- Deployed on Vercel (frontend) and Render (backend), with MongoDB Atlas
| Area | Addition |
|---|---|
AI service (ai-service/) |
A new FastAPI service with a rules engine, LLM extraction, evidence verification and a decision engine |
| Backend | An Interaction model (email / call / meeting / note), analysis history, an AI client, request-ID tracing and rate limiting |
| Frontend | A Lead Details page with interaction history, an "Analyze lead" button and an explainable intelligence panel |
| Fixes | Session restore on page refresh, a logout that really clears the cookie, a duplicate CORS registration, and last_activity_at kept in sync with logged interactions |
What a rep sees on the Lead Details page:
HIGH PRIORITY rule: buying signal
Why?
✓ Requested pricing (email): "could you send over pricing for 50 seats?"
✓ Stated a timeline (email): "We'd like something in place by end of Q4."
✓ Involved their decision process (call): "needs to run it past procurement and her CFO"
⚠ No contact for 10 days.
Recommended action: Follow up by email
The lead has shown verified buying signals.
▸ All evidence (5 quotes)
┌──────────────┐ cookie / JWT ┌──────────────────┐ Bearer service token ┌─────────────────────────┐
│ React (Vite) │ ──────────────► │ Node / Express │ ───────────────────────► │ FastAPI AI service │
│ │ ◄────────────── │ auth, CRUD, │ ◄─────────────────────── │ (stateless, no DB) │
└──────────────┘ │ AI client │ LeadAnalysisResponse │ │
└────────┬─────────┘ │ 1. Rules engine │
│ │ 2. LLM extraction ─────┼──► OpenAI
▼ │ 3. Evidence verifier │
┌──────────────────┐ │ 4. Decision engine │
│ MongoDB │ └─────────────────────────┘
│ leads, users, │
│ interactions, │
│ lead_analyses │
└──────────────────┘
- The browser never calls the AI service. Node checks the user's session, loads the lead and its interactions, and calls FastAPI with a shared service token that only the servers know.
- The AI service has no database. It receives everything it needs in the request, so it can't fetch extra customer data or invent facts about the lead.
- One request ID (
X-Request-ID) is carried from the browser through Node into FastAPI and stored with each analysis, so a single request can be traced across both services' logs.
Lead + interactions
│
▼
1. RULES ENGINE (deterministic, no LLM)
• screens out future-dated records (reported as data issues, never silently dropped)
• facts: last contact, days since contact, awaiting reply since, last inbound channel
• risks: awaiting_reply · inactivity · stalled_new · no_history
│
▼ only valid interactions
2. LLM EXTRACTION (OpenAI, strict JSON-schema output)
• intent, buying signals and objections, each with {interaction_id, quote}
• interaction text is sent as data; the prompt forbids following instructions in it
• malformed output → retried; provider failure → "unavailable" (not an error)
│
▼
3. EVIDENCE VERIFIER
• the interaction ID must exist, and the quote must be an exact, case-sensitive
substring of that interaction (no fuzzy matching) and at least 6 characters long
• failed claims are discarded one by one and counted; the rest are kept
│
▼
4. DECISION ENGINE (deterministic rule table)
• priority: first matching named rule (e.g. reply_overdue → HIGH)
• action and channel from a second table
• reasons built from verified quotes and rule facts
│
▼
LeadAnalysisResponse { rules, insights, decision } → stored in MongoDB
The LLM decides nothing. It reads text and proposes claims. The verifier checks every claim against the source text, and only verified claims reach the decision engine.
Full policies, written so they can be explained in an interview:
ai-service/docs/rules.md: thresholds, contact versus engagement, and the assumptions behind themai-service/docs/decision.md: the priority table, action table, rule order, closed-lead behaviour, channel policy and rules-only fallback
| Layer | Technology |
|---|---|
| Frontend | React 19, Vite, React Router, Tailwind CSS, AG Grid, Axios |
| Backend | Node.js, Express, Mongoose, JWT, bcrypt, express-rate-limit |
| Database | MongoDB (Atlas in production) |
| AI service | Python 3.12+, FastAPI, Pydantic v2, pydantic-settings |
| LLM | OpenAI (gpt-4o-mini by default, configurable), behind an LLMProvider interface |
| Testing | Pytest, pytest-asyncio, Ruff (Python); node:test and Supertest (Node) |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/leads/:id/interactions |
The lead's interaction history, oldest first |
POST |
/api/leads/:id/interactions |
Log an interaction: {type, direction?, content, occurred_at?} |
GET |
/api/leads/:id/analysis |
The latest stored analysis. Never calls the LLM. |
POST |
/api/leads/:id/analysis |
Run a new analysis and store it (rate-limited per user) |
These sit alongside the original /api/auth/* and /api/leads CRUD routes, which are
unchanged.
Errors keep the existing { "error": "..." } format:
| Situation | Status |
|---|---|
| Lead not found | 404 |
| Too many analysis requests | 429 |
| AI service timed out | 504 |
| AI service unavailable | 503 |
| AI service rejected the data | 502 |
| Database failure | 500 |
Internal details are logged, never returned to the browser.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/health |
Liveness check (no auth) |
POST |
/api/v1/leads/analyze |
Full analysis. Requires Authorization: Bearer <AI_SERVICE_TOKEN>. |
Interactive OpenAPI docs, with request, response and error schemas, are at
http://localhost:8000/docs while the service is running.
Prerequisites: Node 18+, Python 3.12+, and a MongoDB instance (Atlas, a local
install, or docker run -d -p 27017:27017 mongo:7).
1. Shared service token (one time):
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"2. AI service (port 8000):
cd ai-service
python -m venv .venv
.venv/Scripts/pip install -r requirements-dev.txt # macOS/Linux: .venv/bin/pip
cp .env.example .env # set AI_SERVICE_TOKEN and OPENAI_API_KEY
.venv/Scripts/python -m uvicorn app.main:app --port 8000 --reloadNo OpenAI key? Set LLM__PROVIDER=mock. The AI part then finds nothing, the UI says
so, and the rules-based decisions still work fully.
3. Backend (port 5000):
cd backend
npm install
cp .env.example .env # set MONGO_URI, JWT_SECRET and the same AI_SERVICE_TOKEN
npm startOptional demo data: node seed.js creates the user demo / demo123 and 100 random
leads.
4. Frontend (port 5173):
cd frontend
npm install
cp .env.example .env # VITE_API_URL=http://localhost:5000/api
npm run devOpen http://localhost:5173 in Chrome or Edge. The auth cookie is Secure, and these
browsers accept that on http://localhost. Then open a lead with View, log a few
interactions and click Analyze lead.
ai-service/.env (see .env.example for all options)
| Variable | Purpose |
|---|---|
AI_SERVICE_TOKEN |
Shared secret with the backend. Analysis endpoints reject every request while it is unset. |
OPENAI_API_KEY |
Required when LLM__PROVIDER=openai |
LLM__PROVIDER / LLM__MODEL |
openai or mock; the model name |
LLM__TIMEOUT_SECONDS, LLM__MAX_RETRIES |
Timeout and retry policy for the LLM call |
LLM__MIN_QUOTE_CHARS |
Shortest quote accepted as evidence |
RULES__INACTIVITY_DAYS, RULES__STALLED_NEW_DAYS, RULES__REPLY_OVERDUE_HOURS |
Business-rule thresholds |
backend/.env (see .env.example)
| Variable | Purpose |
|---|---|
MONGO_URI, JWT_SECRET, CLIENT_URL, PORT |
As in the original app |
AI_SERVICE_URL, AI_SERVICE_TOKEN |
Where the AI service is, and the shared token |
AI_SERVICE_TIMEOUT_MS |
Upstream timeout (default 60 s) |
AI_ANALYSIS_RATE_LIMIT, AI_ANALYSIS_RATE_WINDOW_MS |
Analysis requests allowed per user per window |
frontend/.env: VITE_API_URL. When unset it falls back to the deployed Render
backend.
No secrets are committed. Each service has a .env.example containing placeholders
only.
cd ai-service && .venv/Scripts/python -m pytest # 295 tests, fully offline
cd ai-service && .venv/Scripts/python -m ruff check . && .venv/Scripts/python -m ruff format --check .
cd backend && npm test # 54 tests, no database needed
cd frontend && npm run buildWhat the tests cover:
- Schemas: validation limits, timezone handling, and evidence that must always be present.
- Rules: inactivity at 0, 7, 8, 14 and 30 days; reply overdue at 24 vs 25 hours; closed leads; future-dated data; several risks at once.
- Extraction: a scripted mock LLM covering malformed output, retries, every provider failure, a bad claim discarded while valid claims survive, prompt injection, and making sure logs contain no customer text.
- Decisions: every row of the policy table, conflicting signals, and the rules-only fallback.
- HTTP: service-token auth, error mapping, request-ID forwarding, rate limiting, and a check that the service token never reaches the browser.
Live OpenAI check (opt-in; makes a real API call):
cd ai-service
RUN_LIVE_LLM_TESTS=1 OPENAI_API_KEY=sk-... .venv/Scripts/python -m pytest -m liveVerified with gpt-4o-mini:
- OpenAI accepts the strict schema.
- Extracted claims pass verification.
- A typical analysis uses about 1.1–1.2k prompt tokens, 20–140 completion tokens and around 3 s.
The full stack was also exercised in a headless browser: login, adding interactions, analysis, reload persistence, logout and a mobile-width layout.
What these tests do not prove. They check that each claim is grounded, that the rules are correct and that the system handles failures. They do not measure how accurate the AI's judgement is. A handful of hand-written scenarios is not a dataset, so no accuracy figure is claimed.
Why a separate Python service? The AI work has its own dependencies, its own failure modes (timeouts, rate limits, malformed output) and a strongly typed contract that Pydantic handles well. Keeping it behind an internal HTTP interface leaves the existing Node app's auth and data ownership untouched, and lets the AI service be tested and scaled on its own.
Why rules and AI together, instead of only AI? Arithmetic such as "14 days since last contact" or "the email has had no reply" should never be guessed by an LLM: rules make it exact, cheap and reproducible. The LLM handles only what rules can't, which is understanding free text. Because the rules run first, a lead still gets a useful decision when OpenAI is down.
Why no AI score?
There is no data to calibrate one, so a number like "87" would be invented. Priority
comes from a short, documented table (for example reply_overdue → HIGH), and every
decision records which rule fired.
Why evidence grounding? LLMs can state things the text doesn't support. Requiring an exact quote from a real interaction, and checking it with an exact substring match, makes every AI claim verifiable by the rep and by tests. Failed claims are dropped one at a time, so one bad claim doesn't throw away the good ones.
Why Pydantic and structured output?
The model must return JSON that matches a strict schema, with no extra fields, so the
LLM can't even return a priority field. Parsing free text would be fragile and hard to
test.
Why stored analyses? An analysis costs an LLM call. It runs only when the rep asks, and the result (with its request ID, model and timestamp) is stored, so reopening a lead doesn't call OpenAI again and past recommendations can be audited.
- LLM variability. Identical input can give slightly different signals between runs. In testing this did not change the priority or action, but it can.
- No proven accuracy. The decision table is reasoned policy, not learned from sales
outcomes. The
RecommendationFeedbackmodel exists so that useful / not-useful feedback can be collected later for evaluation; it is not training data. - Contact is not engagement. The CRM can't tell whether an email was opened or a call connected, so outbound messages count as contact. A lead who ignores repeated emails isn't flagged as inactive.
- Channel knowledge. The service isn't told which contact details exist. It recommends phone only if the lead has called in; otherwise it recommends email.
- Cost and latency. Each analysis is one LLM call, about 3 s with
gpt-4o-mini. Analysis runs only on demand and is rate-limited per user. - Rate limits are in memory, per Node process. Several instances would need a shared store.
- No per-user lead access. The original app doesn't scope leads to users, so any logged-in user can see any lead's interactions and analyses.
- Stored analyses contain quotes from customer conversations, as their evidence.
- Not yet built: batch analysis of a whole page of leads, the feedback UI, Docker Compose, and deploying the AI service.
These come from the original assignment code. They don't affect the AI workflow and were deliberately left out of scope:
DELETE /api/leads/:idpassesreq.bodytofindByIdAndDelete.LeadFormshows a success toast ("Failed") when a save fails.- Some lead routes return raw
err.messageto the client. - The
sortquery parameter isn't whitelisted. backend/.gitignorehas comment lines without#.- The frontend linter reports 5 unused-variable errors in the original pages.
Created by Amrit Singh