Hi! I'm Sana.

I am a Senior Software & AI Engineer specializing in System Design, Data Governance, and MLOps. I build robust backend infrastructures (Rust, Python, TypeScript) and deploy sovereign, self-hosted AI systems (RAG, vector search, Computer Vision).

Download CV (PDF)

What people say

Rigorous, curious, and technically solid... a strong asset in successfully completing ambitious tech projects.
Koné Fanhatcha
Koné Fanhatcha, Director of engineering & CTO at Edane SA
He quickly identified performance issues in our infrastructure and designed optimizations that improved the speed and efficiency of our systems.
Narcisse Adingra
Narcisse Adingra, CTO & Co-founder at Djeli
Read all testimonials →

Projects

Stack: A Native, Zero-Container Dev Environment Manager

#Rust, #Tooling, #System Design, #Backend, #CLI
When you are maintaining a legacy application while building its modern replacement, your local development environment quickly becomes a battlefield. At work, we had a legacy app running on an old version of PHP, Vue 2 (requiring an outdated Node version), and Python 3.7. Alongside it, we were bootstrapping the new architecture with PHP 8.4, Vue 3, and Python 3.12.I personally managed this with a bespoke combination of Laravel Herd for PHP, uv for Python, and nvm for Node. It worked for me because I took the time to configure it. But for our junior developers and fresh interns, asking them to juggle these tools just to run both projects simultaneously was an absolute nightmare. They were fighting path conflicts and environment variables instead of writing code. That’s why I built Stack: a native, zero-container multi-project dev environment manager that makes switching contexts as simple as typing stack up.Code’s here: github.com/sanayasfp/stack.The real constraint: Onboarding and Cognitive LoadBefore touching the architecture, the real constraint on a project like this is developer experience. Interns shouldn’t need to learn Docker network bridges or configure five different version managers to fix a bug in the legacy API. The goal was simple: they should be able to clone the repo, run a single command, and get a working local domain routed to the right processes, regardless of whether the project uses PHP 5.6 or 8.4.Why not just use Docker?Containers solve the “works on my machine” problem by shipping an entire OS layer per project. But that comes with virtualization overhead and idle daemons eating RAM.Stack solves the same problem differently. It pins exact versions per project through version managers you’d install anyway (delegating to tools like vfox and uv), shares one downloaded binary across every project needing that version, and runs everything as plain child processes. No hypervisor, no Docker Desktop taking up 4GB of RAM for a simple API. Every project pinning the same service and version shares one running instance, isolated by schema.Under the hood: Simplicity over MagicA stack.toml file in your project defines exactly what is needed:[project]name = "acme-api"[language]php = "8.4.0"[service.mysql]version = "8.0.35"[run]command = "php -S 127.0.0.1:{port} -t public"When you run stack up, the orchestrator reads the manifest, wires up the local domain (e.g. acme-api.localhost), starts MySQL if it’s not already running, boots the PHP dev server, and reverse-proxies the traffic. All native, all blazing fast. It doesn’t try to reinvent package management; it delegates the heavy lifting of language downloads to vfox and uv, and the routing to Caddy.Where it actually stands right nowI want to be precise about this: I started coding Stack in Rust just last week and pushed the first release two days ago. I have completely uninstalled Herd, Laragon, and my previous manager scripts. I am now exclusively dogfooding Stack for my daily work — using it to prove it works, find edge-case bugs, and add new features to improve it.It currently supports Windows (PowerShell & cmd), which was the immediate need, with macOS and Linux on the roadmap. It’s not just a weekend experiment anymore; it’s becoming the foundational tool that keeps our team’s local development sane.You can grab the installer from the repo or read the official documentation: Repo: github.com/sanayasfp/stack Docs: sanayavo.com/stack/

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.

Posts

The Ultimate Guide to a Smooth Dev Environment

#Rust, #Tooling, #PHP, #System Design, #Backend, #CLI
I wrote about Stack a few days ago — covering the short version of why I built it and the whole “no Docker” philosophy. But theory only gets you so far. If you’re looking for the ultimate guide to actually achieving a buttery-smooth dev environment, this is it.No elevator pitch. No marketing fluff. Just the raw, practical reality of getting a project running effortlessly, complete with every weird prompt and console output you’ll encounter along the way.If you just want the dry reference docs, you can find them at sanayavo.com/stack. Otherwise, welcome to the guided tour of a better workflow.What you’re actually gettingA smooth environment means zero bloat. Stack is just one Rust binary. There is no daemon hogging RAM in your system tray, no Docker Desktop spinning up your laptop fans, and absolutely nothing touching your global system PHP install.Instead, it acts as highly intelligent duct tape. It leans on vfox for managing PHP/Node versions, uv for Python, and Caddy for instant local domains. Stack reads a simple stack.toml file in your project root and automatically wires these three tools together.Note: Stack is Windows only right now (PowerShell or cmd). Mac and Linux support are on the roadmap.The Installation: Your one-time headacheA smooth day-to-day workflow requires a tiny bit of upfront setup. Run this to download the installer:irm https://github.com/sanayasfp/stack/releases/latest/download/stackenv-installer.ps1 | iexThen, run this command once per machine:stack setupThis handles the heavy lifting behind the scenes. It adds a hook to your PowerShell profile (so the correct PHP or Node version magically activates when you cd into a directory), installs vfox, uv, and Caddy at heavily-tested versions, and runs caddy trust. That last step is crucial for local HTTPS — more on that in a minute.$ stack setupadded the stack hook for pwshchecking vfox/uv/caddy... vfox: OK (1.0.11) uv: OK (0.11.7) caddy: OK (2.11.4) caddy: local CA trusted (https://*.localhost works with no browser warning)Restart your terminal now. If you skip this, the PowerShell hook won’t be active and you’ll wonder why nothing is working. You will never have to run stack setup again unless you buy a new computer.Starting a project friction-freeLet’s build something real to show off the workflow: a lightweight PHP API backed by MySQL.From scratch$ stack new acme-apidomain: acme-api.localhostlanguages (space to toggle, enter to confirm): [x] phpservices (space to toggle, enter to confirm): [x] mysql php version: 8.3.1 mysql version: 8.0.35created acme-api\stack.tomlnext: cd into it, add a [run] command when you know it, then `stack up`It’s an interactive terminal checklist. Space to toggle, enter to confirm. It feels a bit different the first time, but nothing commits until you press enter. If you accidentally select Node instead of PHP, just hit space again. Zero stress, no do-overs required.If you already have a projectIf your acme-api directory already has a composer.json file specifying "php": "^8.3", Stack is smart enough to skip the manual typing:$ stack initdetected from existing project files: php 8.3 (from composer.json)domain: acme-api.localhoststack init automatically parses composer.json, package.json, or pyproject.toml and pre-fills the wizard for you.The magic artifactStack generates a highly readable manifest:[project]name = "acme-api"domain = "acme-api.localhost"[language]php = "8.3.1"[service.mysql]version = "8.0.35"Commit this stack.toml file to your repo. It is your ultimate “works on my machine” artifact—the lightweight, container-free equivalent of a Dockerfile. When a teammate clones the repo and types stack up, they instantly get the exact same PHP and MySQL versions without installing anything by hand.Teaching it how to runYour stack.toml needs to know how to boot up your dev server. Open the file and add a [run] block:[run]command = "php -S 127.0.0.1:{port} -t public"The {port} placeholder dynamically swaps for an available port at runtime. (Note: If you declare [language.php] but skip the [run] section, Stack falls back to its built-in FastCGI setup using php-cgi.exe and Caddy, which is incredibly solid. But for this guide, we’ll keep it explicit.)stack up and watch it fly$ cd acme-api$ stack upLoaded C:\Users\you\acme-api\stack.toml project: acme-api domain: acme-api.localhost languages: php services: mysqlfirst run for this project — stack.toml will execute: [run] php -S 127.0.0.1:{port} -t publicTrust and run these commands? [y/N] y php: C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe -> PHP 8.3.1 (cli) service.mysql: started (pid 41232, port 3306) schema 'acme_api' — automatic creation not yet implemented; create it manually if needed run: php -S 127.0.0.1:52140 -t public (pid 41244, port 52140) log: C:\Users\you\.stack\logs\acme-api.log routed: http://acme-api.localhost -> 127.0.0.1:52140There are two major things happening here that contribute to a seamless experience:1. The security trust promptBecause [run].command is a literal shell invocation, Stack runs it exactly as written. The first time Stack sees a new or modified command (like after a git pull), it asks you to confirm it. Say yes, and it remembers your choice in ~/.stack/trust.json. Every subsequent stack up is blissfully silent. Need to bypass it for CI? Just pass --yes.2. HTTPS works out of the boxThe console says http://, but https://acme-api.localhost works instantly. No red browser warnings. Remember that caddy trust command from the setup phase? It installs a local Certificate Authority (similar to mkcert), meaning your local environment accurately mirrors production SSL from day one.What “no containers” actually means in practiceOpen a second terminal, cd into a totally different project that also requires MySQL 8.0.35, and run stack up: service.mysql: already running, shared with other projects (pid 41232, port 3306)That’s the beauty of it. One MySQL process serving two projects, isolated by schema rather than burning your CPU and RAM on redundant containers.But here is where the “smooth” factor really shines. Open a third terminal, cd into acme-api, and just run php -v. No stack up, no extra commands:$ cd acme-api$ php -vPHP 8.3.1 (cli) (built: ...)$ cd ..$ php -v'php' is not recognized as an internal or external commandThe version switch happens ambiently on every prompt thanks to that PowerShell hook. Your IDE, composer install, and your test runners all automatically get the correctly pinned version the second they enter the folder. Leave the directory, and it vanishes. No source venv/bin/activate, no nvm use, and no polluting your global system.Fresh PHP installs that don’t suckUsually, a fresh PHP installation means spending an afternoon hunting down “PDO driver not found” or “timezone not set” errors. Stack eliminates this entirely.The first time it downloads a PHP version via vfox, it automatically patches php.ini. It turns on OPcache, bumps memory and upload limits, sets a timezone, and enables the extensions you actually need out of the box (pdo_mysql, pdo_pgsql, pdo_sqlite, sockets, sodium, etc.). It happens once, automatically, and you never have to think about it again.stack doctor — The ultimate pre-flight checkBefore you hand off a project or start debugging a strange issue, run this:$ stack doctor --projectchecking C:\Users\you\acme-api\stack.toml... language.php: OK (C:\Users\you\.vfox\cache\php\v-8.3.1\...\php.exe) service.mysql: OK (managed)This command validates your ports, paths, and {PLACEHOLDER} values against your environment (loading .env first) without starting a single service. If something is broken, you get a clean list upfront instead of discovering cryptic errors halfway through startup.Command your setup from anywhereOnce Stack knows about a project, you don’t even need to be in its folder to manage it:stack describe acme-apistack restart acme-apistack down acme-apistack describe dumps everything—resolved binary paths, the exact location of your hashed php.ini, logs, and routed domains. Stack keeps a local registry of your projects, making global management effortless.Shutting it all down cleanlyWhen the day is over:stack down --allEverything stops. Every project, every shared service, and Caddy. You get actual zero CPU usage, not a lingering phantom container you forgot to kill.A quick note on custom domainsUsing .localhost guarantees a smooth experience because browsers automatically resolve it to the loopback address (RFC 6761) without you needing to hack your hosts file. However, if you are migrating from Laragon or Herd and absolutely need .test domains, there is a one-time setup using Acrylic DNS Proxy.Your New WorkflowScaffold, run, trust once, enjoy automatic HTTPS, commit your manifest, and tear it down cleanly when you’re done.I use this exact workflow for real client work every single day. This isn’t a theoretical happy path; it’s a battle-tested blueprint for an ultimate, smooth dev environment.For the complete stack.toml reference, CLI flags, and advanced features (like using [[clone]] to bootstrap from just a manifest), check out the official docs: Why I built this — the longer rant against Docker/XAMPP for local dev Getting Started Manifest Reference CLI Reference Code: github.com/sanayasfp/stack