Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,7 @@ override.tf.json
# Photos de référence du bench (issue #10) : jamais commitées — poids, contexte ressourcerie.
# Source de vérité : le bucket GCS pick-a-book-505922-reference-photos.
fixtures/reference-photos/

# Sortie du bench (#10) : détections brutes + tableau, dérivées des photos privées et d'appels
# live. Le tableau final validé vit dans la note de décision commitée, pas ici.
tools/bench/output/
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ yarn format # oxfmt (yarn format:check pour vér

yarn api # démarre l'API (http://localhost:3000/health)
yarn web # démarre le front (http://localhost:4200)
yarn bench # départage les adapters VLM (appels live, hors CI) — voir tools/bench/README.md
docker compose up --build # API + front + Postgres + émulateur de bucket

yarn nx run-many -t lint -p api # cibler un projet
Expand All @@ -130,13 +131,19 @@ apps/api/ # NestJS : composition root, orchestration inte
apps/web/ # React : feature-slice
libs/recognition/domain/ # entités, value objects, ports — zéro dépendance technique
libs/recognition/application/ # use cases, parlent aux ports
libs/recognition/infrastructure/ # adapters
libs/recognition/infrastructure/ # adapters (Gemini, Qwen, stub) derrière ShelfScannerPort
libs/shared/result/ # contenu partagé, une lib par sujet nommé
libs/shared/text-match/ # normalisation + comparaison floue de chaînes (bench, réconciliation)
tools/bench/ # départage manuel des adapters VLM sur photos réelles (#10) — hors CI
docker/ # Dockerfile des deux apps — contexte de build : la racine
docs/adr/
docs/decisions/ # notes de décision de niveau inférieur (pas des ADR)
infra/ # infrastructure GCP en Terraform — voir infra/README.md
```

Le glob des workspaces Yarn couvre `apps/*`, `libs/*/*` et `tools/*` — un projet Nx hors de ces
trois emplacements n'est pas lié et perd ses tags (donc les frontières de modules).

`recognition` est le seul bounded context fondé aujourd'hui (ADR 0005). Les autres attendent leur
ADR de découpage — ne pas en créer au jugé.

Expand Down
82 changes: 82 additions & 0 deletions docs/decisions/0001-fournisseur-vlm-par-defaut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Décision — fournisseur VLM par défaut du scan d'étagère

> **Niveau inférieur, pas un ADR.** L'ADR [0005](../adr/0005-reconnaissance-livres-photo-etagere.md)
> tranche le *quoi* (VLM seul, option B, derrière `ShelfScannerPort`) et délègue explicitement le
> choix du fournisseur précis à une décision de niveau inférieur, instrumentée par un bench sur
> photos réelles (phase 3). Cette note est cette décision. Elle se révise sans nouvel ADR.

## Statut

**En attente de la vérité terrain.** Le harnais de bench, les deux adapters et l'endpoint sont
livrés (#10, étapes 1-5). Le run live sur les 10 photos de référence tourne et produit déjà le
contrat, le coût et la latence. **Le gagnant qualité — donc le défaut de prod — ne peut pas être
posé tant que la vérité terrain n'est pas saisie et vérifiée à la main** : sans elle, ni rappel,
ni précision, ni hallucination ne sont mesurables (voir le commentaire du 31/08 sur #10 et
`tools/bench/README.md`).

Défaut actuel de `SHELF_SCANNER_PROVIDER` : `stub`. Il le reste jusqu'à ce que le bench qualité
départage `gemini` et `qwen`.

## Candidats

Les deux configurations déployables (commentaire du 15/08 sur #10) :

1. **Gemini 2.5/3.6 Flash** via `GEMINI_API_KEY` — décodage JSON contraint par schéma natif.
2. **Qwen3-VL** via **OpenRouter** (`OPENROUTER_API_KEY`) — client OpenAI-compatible.

Claude reste « non construit en V1 ». Un troisième candidat ne se justifierait que si le tableau
sort serré.

## Méthode

`tools/bench` envoie chaque photo de référence aux deux fournisseurs (appels live, hors CI),
confronte la lecture à la vérité terrain avec une comparaison tolérante aux fautes
(`shared-text-match`, seuil 0.85), et micro-moyenne les compteurs. Un couple `(auteur, titre)` est
correct si les **deux** champs correspondent ; une tranche illisible non lue est un faux négatif,
une photo sans livre lisible se lit en tableau vide.

## Mesures

### Contrat, coût, latence (run du 2026-09-02, sans vérité terrain)

Les deux fournisseurs ont tourné de bout en bout (OpenRouter atteint après ajout de son domaine à
l'egress de l'environnement ; en environnement proxifié, le runner a besoin de `NODE_USE_ENV_PROXY=1`
pour que le `fetch` de Node passe par le proxy — cf. `tools/bench/README.md`).

| Métrique | gemini | qwen |
|---|---|---|
| Modèle | `gemini-3.6-flash` | `qwen/qwen3-vl-235b-a22b-instruct` |
| Photos scannées | 10 | 10 |
| Échecs adapter | 1 | 1 |
| Latence médiane | 32,7 s | 4,8 s |
| Tokens (prompt / complétion) | 11808 / 8389 | 50138 / 7356 |
| Coût total | $0,0245 | $0,0232 |
| Coût / scan | $0,0025 | $0,0023 |

Coûts quasi identiques (~0,25 ¢ vs ~0,23 ¢/scan), Qwen ~7× plus rapide. Mais le **nombre** de
détections trahit déjà des régimes très différents — un signal opérationnel, **pas** un verdict
qualité (celui-là attend la vérité terrain) :

- **Gemini** : détections régulières (5 à 53 livres selon la densité), grâce au décodage contraint
par schéma natif. L'unique échec est un **rejet du domaine** — le modèle a renvoyé un auteur vide,
`Author` l'a refusé, et le payload entier est rejeté en bloc (`ShelfScanFailed`) : le comportement
« rien de partiel ne remonte » voulu par l'ADR 0005, vérifié en vrai.
- **Qwen** : très instable sous le même prompt en `json_object` (sans schéma natif) — **5 photos sur
10 renvoient 0 livre**, une en renvoie **158** (bien au-delà du réel), et l'unique échec est un
**JSON tronqué** (réponse coupée à ~48 ko). Un modèle qui rend 0 sur une étagère pleine et 158 sur
une autre est un drapeau rouge à confirmer sur la vérité terrain — c'est exactement le genre
d'écart que le rappel et l'hallucination mesureront.

Ces chiffres valident la chaîne de bout en bout pour les **deux** adapters et donnent coût et
latence. Ils ne disent **rien de définitif** sur la qualité : un fournisseur peut détecter beaucoup
et inventer autant, ou détecter peu et rater le reste. C'est la vérité terrain qui tranche.

### Qualité (rappel, précision, hallucination)

**En attente de la vérité terrain.** Une fois `tools/bench/ground-truth.yaml` saisi et vérifié,
relancer le bench et coller ici le tableau complet, puis désigner le gagnant.

## Décision

_À écrire une fois la qualité mesurée : fournisseur gagnant + `SHELF_SCANNER_PROVIDER` posé sur
lui par défaut, dans `apps/api/src/config/environment.ts` / `.env.example`._
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,17 @@ import recorded from './recorded/qwen-shelf-scan.json' with { type: 'json' };
const chatRequestSchema = z.object({
model: z.string(),
temperature: z.number(),
response_format: z.object({ type: z.string() }),
max_tokens: z.number(),
response_format: z.object({
type: z.string(),
json_schema: z
.object({
name: z.string(),
strict: z.boolean(),
schema: z.object({ required: z.array(z.string()) }),
})
.optional(),
}),
messages: z.array(
z.object({
role: z.string(),
Expand Down Expand Up @@ -102,18 +112,41 @@ describe('QwenShelfScannerAdapter builds its request', () => {
expect(request.messages[0]?.content[1]?.image_url?.url).toBe('data:image/jpeg;base64,/9j/4A==');
});

it('authenticates with a bearer token and asks for a JSON answer', async () => {
it('authenticates with a bearer token and constrains the answer to the schema', async () => {
const transport = respondWith(recorded);
await adapterWith(transport).scan(photo);
const { url, body, headers } = requestOf(transport);

expect(headers.get('authorization')).toBe('Bearer test-key');
expect(url).not.toContain('test-key');

// json_schema, not json_object: free-form JSON let the model truncate and emit empty
// fields on dense shelves (issue #10). The schema is the same contract Gemini decodes
// against, so the two providers are held to one shape.
const request = chatRequestSchema.parse(JSON.parse(body));
expect(request.response_format.type).toBe('json_object');
expect(request.response_format.type).toBe('json_schema');
expect(request.response_format.json_schema?.strict).toBe(true);
expect(request.response_format.json_schema?.schema.required).toContain('books');
expect(request.temperature).toBe(0);
});
});

describe('QwenShelfScannerAdapter caps and pins its request', () => {
it('gives the answer room for a full shelf so a long list is not truncated', async () => {
const transport = respondWith(recorded);
await adapterWith(transport).scan(photo);

expect(chatRequestSchema.parse(JSON.parse(requestOf(transport).body)).max_tokens).toBe(8192);
});

it('defaults to the OCR-focused qwen2.5-vl-72b, not the unstable 235B (issue #10)', async () => {
const transport = respondWith(recorded);
await adapterWith(transport).scan(photo);

expect(chatRequestSchema.parse(JSON.parse(requestOf(transport).body)).model).toBe(
'qwen/qwen2.5-vl-72b-instruct',
);
});

it('reports an empty shelf as an empty array, not a failure', async () => {
const empty = { choices: [{ message: { content: '{"books":[]}' } }] };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@ import { ShelfScanFailed } from '@pick-a-book/recognition-domain';
import type { DetectedBook, ShelfPhoto, ShelfScannerPort } from '@pick-a-book/recognition-domain';
import { z } from 'zod';

import { SHELF_SCAN_PROMPT } from './shelf-scan-prompt.js';
import { SHELF_SCAN_JSON_SCHEMA, SHELF_SCAN_PROMPT } from './shelf-scan-prompt.js';
import { toDetectedBooks } from './shelf-scan-response.js';

const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
const DEFAULT_MODEL = 'qwen/qwen3-vl-235b-a22b-instruct';
// qwen2.5-vl-72b, not qwen3-vl-235b: the 235B was unusable on dense shelves in the bench —
// it returned 0 books on half the photos and truncated its JSON on others, where the 72B stays
// stable (issue #10). The 72B is the OCR-focused line; the 235B a general model with vision.
const DEFAULT_MODEL = 'qwen/qwen2.5-vl-72b-instruct';
// Enough head-room for a full dense shelf (~100 spines) so a legitimate long list is not cut
// off mid-JSON. It does not tame a runaway repetition — that still hits the cap and fails, which
// is the right outcome — it only stops truncating honest answers (issue #10).
const DEFAULT_MAX_TOKENS = 8192;

export interface QwenConfiguration {
readonly apiKey: string;
Expand All @@ -18,6 +25,8 @@ export interface QwenConfiguration {
* is a different weight class from a quantised local one (issue #10).
*/
readonly baseUrl?: string;
/** Completion-token ceiling. Overridable, but the default already fits a full shelf. */
readonly maxTokens?: number;
}

/** The slice of the chat-completions envelope this adapter depends on, and nothing more. */
Expand All @@ -38,13 +47,15 @@ const chatEnvelopeSchema = z.object({
export class QwenShelfScannerAdapter implements ShelfScannerPort {
private readonly model: string;
private readonly baseUrl: string;
private readonly maxTokens: number;

constructor(
private readonly configuration: QwenConfiguration,
private readonly transport: typeof globalThis.fetch = globalThis.fetch,
) {
this.model = configuration.model ?? DEFAULT_MODEL;
this.baseUrl = configuration.baseUrl ?? DEFAULT_BASE_URL;
this.maxTokens = configuration.maxTokens ?? DEFAULT_MAX_TOKENS;
}

async scan(photo: ShelfPhoto): Promise<DetectedBook[]> {
Expand All @@ -61,28 +72,6 @@ export class QwenShelfScannerAdapter implements ShelfScannerPort {

private async post(photo: ShelfPhoto): Promise<Response> {
const url = `${this.baseUrl}/chat/completions`;
const body = {
model: this.model,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: SHELF_SCAN_PROMPT },
{
type: 'image_url',
image_url: {
url: `data:${photo.mediaType};base64,${Buffer.from(photo.bytes).toString('base64')}`,
},
},
],
},
],
// `json_object` is the OpenAI-compatible way to ask for JSON. Weaker than Gemini's
// schema-constrained decoding, which is exactly why the answer is validated downstream
// rather than trusted.
response_format: { type: 'json_object' },
temperature: 0,
};

let response: Response;
try {
Expand All @@ -92,7 +81,7 @@ export class QwenShelfScannerAdapter implements ShelfScannerPort {
'content-type': 'application/json',
authorization: `Bearer ${this.configuration.apiKey}`,
},
body: JSON.stringify(body),
body: JSON.stringify(this.requestBody(photo)),
});
} catch (cause) {
throw new ShelfScanFailed(`Qwen is unreachable (${describe(cause)})`, { cause });
Expand All @@ -104,6 +93,34 @@ export class QwenShelfScannerAdapter implements ShelfScannerPort {

return response;
}

private requestBody(photo: ShelfPhoto): object {
const dataUrl = `data:${photo.mediaType};base64,${Buffer.from(photo.bytes).toString('base64')}`;

return {
model: this.model,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: SHELF_SCAN_PROMPT },
{ type: 'image_url', image_url: { url: dataUrl } },
],
},
],
// Schema-constrained decoding, the OpenAI-compatible way: the same contract Gemini
// decodes against (`SHELF_SCAN_JSON_SCHEMA`), held over the model's grammar. Plain
// `json_object` let dense shelves truncate the JSON and emit empty author/title fields
// (issue #10) — the schema removes that failure mode. The answer is still validated
// downstream: `strict` narrows the shape, not the meaning.
response_format: {
type: 'json_schema',
json_schema: { name: 'shelf_scan', strict: true, schema: SHELF_SCAN_JSON_SCHEMA },
},
max_tokens: this.maxTokens,
temperature: 0,
};
}
}

async function readJson(response: Response): Promise<unknown> {
Expand Down
2 changes: 1 addition & 1 deletion libs/recognition/infrastructure/src/lib/recorded/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ nécessaire non plus — `ShelfPhoto` se construit avec quelques octets valides.

| Fichier | Origine | Fiabilité |
|---|---|---|
| `gemini-shelf-scan.json` | **Appel réel**, capturé une fois le 2026-08-31 sur `gemini-3.6-flash`, photo de référence `20260801_114252.jpg` | Authentique : l'enveloppe est exactement celle que renvoie l'API |
| `gemini-shelf-scan.json` | **Appel réel**, capturé une fois le 2026-08-31 sur `gemini-3.6-flash`, photo de référence `shelf-fixture-9.jpg` | Authentique : l'enveloppe est exactement celle que renvoie l'API |
| `qwen-shelf-scan.json` | **Écrite à la main**, d'après la forme documentée de l'API chat-completions OpenAI | ⚠️ Non authentique — voir ci-dessous |

## Pourquoi la fixture Qwen n'est pas un enregistrement
Expand Down
19 changes: 13 additions & 6 deletions libs/recognition/infrastructure/src/lib/shelf-scan-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,27 @@
* padding the list to look thorough.
*/
export const SHELF_SCAN_PROMPT = [
'You are reading the spines of books on a shelf, photographed with a phone.',
'You are cataloguing every book on a shelf, photographed with a phone. A shelf like this holds',
'50 to 100 books; a short list means you stopped too early.',
'',
'Return one entry per book you can actually read. For each one:',
'Scan the whole image systematically, band by band, left to right and top to bottom, and return',
'one entry per book. For each one:',
'- "author": the author name printed on the spine.',
'- "title": the title printed on the spine.',
'- "confidence": your own certainty for that entry, between 0 and 1.',
'',
'Rules:',
'- Never invent. If a spine is partly readable, do not complete it towards a work you',
' expect — report only what is printed, with a low confidence.',
'- Be exhaustive: do not stop until you have swept the entire shelf. Leaving a readable book out',
' is the main mistake to avoid.',
'- Never invent. Report only what is printed; for a partly legible spine, give your best reading',
' with a low confidence rather than a work you merely expect.',
'- Every entry needs BOTH an author and a title. If you can read only one of the two, skip the',
' spine — never emit an empty "author" or "title".',
'- Report each book once. Do not repeat an entry.',
'- Do not confuse the author or the title with the publisher or the collection',
' (Gallimard, Folio, Points, Le Livre de Poche and the like are never the author).',
'- Skip any spine you cannot read. An empty list is a valid answer.',
'- Spines may be rotated or upside down; read them anyway.',
'- Spines may be rotated or upside down; read them anyway. A spine you truly cannot read is',
' skipped, and an empty list is a valid answer.',
'',
'Answer with JSON only, of the form {"books": [{"author": "", "title": "", "confidence": 0}]}.',
].join('\n');
Expand Down
24 changes: 24 additions & 0 deletions libs/shared/text-match/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# shared-text-match

Comparaison de chaînes courtes — noms d'auteur, titres — **tolérante aux différences qui ne
changent pas le sens** : casse, accents, ponctuation, espaces, et la faute d'OCR isolée.

Lib partagée (`type:shared`, `context:none`) : importable par tous, n'important aucun contexte
(ADR 0002). Elle existe pour ne pas être recopiée.

- Le **bench de reconnaissance** (#10) s'en sert pour confronter une détection `(auteur, titre)`
à la vérité terrain — un titre correct lu avec une lettre en trop reste correct.
- La **réconciliation bibliographique** (contexte futur) confrontera de la même façon un titre lu
au référentiel. Même geste, même normalisation.

## API

| Fonction | Rôle |
|---|---|
| `normalizeText(raw)` | Forme canonique : minuscules, sans diacritiques, alphanumériques séparés par une espace simple. |
| `similarity(a, b)` | Ressemblance dans `[0, 1]` (1 = identique après normalisation). Distance de Levenshtein rapportée à la longueur. |
| `fuzzyEquals(a, b, threshold?)` | `similarity(a, b) >= threshold` — seuil par défaut `0.85`. |

Le seuil par défaut laisse passer une ou deux lettres de glissement sur un titre de longueur
normale tout en séparant deux œuvres réellement différentes. Un appelant qui a besoin d'un autre
compromis passe le sien.
25 changes: 25 additions & 0 deletions libs/shared/text-match/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import baseConfig from '../../../eslint.config.mjs';

export default [
...baseConfig,
{
files: ['**/*.json'],
rules: {
'@nx/dependency-checks': [
'error',
{
ignoredFiles: [
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
'{projectRoot}/vitest.config.{js,cjs,mjs,ts,cts,mts}',
],
},
],
},
languageOptions: {
parser: await import('jsonc-eslint-parser'),
},
},
{
ignores: ['**/out-tsc'],
},
];
Loading
Loading