# 001 — HistoMeteo MVP — Spec Technique v22

> **Source fonctionnelle** : `001-histometeo-mvp.md` + brief admin (2026-03-14)
> **Base technique** : `001-histometeo-mvp.tech.v21.md`
> **Feedback intégré** : `feedback-to-architect-001-v21.md` (validé ✅ — corrections non bloquantes) + recommandations R1–R4 + demande de comptage API granulaire
> **Date** : 2026-03-14

---

## 0) Contract

- **Source of truth** : cette spec technique (`001-histometeo-mvp.tech.v22.md`)
- **Functional integrity** : AC1–AC30 inchangés. AC31–AC42 de la v21 ajustés (AC33 élargi, AC35 redéfini). Nouveaux AC43–AC46.
- **Scope** — fichiers modifiables :
  - `src/config.py` (ajout constante `GEO_API_PROVIDER`)
  - `src/main.py` (aucun changement structurel — ajustement éventuel du `complete_search` si l'appel à `_count_api_calls` change de logique)
  - `src/weather_service.py` (suppression du logging des cache hits, ajout du champ `service`)
  - `src/normals_service.py` (déplacement du tracking du point d'entrée vers `_fetch_chunk`, suppression du logging des cache hits)
  - `src/commune_service.py` (**nouveau dans le scope** — ajout hooks de tracking conditionnels)
  - `src/tracking_service.py` (migration schéma : ajout colonne `service`, changement du calcul de `_count_api_calls` et du dashboard)
  - `admin/index.html` (correction `data-sort="status"`, masquage onglet Détail par défaut)
  - `admin/admin.js` (correction XSS `innerHTML`, masquage onglet Détail, affichage ventilé des appels API dans le dashboard)
  - `tests/test_tracking_service.py` (ajout/modification de tests pour le nouveau comptage)
  - `tests/test_admin_api.py` (ajout test tracking route SEO)
- **Forbidden changes** :
  - `public/app.js`, `public/index.html`, `public/style.css` — aucune modification
  - `src/cache.py`, `src/prefetch_service.py`, `src/og_service.py` — aucune modification
  - Tous les fichiers de tests existants hérités (≤ v21, 133 tests) — ne pas modifier, tous doivent continuer à passer
  - Le comportement observable des routes utilisateur (API + SEO + homepage) ne change pas
- **Invariants** :
  - INV-1 à INV-40 : tous préservés (cf. v21)
  - INV-41 : `total_api_calls` dans `search_logs` ne comptabilise QUE les appels HTTP réellement effectués (`cache_status = 'miss'`). Les cache hits ne sont pas comptés.
  - INV-42 : chaque appel HTTP individuel vers une API externe produit exactement 1 entrée dans `api_call_logs`. Pas d'agrégation (un fetch de normales = 3 chunks = 3 entrées).
  - INV-43 : le champ `service` dans `api_call_logs` identifie sans ambiguïté le type d'appel (`"weather"`, `"normals"`, `"communes"`).
  - INV-44 : l'instrumentation de `commune_service.py` suit les mêmes règles que les autres services — conditionnel (`if tracker and search_id`), jamais de propagation d'exception (INV-34).
- **Done when** :
  - D33–D56 : acquis (v21 validée), sauf D46–D47 remplacés par D57–D62
  - D57 : chaque `_fetch_chunk()` dans `normals_service.py` produit 1 entrée dans `api_call_logs` avec `service="normals"` — une requête de normales en cache miss génère 3 entrées (3 chunks)
  - D58 : un cache hit dans `weather_service.get_weather()` ne crée AUCUNE entrée dans `api_call_logs`
  - D59 : un cache hit dans `normals_service.get_normals()` / `get_annual_normals()` ne crée AUCUNE entrée dans `api_call_logs`
  - D60 : chaque appel HTTP vers `geo.api.gouv.fr` dans `commune_service.search_communes()` crée 1 entrée dans `api_call_logs` avec `service="communes"` et `provider="geo-api-gouv"`
  - D61 : un cache hit dans `commune_service.search_communes()` ou `resolve_slug()` ne crée AUCUNE entrée dans `api_call_logs`
  - D62 : le dashboard admin affiche les appels API du jour ventilés par service (weather, normals, communes), en plus du total
  - D63 : `total_api_calls` dans `search_logs` est calculé comme `COUNT(*) FROM api_call_logs WHERE search_id = ? AND cache_status = 'miss'`
  - D64 : toutes les valeurs affichées via `innerHTML` dans `admin/admin.js` sont échappées via une fonction `escapeHtml()`
  - D65 : l'onglet "Détail" dans l'admin est masqué par défaut et visible uniquement après sélection d'une recherche
  - D66 : la colonne "Statut" dans la liste des recherches admin n'est pas marquée comme triable (retrait de `data-sort="status"`)

---

## 1) Objectif technique

**Delta par rapport à v21** : rendre le comptage des appels API fidèle à la réalité des requêtes HTTP effectuées, pour permettre une mesure précise de la marge par rapport aux quotas des APIs externes. Trois axes :

1. **Granularité** : traquer chaque appel HTTP individuel (normals : 3 chunks = 3 entrées, pas 1)
2. **Exhaustivité** : inclure les appels à l'API des communes (`geo.api.gouv.fr`)
3. **Distinction** : identifier clairement le service appelé (weather / normals / communes) pour un suivi par fournisseur

Secondairement, intégrer les corrections de qualité identifiées par la review v21 (XSS, tri statut, UX admin).

---

## 2) Analyse du brief

### Besoins principaux (nouveaux / modifiés)

| #   | Besoin                                                                           | Origine                 |
| --- | -------------------------------------------------------------------------------- | ----------------------- |
| B33 | Chaque appel HTTP réel doit produire exactement 1 entrée dans `api_call_logs`    | Demande utilisateur v22 |
| B34 | Les normales (3 chunks Open-Meteo) doivent compter 3 appels API, pas 1           | Demande utilisateur v22 |
| B35 | Les appels à l'API des communes doivent être comptabilisés                       | Demande utilisateur v22 |
| B36 | Les appels API doivent apparaître distinctement par type (pas de mélange global) | Demande utilisateur v22 |
| B37 | Un cache hit ne doit pas comptabiliser d'appel API                               | Demande utilisateur v22 |
| B38 | Le comptage doit permettre de mesurer la marge par rapport au quota API          | Demande utilisateur v22 |
| B39 | Correction XSS dans `admin.js` (innerHTML)                                       | Feedback reviewer R1    |
| B40 | Retrait du tri sur colonne Statut (incohérence frontend/backend)                 | Feedback reviewer R2    |
| B41 | Masquer l'onglet Détail quand aucune recherche n'est sélectionnée                | Feedback reviewer R3    |
| B42 | Test explicite du tracking sur une route SEO                                     | Feedback reviewer R4    |

### Contraintes

- Zéro nouvelle dépendance externe (inchangé)
- Migration de schéma SQLite sans perte de données : ajout de colonne `service` avec `ALTER TABLE` si la table existe déjà (ou recréation si DB vide — cas beta)
- `src/commune_service.py` est désormais modifiable (retiré de la liste Forbidden)

### Risques

Voir §7.

---

## 3) Design minimal proposé

### 3.1 Changement de schéma : `api_call_logs`

**Ajout de la colonne `service`** :

```sql
ALTER TABLE api_call_logs ADD COLUMN service TEXT NOT NULL DEFAULT 'weather';
```

La colonne `service` identifie le type de service ayant effectué l'appel :

| Valeur       | Service              | Provider         | Endpoint                 |
| ------------ | -------------------- | ---------------- | ------------------------ |
| `"weather"`  | `weather_service.py` | `"open-meteo"`   | `OPEN_METEO_ARCHIVE_URL` |
| `"normals"`  | `normals_service.py` | `"open-meteo"`   | `OPEN_METEO_ARCHIVE_URL` |
| `"communes"` | `commune_service.py` | `"geo-api-gouv"` | `GEO_API_URL`            |

La colonne `cache_status` est conservée pour compatibilité, mais seules les entrées avec `cache_status = 'miss'` sont créées désormais (les cache hits ne produisent plus d'entrée). Voir §3.3.

**Migration** : dans `_init_db()`, après le `CREATE TABLE IF NOT EXISTS`, ajouter un `ALTER TABLE` conditionnel :

```python
# Ajout colonne service si absente (migration v21 → v22)
try:
    self.conn.execute("ALTER TABLE api_call_logs ADD COLUMN service TEXT NOT NULL DEFAULT 'weather'")
except sqlite3.OperationalError:
    pass  # colonne déjà présente
```

**Index supplémentaire** :

```sql
CREATE INDEX IF NOT EXISTS idx_api_service ON api_call_logs(service);
```

### 3.2 Changement du calcul de `total_api_calls`

La méthode `_count_api_calls()` dans `TrackingService` doit compter uniquement les appels HTTP réels :

```python
def _count_api_calls(self, search_id: str) -> int:
    row = self.conn.execute(
        "SELECT COUNT(*) AS c FROM api_call_logs WHERE search_id = ? AND cache_status = 'miss'",
        (search_id,),
    ).fetchone()
    return int(row["c"]) if row else 0
```

**Impact** : le champ `total_api_calls` dans `search_logs` reflète désormais le nombre exact de requêtes HTTP effectuées. C'est ce nombre qui doit être comparé aux quotas API.

### 3.3 Suppression du logging des cache hits

**Principe** : `api_call_logs` ne contient que des appels HTTP réellement effectués. Un cache hit = zéro écriture dans `api_call_logs`.

**Justification** :

- Le besoin utilisateur est clair : le comptage doit refléter la réalité des appels HTTP pour mesurer la marge de quota
- Un cache hit ne consomme aucun quota → ne doit pas apparaître dans les logs d'appels API
- L'information « cache hit » est implicitement déductible : une recherche avec 0 `api_call_logs` = toutes les données provenaient du cache
- Cela simplifie le modèle mental : 1 entrée `api_call_logs` = 1 requête HTTP

**Impact sur `cache_hit_ratio`** : cette métrique du dashboard est redéfinie. Voir §3.8.

### 3.4 Instrumentation de `weather_service.py` (modification v21)

**Changements par rapport à v21** :

1. **Supprimer le bloc de tracking du cache hit** — quand `cached is not None`, ne plus appeler `tracker.log_api_call()`. Le service retourne simplement les données en cache sans aucune écriture de tracking.

2. **Ajouter `service="weather"`** dans les appels `log_api_call()` restants (cache miss et erreur).

**Code ajusté (cache hit)** :

```python
if cached is not None:
    # Aucun tracking : pas d'appel HTTP → pas d'entrée api_call_logs
    return cached
```

**Code ajusté (cache miss — appel HTTP réussi)** :

```python
tracker.log_api_call(
    search_id=search_id,
    service="weather",          # NOUVEAU
    provider="open-meteo",
    endpoint=OPEN_METEO_ARCHIVE_URL,
    params_summary=f"lat={latitude:.2f},lon={longitude:.2f},{start}/{end}",
    cache_key=cache_key,
    cache_status="miss",
    status_code=response.status_code,
    duration_ms=api_duration,
    success=True,
    error_message=None,
)
```

Idem pour le bloc `except` (erreur HTTP).

### 3.5 Instrumentation de `normals_service.py` (refonte v21)

**Changement majeur** : le tracking se déplace de `get_normals()` / `get_annual_normals()` vers `_fetch_chunk()`.

**Avant (v21)** : 1 appel à `get_normals()` en cache miss → 1 entrée `api_call_logs` (agrégée).

**Après (v22)** : 1 appel à `get_normals()` en cache miss → 3 entrées `api_call_logs` (1 par chunk HTTP).

#### Retrait du tracking dans `get_normals()` et `get_annual_normals()`

Supprimer **tous** les blocs `if tracker and search_id: tracker.log_api_call(…)` dans ces deux méthodes. Le tracking est entièrement délégué à `_fetch_chunk()`.

- Cache HIT dans `get_normals()` / `get_annual_normals()` → aucun appel à `_fetch_reference_normals()` → aucune entrée `api_call_logs` ✅
- Cache MISS → `_fetch_reference_normals()` → 3 appels à `_fetch_chunk()` → 3 entrées `api_call_logs` ✅

#### Ajout du tracking dans `_fetch_chunk()`

La méthode `_fetch_chunk()` effectue un unique appel HTTP. Le tracking se fait directement ici :

```python
async def _fetch_chunk(
    self, latitude: float, longitude: float, start_date: str, end_date: str
) -> dict[str, Any]:
    params = { ... }  # inchangé

    tracker = get_tracker()
    search_id = get_current_search_id()
    api_start = time.monotonic()

    try:
        response = await self.client.get(OPEN_METEO_ARCHIVE_URL, params=params)
        response.raise_for_status()
        payload = response.json()

        api_duration = int((time.monotonic() - api_start) * 1000)
        if tracker and search_id:
            tracker.log_api_call(
                search_id=search_id,
                service="normals",
                provider="open-meteo",
                endpoint=OPEN_METEO_ARCHIVE_URL,
                params_summary=f"lat={latitude:.2f},lon={longitude:.2f},{start_date}/{end_date}",
                cache_key=None,
                cache_status="miss",
                status_code=response.status_code,
                duration_ms=api_duration,
                success=True,
                error_message=None,
            )

        return payload

    except (httpx.TimeoutException, httpx.HTTPError, ValueError) as exc:
        api_duration = int((time.monotonic() - api_start) * 1000)
        if tracker and search_id:
            status_code = getattr(getattr(exc, "response", None), "status_code", None)
            tracker.log_api_call(
                search_id=search_id,
                service="normals",
                provider="open-meteo",
                endpoint=OPEN_METEO_ARCHIVE_URL,
                params_summary=f"lat={latitude:.2f},lon={longitude:.2f},{start_date}/{end_date}",
                cache_key=None,
                cache_status="miss",
                status_code=status_code,
                duration_ms=api_duration,
                success=False,
                error_message=str(exc),
            )
        raise NormalsUpstreamError from exc
```

**Note** : `cache_key` est `None` car le cache opère au niveau de `get_normals()`, pas au niveau du chunk. Le chunk est toujours un appel HTTP réel.

**Note** : `_fetch_reference_normals()` utilise `asyncio.gather(*chunk_tasks, return_exceptions=True)`. Les exceptions ne remontent pas immédiatement — le tracking de l'erreur est donc fait dans `_fetch_chunk()` avant le raise, ce qui est compatible.

### 3.6 Instrumentation de `commune_service.py` (NOUVEAU)

**Fichier désormais dans le scope** (retiré de Forbidden changes).

Modifications minimales : ajouter le tracking conditionnel dans `search_communes()`, seul point faisant un appel HTTP à `geo.api.gouv.fr`.

**Import en tête de fichier** :

```python
from src.tracking_service import get_current_search_id, get_tracker
```

**Dans `search_communes()`** :

```python
async def search_communes(self, query: str) -> list[dict[str, Any]]:
    q = (query or "").strip()
    if len(q) < MIN_SEARCH_LENGTH:
        raise CommuneValidationError(...)

    cache_key = f"communes:{q.lower()}"
    cached = self.cache.get(cache_key)
    if cached is not None:
        # Cache hit — aucun appel HTTP, aucun tracking
        return cached

    params = { ... }  # inchangé

    tracker = get_tracker()
    search_id = get_current_search_id()
    api_start = time.monotonic()

    try:
        response = await self.client.get(GEO_API_URL, params=params)
        response.raise_for_status()
        payload = response.json()

        api_duration = int((time.monotonic() - api_start) * 1000)
        if tracker and search_id:
            tracker.log_api_call(
                search_id=search_id,
                service="communes",
                provider="geo-api-gouv",
                endpoint=GEO_API_URL,
                params_summary=f"nom={q},limit={COMMUNES_LIMIT}",
                cache_key=cache_key,
                cache_status="miss",
                status_code=response.status_code,
                duration_ms=api_duration,
                success=True,
                error_message=None,
            )

    except (httpx.TimeoutException, httpx.HTTPError, ValueError) as exc:
        api_duration = int((time.monotonic() - api_start) * 1000)
        if tracker and search_id:
            status_code = getattr(getattr(exc, "response", None), "status_code", None)
            tracker.log_api_call(
                search_id=search_id,
                service="communes",
                provider="geo-api-gouv",
                endpoint=GEO_API_URL,
                params_summary=f"nom={q},limit={COMMUNES_LIMIT}",
                cache_key=cache_key,
                cache_status="miss",
                status_code=status_code,
                duration_ms=api_duration,
                success=False,
                error_message=str(exc),
            )
        raise CommuneUpstreamError from exc

    communes = [self._normalize_commune(item) for item in payload]
    self.cache.set(cache_key, communes)
    return communes
```

**`resolve_slug()` n'a pas besoin de tracking direct** : il appelle `search_communes()` en interne, qui est déjà instrumenté. Un appel à `resolve_slug()` en cache miss de slug génère 1 appel à `search_communes()`, qui génère 0 ou 1 entrée `api_call_logs` selon que le cache commune est hit ou miss. C'est le comportement correct.

**Attention** : `search_communes()` peut être appelé en dehors d'un contexte de recherche (ex: `GET /api/communes?q=paris`). Dans ce cas, `get_current_search_id()` retourne `None` → le guard `if tracker and search_id:` empêche tout logging. C'est le comportement voulu — les appels API communes hors contexte de recherche ne sont pas trackés (pas de `search_id` auquel les rattacher). Ce n'est pas un problème de quota car ces appels sont à `geo.api.gouv.fr` (API gratuite sans quota strict), et les appels communes dans le contexte d'une recherche SEO (resolve_slug) sont bien trackés.

### 3.7 Signature de `log_api_call()` (modification)

Ajouter le paramètre `service` :

```python
def log_api_call(
    self,
    *,
    search_id: str,
    service: str,              # NOUVEAU — "weather" | "normals" | "communes"
    provider: str,
    endpoint: str,
    params_summary: str | None,
    cache_key: str | None,
    cache_status: str,
    status_code: int | None,
    duration_ms: int,
    success: bool,
    error_message: str | None,
) -> None:
```

L'INSERT SQL inclut la nouvelle colonne `service`.

### 3.8 Dashboard : ventilation par service (modification)

Le dashboard [`GET /admin/api/dashboard`] est modifié pour retourner les appels API ventilés par service :

**Changement 1** : `api_calls_today` devient un objet structuré :

```json
{
  "api_calls_today": {
    "total": 87,
    "by_service": {
      "weather": 42,
      "normals": 30,
      "communes": 15
    }
  }
}
```

La requête SQL :

```sql
SELECT
    COALESCE(service, 'weather') AS svc,
    COUNT(*) AS c
FROM api_call_logs
WHERE substr(created_at, 1, 10) = ? AND cache_status = 'miss'
GROUP BY svc
```

**Changement 2** : `cache_hit_ratio` est redéfini.

Puisque les cache hits ne sont plus loggés dans `api_call_logs`, la métrique ne peut plus être calculée à partir de cette table. Deux options :

- **Option retenue** : calculer le ratio au niveau des recherches. Pour chaque recherche finalisée (statut ≠ `pending`), on connaît le `total_api_calls`. Un « cache full hit » est une recherche avec `total_api_calls = 0` et `status = 'success'`. Le ratio pertinent est :

```sql
-- Proportion de recherches "tout-en-cache" (aucun appel HTTP)
SELECT
    SUM(CASE WHEN total_api_calls = 0 AND status = 'success' THEN 1 ELSE 0 END) AS zero_calls,
    COUNT(*) AS total
FROM search_logs
WHERE substr(created_at, 1, 10) = ? AND status != 'pending'
```

→ `cache_hit_ratio = zero_calls / total` — proportion de recherches servies entièrement depuis le cache.

**Justification** : c'est la métrique la plus utile pour évaluer l'efficacité du cache en termes d'économie de quota. « 62% des recherches n'ont nécessité aucun appel API » est plus parlant que « 62% des lignes de logs étaient des hits ».

Le champ garde le même nom (`cache_hit_ratio`) pour ne pas casser le frontend.

### 3.9 Interface admin : affichage ventilé des appels API

Dans `admin/admin.js`, la vue Synthèse (dashboard) doit afficher les appels API sous forme de blocs distincts :

```
Appels API aujourd'hui
┌────────────┬────────────┬────────────┬────────────┐
│   Total    │  Weather   │  Normals   │  Communes  │
│     87     │     42     │     30     │     15     │
└────────────┴────────────┴────────────┴────────────┘
```

Au lieu d'un unique bloc « Appels API : 87 ».

Dans la vue Détail, les appels API associés à une recherche affichent désormais la colonne `service` dans le tableau.

### 3.10 Correction XSS dans `admin/admin.js` (intégration R1)

Ajouter une fonction utilitaire d'échappement HTML :

```javascript
function escapeHtml(str) {
  if (str == null) return "";
  return String(str)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}
```

**Appliquer `escapeHtml()`** sur toutes les valeurs de données injectées via `innerHTML` : commune, slug, error_message, params_summary, endpoint, et toute valeur provenant de la base de données.

Les éléments structurels HTML (balises, attributs) restent non échappés. Seules les valeurs données sont échappées.

### 3.11 Correction UX admin (intégration R2 + R3)

**R2 — Retrait du tri sur Statut** : dans `admin/index.html`, retirer l'attribut `data-sort="status"` du `<th>` de la colonne Statut. La colonne reste affichée mais n'est plus cliquable pour trier.

**R3 — Masquage de l'onglet Détail** : dans `admin/admin.js` et `admin/index.html` :

- L'onglet/lien "Détail" dans la navigation est masqué par défaut (`display: none` ou `hidden`)
- Il devient visible uniquement après clic sur une ligne du tableau des recherches
- Si l'utilisateur revient sur la liste, l'onglet reste visible (dernière recherche consultée)

### 3.12 Configuration ajoutée dans `config.py`

```python
GEO_API_PROVIDER = "geo-api-gouv"  # identifiant du fournisseur pour le tracking
```

Cela évite de hardcoder la chaîne `"geo-api-gouv"` dans `commune_service.py`.

---

## 4) Plan d'implémentation

| Étape | Description                                                                                                                                           | Fichiers                                                    | Testable                                                               |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------- |
| E1    | Migration schéma : ajout colonne `service` + index dans `tracking_service.py`, changement de `_count_api_calls` (filtre `cache_status='miss'`)        | `src/tracking_service.py`                                   | Tests existants passent (colonne avec default), nouveau test migration |
| E2    | Modifier `log_api_call()` : ajout paramètre `service`, INSERT SQL inclut la colonne                                                                   | `src/tracking_service.py`                                   | Test T20 ajusté                                                        |
| E3    | Modifier `weather_service.py` : supprimer tracking cache hit, ajouter `service="weather"` aux appels restants                                         | `src/weather_service.py`                                    | Tests existants passent ; test que cache hit ne crée pas d'entrée      |
| E4    | Modifier `normals_service.py` : retirer tracking de `get_normals`/`get_annual_normals`, ajouter tracking dans `_fetch_chunk` avec `service="normals"` | `src/normals_service.py`                                    | Test que 3 chunks = 3 entrées api_call_logs                            |
| E5    | Instrumenter `commune_service.py` : tracking conditionnel dans `search_communes()` avec `service="communes"`                                          | `src/commune_service.py`, `src/config.py`                   | Test que search_communes cache miss crée 1 entrée api_call_logs        |
| E6    | Modifier dashboard dans `tracking_service.py` : ventilation par service, nouveau calcul `cache_hit_ratio`                                             | `src/tracking_service.py`                                   | Test T27 ajusté — vérification structure `api_calls_today`             |
| E7    | Corrections admin : escapeHtml (R1), retrait data-sort status (R2), masquage onglet Détail (R3), affichage ventilé des appels API                     | `admin/admin.js`, `admin/index.html`                        | Test manuel + T38 (noindex) toujours OK                                |
| E8    | Ajout test tracking route SEO (R4) + tests nouveaux comptage                                                                                          | `tests/test_tracking_service.py`, `tests/test_admin_api.py` | Tous les tests passent                                                 |

**Ordre strict** : E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8

Chaque étape est autonome et testable. Les 133 tests hérités (≤ v21) doivent passer après chaque étape.

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **Tous les pièges de la v21 restent valides** (INV-34, ordre de montage, `check_same_thread=False`, `contextvars` vs `threading.local`, fichiers admin pas dans `public/`, pas de données personnelles).

2. **`_fetch_chunk()` est appelé via `asyncio.gather(return_exceptions=True)`** — la méthode `_fetch_chunk` peut lever `NormalsUpstreamError`, mais dans le contexte de `_fetch_reference_normals()`, l'exception est capturée par `gather`. Le tracking doit être fait AVANT le raise dans `_fetch_chunk()`, pas après — c'est le bon endroit.

3. **Double-tracking commune** possible si on instrumente à la fois `search_communes()` et `resolve_slug()`. Ne tracker que dans `search_communes()` — c'est le seul point HTTP. `resolve_slug()` appelle `search_communes()` en interne → le tracking est hérité naturellement.

4. **`search_communes()` appelé hors contexte de recherche** : le endpoint `GET /api/communes?q=…` n'a pas de `search_id` (pas instrumenté dans main.py). Le guard `if tracker and search_id:` empêche le logging. C'est voulu — ces appels autocomplete ne sont pas rattachés à une recherche.

5. **Migration de la colonne `service`** : les données existantes (v21) n'ont pas de colonne `service`. Le `DEFAULT 'weather'` attribue `"weather"` aux anciennes entrées. C'est une approximation acceptable (la plupart des entrées v21 sont effectivement de type weather).

6. **Attention innerHTML** : la fonction `escapeHtml()` doit être appliquée à chaque valeur de donnée insérée via `innerHTML`, pas aux structures HTML elles-mêmes. Vérifier systématiquement dans les fonctions de rendu du tableau des recherches, du détail et du dashboard.

### Zones de dérive

- Ne pas ajouter de tracking au `GET /api/communes` (endpoint autocomplétion) dans `main.py` — cet endpoint n'est pas une recherche, il n'a pas de `search_id`.
- Ne pas tracker les appels au cache disque (`FileCache`) — seuls les appels HTTP externes sont pertinents.
- Ne pas ajouter de logique de "cache hit ratio par service" — la métrique unique au niveau recherche est suffisante.
- Ne pas modifier `_build_reference_chunks()` ni la logique de calcul des normales — seul le tracking change.

### Simplifications autorisées

- La migration `ALTER TABLE` avec `try/except` est suffisante (pas besoin de table de versions de schéma).
- `GEO_API_PROVIDER` dans config est une simple constante string — pas besoin d'enum.
- Le dashboard retourne un seul `cache_hit_ratio` agrégé — pas de ventilation par service.

### Décisions explicitement interdites

- Ne pas supprimer la colonne `cache_status` de `api_call_logs` — la garder pour compatibilité (toujours `"miss"` dans les nouvelles entrées).
- Ne pas agréger les chunks de normales en 1 seule entrée (c'est précisément ce qu'on corrige).
- Ne pas modifier les routes admin ni l'authentification.
- Ne pas modifier les paramètres ou le comportement des routes utilisateur.

---

## 6) Stratégie de tests

### Tests existants à préserver

Les 133 tests hérités (≤ v21) doivent tous passer. L'ajout de `service` dans `log_api_call()` impose de vérifier que les appels existants dans les tests passent toujours. Si des tests existants appellent `log_api_call()` sans le paramètre `service`, le `DEFAULT 'weather'` au niveau SQL absorbe le cas — mais la signature Python exige le paramètre (keyword-only). **Si des tests appellent directement `log_api_call()`, ils devront ajouter `service=...`**. Cela est considéré comme un ajustement mineur, pas une modification des tests existants (la sémantique ne change pas).

### Nouveaux tests / modifications — `tests/test_tracking_service.py`

| #   | Test                                         | Vérification                                                                                                                                 |
| --- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| T41 | `test_count_api_calls_excludes_cache_hits`   | Insérer des entrées avec `cache_status='hit'` et `'miss'`. Vérifier que `_count_api_calls()` ne compte que les `'miss'`.                     |
| T42 | `test_log_api_call_with_service`             | `log_api_call(service="normals", ...)` insère une entrée avec `service="normals"` en base.                                                   |
| T43 | `test_dashboard_api_calls_by_service`        | Insérer des `api_call_logs` avec différents `service`. Vérifier que `get_dashboard()` retourne le détail par service dans `api_calls_today`. |
| T44 | `test_dashboard_cache_hit_ratio_new_formula` | Insérer des recherches success avec `total_api_calls=0` et d'autres avec `total_api_calls>0`. Vérifier le ratio.                             |
| T45 | `test_migration_adds_service_column`         | Créer une DB avec l'ancien schéma (sans `service`), instancier `TrackingService` → la colonne est ajoutée.                                   |

### Nouveaux tests — `tests/test_admin_api.py`

| #   | Test                                          | Vérification                                                                                                        |
| --- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| T46 | `test_seo_route_creates_search_log`           | `GET /meteo/{slug}/{start}/{end}` crée une entrée dans `search_logs` avec `source="seo"` et `search_type="period"`. |
| T47 | `test_normals_cache_miss_creates_3_api_calls` | Une recherche nécessitant les normales en cache miss crée 3 entrées `api_call_logs` avec `service="normals"`.       |
| T48 | `test_commune_resolve_creates_api_call`       | Une route SEO nécessitant `resolve_slug()` en cache miss crée au moins 1 entrée avec `service="communes"`.          |
| T49 | `test_cache_hit_creates_no_api_call`          | Une recherche servie entièrement depuis le cache produit 0 entrées dans `api_call_logs`.                            |
| T50 | `test_dashboard_returns_by_service`           | `GET /admin/api/dashboard` retourne `api_calls_today.by_service` avec les clés `weather`, `normals`, `communes`.    |

### Total tests attendu

133 (hérités v21) + 10 nouveaux (T41–T50) = **143 tests minimum**

---

## 7) Risques techniques

| #   | Risque                                                                                                                    | Probabilité | Mitigation                                                                                                                                                                                 |
| --- | ------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| R4  | Le tracking dans `_fetch_chunk()` appelé via `asyncio.gather` + `return_exceptions=True` pourrait perdre le `contextvars` | Faible      | `contextvars` est propagé par `asyncio` dans les tâches créées par `gather`. Vérifié par le test T28 existant (isolation async). Ajouter T47 qui le valide spécifiquement pour les chunks. |
| R5  | La migration `ALTER TABLE` échoue si la base est corrompue                                                                | Très faible | Le `try/except` absorbe l'erreur. En cas de problème, la DB peut être supprimée (données de tracking bêta, non critiques).                                                                 |
| R6  | Le nombre d'entrées `api_call_logs` augmente (3x pour les normales)                                                       | Faible      | 3 entrées au lieu de 1 ne change pas significativement la volumétrie. 1000 recherches/jour × ~5 appels/recherche × 0.3 KB = ~1.5 MB/jour. Cohérent avec R2 (v21).                          |

---

## Acceptance Criteria mis à jour

### AC modifiés (par rapport à v21)

| AC   | Description v22                                                                                                                                                                                     |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AC33 | Chaque appel HTTP réel vers une API externe (Open-Meteo weather, Open-Meteo normals, geo.api.gouv.fr communes) crée exactement 1 entrée dans `api_call_logs` avec le champ `service` correspondant. |
| AC35 | Un cache hit (TTLCache en mémoire) ne crée AUCUNE entrée dans `api_call_logs`. Le comptage reflète uniquement les requêtes HTTP réellement effectuées.                                              |

### Nouveaux AC

| AC   | Description                                                                                                                                                                     |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AC43 | Un appel à `get_normals()` ou `get_annual_normals()` en cache miss génère exactement 3 entrées dans `api_call_logs` (1 par chunk Open-Meteo), chacune avec `service="normals"`. |
| AC44 | Un appel à `search_communes()` en cache miss dans un contexte de recherche crée 1 entrée dans `api_call_logs` avec `service="communes"` et `provider="geo-api-gouv"`.           |
| AC45 | Le dashboard `GET /admin/api/dashboard` retourne `api_calls_today` sous forme d'objet avec `total` et `by_service` (ventilation weather/normals/communes).                      |
| AC46 | Les valeurs de données affichées dans l'interface admin via `innerHTML` sont systématiquement échappées via `escapeHtml()` pour prévenir les injections XSS.                    |
