File size: 15,735 Bytes
4124165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
# Client CRM Integration Guide

How a SaaS CRM consumes the Delivery Note OCR API and turns a raw extraction into
resolved, database-linked, human-verifiable records.

This guide is the contract between **our stateless OCR backend** and **the
client-side CRM**. It exists because the two systems have deliberately different
responsibilities: the server never touches the client database, and the client
never re-implements OCR or accounting logic.

---

## 1. Separation of Concerns

The system is split along a clean architecture boundary. Everything that is
*document-intrinsic* (what the paper says, whether the numbers are internally
consistent) lives on the server. Everything that is *tenant-specific* (which of
your suppliers/products this refers to) lives on the client.

```mermaid
flowchart LR
    subgraph Server["Server Side β€” our API (stateless)"]
        OCR["OCR extraction"]
        SCHEMA["JSON Schema enforcement"]
        REPAIR["Deterministic accounting repair"]
        WARN["Consistency warnings"]
    end
    subgraph Client["Client Side β€” SaaS CRM (stateful, per-tenant)"]
        NORM["Text normalization"]
        CAND["Candidate generation (pg_trgm)"]
        RANK["Attribute reranking"]
        CONF["Confidence fusion"]
        UI["Human-in-the-loop review UI"]
        DB[("PostgreSQL<br/>(tenant-scoped)")]
    end

    Image[/"BL image"/] --> OCR --> SCHEMA --> REPAIR --> WARN
    WARN -->|"JSON response"| NORM
    NORM --> CAND --> RANK --> CONF --> UI
    CAND <--> DB
    UI -->|"confirmed links"| DB
```

### Server side (our API) β€” what you receive, not what you resolve

| Responsibility | Detail |
| --- | --- |
| **OCR extraction** | Single-pass structured extraction from the BL image. |
| **Schema enforcement** | Output validated against `/v1/schema`; `schema_valid` + `schema_errors` reported. |
| **Deterministic repair** | Price-column confusions corrected by accounting identities (e.g. `pharmacist_price_ttc = line_total_ttc / quantity`). Provenance in `repairs[]`. |
| **Consistency warnings** | Row-count, line arithmetic, and total reconciliation surfaced in `validation_warnings[]`. |

The server is **stateless** and **tenant-agnostic**. It has no notion of *which*
supplier or product a value refers to. It never connects to your database.

Consumed via `POST /v1/ocr` (multipart) or `POST /v1/ocr/base64` (JSON). See
`docs/api-spec.md` for the full response shape.

### Client side (SaaS CRM) β€” what you own

| Responsibility | Detail |
| --- | --- |
| **PostgreSQL** | Tenant catalogs: products, suppliers, pharmacies, plus public directories. |
| **Tenant scoping** | Every query is filtered by `tenant_id`; candidates never cross tenants. |
| **Candidate generation** | `pg_trgm` GIN-indexed fuzzy lookup + exact-identifier lookup. |
| **Attribute reranking** | Deterministic re-scoring of candidates by dosage, form, pack size. |
| **Confidence fusion** | Combine OCR confidence, DB match score, and top-2 margin. |
| **Review UI** | Highlight low-confidence fields; capture the human's final choice. |

> **Golden rule:** entity identity is a client decision. The server gives you
> *values and how sure it is of reading them*; you decide *what they map to* in
> your world.

---

## 2. Entity Resolution Cascade (Client Side)

Run a tiered cascade over the OCR data. Each entity to resolve β€” supplier,
client/pharmacy, and every product line β€” descends the tiers until it matches or
falls through to manual review.

```
Tier 1  Exact identifier   ──match──▢ auto-link (highest confidence)
   β”‚ no match
Tier 2  Fuzzy DB match     ──match──▢ rank candidates, auto-link if clear
   β”‚ no confident match           else β–Ά suggest candidates in UI
Tier 3  Unresolved         ─────────▢ manual human selection in the CRM UI
```

### 2.0 One-time database setup

```sql
-- Extensions (run once per database, superuser).
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS unaccent;

-- A normalized, generated column keeps the GIN index and the query in sync.
-- IMMUTABLE wrapper around unaccent() so it can be used in a generated column.
CREATE OR REPLACE FUNCTION normalize_label(txt text)
RETURNS text
LANGUAGE sql IMMUTABLE STRICT AS $$
  SELECT regexp_replace(
           lower(public.unaccent('public.unaccent', txt)),
           '[^a-z0-9]+', ' ', 'g')
$$;

ALTER TABLE products
  ADD COLUMN IF NOT EXISTS designation_norm text
  GENERATED ALWAYS AS (normalize_label(designation)) STORED;

-- GIN trigram index on the *normalized* column β€” the lookups below rely on it.
CREATE INDEX IF NOT EXISTS idx_products_designation_norm_trgm
  ON products USING gin (designation_norm gin_trgm_ops);

-- Exact-identifier lookups stay on a plain btree.
CREATE INDEX IF NOT EXISTS idx_products_code   ON products (tenant_id, product_code);
CREATE INDEX IF NOT EXISTS idx_suppliers_taxid ON suppliers (tenant_id, tax_id);
```

> **Why normalize before indexing?** The GIN index is built on
> `designation_norm`. If the query normalizes text a *different* way than the
> index was built, Postgres cannot use the index and silently falls back to a
> sequential scan. Normalize identically on both sides β€” always via
> `normalize_label()`.

### 2.1 Client-side text normalization (Python, before querying)

Normalize the OCR value with the **same** rules as `normalize_label()` before it
reaches SQL. This guarantees consistent GIN lookups and stable trigram scores.

```python
import re
import unicodedata


def normalize_label(text: str | None) -> str:
    """Mirror the SQL normalize_label(): unaccent, lowercase, collapse non-alnum."""
    if not text:
        return ""
    # Strip accents (Γ© -> e) the same way SQL unaccent does.
    decomposed = unicodedata.normalize("NFKD", text)
    ascii_text = decomposed.encode("ascii", "ignore").decode("ascii")
    ascii_text = ascii_text.lower()
    return re.sub(r"[^a-z0-9]+", " ", ascii_text).strip()


# Structured attribute extraction from a pharmaceutical designation.
_DOSAGE_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s?(mg|g|ml|mcg|ui|%)", re.IGNORECASE)
_PACK_RE = re.compile(r"(?:bte|b|boite|t|tube|fl|flacon)\s?/?\s?(\d+)", re.IGNORECASE)
_FORM_KEYWORDS = {
    "gel": "gel", "cp": "comprime", "comprime": "comprime", "gelule": "gelule",
    "sirop": "sirop", "sol": "solution", "susp": "suspension", "pom": "pommade",
    "cr": "creme", "creme": "creme", "inj": "injectable", "sach": "sachet",
}


def parse_attributes(designation: str | None) -> dict:
    """Pull dosage, galenic form, and pack size out of a free-text designation."""
    norm = normalize_label(designation)
    dosage = None
    if m := _DOSAGE_RE.search(designation or ""):
        dosage = f"{m.group(1).replace(',', '.')}{m.group(2).lower()}"
    pack_size = int(m.group(1)) if (m := _PACK_RE.search(designation or "")) else None
    form = next((v for k, v in _FORM_KEYWORDS.items() if k in norm.split()), None)
    return {"norm": norm, "dosage": dosage, "form": form, "pack_size": pack_size}
```

### 2.2 Tier 1 β€” Exact identifier match

Use identifiers that are globally stable: supplier `tax_id`, product `product_code`.

```sql
-- Product by exact code (tenant-scoped).
SELECT id, product_code, designation, dosage, galenic_form, pack_size
FROM products
WHERE tenant_id = %(tenant_id)s
  AND product_code = %(product_code)s
LIMIT 1;

-- Supplier by exact tax id (digits only, to survive OCR spacing/punctuation).
SELECT id, name, tax_id
FROM suppliers
WHERE tenant_id = %(tenant_id)s
  AND regexp_replace(tax_id, '\D', '', 'g') = regexp_replace(%(tax_id)s, '\D', '', 'g')
LIMIT 1;
```

A Tier-1 hit is an auto-link: identity is certain, and DB match score = `1.0`.

### 2.3 Tier 2 β€” Fuzzy database match (`pg_trgm`)

When there is no exact identifier (or the code is low-confidence), fall back to
trigram similarity on the normalized designation. Return the top-N candidates β€”
**do not** auto-pick here; hand them to reranking (Β§3).

```sql
-- :q_norm is normalize_label(ocr_designation) computed in Python (Β§2.1).
SELECT
    id,
    product_code,
    designation,
    dosage,
    galenic_form,
    pack_size,
    similarity(designation_norm, %(q_norm)s) AS name_sim
FROM products
WHERE tenant_id = %(tenant_id)s
  AND designation_norm %% %(q_norm)s          -- %% = trigram "similar" operator, uses the GIN index
ORDER BY name_sim DESC
LIMIT 10;
```

```sql
-- Set the similarity floor for the %% operator per session/transaction.
SET pg_trgm.similarity_threshold = 0.30;
```

Directory lookups (suppliers, pharmacies) follow the same pattern against their
own normalized, GIN-indexed name columns.

### 2.4 Tier 3 β€” Unresolved

If Tier 1 finds nothing and Tier 2 returns no candidate above the acceptance
threshold (Β§4), the entity is **unresolved**. Present the raw OCR value plus the
best fuzzy suggestions and require an explicit human selection. Persist that
choice as tenant training data so future matches improve.

---

## 3. Product Attribute Reranking (Client Side)

Trigram name similarity alone confuses same-name products of different strengths
or pack sizes (`DOLIPRANE 1000MG BTE/8` vs `DOLIPRANE 500MG BTE/16`). Rerank the
Tier-2 candidates by fusing name similarity with structured attributes.

### Scoring formula

For each candidate, all components are normalized to `[0, 1]`:

```
match_score = 0.45 Β· name_sim
            + 0.30 Β· dosage_match
            + 0.15 Β· pack_match
            + 0.10 Β· form_match
```

| Component | Weight | How to compute (each in [0, 1]) |
| --- | ---: | --- |
| `name_sim` | 0.45 | Trigram `similarity(designation_norm, q_norm)` from the SQL. |
| `dosage_match` | 0.30 | `1.0` if normalized dosages are equal (`1000mg == 1000mg`); `0.0` otherwise. Treat missing OCR dosage as `0.5` (unknown, not contradictory). |
| `pack_match` | 0.15 | `1.0` if pack sizes equal; else `max(0, 1 βˆ’ |aβˆ’b| / max(a, b))`; `0.5` if unknown. |
| `form_match` | 0.10 | `1.0` if galenic forms equal after normalization; `0.0` if both known and different; `0.5` if unknown. |

The weights sum to `1.0`, so `match_score` is directly comparable across
candidates and interpretable as a DB match score in Β§4.

### Reference implementation

```python
def rerank_candidates(ocr_attrs: dict, candidates: list[dict]) -> list[dict]:
    """Return candidates sorted by fused match_score (desc)."""
    for c in candidates:
        cand = parse_attributes(c["designation"])
        c["dosage_match"] = _cmp_exact(ocr_attrs["dosage"], cand["dosage"] or c.get("dosage"))
        c["pack_match"] = _cmp_pack(ocr_attrs["pack_size"], cand["pack_size"] or c.get("pack_size"))
        c["form_match"] = _cmp_exact(ocr_attrs["form"], cand["form"] or c.get("galenic_form"))
        c["match_score"] = (
            0.45 * c["name_sim"]
            + 0.30 * c["dosage_match"]
            + 0.15 * c["pack_match"]
            + 0.10 * c["form_match"]
        )
    return sorted(candidates, key=lambda c: c["match_score"], reverse=True)


def _cmp_exact(a, b) -> float:
    if a is None or b is None:
        return 0.5                      # unknown β€” neither confirms nor contradicts
    return 1.0 if str(a).lower() == str(b).lower() else 0.0


def _cmp_pack(a, b) -> float:
    if a is None or b is None:
        return 0.5
    if a == b:
        return 1.0
    return max(0.0, 1.0 - abs(a - b) / max(a, b))
```

---

## 4. Confidence Fusion & UI Highlighting (Client Side)

### 4.1 Final field confidence

The final confidence a reviewer sees blends three independent signals:

1. **OCR confidence** β€” how clearly the value was *read* (from the API,
   `field.confidence ∈ [0, 1]`).
2. **DB match score** β€” how well it *resolved* to a catalog entry (`match_score`
   from Β§3, or `1.0` for a Tier-1 exact hit).
3. **Margin factor** β€” how *decisive* the win was over the runner-up. A 0.91 vs
   0.90 near-tie is risky even if both scores are high.

```
margin        = match_score(top1) βˆ’ match_score(top2)      # 0 if only one candidate
margin_factor = 0.5 + 0.5 Β· min(margin / MARGIN_FULL, 1)    # MARGIN_FULL β‰ˆ 0.15
                                                            # β†’ 0.5 (tie) … 1.0 (clear win)

final_confidence = ocr_confidence Β· db_match_score Β· margin_factor
```

Multiplicative fusion is deliberately conservative: any weak signal (a blurry
read, a poor match, or a near-tie) drags the result down, which is the correct
bias for a human-in-the-loop workflow. For an unresolved (Tier 3) field, set
`db_match_score = 0` so `final_confidence = 0` and the field always demands
review.

```python
MARGIN_FULL = 0.15

def final_confidence(ocr_conf: float, ranked: list[dict]) -> float:
    if not ranked:
        return 0.0
    top1 = ranked[0]["match_score"]
    top2 = ranked[1]["match_score"] if len(ranked) > 1 else 0.0
    margin_factor = 0.5 + 0.5 * min((top1 - top2) / MARGIN_FULL, 1.0)
    return round(ocr_conf * top1 * margin_factor, 4)
```

### 4.2 UI highlighting for human verification

Map `final_confidence` to a review state. Anything **below 80%** is boxed for
verification; the color signals urgency.

| `final_confidence` | State | UI treatment |
| --- | --- | --- |
| `β‰₯ 0.80` | Trusted | No box (or subtle green). Auto-accepted. |
| `0.60 – 0.80` | Review | **Orange** highlight box; pre-filled with the top candidate, editable. |
| `< 0.60` | Uncertain | **Red** highlight box; requires explicit confirmation before save. |

```python
def review_state(conf: float) -> str:
    if conf >= 0.80:
        return "trusted"      # green / no box
    if conf >= 0.60:
        return "review"       # orange box
    return "uncertain"        # red box
```

**Where to draw the box.** Highlight the corresponding input in the CRM form
(product row, supplier field, total). If you later enable provider-side
geometry (bounding boxes / source spans β€” see the report's provenance section),
overlay the box directly on the rendered BL image at the field's coordinates so
the reviewer's eye goes straight to the source text. Until then, field-level
highlighting on the form is sufficient and keeps the client fully decoupled from
provider geometry support.

### 4.3 Worked example

```
OCR:  "DOLIPRAN 1000 BTE/8", ocr_confidence = 0.82  ->  attrs: dosage 1000mg, pack 8, form unknown

Candidate A: DOLIPRANE 1000MG BTE/8
    name_sim 0.86  dosage match 1.0  pack match 1.0  form unknown 0.5
    match = 0.45*0.86 + 0.30*1.0 + 0.15*1.0 + 0.10*0.5 = 0.887

Candidate B: DOLIPRANE 500MG BTE/16
    name_sim 0.71  dosage mismatch 0.0  pack |8-16|/16 -> 0.5  form unknown 0.5
    match = 0.45*0.71 + 0.30*0.0 + 0.15*0.5 + 0.10*0.5 = 0.445

margin        = 0.887 - 0.445 = 0.442   ->  margin_factor = 1.0  (clear win)
final_conf    = 0.82 * 0.887 * 1.0      =  0.727            ->  "review" (orange)
```

Even though the match is unambiguous, the mediocre OCR read (`0.82`) pulls the
final confidence into the orange band β€” so the reviewer confirms the strength
before it is committed. That is the intended, safety-first behavior.

---

## 5. Integration Checklist

- [ ] Call `/v1/ocr` (or `/v1/ocr/base64`); keep `repair=true`, `validate=true`.
- [ ] Reject/queue the document if `schema_valid` is `false` or `parse_error` is set.
- [ ] Surface `validation_warnings` to the reviewer (they are non-blocking).
- [ ] Normalize every text value with `normalize_label()` before querying.
- [ ] Run the Tier 1 β†’ 2 β†’ 3 cascade per entity (supplier, pharmacy, each product).
- [ ] Rerank Tier-2 candidates with the weighted attribute formula (Β§3).
- [ ] Compute `final_confidence` and set the UI review state (Β§4).
- [ ] Persist confirmed human selections as tenant-scoped resolution history.