<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="http://sanayavo.com/feed.xml" rel="self" type="application/atom+xml" /><link href="http://sanayavo.com/" rel="alternate" type="text/html" /><updated>2026-07-22T21:12:27+00:00</updated><id>http://sanayavo.com/feed.xml</id><title type="html">Sana Yavo’s blog</title><subtitle>Technical case studies and thoughts on Backend Engineering, System Design,  and Practical Artificial Intelligence.
</subtitle><author><name>Sana YAVO</name><email>contact@sanayavo.com</email></author><entry xml:lang="en"><title type="html">Laplace Nearby: A WhatsApp Bot That Finds ‘The Nearest Maquis’ Without Torching the LLM Budget</title><link href="http://sanayavo.com/2026/06/22/laplace-nearby-en.html" rel="alternate" type="text/html" title="Laplace Nearby: A WhatsApp Bot That Finds ‘The Nearest Maquis’ Without Torching the LLM Budget" /><published>2026-06-22T14:15:00+00:00</published><updated>2026-06-22T14:15:00+00:00</updated><id>http://sanayavo.com/2026/06/22/laplace-nearby-en</id><content type="html" xml:base="http://sanayavo.com/2026/06/22/laplace-nearby-en.html"><![CDATA[<p>In Abidjan, a huge number of local businesses — the neighborhood <em>maquis</em> (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. <strong>Laplace Nearby</strong> 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.</p>

<p>The assistant itself is internally called <strong>Simon</strong>. This post is about how Simon is actually built — the parts I think are worth talking about, not a marketing pitch.</p>

<p>Repo: <a href="https://github.com/sanayasfp/laplace-nearby">github.com/sanayasfp/laplace-nearby</a>.</p>

<h2 id="decision-1-the-engine-doesnt-know-its-talking-to-whatsapp">Decision #1: the engine doesn’t know it’s talking to WhatsApp</h2>

<p>The core of the system is <code class="language-plaintext highlighter-rouge">SimonEngine</code>, a small orchestrator that takes a channel-agnostic <code class="language-plaintext highlighter-rouge">InteractionRequest</code> and returns a channel-agnostic <code class="language-plaintext highlighter-rouge">InteractionResponse</code>. 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:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="nx">process</span><span class="p">(</span><span class="nx">req</span><span class="p">:</span> <span class="nx">InteractionRequest</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">InteractionResponse</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">context</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">contextManager</span><span class="p">.</span><span class="nx">load</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">profileId</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">flow</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">flowRegistry</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">session</span><span class="p">.</span><span class="nx">activeFlow</span> <span class="o">||</span> <span class="nx">IDLE</span><span class="p">);</span>

  <span class="kd">const</span> <span class="nx">limitResult</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">rateLimitService</span><span class="p">.</span><span class="nx">check</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">rateLimit</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">limitResult</span><span class="p">.</span><span class="nx">allowed</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="p">{</span> <span class="na">messages</span><span class="p">:</span> <span class="nx">limitResult</span><span class="p">.</span><span class="nx">notify</span> <span class="p">?</span> <span class="p">[</span><span class="nx">Responses</span><span class="p">.</span><span class="nx">tooManyMessages</span><span class="p">(...)]</span> <span class="p">:</span> <span class="p">[]</span> <span class="p">,</span> <span class="p">...</span> <span class="p">};</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">flow</span><span class="p">.</span><span class="nx">handle</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">context</span><span class="p">,</span> <span class="nx">interactionId</span><span class="p">);</span>
  <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">contextManager</span><span class="p">.</span><span class="nx">save</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">profileId</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">contextUpdate</span><span class="p">);</span>
  <span class="k">return</span> <span class="p">{</span> <span class="p">...</span><span class="nx">response</span><span class="p">,</span> <span class="na">sideEffects</span><span class="p">:</span> <span class="p">[</span><span class="nx">summaryEffect</span><span class="p">,</span> <span class="p">...</span><span class="nx">response</span><span class="p">.</span><span class="nx">sideEffects</span><span class="p">]</span> <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<p>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.</p>

<h2 id="decision-2-dont-call-the-llm-if-you-dont-have-to">Decision #2: don’t call the LLM if you don’t have to</h2>

<p>Every 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.</p>

<p><code class="language-plaintext highlighter-rouge">FastIntentDetector</code> 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: <em>“Reduce LLM costs and latency for unambiguous user requests.”</em> Only when nothing matches does <code class="language-plaintext highlighter-rouge">IntentService</code> fall back to Gemini (<code class="language-plaintext highlighter-rouge">gemini-2.5-flash-lite</code>), 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”).</p>

<p>Both paths report where the classification came from — <code class="language-plaintext highlighter-rouge">REGEX</code> or <code class="language-plaintext highlighter-rouge">LLM</code> — to a Prometheus counter (<code class="language-plaintext highlighter-rouge">simonIntentTotal</code>), 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.</p>

<h2 id="decision-3-addresses-in-abidjan-dont-work-like-addresses-elsewhere">Decision #3: addresses in Abidjan don’t work like addresses elsewhere</h2>

<p>A huge share of real addresses given in chat aren’t geocodable strings — they’re descriptions: “je suis vers la cité Abdoulaye Diallo.” <code class="language-plaintext highlighter-rouge">AddressCodingService</code> 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 <strong>Nouchi</strong> (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.</p>

<h2 id="decision-4-search-is-one-sql-function-three-signals-fused">Decision #4: search is one SQL function, three signals, fused</h2>

<p>This is the part of the codebase I’m most proud of. <code class="language-plaintext highlighter-rouge">search_nearby_places</code> is a single Postgres function that blends three independent ranking signals for every candidate business inside a radius:</p>

<ul>
  <li><strong>Full-text rank</strong> (<code class="language-plaintext highlighter-rouge">ts_rank_cd</code> against a French <code class="language-plaintext highlighter-rouge">tsquery</code>) — good when the user typed something close to the business’s actual name or category.</li>
  <li><strong>Vector similarity rank</strong> — cosine distance between the query embedding (Gemini <code class="language-plaintext highlighter-rouge">text-embedding-004</code>, <code class="language-plaintext highlighter-rouge">pgvector</code> 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.</li>
  <li><strong>Geographic proximity</strong> (<code class="language-plaintext highlighter-rouge">ST_Distance</code> over a PostGIS <code class="language-plaintext highlighter-rouge">geography</code> column) — because “closest” always matters, and can’t be papered over by relevance alone.</li>
</ul>

<p>These get combined with a weighted <strong>Reciprocal Rank Fusion</strong>: <code class="language-plaintext highlighter-rouge">score = w_fts/(k + fts_rank) + w_vec/(k + vec_rank) + w_prox * proximity_term</code>, 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.</p>

<p>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 <code class="language-plaintext highlighter-rouge">LIMIT</code> 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 <code class="language-plaintext highlighter-rouge">ROW_NUMBER() OVER (PARTITION BY is_premium ...)</code>. 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.</p>

<h2 id="decision-5-side-effects-are-data-not-actions">Decision #5: side effects are data, not actions</h2>

<p>When 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 <code class="language-plaintext highlighter-rouge">SideEffect</code> object describing what should happen. A <code class="language-plaintext highlighter-rouge">PgmqSideEffectDispatcher</code> collects these, groups them by target queue, and pushes them via <code class="language-plaintext highlighter-rouge">pgmq.send_batch</code> (Postgres’s own message-queue extension) <em>inside the same database transaction</em> 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 <code class="language-plaintext highlighter-rouge">analytics-worker</code>, a <code class="language-plaintext highlighter-rouge">place-embedding-worker</code>, a <code class="language-plaintext highlighter-rouge">place-tagging-worker</code>) drain those queues asynchronously.</p>

<h2 id="the-stack-plainly">The stack, plainly</h2>

<p>Fastify + TypeScript on Node.js, Prisma over Supabase Postgres (with <code class="language-plaintext highlighter-rouge">pgvector</code>, <code class="language-plaintext highlighter-rouge">pgmq</code>, PostGIS, <code class="language-plaintext highlighter-rouge">pg_cron</code>, and <code class="language-plaintext highlighter-rouge">pg_net</code> 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.</p>

<h2 id="where-it-stands">Where it stands</h2>

<p><code class="language-plaintext highlighter-rouge">package.json</code> says <code class="language-plaintext highlighter-rouge">0.4.0-rc.1</code> 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.</p>

<p>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: <a href="https://github.com/sanayasfp/laplace-nearby">github.com/sanayasfp/laplace-nearby</a>.</p>]]></content><author><name>Sana YAVO</name><email>contact@sanayavo.com</email></author><category term="System Design" /><category term="Backend" /><category term="AI/LLM" /><category term="PostgreSQL" /><category term="TypeScript" /><summary type="html"><![CDATA[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.]]></summary></entry><entry xml:lang="fr"><title type="html">Laplace Nearby : un bot WhatsApp qui trouve « le maquis le plus proche » sans exploser la facture LLM</title><link href="http://sanayavo.com/2026/06/22/laplace-nearby.html" rel="alternate" type="text/html" title="Laplace Nearby : un bot WhatsApp qui trouve « le maquis le plus proche » sans exploser la facture LLM" /><published>2026-06-22T14:15:00+00:00</published><updated>2026-06-22T14:15:00+00:00</updated><id>http://sanayavo.com/2026/06/22/laplace-nearby</id><content type="html" xml:base="http://sanayavo.com/2026/06/22/laplace-nearby.html"><![CDATA[<p>À Abidjan, une grande partie des commerces de proximité — le maquis du quartier, la pharmacie, le réparateur de téléphones — n’ont ni site web, ni fiche Google Business, ni même une adresse postale formelle la moitié du temps. Ce qu’ils ont, en revanche, c’est WhatsApp. <strong>Laplace Nearby</strong> part de ce constat : plutôt que de construire une application que les gens doivent télécharger, autant construire l’expérience de recherche dans l’application que tout le monde a déjà ouverte, et laisser les gens décrire ce qu’ils cherchent en langage naturel — « il me faut une pharmacie », « j’ai envie de porcodjo » — plutôt que de remplir un formulaire de filtres.</p>

<p>L’assistant lui-même s’appelle en interne <strong>Simon</strong>. Ce billet parle de la façon dont Simon est réellement construit — les parties que je trouve intéressantes à raconter, pas un argumentaire commercial.</p>

<p>Le dépôt : <a href="https://github.com/sanayasfp/laplace-nearby">github.com/sanayasfp/laplace-nearby</a>.</p>

<h2 id="décision-n1--le-moteur-ignore-quil-parle-à-whatsapp">Décision n°1 : le moteur ignore qu’il parle à WhatsApp</h2>

<p>Le cœur du système est <code class="language-plaintext highlighter-rouge">SimonEngine</code>, un petit orchestrateur qui prend en entrée une <code class="language-plaintext highlighter-rouge">InteractionRequest</code> indépendante du canal et renvoie une <code class="language-plaintext highlighter-rouge">InteractionResponse</code> tout aussi indépendante. Les spécificités WhatsApp — format des webhooks, mise en forme des messages, boutons et listes — vivent entièrement dans une couche d’adaptateurs/renderers en dehors du moteur. Le moteur lui-même fait quatre choses à chaque message : charger le contexte de session de l’utilisateur, vérifier le rate limiting, transmettre le message au flow conversationnel actif (idle, recherche, ou enregistrement), puis sauvegarder le résultat — en émettant systématiquement un effet de bord analytique avec le nom du flow, la transition d’état et la latence, qu’il se soit passé quelque chose d’intéressant ou non :</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="nx">process</span><span class="p">(</span><span class="nx">req</span><span class="p">:</span> <span class="nx">InteractionRequest</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">InteractionResponse</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">context</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">contextManager</span><span class="p">.</span><span class="nx">load</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">profileId</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">flow</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">flowRegistry</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">session</span><span class="p">.</span><span class="nx">activeFlow</span> <span class="o">||</span> <span class="nx">IDLE</span><span class="p">);</span>

  <span class="kd">const</span> <span class="nx">limitResult</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">rateLimitService</span><span class="p">.</span><span class="nx">check</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">rateLimit</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">limitResult</span><span class="p">.</span><span class="nx">allowed</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="p">{</span> <span class="na">messages</span><span class="p">:</span> <span class="nx">limitResult</span><span class="p">.</span><span class="nx">notify</span> <span class="p">?</span> <span class="p">[</span><span class="nx">Responses</span><span class="p">.</span><span class="nx">tooManyMessages</span><span class="p">(...)]</span> <span class="p">:</span> <span class="p">[]</span> <span class="p">,</span> <span class="p">...</span> <span class="p">};</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">flow</span><span class="p">.</span><span class="nx">handle</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">context</span><span class="p">,</span> <span class="nx">interactionId</span><span class="p">);</span>
  <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">contextManager</span><span class="p">.</span><span class="nx">save</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">profileId</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">contextUpdate</span><span class="p">);</span>
  <span class="k">return</span> <span class="p">{</span> <span class="p">...</span><span class="nx">response</span><span class="p">,</span> <span class="na">sideEffects</span><span class="p">:</span> <span class="p">[</span><span class="nx">summaryEffect</span><span class="p">,</span> <span class="p">...</span><span class="nx">response</span><span class="p">.</span><span class="nx">sideEffects</span><span class="p">]</span> <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Ce n’est pas de l’architecture pour le plaisir. Cela signifie que la logique de recherche, le flow d’enregistrement, le rate limiting et les métriques n’auront pas besoin d’être réécrits si un second canal (SMS, un widget web, peu importe) arrive un jour — ils n’ont jamais été couplés à WhatsApp en premier lieu.</p>

<h2 id="décision-n2--ne-pas-appeler-le-llm-si-on-peut-sen-passer">Décision n°2 : ne pas appeler le LLM si on peut s’en passer</h2>

<p>Chaque message entrant a besoin d’une intention — s’agit-il d’une recherche, d’une demande d’enregistrement, de bavardage, d’un remerciement, d’une insulte, d’un « stop » ? Faire passer chaque message par un appel LLM est la solution de facilité, et aussi la plus lente et la plus coûteuse. La classification d’intention est donc à deux étages.</p>

<p><code class="language-plaintext highlighter-rouge">FastIntentDetector</code> s’exécute en premier : un ensemble volontairement exhaustif d’expressions régulières couvrant le français, l’anglais, et l’argot local qu’on croise réellement dans une conversation WhatsApp à Abidjan — « wesh », « cc », « gab » (guichet automatique), « essence/gazoil », des dizaines de variantes orthographiques de « merci », « stop », « annule ». Sa docstring dit exactement à quoi il sert : <em>« Reduce LLM costs and latency for unambiguous user requests »</em> (réduire les coûts et la latence liés au LLM pour les requêtes non ambiguës). Ce n’est que lorsque rien ne correspond que <code class="language-plaintext highlighter-rouge">IntentService</code> bascule sur Gemini (<code class="language-plaintext highlighter-rouge">gemini-2.5-flash-lite</code>), avec une température à 0, un mode de réponse JSON, et un plafond de 150 tokens, en demandant une sortie structurée : l’intention, un mot-clé de recherche extrait, et une liste de termes sémantiquement proches pour élargir la recherche (« j’ai envie de porcodjo » → mot-clé « porcodjo restaurant » ; « mon habit est sale » → « pressing nettoyage vêtement »).</p>

<p>Les deux chemins signalent leur origine — <code class="language-plaintext highlighter-rouge">REGEX</code> ou <code class="language-plaintext highlighter-rouge">LLM</code> — à un compteur Prometheus (<code class="language-plaintext highlighter-rouge">simonIntentTotal</code>), ce qui fait que la part du trafic détournée du modèle payant est quelque chose qu’on peut littéralement observer sur un dashboard, pas quelque chose qu’on devine. Et l’appel à Gemini lui-même est protégé par un circuit breaker : s’il se déclenche, le système ne plante pas la conversation, il dégrade vers une réponse neutre de type « bavardage » avec une confiance à 0, et continue.</p>

<h2 id="décision-n3--les-adresses-à-abidjan-ne-fonctionnent-pas-comme-ailleurs">Décision n°3 : les adresses à Abidjan ne fonctionnent pas comme ailleurs</h2>

<p>Une grande partie des adresses données dans le chat ne sont pas des chaînes géocodables — ce sont des descriptions : « je suis vers la cité Abdoulaye Diallo ». <code class="language-plaintext highlighter-rouge">AddressCodingService</code> prend ce type d’entrée, vérifie d’abord un cache sémantique (hash exact, puis similarité par embedding au-delà d’un seuil de 0,88) pour éviter de repayer un traitement déjà résolu, et en cas d’échec, demande à Gemini de la transformer en adresse standardisée, un quartier extrait, et — c’est le point important — un indicateur précisant si la description est assez précise pour être géocodée, ou si la seule réponse honnête est de demander à l’utilisateur d’envoyer un point GPS. Il existe même un dictionnaire dédié de termes <strong>nouchi</strong> (l’argot de rue abidjanais) qui alimente ce pipeline, parce qu’un outillage NLP générique ne sait pas ce que désigne un arrêt de « gbaka » ou le surnom d’un quartier.</p>

<h2 id="décision-n4--la-recherche-cest-une-seule-fonction-sql-trois-signaux-fusionnés">Décision n°4 : la recherche, c’est une seule fonction SQL, trois signaux, fusionnés</h2>

<p>C’est la partie du code dont je suis le plus fier. <code class="language-plaintext highlighter-rouge">search_nearby_places</code> est une unique fonction Postgres qui combine trois signaux de classement indépendants pour chaque commerce candidat dans un rayon donné :</p>

<ul>
  <li><strong>Le rang texte intégral</strong> (<code class="language-plaintext highlighter-rouge">ts_rank_cd</code> sur un <code class="language-plaintext highlighter-rouge">tsquery</code> en français) — utile quand l’utilisateur a tapé quelque chose de proche du nom ou de la catégorie réelle du commerce.</li>
  <li><strong>Le rang de similarité vectorielle</strong> — distance cosinus entre l’embedding de la requête (Gemini <code class="language-plaintext highlighter-rouge">text-embedding-004</code>, <code class="language-plaintext highlighter-rouge">pgvector</code> avec un index HNSW) et l’embedding de chaque lieu — utile quand l’utilisateur décrit ce qu’il veut avec ses propres mots plutôt qu’en reprenant une étiquette.</li>
  <li><strong>La proximité géographique</strong> (<code class="language-plaintext highlighter-rouge">ST_Distance</code> sur une colonne <code class="language-plaintext highlighter-rouge">geography</code> PostGIS) — parce que « le plus proche » compte toujours, et ne peut pas être compensé par la seule pertinence.</li>
</ul>

<p>Ces signaux sont combinés via une <strong>Reciprocal Rank Fusion</strong> pondérée : <code class="language-plaintext highlighter-rouge">score = w_fts/(k + fts_rank) + w_vec/(k + vec_rank) + w_prox * proximité</code>, avec des poids qui varient selon que l’utilisateur ait donné ou non un mot-clé — 45/45/10 entre texte, vecteur et proximité s’il y a un mot-clé à faire correspondre ; 85% piloté par le vecteur sinon, puisque la recherche texte intégral n’a rien à quoi s’accrocher dans une requête purement descriptive. Il existe aussi un mécanisme de « palier premium » : une seconde passe de classement, partitionnée par palier, garantit aux commerces payants/référencés un petit quota de places sans pour autant noyer la pertinence pour l’ensemble des résultats.</p>

<p>Je suis revenu plus tard réécrire cette même fonction pour la performance, après avoir remarqué qu’elle effectuait du travail redondant : fusionner deux CTE qui relisaient deux fois les mêmes lignes, ajouter une <code class="language-plaintext highlighter-rouge">LIMIT</code> à l’intérieur du CTE de classement vectoriel spécifiquement pour que l’index HNSW puisse s’arrêter court plutôt que de trier tout le pool de candidats, et remplacer deux matérialisations complètes séparées (résultats généraux, résultats premium) par une seule passe fenêtrée utilisant <code class="language-plaintext highlighter-rouge">ROW_NUMBER() OVER (PARTITION BY is_premium ...)</code>. C’est le genre de correction vraiment satisfaisant — même résultat, mesurablement moins de travail par requête — et c’est le genre de chose qui n’apparaît que lorsque l’usage réel met sous pression un premier jet.</p>

<h2 id="décision-n5--les-effets-de-bord-sont-des-données-pas-des-actions">Décision n°5 : les effets de bord sont des données, pas des actions</h2>

<p>Quand un lieu est enregistré, qu’une interaction doit être journalisée pour l’analytique, ou qu’un utilisateur doit recevoir une notification, le moteur n’exécute pas ce travail en ligne — il renvoie un simple objet <code class="language-plaintext highlighter-rouge">SideEffect</code> décrivant ce qui doit se produire. Un <code class="language-plaintext highlighter-rouge">PgmqSideEffectDispatcher</code> collecte ces effets, les regroupe par file cible, et les pousse via <code class="language-plaintext highlighter-rouge">pgmq.send_batch</code> (l’extension de file de messages propre à Postgres) <em>dans la même transaction de base de données</em> que celle qui marque un lieu nouvellement enregistré comme « en attente d’indexation ». Ce détail compte : cela signifie qu’un lieu ne peut pas se retrouver à moitié enregistré — visible dans l’application mais jamais réellement indexé pour la recherche — parce que la mise à jour du statut et l’écriture dans la file valident ensemble, ou aucune des deux. Des Supabase Edge Functions en bout de chaîne (un <code class="language-plaintext highlighter-rouge">analytics-worker</code>, un <code class="language-plaintext highlighter-rouge">place-embedding-worker</code>, un <code class="language-plaintext highlighter-rouge">place-tagging-worker</code>) vident ces files de manière asynchrone.</p>

<h2 id="la-stack-sans-détour">La stack, sans détour</h2>

<p>Fastify + TypeScript sur Node.js, Prisma au-dessus de Supabase Postgres (avec <code class="language-plaintext highlighter-rouge">pgvector</code>, <code class="language-plaintext highlighter-rouge">pgmq</code>, PostGIS, <code class="language-plaintext highlighter-rouge">pg_cron</code> et <code class="language-plaintext highlighter-rouge">pg_net</code> qui font un vrai travail, pas juste des lignes dans un fichier de dépendances), Redis pour le cache et l’état du rate limiting, Gemini à la fois pour le NLU et les embeddings, Geoapify pour le géocodage, et des métriques Prometheus intégrées dès le départ plutôt que rajoutées après coup.</p>

<h2 id="où-en-est-le-projet">Où en est le projet</h2>

<p>Le <code class="language-plaintext highlighter-rouge">package.json</code> indique <code class="language-plaintext highlighter-rouge">0.4.0-rc.1</code>, et je tiens à laisser ce contexte tel quel : c’est un système réel et fonctionnel qui me sert à raisonner sur la recherche hybride et l’ingénierie conversationnelle, en évolution active vers une version 1.0 — pas un produit fini, à grande échelle, avec un portefeuille de clients derrière. La conception indépendante du canal existe précisément pour que, si ce projet doit un jour dépasser le cadre d’un bot WhatsApp, le moteur sous-jacent n’ait pas besoin d’être reconstruit.</p>

<p>Si vous voulez débattre des poids de la RRF, me dire que PostGIS était superflu, ou pointer une meilleure façon de structurer le dispatcher d’effets de bord, le code est public : <a href="https://github.com/sanayasfp/laplace-nearby">github.com/sanayasfp/laplace-nearby</a>.</p>]]></content><author><name>Sana YAVO</name><email>contact@sanayavo.com</email></author><category term="System Design" /><category term="Backend" /><category term="AI/LLM" /><category term="PostgreSQL" /><category term="TypeScript" /><summary type="html"><![CDATA[À Abidjan, une grande partie des commerces de proximité — le maquis du quartier, la pharmacie, le réparateur de téléphones — n’ont ni site web, ni fiche Google Business, ni même une adresse postale formelle la moitié du temps. Ce qu’ils ont, en revanche, c’est WhatsApp. Laplace Nearby part de ce constat : plutôt que de construire une application que les gens doivent télécharger, autant construire l’expérience de recherche dans l’application que tout le monde a déjà ouverte, et laisser les gens décrire ce qu’ils cherchent en langage naturel — « il me faut une pharmacie », « j’ai envie de porcodjo » — plutôt que de remplir un formulaire de filtres.]]></summary></entry><entry xml:lang="en"><title type="html">Car Inspect AI: Teaching YOLO to Read a Car the Way an Inspector Does</title><link href="http://sanayavo.com/2026/06/22/car-inspect-ai-en.html" rel="alternate" type="text/html" title="Car Inspect AI: Teaching YOLO to Read a Car the Way an Inspector Does" /><published>2026-06-22T14:00:00+00:00</published><updated>2026-06-22T14:00:00+00:00</updated><id>http://sanayavo.com/2026/06/22/car-inspect-ai-en</id><content type="html" xml:base="http://sanayavo.com/2026/06/22/car-inspect-ai-en.html"><![CDATA[<p>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. <strong>Car Inspect AI</strong> 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.</p>

<p>Code’s here: <a href="https://github.com/sanayasfp/car-inspect-ai">github.com/sanayasfp/car-inspect-ai</a>.</p>

<h2 id="starting-with-the-actual-constraint-data">Starting with the actual constraint: data</h2>

<p>Before touching a model, the real constraint on a project like this is data. I trained on the public <strong>Car Parts Segmentation</strong> 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.</p>

<h2 id="why-yolo11n-specifically">Why YOLO11n specifically</h2>

<p>I picked <strong>YOLO11n</strong> — the nano variant — deliberately, not just because “YOLO is what people use for object detection.” Two things mattered:</p>

<ul>
  <li><strong>It’s small enough to run on modest hardware.</strong> 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.</li>
  <li><strong>With only 500 images, model capacity is a liability, not an asset.</strong> 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.</li>
</ul>

<p>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).</p>

<p>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: <strong>87% mAP</strong> 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).</p>

<h2 id="rolling-my-own-tiny-orm-instead-of-reaching-for-sqlalchemy">Rolling my own tiny ORM instead of reaching for SQLAlchemy</h2>

<p>This 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:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">dataclasses</span><span class="p">.</span><span class="n">dataclass</span>
<span class="k">class</span> <span class="nc">TrainLogsModel</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">_table_name</span> <span class="o">=</span> <span class="s">"train_logs"</span>

    <span class="n">name</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">epochs</span><span class="p">:</span> <span class="nb">int</span>
    <span class="n">model</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">path</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">completed</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="nb">type</span><span class="o">=</span><span class="nb">bool</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">).</span><span class="nb">set</span><span class="p">()</span>
    <span class="nb">id</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">autoincrement</span><span class="o">=</span><span class="bp">True</span><span class="p">).</span><span class="nb">set</span><span class="p">()</span>
    <span class="n">created_at</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">float</span><span class="p">]</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="k">lambda</span><span class="p">:</span> <span class="n">dt</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">timestamp</span><span class="p">()).</span><span class="nb">set</span><span class="p">()</span>
    <span class="n">resumed_from</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">foreign_key</span><span class="o">=</span><span class="s">"id"</span><span class="p">,</span> <span class="n">foreign_table</span><span class="o">=</span><span class="n">_table_name</span><span class="p">).</span><span class="nb">set</span><span class="p">()</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">BaseModel</code> reads the dataclass’s type annotations and field metadata and turns them into SQL column definitions (<code class="language-plaintext highlighter-rouge">INTEGER</code>, <code class="language-plaintext highlighter-rouge">TEXT</code>, <code class="language-plaintext highlighter-rouge">REAL</code>, 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.</p>

<p>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 <code class="language-plaintext highlighter-rouge">resumed_from</code> foreign key back to the run it continued, so I have an actual lineage of experiments instead of a folder full of <code class="language-plaintext highlighter-rouge">best_v2_final_FINAL.pt</code> files.</p>

<h2 id="the-interface-streamlit-on-purpose">The interface: Streamlit, on purpose</h2>

<p>The 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.</p>

<h2 id="where-it-actually-stands-right-now">Where it actually stands right now</h2>

<p>I 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.</p>

<p>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.</p>

<p>Repo’s public if you want to see the training code, the mini-ORM, or argue that I should’ve just used SQLAlchemy: <a href="https://github.com/sanayasfp/car-inspect-ai">github.com/sanayasfp/car-inspect-ai</a>.</p>]]></content><author><name>Sana YAVO</name><email>contact@sanayavo.com</email></author><category term="Computer Vision" /><category term="Machine Learning" /><category term="Python" /><category term="MLOps" /><summary type="html"><![CDATA[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.]]></summary></entry><entry xml:lang="fr"><title type="html">Car Inspect AI : apprendre à YOLO à lire une voiture comme le ferait un inspecteur</title><link href="http://sanayavo.com/2026/06/22/car-inspect-ai.html" rel="alternate" type="text/html" title="Car Inspect AI : apprendre à YOLO à lire une voiture comme le ferait un inspecteur" /><published>2026-06-22T14:00:00+00:00</published><updated>2026-06-22T14:00:00+00:00</updated><id>http://sanayavo.com/2026/06/22/car-inspect-ai</id><content type="html" xml:base="http://sanayavo.com/2026/06/22/car-inspect-ai.html"><![CDATA[<p>Quiconque a déjà loué une voiture ou déposé un dossier d’assurance connaît le rituel : quelqu’un fait le tour du véhicule, prend quelques photos, et une personne décide plus tard si telle marque sur le pare-chocs était déjà là. C’est lent, c’est subjectif, et les litiges entre locataires, propriétaires et assureurs sur les “dommages préexistants” sont fréquents, précisément parce que tout repose sur la parole de quelqu’un face à une poignée de photos. <strong>Car Inspect AI</strong> est ma tentative de m’attaquer à ce problème : un système de vision par ordinateur qui détecte et identifie automatiquement les différentes parties d’un véhicule à partir d’une photo, comme première brique vers une inspection plus objective et automatisée.</p>

<p>Le code est ici : <a href="https://github.com/sanayasfp/car-inspect-ai">github.com/sanayasfp/car-inspect-ai</a>.</p>

<h2 id="partir-de-la-vraie-contrainte--les-données">Partir de la vraie contrainte : les données</h2>

<p>Avant de toucher au moindre modèle, la vraie contrainte d’un projet comme celui-ci, ce sont les données. J’ai entraîné le modèle sur le jeu de données public <strong>Car Parts Segmentation</strong> (Kitsuchart Pasupa et al.), 500 images annotées de berlines, pickups et SUV au format COCO, couvrant 18 parties distinctes du véhicule — pare-chocs, portes, feux, rétroviseurs, capot, coffre, roues, etc. — photographiées de face, de dos et sous des angles inclinés, avec plaques et visages floutés pour la confidentialité. 500 images, ce n’est pas énorme à l’échelle du deep learning, et cette contrainte a façonné presque toutes les décisions qui ont suivi.</p>

<h2 id="pourquoi-yolo11n-précisément">Pourquoi YOLO11n précisément</h2>

<p>J’ai choisi <strong>YOLO11n</strong> — la variante “nano” — de manière délibérée, pas simplement parce que “YOLO, c’est ce qu’on utilise pour la détection d’objets.” Deux raisons :</p>

<ul>
  <li><strong>C’est assez léger pour tourner sur du matériel modeste.</strong> Un outil pensé pour des garages, des petites agences de location ou des inspecteurs indépendants ne sert à rien s’il exige un GPU puissant pour faire de l’inférence. YOLO11n sacrifie un peu de précision brute contre une empreinte qui tourne confortablement sur CPU ou sur un GPU d’entrée de gamme.</li>
  <li><strong>Avec seulement 500 images, la capacité du modèle est un handicap, pas un atout.</strong> Un modèle plus large a plus de marge pour sur-apprendre un petit jeu de données. Une architecture légère, combinée à une augmentation de données agressive, était le choix le plus honnête compte tenu de ce que j’avais réellement pour entraîner.</li>
</ul>

<p>Pour tirer le maximum de ce petit jeu de données, le prétraitement a inclus un redimensionnement aux dimensions d’entrée attendues par YOLO, ainsi qu’une augmentation par rotation (pour simuler différents angles de prise de vue), un flip horizontal, et l’ajout de bruit gaussien (pour rendre le modèle moins sensible aux variations d’éclairage — un vrai problème quand les photos viennent d’un téléphone au hasard sur un parking, pas d’un studio).</p>

<p>L’entraînement s’est fait sur 50 époques avec un split 80/20 entraînement/validation, une taille de batch de 16, et un arrêt anticipé si la performance sur le jeu de validation stagnait pendant 10 époques consécutives. Résultat : <strong>87% de mAP</strong> sur le jeu de validation, avec les meilleures performances sur les parties géométriquement bien définies comme les roues et les portes — exactement là où on s’attend à ce qu’un détecteur soit le plus fiable, et exactement le genre de résultat qui indique où concentrer les efforts ensuite (les petites parties ambiguës comme les rétroviseurs restent les cas les plus difficiles).</p>

<h2 id="coder-mon-propre-petit-orm-plutôt-que-daller-chercher-sqlalchemy">Coder mon propre petit ORM plutôt que d’aller chercher SQLAlchemy</h2>

<p>C’est la partie du projet que la plupart des gens survoleraient, mais c’est celle où j’ai le plus appris. L’application doit suivre les runs d’entraînement — quel modèle, combien d’époques, si l’entraînement s’est terminé, où se trouve le checkpoint, et s’il reprend un run précédent. Plutôt que d’importer SQLAlchemy pour ce qui reste fondamentalement une poignée de tables, j’ai écrit moi-même une petite couche de modèles basée sur les dataclasses :</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">dataclasses</span><span class="p">.</span><span class="n">dataclass</span>
<span class="k">class</span> <span class="nc">TrainLogsModel</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">_table_name</span> <span class="o">=</span> <span class="s">"train_logs"</span>

    <span class="n">name</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">epochs</span><span class="p">:</span> <span class="nb">int</span>
    <span class="n">model</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">path</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">completed</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="nb">type</span><span class="o">=</span><span class="nb">bool</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">False</span><span class="p">).</span><span class="nb">set</span><span class="p">()</span>
    <span class="nb">id</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">autoincrement</span><span class="o">=</span><span class="bp">True</span><span class="p">).</span><span class="nb">set</span><span class="p">()</span>
    <span class="n">created_at</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">float</span><span class="p">]</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="k">lambda</span><span class="p">:</span> <span class="n">dt</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">timestamp</span><span class="p">()).</span><span class="nb">set</span><span class="p">()</span>
    <span class="n">resumed_from</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">foreign_key</span><span class="o">=</span><span class="s">"id"</span><span class="p">,</span> <span class="n">foreign_table</span><span class="o">=</span><span class="n">_table_name</span><span class="p">).</span><span class="nb">set</span><span class="p">()</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">BaseModel</code> lit les annotations de type et les métadonnées de la dataclass et les transforme en définitions de colonnes SQL (<code class="language-plaintext highlighter-rouge">INTEGER</code>, <code class="language-plaintext highlighter-rouge">TEXT</code>, <code class="language-plaintext highlighter-rouge">REAL</code>, avec clés primaires, autoincrément et clés étrangères gérées explicitement), et déduit les noms de table à partir des noms de classe en snake_case, camelCase ou PascalCase selon le besoin. C’est une fraction de ce que fait un vrai ORM — pas de query builder, pas de système de migrations — mais écrire cette fraction à la main m’a obligé à vraiment comprendre ce qu’un ORM automatise, plutôt que d’en importer un et de faire confiance à la magie. C’est un arbitrage que je referais : pour un projet de cette taille, écrire 100 lignes pour comprendre le mécanisme valait mieux que 10 lignes qui le cachent.</p>

<p>Ce modèle alimente une fonctionnalité réellement utile : la page d’entraînement permet de choisir soit un YOLO11n de base, soit n’importe quel checkpoint précédent, de lancer l’entraînement et de le journaliser — y compris une clé étrangère <code class="language-plaintext highlighter-rouge">resumed_from</code> pointant vers le run dont il repart, ce qui me donne une vraie généalogie d’expériences plutôt qu’un dossier plein de <code class="language-plaintext highlighter-rouge">best_v2_final_FINAL.pt</code>.</p>

<h2 id="linterface--streamlit-par-choix">L’interface : Streamlit, par choix</h2>

<p>Le tout est enveloppé dans une petite application Streamlit multi-pages — une page d’accueil, une page “enregistrer une voiture” (upload des photos avant/arrière/gauche/droite plus couleur et numéro de plaque), la page d’entraînement décrite plus haut, et une page de brouillons pour les expérimentations en cours. Streamlit était le bon choix ici précisément parce que ce n’est pas l’objet du projet : je voulais passer mon temps sur le modèle de détection et sur le suivi des entraînements, pas sur un frontend fait main, et Streamlit s’efface pour ça.</p>

<h2 id="où-en-est-vraiment-le-projet-aujourdhui">Où en est vraiment le projet aujourd’hui</h2>

<p>Je préfère être précis plutôt que d’arrondir : le modèle de détection des parties et le pipeline d’entraînement/versioning fonctionnent et sont mesurés (ce chiffre de 87% de mAP est réel, issu d’un run effectif, pas d’une estimation). Le pipeline d’inspection complet — fusionner les quatre angles d’un véhicule en un seul rapport fiable, et passer de “voici les parties détectées” à un véritable verdict de dommage ou de fraude — reste un chantier actif, pas un produit terminé. Le README liste la notation automatique de la gravité des dommages et l’intégration à un historique véhicule comme axes futurs, et c’est exact : c’est la feuille de route, pas quelque chose que je prétends déjà fonctionnel de bout en bout.</p>

<p>Si je devais résumer ce qui est réellement acquis aujourd’hui : un détecteur de parties de véhicule léger, honnêtement benchmarké, entraîné de manière reproductible, avec sa propre couche minimale de suivi d’expériences construite à partir de zéro. C’est une ambition plus modeste que “système de détection de fraude automatisé”, mais c’est la vraie — et c’est une meilleure fondation pour construire la suite.</p>

<p>Le dépôt est public si vous voulez voir le code d’entraînement, le mini-ORM, ou me convaincre que j’aurais dû simplement utiliser SQLAlchemy : <a href="https://github.com/sanayasfp/car-inspect-ai">github.com/sanayasfp/car-inspect-ai</a>.</p>]]></content><author><name>Sana YAVO</name><email>contact@sanayavo.com</email></author><category term="Computer Vision" /><category term="Machine Learning" /><category term="Python" /><category term="MLOps" /><summary type="html"><![CDATA[Quiconque a déjà loué une voiture ou déposé un dossier d’assurance connaît le rituel : quelqu’un fait le tour du véhicule, prend quelques photos, et une personne décide plus tard si telle marque sur le pare-chocs était déjà là. C’est lent, c’est subjectif, et les litiges entre locataires, propriétaires et assureurs sur les “dommages préexistants” sont fréquents, précisément parce que tout repose sur la parole de quelqu’un face à une poignée de photos. Car Inspect AI est ma tentative de m’attaquer à ce problème : un système de vision par ordinateur qui détecte et identifie automatiquement les différentes parties d’un véhicule à partir d’une photo, comme première brique vers une inspection plus objective et automatisée.]]></summary></entry></feed>