Skip to content

Latest commit

 

History

History
610 lines (466 loc) · 36.6 KB

File metadata and controls

610 lines (466 loc) · 36.6 KB

jak.ma — AI Architecture

Evidence boundary: this is a design and operations note, not a public benchmark report. The repository can reproduce the test-suite claims. Traffic share, latency, cost, worker-count and reliability figures below are historical observations or planning assumptions unless a section includes its source data and calculation command.

Production retrieval-augmented chatbot + tool-calling agent for the Moroccan Darija home-services marketplace at jak.ma. This document describes the LLM serving stack, the multi-provider routing layer, the two-pass classify→generate pipeline, the single-round tool-calling agent for follow-ups, the romanized-Darija classification stack, and the cost / latency / quality math behind every model decision.

Sister doc to README.md (product) and COMPLETION_REPORT.md (recruiter pitch).

Last updated: 2026-05-23


TL;DR

  • Two-pass pipeline for initial queries: regex → LLM classify (Pass 1) then RAG-grounded streaming chat (Pass 2).
  • Single-round tool-calling agent for follow-ups: 3 tools (lookupWorkerById, getRecentReviews, estimatePrice), allow-list-scoped, bounded loop.
  • Three-tier provider chain: gemini-3-flash default → claude-sonnet-4-5 for hard queries → HuggingFace Darija LoRA as last-resort fallback. All three return OpenAI-shape so downstream code is provider-agnostic.
  • Romanized-Darija (Arabizi) classifier with ~260 keywords across 12 trades and 15 cities + neighborhoods, covering the 3=ع · 7=ح · 9=ق · 8=غ · 5=خ · 6=ط numeric conventions. 85%+ of Darija queries resolve via regex in <20ms with zero LLM cost.
  • 57 automated tests (36 classifier + 21 agent) lock the behavior in place.
  • Public debug endpoint /api/ai/classify shows the matched keyword + pipeline path for any query, in real time.
  • Hot-path numbers: p50 ~1.2s, p99 ~2.8s, ~$0.001/query blended, refusal <1%, fallback tier reached <0.5% in steady state.

The problem

Standard LLM apps either pick one provider (vendor lock, no graceful degradation when that provider rate-limits or burns credits) or build half-baked routing that just retries the same model with backoff. jak.ma is a public chatbot — every minute it returns a 503 is a Moroccan user who never finds a plumber. Vendor outages happen; credit cards expire; rate limits trip. Routing is the floor.

The second problem is Darija coverage. Most LLMs are trained heavily on Modern Standard Arabic; Moroccan Darija — which mixes Arabic, French, Berber, and Spanish, and is commonly written in Latin script with numeric letter substitutes (3=ع, 7=ح, 9=ق, ...) — falls between the cracks. Picking the wrong model means worse classifications, more refusals, awkward responses. That makes model choice an engineering decision, not a vibe call.

The third problem is follow-up queries. After a user gets 3 worker recommendations, they ask "is the first one good?" or "shchhal kayseweh?" (how much will it cost). These don't fit retrieve-then-generate because there's nothing new to retrieve — the answer is in the data we already showed, plus a price model. That's the tool-calling agent's job.


Architecture

                              User query (text + optional image + history)
                                              │
                                              ▼
                              ┌──────────────────────────────┐
                              │  AGENT-PATH detection (Pass 0.25)
                              │  Allow-list non-empty AND     │
                              │  query matches follow-up regex │
                              │  (price / reviews / details /  │
                              │   anaphoric)?                  │
                              └────────┬─────────────┬────────┘
                                       │             │
                                       │ yes         │ no
                                       ▼             ▼
              ┌────────────────────────────────┐    ┌──────────────────────────┐
              │  AGENT LOOP                    │    │  PASS 1 — Classify       │
              │  (lib/agent-loop.js)           │    │  1. regex_classify()     │  ~85% hit, 0 LLM cost
              │                                │    │     ↓ miss               │
              │  Iter 1: Claude Sonnet + tools │    │  2. callLLM(routing)     │  → gemini-3-flash
              │         ├─ lookupWorkerById    │    │     JSON: {trade, city,  │     jsonMode, 200 tok
              │         ├─ getRecentReviews    │    │      urgency, conf}      │
              │         └─ estimatePrice       │    └────────────┬─────────────┘
              │  Execute tools (max 3, 1.5s ea)│                 ▼
              │  Iter 2: Sonnet (no tools)     │    ┌──────────────────────────┐
              │         → final Darija answer  │    │  RETRIEVE candidates     │
              └────────────────┬───────────────┘    │  MongoDB strict filter   │
                               │                    │  (trade × city)          │
                               │                    │  + city-fallback layer   │
                               │                    │  → top 8 workers         │
                               │                    └────────────┬─────────────┘
                               │                                 ▼
                               │                    ┌──────────────────────────┐
                               │                    │  PASS 2 — Stream answer  │
                               │                    │  callLLM(messages, {     │
                               │                    │    stream: true,         │
                               │                    │    routing: { hasImage,  │
                               │                    │      multiTrade, ... }}) │
                               │                    │                          │
                               │                    │  Router picks provider:  │
                               │                    │  hard → Sonnet 4.5       │
                               │                    │  else → Gemini 3 Flash   │
                               │                    │  both fail → HF Darija   │
                               │                    └────────────┬─────────────┘
                               │                                 │
                               └─────────────────┬───────────────┘
                                                 ▼
                                  SSE → user (Darija)
                                  + thinking events (tool calls visible)
                                  + <<WORKERS:id1,id2>> markers
                                  + <<MULTI:cat1|cat2>> if multi-trade
                                  + agent telemetry in done event

Files

Path What it does
server.js Express app. Holds callClaude, callGemini, callHF, callLLM (router), and HTTP routes including /api/ai/chat, /api/ai/vision, /api/ai/smartmsg, /api/ai/classify, /api/health.
lib/grounded-retrieval.js handleGroundedChat — orchestrates: agent-path detection → Pass 1 → retrieve → Pass 2 streaming → verifier → eval_logs. Holds the follow-up intent regex and the <<WORKERS:>> allow-list extractor.
lib/tools.js Tool registry: 3 tool schemas (Anthropic + Gemini formats) + implementations + input validators. Enforces allow-list and last-3-only phone exposure.
lib/agent-loop.js Provider-agnostic single-round agent loop. Max 2 LLM iterations, max 3 tools per iter, 1.5s per-tool timeout, hard tool-name allow-list.
lib/text-classifier.js The regex pre-filter: KEYWORD_TO_CAT, KEYWORD_TO_CITY, MULTI_TRADE_PATTERNS, detectFromText, detectMultiTrade. ~260 trade keywords, ~90 city keywords, 6 multi-trade pattern families.
lib/price-fairness.js 1-dim price-fairness checker (hard-rule baseline + LLM evaluation).
lib/hybrid-retrieval.js BM25 (MongoDB $text) + dense embedding scaffolding. Embeddings opt-in via USE_EMBEDDINGS=1.
scripts/price-engine.js computePriceRange(worker) — city × trade × signals → price band. Powers the estimatePrice agent tool.
scripts/reclassify-workers.js One-time offline worker categorization batch job (Gemini Flash via raw fetch).
public/js/local-classifier.js Browser-side TF.js MobileNet vision classifier (<250 ms, no server round-trip when confidence ≥ 0.7).
tests/text-classifier.test.js 36 tests locking the regex behavior across all 12 trades × 5 input forms, 15 cities, multi-trade patterns, and Arabizi conventions.
tests/agent-loop.test.js 21 tests for the agent stack: tool schemas, allow-list, intent detection, agent loop semantics, error wrapping.

The two-pass pipeline (initial queries)

Pass Purpose Input Output Token budget Latency budget
Pass 0 (regex) Fast-path classify raw Darija query {trade, city, urgency, budget, confidence: 0.85, source: 'regex'} 0 LLM tokens <20 ms
Pass 1 (LLM fallback) Catch the long tail query + context same JSON, source: 'llm' ~500 in / ~50 out PASS_1_BUDGET_MS (2 s)
Retrieve Hard-grounding trade + city up to 8 workers 0 <50 ms
Pass 2 (chat) Generate Darija recommendation system prompt + 8 workers + history streaming text + <<WORKERS:>> / <<MULTI:>> markers ~1500 in / ~150 out PASS_2_BUDGET_MS (5 s)
Verify Defense-in-depth model output strip leaked _ids, validate IDs against candidates 0 <2 ms

Critical engineering choices:

  • Regex catches ~85% of Darija queries (up from ~30% before the romanization expansion). Costs nothing, holds confidence guarantees because the rules are inspectable. The LLM only sees the long tail.
  • Pass 2's system prompt is hard-grounded — the model is explicitly forbidden from inventing workers, phone numbers, prices, or districts. Outputs are post-validated by extracting <<WORKERS:id1,id2>> markers and confirming every ID is in the retrieved candidate set.
  • The <<WORKERS:>> / <<MULTI:>> / <<DRAFT:>> markers are stripped from the user-visible bubble client-side but parsed for downstream UI (worker cards, multi-trade timeline rendering, pre-composed WhatsApp drafts).

The classification stack

This is the layer that turns user-typed Darija into a trade + city tuple. It's the dominant cost driver, so it's the dominant optimization target.

Five input forms (all mixable in one query)

# Form Example
1 MSA / Arabic script بغيت بلومبي فطنجة
2 Moroccan Darija in Arabic بغيت معلم لإصلاح اللمبة
3 Arabizi (Latin + numerals) 9hraba 6ay7a f maarif — Arabizi conventions: 3=ع, 7=ح, 9=ق, 8=غ, 5=خ, 6=ط
4 French loanwords plombier urgence f mohammedia
5 English fallbacks plumber needed in casablanca

Real production queries mix all 5 freely, e.g. "carpenter bois f gueliz" (English + French + Marrakech neighborhood).

KEYWORD_TO_CAT — 260 keywords across 12 trades

Each trade has 4 keyword families:

Family Example for بلومبي (plumbing)
Trade name (Ar + Fr + En + Darija) بلومبي, plombier, plumber, bloumbi, ploumbi, lploumbi
Problem nouns (Ar) صنبور, تيوبو, بانيو, طواليت, سرب, حمام تسريب
Problem nouns (Fr + En) fuite, robinet, douche, wc, chauffe-eau, cumulus, sanitaire
Darija romanized chauffao, robina, t9tar, tasrib, canalisation, lavabo, baluwa

KEYWORD_TO_CITY — 90 keywords across 15 cities + neighborhoods

Each city gets: Arabic name + French/English spellings + Darija romanizations + Arabizi forms (where applicable) + key neighborhood names.

City Variants
الدار البيضاء casa, lcasa, dar lbida, maarif, anfa, ain chock, mohammedia, bouskoura, hay hassani, sidi moumen
طنجة tanger, tanja, tnja, 6anja (Arabizi), 6nja, ttanja, malabata, charf
مراكش marrakech, marrakesh, mraks, lmraks, gueliz, medina marrakech, menara, red city
القنيطرة kenitra, kénitra, qnitra, knitra, 9nitra (Arabizi), l9nitra
خريبكة khouribga, khribga, 5ribga (Arabizi), lkhribga
... (15 cities total — see lib/text-classifier.js)

MULTI_TRADE_PATTERNS — 6 renovation phrase families

When the user describes a project that needs multiple trades (bathroom renovation = plumber + tiler + electrician + painter), we detect the phrase and fan out retrieval to all implicated trades.

Phrase family Trades returned
Bathroom: jded l7mam, nrm l7mam, sallat l7mam, tjdid hammam بلومبي, كلامبيستري, طريسيان, صباغة
Apartment / home: nrmm ddar, jded chi9a بناء, طريسيان, صباغة, ديكور
New house: bnay dar, nbni dar, construction maison بناء, طريسيان, بلومبي, كلامبيستري
Kitchen: jded kuzina, nbeddel kuzina, kitchen renovation نجارة, طريسيان, بلومبي, كلامبيستري
Roof / terrace: jded ssath, fuite stah, étanchéité بناء, كلامبيستري, صباغة
Generic renovate: baghi nrmm, nbeddel kullshi, renovation بناء, طريسيان, صباغة, ديكور

Pass-1 LLM classifier prompt (long-tail fallback)

When the regex misses (~15% of queries), Pass 1 fires a Gemini Flash JSON call. The prompt is intentionally long — it documents the Arabizi conventions, lists every supported neighborhood, and provides 25+ examples covering trade-name explicit / problem-noun only / neighborhood-only / off-topic / multi-trade.

Cost impact: +300 tokens per Pass-1 call (cached after first hit via Gemini context caching). Worth it because Pass 1 fires on the genuinely ambiguous long tail.

/api/ai/classify — public debug endpoint

GET  /api/ai/classify?q=bghit+plombier+f+tanja
POST /api/ai/classify   { "query": "..." }

Returns the matched keyword, pattern group, multi-trade pattern, and pipeline-path narration. Zero LLM cost. Designed for: (a) interview demos, (b) production debugging when a customer reports a misclassification, (c) keyword-coverage gap analysis.

Example response:

{
  "query": "9hraba 6ay7a f maarif",
  "elapsed_ms": 4,
  "resolution": "regex (Pass 0)",
  "trade": {
    "value": "طريسيان",
    "matched_keyword": "9hraba",
    "pattern_group": "طريسيان|تريسيان|trissyan|...|9hraba|9hrba|..."
  },
  "city": {
    "value": "الدار البيضاء",
    "matched_keyword": "maarif",
    "pattern_group": "كازا|...|maarif|anfa|..."
  },
  "multi_trade": { "cats": null, ... },
  "pipeline_note": "Pass 0 regex hit — no LLM call needed. This query resolves instantly."
}

The tool-calling agent (follow-up queries)

The agent path fires when the user asks a follow-up about workers already cited in the conversation. The decision is gated by two signals (both must be true):

  1. Allow-list non-empty — at least one <<WORKERS:>> marker appears earlier in the conversation, OR workers_cited[] sidecar is set on a prior assistant message, OR workerContext is set on the request body (for fresh chats on /w/:id worker detail pages).
  2. Follow-up intent regex — the query matches one of 4 intent families: anaphoric reference (the first, الأول, that one), price intent (shchhal, how much, سعر), opinion/reviews (reviews, is he good, تقييم, مزيان), or details intent (tell me more, تفاصيل, more about).

The 3 tools

Tool Input Output Data source
lookupWorkerById { workerId: hex24 } full worker details (name, city, zone, description, price range, rating, experience, last-3 phone digits) MongoDB workers.findOne(_id, approved: true)
getRecentReviews { workerId, limit?: 1–5 } last N reviews (reviewer initials, stars, text), avg rating, total count embedded reviews[] array on worker doc
estimatePrice { trade, city, options?: { experience_years, urgency, company } } { price_min, price_max, price_unit, currency, baseline_n } scripts/price-engine.js#computePriceRange() + MongoDB count grounding

Safety constraints

  • Allow-list enforcement: lookupWorkerById and getRecentReviews refuse workerId values not in the conversation context. Returns { error: "worker_not_in_context" }. Prevents the LLM from enumerating arbitrary worker records via tool calls.
  • Phone privacy: full phone number never exposed via tools. Only the last 3 digits return, with a privacy_note reminder for the LLM. The full phone is still accessible to the user via the existing UI WhatsApp button.
  • Hard tool-name allow-list at the agent loop layer: refuses any tool_use block whose name isn't in {lookupWorkerById, getRecentReviews, estimatePrice}. Defense in depth against the LLM hallucinating tool names.
  • Per-tool 1.5s timeout + max 3 tools per iteration + max 2 LLM iterations. Total agent latency hard-capped at ~5s.
  • Final iteration drops the tools array so the model is forced to produce a user-facing text answer.

Agent loop semantics

// lib/agent-loop.js — simplified

async function runAgentLoop({ messages, tools, callClaude, ctx, onThinking, maxIterations = 2 }) {
  const conversation = [...messages];
  const toolsCalled = [];

  for (let iter = 0; iter < maxIterations; iter++) {
    const isLast = iter === maxIterations - 1;
    const opts = isLast ? {} : { tools };  // last iter: no tools, force text

    const resp = await callClaude(conversation, opts);
    const msg = (await resp.json()).choices[0].message;
    const calls = msg.tool_calls || [];

    if (calls.length === 0 || isLast) {
      return { response: resp, iterations: iter + 1, toolsCalled };
    }

    // Echo assistant turn back into conversation (raw Anthropic blocks)
    conversation.push({ role: 'assistant', content: msg.anthropic_content_blocks });

    // Execute tools, push tool_result blocks as user turn
    const results = await Promise.all(calls.slice(0, 3).map(tc => executeTool(tc.function.name, JSON.parse(tc.function.arguments), ctx)));
    conversation.push({ role: 'user', content: results.map((r, i) => ({
      type: 'tool_result', tool_use_id: calls[i].id, content: JSON.stringify(r.result || { error: r.error })
    })) });
  }
}

Provider choice

Agent loop uses Claude Sonnet 4.5 directly (bypasses the router). Reasoning:

  • Better tool-calling reliability than Gemini Flash on multi-step reasoning.
  • Mixing providers mid-conversation breaks the tool-call state shape — single-provider keeps the loop simple.
  • HF Darija LoRA can't do tool calling at all; the router auto-skips its tier when tools is set.
  • Cost overhead: agent path fires on ~10% of queries × +$0.002 per call vs the Gemini path = +$0.0002 blended. Acceptable.

UI integration: zero frontend changes

The agent emits standard thinking SSE events between tool calls:

data: {"thinking":{"stage":"agent","text":"🔍 كنشوف تفاصيل المعلم (898a0e)…","ts":...,"t":...}}
data: {"thinking":{"stage":"agent","text":"✅ تم الحصول على التفاصيل (218ms)","ts":...,"t":...}}
data: {"thinking":{"stage":"agent","text":"⭐ كنقلب على التقييمات الأخيرة…","ts":...,"t":...}}
data: {"thinking":{"stage":"agent","text":"✅ جبت 3 تقييم (218ms)","ts":...,"t":...}}
data: {"text":"**Climatizone** مزيان بزاف! عندو 20 عام ديال التجربة..."}
data: {"done":true,"agent":{"iterations":2,"tools_called":["lookupWorkerById","getRecentReviews"]}}

Frontend's existing thinking event handler renders them as a collapsible reasoning pane. No new event types needed.


The three-tier provider router

// server.js — simplified

async function callLLM(messages, opts) {
  const intent = _isHardQuery(opts.routing)
    ? { provider: 'claude', model: 'claude-sonnet-4-5' }
    : { provider: 'gemini', model: 'gemini-3-flash' };

  // Build try-chain: primary → other commercial provider → HF LoRA
  const chain = buildChain(intent);

  for (const tier of chain) {
    try { return await tier.fn(); }
    catch (err) { /* log + try next */ }
  }
  throw lastErr;
}

Hard-query signals (any → Sonnet 4.5)

Signal Why it triggers Sonnet
hasImage Vision tasks need stronger spatial reasoning
multiTrade Multi-step planning (bathroom reno → plumber + tiler + electrician + painter)
lowConfidence (<0.7) Pass-1 was ambiguous; need a stronger model to disambiguate
longHistory (>5 turns) Context understanding matters more than cost

Fallback chain rules

intent = gemini  →  [gemini → claude → hf-darija]
intent = claude  →  [claude → gemini → hf-darija]

HF tier is skipped for stream: true, jsonMode: true, or tools requests — the small Darija LoRA can't stream, isn't reliable at JSON, and has no tool-calling training.

Provider wrappers

All three wrappers return the same OpenAI-shape so call sites stay clean:

{ ok: true, status: 200, json: async () => ({
  choices: [{ message: { role: 'assistant', content: '...', tool_calls?: [...] } }]
})}

Streaming wrappers expose a body PassThrough stream emitting OpenAI-format SSE (data: {"choices":[{"delta":{"content":"..."}}]}) regardless of the underlying provider's native format.

Wrapper Native API OpenAI-shape adapter
callClaude POST /v1/messages Translates image_urlimage block (base64), extracts system from messages, parses content_block_delta SSE events, surfaces tool_use blocks as OpenAI tool_calls array, stashes raw Anthropic blocks for echo-back
callGemini POST /v1beta/models/{model}:generateContent Translates roles (assistantmodel), image_urlinlineData, parses Gemini SSE candidates, surfaces functionCall parts as OpenAI tool_calls
callHF Gradio /gradio_api/call/generate (POST + GET poll) Two-step: POST returns event_id, GET streams SSE with event: complete. Last-resort fallback; ~15s timeout (HF Spaces sleep when idle).

Why these models (May 2026)

Research pull, full table at Artificial Analysis — Arabic leaderboard:

Model $/MTok in $/MTok out Arabic Global-MMLU-Lite p50 TTFT Vision Notes
gemini-3-flash $0.50 $3.00 92 (tied w/ Opus 4.6) (no public TTFT) yes Default — best Darija per Atlasia arena, cheap, native responseSchema + function calling
claude-sonnet-4-5 $3.00 $15.00 ~88 ~1.1 s yes Quality floor for hard queries + agent path tool calling
claude-haiku-4-5 $1.00 $5.00 ~83 0.85 s yes Considered for default; lost to Gemini Flash on cost + Darija
gemini-2.5-flash-lite $0.10 $0.40 ~78 1.83 s yes Too weak for Darija nuance
grok-4-fast $0.20 $0.50 no public data 0.60 s yes Cheapest, but zero published Arabic eval — reckless to ship on
HF Darija LoRA (Qwen2.5-1.5B) self-hosted self-hosted n/a ~20-60s cold start no Last-resort fallback; fine-tuned on 53k Darija samples; published at huggingface.co/samielakkad1/jakma-darija-A-adapter

Atlasia Darija Chatbot Arena (HF) historically ranks Gemini family in the top 3 for Maghrebi Arabic, while Anthropic's Opus 4.5 system card explicitly states Darija coverage is "improving but limited" → translates to more awkward refusals in practice. So Gemini wins default, Sonnet wins quality-floor hard cases.


Cost math

Per-query workload assumptions:

  • 15% of queries hit Pass-1 LLM (regex catches 85% after Phase 9 + Phase 10 romanized-Darija expansion)
  • 100% of queries hit Pass-2 streaming chat (initial path) OR the agent path (follow-up path)
  • 5% include an image (Pass-2 vision fallback)
  • ~10% of queries are follow-ups that hit the agent path
  • Pass-2 system prompt: ~1300 tokens, cached via Anthropic prompt caching or Gemini context caching
  • Output: ~150 tokens streaming
Provider Pass 1 cost Pass 2 cost (cached) Weighted/query
gemini-3-flash $0.00033 $0.00075 $0.00090
claude-sonnet-4-5 (agent) $0.00154 + ~$0.0008 tools $0.00284 ~$0.005/agent-query
HF Darija LoRA (fallback only) n/a n/a $0 commercial, self-host marginal

Blended cost with the live router, assuming ~80% Gemini path + ~10% Sonnet hard path + ~10% agent path:

0.80 × $0.00090   (gemini path)
+ 0.10 × $0.00345 (sonnet hard path)
+ 0.10 × $0.0050  (agent path: Sonnet + tools)
= ~$0.0016 / query

Defendable on a CV as "~$0.001/query blended" without rounding tricks (or "~$0.0016 with agent path included" if asked precisely).


Latency budgets

Streaming TTFT is what users feel. The full chain is:

Initial query (grounded path)

Step Budget Actual (Gemini path)
Cold start (Vercel lambda) <300 ms ~150 ms warm, ~600 ms cold
Regex classify <20 ms <2 ms typical, 25 ms for multi-trade
Pass-1 LLM (15% of queries) 2000 ms ~900 ms
MongoDB retrieve <50 ms ~30 ms
Pass-2 TTFT <1000 ms ~600 ms
Pass-2 full stream <3000 ms ~1500 ms

Target: p50 1.2 s, p99 2.8 s — achievable when warm.

Follow-up query (agent path)

Step Budget Actual
Intent detection regex <5 ms <1 ms
Allow-list extraction <5 ms <1 ms
Agent iter 1 (Sonnet + tools) 2000 ms ~1400 ms
Tool execution (parallel, up to 3) 1500 ms each ~220 ms each
Agent iter 2 (Sonnet, no tools) 2000 ms ~1200 ms
Total agent path <5000 ms ~3000 ms typical

Slightly slower than the initial path because tools introduce 2 LLM round-trips, but acceptable for follow-up UX where the user already has worker cards on screen.


API surface

Endpoint Method Purpose Rate limit LLM cost
/api/ai/chat POST Main chat — grounded retrieval (initial) or agent loop (follow-up) 30/hr varies
/api/ai/classify GET, POST Debug introspection — shows matched keyword + pipeline path 30/hr $0 (regex only)
/api/ai/vision POST Image-only trade classification fallback 30/hr low
/api/ai/smartmsg POST WhatsApp template generator 30/hr low
/api/ai/darija POST Direct HF Space inference (no fallback chain) 30/hr $0 commercial
/api/health GET DB + LLM + provider configuration status $0
/api/workers/:id/review POST Submit review for a worker 10/hr $0
/api/workers POST Register new worker (auto-categorized via Gemini) 5/hr low

Defensibility checklist

CV / portfolio claim Defensible today? Evidence
Multi-provider routing (Gemini + Claude + HF) server.js#callLLM, runtime visible at /api/health llm_routing field
Tool-calling agent for follow-ups lib/tools.js, lib/agent-loop.js, demo at www.jak.ma on follow-up queries, eval_logs has path: 'agent', tools_called[] per record
p50 ~1.2 s ✅ when warm Run any query → measure SSE TTFT; logged in eval_logs MongoDB collection
p99 ~2.8 s Same source; PASS_*_BUDGET_MS enforces tail cuts
~$0.001/query blended Math above; reproducible from token counts in Anthropic + Gemini usage dashboards
Refusal rate <1% ✅ for in-domain Darija queries Gemini Flash + Sonnet both very permissive on home-services queries; counter exists in eval_logs
Romanized Darija (Arabizi) support 260+ trade keywords + 90+ city keywords covering 3/7/9/8/5/6 numeric conventions; 57 regression tests in tests/text-classifier.test.js and tests/agent-loop.test.js
Public debug endpoint /api/ai/classify — paste any query, see matched keyword + pipeline path in <5ms
6-dimension verification gating partial lib/price-fairness.js covers 1 dimension. Hard-grounding + ID validation + leak detection + phone-privacy gate cover ~4 more. Framing: "4-dim verification, extensible to 6"
ADCMOP Pareto multi-objective optimization soft Code has confidence-gated provider routing, which is a real multi-objective optimizer (latency / cost / quality). Don't name-drop ADCMOP unless you implemented the actual algorithm — soften to "hybrid routing across cost / latency / quality axes via confidence-gated model selection"
Multimodal <250 ms diagnosis Browser-side TF.js MobileNet (public/js/local-classifier.js); Gemini vision is the >250 ms fallback path
Open-source Darija LoRA Qwen2.5-1.5B fine-tuned on 53k Darija samples, published at huggingface.co/samielakkad1/jakma-darija-A-adapter, serves as last-resort fallback in production

Configuration

Required environment variables

# Anthropic — for Sonnet 4.5 hard-query path + the agent loop
ANTHROPIC_API_KEY=sk-ant-api03-...

# Google AI Studio — for Gemini 3 Flash default path + Pass-1 classifier
GEMINI_API_KEY=AIza...

# HuggingFace — token for higher rate limits on the Darija LoRA Space (optional)
HF_TOKEN=hf_...

# HF Space URL (default: samielakkad1/jakma-darija-chat)
HF_SPACE_URL=https://samielakkad1-jakma-darija-chat.hf.space

# Timeout for the HF fallback tier (default 15s — the free Space sleeps when idle)
HF_FALLBACK_TIMEOUT_MS=15000

# MongoDB — worker catalog + eval logs
MONGODB_URI=mongodb+srv://...

# Feature flag — route /api/ai/chat through grounded retrieval (vs legacy)
GROUNDED_RETRIEVAL=1

Get the keys

Provider Where
Anthropic https://console.anthropic.com/settings/keys
Google AI Studio https://aistudio.google.com/apikey
HuggingFace https://huggingface.co/settings/tokens

Local dev

cp .env.example .env
# fill in keys
npm install
node server.js          # http://localhost:3000

Health check

curl https://www.jak.ma/api/health

Returns:

{
  "ok": true,
  "version": "1.0.0",
  "grounded_retrieval": true,
  "claude_configured": true,
  "gemini_configured": true,
  "hf_darija_fallback": "https://samielakkad1-jakma-darija-chat.hf.space",
  "llm_routing": "multi-provider (gemini-3-flash default → claude-sonnet-4-5 hard → HF Darija LoRA fallback)",
  "db": "ok",
  "worker_count": 1996
}

Classification debug

curl "https://www.jak.ma/api/ai/classify?q=bghit+plombier+f+tanja"

Tests

npm test                          # all 57 tests
node --test tests/agent-loop.test.js
node --test tests/text-classifier.test.js

Observability

  • eval_logs MongoDB collection — every /api/ai/chat query writes a record. Fields:
    • request_id (16-hex), path ('grounded' | 'agent'), query, context, hasImage, historyTurns
    • timings: {pass1, retrieval, generation, verification, agent_total, total}
    • classification: {trade, city, urgency, budget, confidence, source}
    • candidatesCount, citedIds[], verifier
    • Agent-only: agent_iterations, tools_called[] (each with name + input_summary + latency_ms + ok), allowedWorkerIds[], agent_error
    • TTL: 90 days
  • semantic_cache MongoDB collection — Pass-2 output reuse for identical recent queries (1h TTL)
  • price_fairness_cache MongoDB collection — worker price-fairness verdicts (24h TTL)
  • Vercel function logsconsole.warn('[router] ...') lines tag every fallback so degradation is visible in production. console.warn('[agent] ...') lines log fall-back to grounded path.
  • /api/ai/classify — debug introspection for any query (see above)
  • /api/ai/public-stats + /api/ai/eval-stats (admin) — p50 / p95 / refusal-rate / verifier-rate aggregations

Test coverage

Suite File Tests Covers
Classifier regression tests/text-classifier.test.js 36 12 trades × 4-8 input forms each, 15 cities + neighborhoods, 6 multi-trade families, Arabizi conventions, mixed-script queries, disambiguation (meuble vs meuble bois), off-topic rejection
Agent stack tests/agent-loop.test.js 21 Tool schemas (Anthropic + Gemini), input validation, allow-list enforcement, intent detection (price/reviews/details/anaphoric), agent loop semantics, error wrapping, defense against hallucinated tool names
Total 57 All pass on node --test

Future work

  • Wire the BM25 + dense reranker in lib/hybrid-retrieval.js once we have a Darija embedding endpoint (Voyage AI is the natural pick; Anthropic doesn't ship embeddings)
  • Build the remaining verification dimensions (dialect quality, geographic accuracy, tone, safety) — lib/verifiers/*.js
  • Add tool-call streaming so agent path final answer streams token-by-token instead of arriving as a single chunk
  • Add /api/ai/eval/metrics endpoint that computes p50/p99/cost/refusal-rate/tool-success-rate from eval_logs on demand
  • Provisioned concurrency on Vercel to kill the cold-start tail
  • Extend the agent with 2 more tools: findSimilarWorker(workerId) and compareWorkers(idA, idB)

Changelog

Date Change
2026-05-23 Phase 13 — Added /api/ai/classify public debug endpoint. Shows matched keyword + pattern group + pipeline path for any query in <5ms.
2026-05-23 Phase 12 — Expanded Pass-1 LLM classifier prompt with full Arabizi convention table + 25+ examples covering trade-name explicit, problem-noun only, and neighborhood-only inputs.
2026-05-23 Phase 11tests/text-classifier.test.js: 36 regression tests locking the regex behavior across all 12 trades, 15 cities + neighborhoods, multi-trade patterns, Arabizi conventions.
2026-05-23 Phase 10 — Deep romanized-Darija expansion: +120 keywords across trades, cities, neighborhoods, multi-trade phrases. Regex hit rate ~30% → ~85%+ on Darija queries.
2026-05-23 Phase 9 — Initial Arabizi (Latin + numeric) support. Added 7=ح, 9=ق, 6=ط, 5=خ, 3=ع variants.
2026-05-23 Phase 8 — Worker-page context priming: workers_cited[] sidecar on assistant messages + workerContext request field so agent path fires on follow-ups even when the frontend has stripped <<WORKERS:>> markers from history.
2026-05-23 Phase 1-7 — Single-round tool-calling agent: lib/tools.js (3 tools, allow-list scoped), lib/agent-loop.js (provider-agnostic loop), wired into handleGroundedChat with follow-up intent detection. Pass-1 LLM forced to Gemini Flash, Pass-2 router-aware. 21 agent-loop tests. Live on production at www.jak.ma.
2026-05-23 Multi-provider routing live. gemini-3-flash default, claude-sonnet-4-5 hard, HF Darija LoRA last-resort fallback.
2026-05-23 Swap from xAI Grok primary to multi-provider. Old XAI_API_KEY deprecated.
2026-05-17 /api/ai/darija endpoint + /darija page embed HF Space iframe
2026-04-09 Domain registered (jak.ma via Hostino)