Hi! I'm Sana.

Software Engineer specializing in Backend and System Design, with a focus on the intersection of software engineering and Machine Learning.

Projects

Laplace Nearby: A WhatsApp Bot That Finds 'The Nearest Maquis' Without Torching the LLM Budget

#System Design, #Backend, #AI/LLM, #PostgreSQL, #TypeScript
In Abidjan, a huge number of local businesses — the neighborhood maquis (informal restaurant), the pharmacy, the guy who fixes phones — don’t have a website, a Google Business listing, or even a formal street address half the time. What they do have is WhatsApp. Laplace Nearby starts from that observation: instead of building an app people have to download, build the search experience inside the app everyone already has open, and let people describe what they want in plain language — “I need a pharmacy,” “j’ai envie de porcodjo” — rather than filling out a filter form.The assistant itself is internally called Simon. This post is about how Simon is actually built — the parts I think are worth talking about, not a marketing pitch.Repo: github.com/sanayasfp/laplace-nearby.Decision #1: the engine doesn’t know it’s talking to WhatsAppThe core of the system is SimonEngine, a small orchestrator that takes a channel-agnostic InteractionRequest and returns a channel-agnostic InteractionResponse. WhatsApp specifics — webhook payloads, message formatting, buttons and lists — live entirely in an adapter/renderer layer outside the engine. The engine itself just does four things on every message: load the user’s session context, check rate limiting, hand the message to whichever conversational flow is currently active (idle, search, or register), and save the result — always emitting an analytics side effect with the flow name, the state transition, and the latency, whether or not anything interesting happened:async process(req: InteractionRequest): Promise<InteractionResponse> { const context = await this.contextManager.load(req.profileId); const flow = this.flowRegistry.get(context.session.activeFlow || IDLE); const limitResult = this.rateLimitService.check(context.rateLimit); if (!limitResult.allowed) { return { messages: limitResult.notify ? [Responses.tooManyMessages(...)] : [] , ... }; } const response = await flow.handle(req, context, interactionId); await this.contextManager.save(req.profileId, response.contextUpdate); return { ...response, sideEffects: [summaryEffect, ...response.sideEffects] };}This isn’t architecture for its own sake. It means the search logic, the registration flow, the rate limiting, and the metrics don’t need to be rewritten if a second channel (SMS, a web widget, whatever) shows up later — they were never coupled to WhatsApp in the first place.Decision #2: don’t call the LLM if you don’t have toEvery incoming message needs an intent — is this a search, a registration request, small talk, a thank-you, an insult, “stop”? Routing every single message through an LLM call is the easy way to build this, and also the expensive and slow way. So intent classification is two-tiered.FastIntentDetector runs first: a deliberately exhaustive set of regex patterns covering French, English, and local slang and abbreviations you’d actually see in a WhatsApp chat in Abidjan — “wesh,” “cc,” “gab” (cash machine), “essence/gazoil” (fuel), dozens of spelling variants of “merci,” “stop,” “annule.” Its docstring says exactly what it’s for: “Reduce LLM costs and latency for unambiguous user requests.” Only when nothing matches does IntentService fall back to Gemini (gemini-2.5-flash-lite), with temperature 0, JSON response mode, and a tight 150-token cap, asking for structured output: the intent, an extracted search keyword, and a list of semantically related terms to widen the search (“j’ai envie de porcodjo” → keyword “porcodjo restaurant”; “mon habit est sale” → “pressing nettoyage vêtement”).Both paths report where the classification came from — REGEX or LLM — to a Prometheus counter (simonIntentTotal), so the fraction of traffic being deflected from the paid model is something you can actually watch on a dashboard, not something you have to guess at. And the Gemini call itself sits behind a circuit breaker: if it trips, the system doesn’t crash the conversation, it degrades to a neutral “chitchat” response with confidence 0 and moves on.Decision #3: addresses in Abidjan don’t work like addresses elsewhereA huge share of real addresses given in chat aren’t geocodable strings — they’re descriptions: “je suis vers la cité Abdoulaye Diallo.” AddressCodingService takes that kind of input, checks a semantic cache (exact hash first, then embedding similarity above a 0.88 threshold) to avoid re-paying for something already resolved, and if it’s a miss, asks Gemini to turn it into a standardized address, an extracted neighborhood, and — importantly — a flag for whether the description is precise enough to geocode at all or whether the only honest answer is “ask the user to drop a GPS pin.” There’s even a dedicated dictionary of Nouchi (Abidjan street slang) terms feeding into this pipeline, because generic NLP tooling doesn’t know what a “gbaka” stop or a given neighborhood nickname refers to.Decision #4: search is one SQL function, three signals, fusedThis is the part of the codebase I’m most proud of. search_nearby_places is a single Postgres function that blends three independent ranking signals for every candidate business inside a radius: Full-text rank (ts_rank_cd against a French tsquery) — good when the user typed something close to the business’s actual name or category. Vector similarity rank — cosine distance between the query embedding (Gemini text-embedding-004, pgvector with an HNSW index) and each place’s embedding — good when the user described what they want in their own words instead of matching a label. Geographic proximity (ST_Distance over a PostGIS geography column) — because “closest” always matters, and can’t be papered over by relevance alone.These get combined with a weighted Reciprocal Rank Fusion: score = w_fts/(k + fts_rank) + w_vec/(k + vec_rank) + w_prox * proximity_term, with the weights shifting based on whether the user gave a keyword at all — 45/45/10 between text, vector, and proximity when there’s a keyword to match; 85% vector-driven when there isn’t, since full-text search has nothing to grab onto in a purely descriptive query. There’s also a “premium tier” mechanism: a second ranking pass, partitioned by tier, guarantees paying/listed businesses a small quota of slots without letting them drown out relevance for the general result set.I later went back and rewrote this same function for performance after noticing it was doing redundant work: merging two CTEs that were reading the same rows twice, adding a LIMIT inside the vector-ranking CTE specifically so the HNSW index can short-circuit instead of sorting the entire candidate pool, and replacing two separate full materializations (general results, premium results) with a single windowed pass using ROW_NUMBER() OVER (PARTITION BY is_premium ...). That’s a genuinely satisfying kind of fix — same output, measurably less work per query — and it’s the sort of thing that only shows up once real usage patterns put pressure on a first draft.Decision #5: side effects are data, not actionsWhen a place gets registered, or an interaction needs logging for analytics, or a user needs a notification, the engine doesn’t go do that work inline — it returns a plain SideEffect object describing what should happen. A PgmqSideEffectDispatcher collects these, groups them by target queue, and pushes them via pgmq.send_batch (Postgres’s own message-queue extension) inside the same database transaction that marks a newly registered place as “queued.” That detail matters: it means a place can’t end up half-registered — visible in the app but never actually indexed for search — because the status update and the queue write either both commit or neither does. Supabase Edge Functions on the other end (an analytics-worker, a place-embedding-worker, a place-tagging-worker) drain those queues asynchronously.The stack, plainlyFastify + TypeScript on Node.js, Prisma over Supabase Postgres (with pgvector, pgmq, PostGIS, pg_cron, and pg_net doing real work, not just sitting in a dependency list), Redis for caching and rate-limit state, Gemini for both NLU and embeddings, Geoapify for geocoding, and Prometheus metrics wired in from the start rather than bolted on later.Where it standspackage.json says 0.4.0-rc.1 and I mean to leave that context in: this is a real, working system I use to reason about hybrid search and conversational engineering, actively evolving toward a 1.0 — not a finished, scaled product with a client roster behind it. The channel-agnostic design exists specifically so that if this ever needs to be more than a WhatsApp bot, the engine underneath doesn’t need to be rebuilt.If you want to argue about the RRF weights, tell me PostGIS was overkill, or point out a better way to structure the side-effect dispatcher — the code’s public: github.com/sanayasfp/laplace-nearby.

Car Inspect AI: Teaching YOLO to Read a Car the Way an Inspector Does

#Computer Vision, #Machine Learning, #Python, #MLOps
Anyone who’s rented a car or filed an insurance claim knows the ritual: someone walks around the vehicle, snaps a few photos, and a human decides later whether that mark on the bumper was already there. It’s slow, it’s subjective, and disputes between renters, owners, and insurers over “pre-existing damage” are extremely common precisely because the whole process runs on someone’s word against a handful of photos. Car Inspect AI is my attempt to chip away at that problem: a computer-vision system that automatically detects and labels the individual parts of a vehicle from a photo, as a first building block toward more objective, automated inspection.Code’s here: github.com/sanayasfp/car-inspect-ai.Starting with the actual constraint: dataBefore touching a model, the real constraint on a project like this is data. I trained on the public Car Parts Segmentation dataset (Kitsuchart Pasupa et al.), 500 annotated images of sedans, pickups, and SUVs in COCO format, covering 18 distinct vehicle parts — bumpers, doors, lights, mirrors, hood, trunk, wheels, and so on, shot from front, back, and angled views, with plates and faces blurred for privacy. 500 images is not a lot by deep-learning standards, and that constraint shaped almost every other decision in the project.Why YOLO11n specificallyI picked YOLO11n — the nano variant — deliberately, not just because “YOLO is what people use for object detection.” Two things mattered: It’s small enough to run on modest hardware. A tool meant for garages, small rental agencies, or independent inspectors is useless if it needs a beefy GPU to run inference. YOLO11n trades some raw accuracy for a footprint that runs comfortably on a CPU or an entry-level GPU. With only 500 images, model capacity is a liability, not an asset. A larger model has more room to overfit a small dataset. A lightweight architecture, combined with aggressive data augmentation, was the more honest choice given what I actually had to train on.To stretch that small dataset further, preprocessing included resizing to YOLO’s expected input dimensions and augmenting with rotation (simulating different shooting angles), horizontal flips, and Gaussian noise (to make the model less sensitive to lighting variation — a real problem when photos come from random phone cameras in a parking lot, not a studio).Training ran for 50 epochs with an 80/20 train/validation split, batch size 16, and early stopping if validation performance stalled for 10 consecutive epochs. The result: 87% mAP on the validation set, with the strongest performance on well-defined, geometrically simple parts like wheels and doors — exactly where I’d expect a detector to do best, and exactly the kind of result that tells you where to focus next (smaller/ambiguous parts like mirrors are the harder cases).Rolling my own tiny ORM instead of reaching for SQLAlchemyThis is the part of the project I’d guess most people skip past, but it’s the part I learned the most from. The app needs to track training runs — which model, how many epochs, whether it completed, where the checkpoint lives, and whether it resumes from a previous run. Instead of pulling in SQLAlchemy for what’s fundamentally a handful of tables, I wrote a small dataclass-based model layer myself:@dataclasses.dataclassclass TrainLogsModel(BaseModel): _table_name = "train_logs" name: str epochs: int model: str path: str completed: bool = Field(type=bool, default=False).set() id: Optional[int] = Field(type=int, primary_key=True, autoincrement=True).set() created_at: Optional[float] = Field(type=int, default=lambda: dt.now().timestamp()).set() resumed_from: Optional[int] = Field(type=int, foreign_key="id", foreign_table=_table_name).set()BaseModel reads the dataclass’s type annotations and field metadata and turns them into SQL column definitions (INTEGER, TEXT, REAL, with primary keys, autoincrement, and foreign keys handled explicitly), and derives table names from class names in snake_case, camelCase, or PascalCase depending on what’s asked for. It’s a fraction of what a real ORM does — no query builder, no migrations system — but building even that fraction by hand forced me to actually understand what an ORM is automating away, rather than just importing one and trusting the magic. That’s the trade I’d make again: for a project this size, writing 100 lines to understand the mechanism beat 10 lines that hide it.That model backs a genuinely useful feature: the training page lets me pick either a fresh YOLO11n base or any previous checkpoint, kick off training, and log it — including a resumed_from foreign key back to the run it continued, so I have an actual lineage of experiments instead of a folder full of best_v2_final_FINAL.pt files.The interface: Streamlit, on purposeThe whole thing is wrapped in a small multi-page Streamlit app — a home page, a “register a car” page (upload front/back/left/right photos plus color and plate number), the training page described above, and a scratch page for in-progress experiments. Streamlit was the right call here specifically because it isn’t the point of the project: I wanted to spend my time on the detection model and the training/versioning story, not on hand-rolling a frontend, and Streamlit gets out of the way for that.Where it actually stands right nowI want to be precise about this rather than round it up: the part-detection model and the training/versioning pipeline are working and measured (that 87% mAP figure is real, from an actual run, not an estimate). The full inspection pipeline — fusing all four angles of a vehicle into a single confident report, and moving from “these are the detected parts” to an actual damage or fraud verdict — is still active, in-progress work, not a finished product. The README lists automated damage severity scoring and vehicle-history integration as future directions, and that’s accurate: those are the roadmap, not something I’m claiming already works end-to-end.If I were pitching what’s genuinely done today: a lightweight, honestly-benchmarked vehicle part detector, trained reproducibly, with its own minimal experiment-tracking layer built from first principles. That’s a smaller claim than “automated fraud detection system,” but it’s the true one — and it’s a better foundation to build the rest on top of.Repo’s public if you want to see the training code, the mini-ORM, or argue that I should’ve just used SQLAlchemy: github.com/sanayasfp/car-inspect-ai.