Revue de code globale — fin Phase 2.5 (2026-05-10)
Scope : audit de la Phase 2.5 (entre v0.3.0 et HEAD), 36 commits, 186 fichiers modifiés (+9 844 / −5 450 lignes). Les gros morceaux : décommissionnement Phase 0 (V6 + suppression des modules ingestion/ + analysis/ legacy + pages frontend recommendations/ / history/ / sources/ / test-sources/), runtime config v1 + v1.5 (12 clés, 5 routers @Primary, slider llm.timeout-seconds), Server-Sent Events sur le narratif (4 PR : backend JobEventPublisher + frontend JobStreamService + reattach pending + UX phase visible), clé Anthropic en SECRET runtime, panneau État Ollama avec eject VRAM + pull/delete model dialogs, instrumentType end-to-end (chip dossier + watchlist sidebar + persist BDD V7), drag-drop portfolios, lifecycle position OPEN/CLOSED, .env ports configurables, Swagger UI sur le profil local, refactor provideRepositories(), ThemeService / LanguageService SSR-safe, journal split. Modules Phase 0 décommissionnés hors scope sauf résidus.
Méthode : revue par agent automatisé, read-only, briefée sur les conventions ground truth (CLAUDE.md, architecture.md, ddd.md, fonctionnalites.md) et sur les findings des audits 2026-05-02 et 2026-05-06. Lecture commit par commit du diff Phase 2.5, croisement avec les nouveaux tests, vérification des résiduels des findings précédents. Findings classés par sévérité avec référence fichier:ligne.
État du commit au moment de la revue : branche master, dernier commit a608967 fix(watchlist): persist instrumentType on POST add. 12 clés runtime, 5 routers @Primary (Market / News / Analyst / Earnings / LLM), 6 caches Caffeine, 10 repositories frontend + 3 services dédiés (JobStreamService, LlmTimeoutService, OllamaStatusService). 50 fichiers de test backend, 21 specs frontend. Migrations Flyway V1→V7. Phase 0 entièrement supprimée du checkout. La majorité des findings critiques de l'audit fin Phase 2 sont fixés (#1 Settings expose les 7 toggles, #2 FinnhubAnalystClientTest ajouté, #4 hint Sector→Finnhub sur la card Twelve Data, #5 @TransactionalEventListener(AFTER_COMMIT) sur CacheTtlListener, #6 fail-open watchlist testé). #3 (stratégie de cache) et #21 (drift doc cache prefix) restent en dette technique 🟡.
Résumé exécutif
App globalement saine pour un tag v0.4.0. La Phase 2.5 a livré beaucoup de plomberie et a mieux documenté les rationales (notes implémentation longues sur journal-livraisons.md). Les patterns de Phase 2 ont tenu : ports/adapters cohérents, fail-soft sur les endpoints paid-tier, SSE livré avec un test seam propre (internal open fun createEmitter()) qui évite la reflection / les mocks fragiles sur SseEmitter. La discipline frontend reste très bonne : signal-based 100 %, zoneless, i18n complète FR/EN, provideRepositories() extrait proprement des providers Angular natifs.
Principaux risques identifiés : (1) Boot fragile sans ANTHROPIC_API_KEY — application.yml:43 lit key: ${ANTHROPIC_API_KEY} sans default, alors que toute la philosophie Phase 2.5 est que la clé soit éditable runtime via app_config. Un fresh clone sans env var crashe à @Value injection. (2) WatchlistService.add fait un appel network (tickerService.load) à l'intérieur d'un @Transactional — viole le pattern « LLM call hors transaction » documenté en architecture.md (« le slow LLM call must not hold a DB connection »). Même problème ici sur Twelve Data (1-3 s typique en cache miss). Single-user le rend invisible mais c'est exactement la classe de bug que Phase 1 cherchait à éviter. (3) LlmTimeoutService partiellement mort — le service est primé au boot via provideAppInitializer et refresh() est appelé sur save du slider, mais millis() n'est appelé nulle part dans la base de code (le seul consommateur historique, le poller, a été supprimé en PR2 SSE). La docstring mentionne encore AnalysisJobStore (décommissionné en V6) et narrative job polling (remplacé par SSE), et configuration.ts:282 parle de « next poll tick » qui n'existe plus. Le code marche par accident. (4) Le SSE streamJob ne valide pas que jobId appartient bien à symbol — n'importe quel client avec un jobId peut sniffer les events de n'importe quel ticker. Pas un problème en single-user no-auth, à câbler avant Phase 5 OAuth2.
Forces : (a) tests-as-documentation très bien tenus sur JobEventPublisherTest (12 scenarii narratifs incluant emitter cassé, replay-on-reconnect, multi-emitters, terminal-job late connect), OllamaStatusServiceTest (19 tests avec MockWebServer, fail-soft sur tout), WatchlistServiceTest (le fail-open posture documenté ET testé maintenant) ; (b) ConfigController masque proprement les 3 SECRETs et le test GET config returns the twelve known keys with secrets masked le pin en clair ; (c) la décommission Phase 0 est sans résidus visibles dans le code main (vérifié par grep -rn "AnalysisJobStore\|FeedSource\|AnalysisExecutor\|RssFetcher" backend/src/main qui ne retourne rien) ; (d) le SSE wire-format est testé des deux côtés (backend JobEventPublisherTest + frontend JobStreamService.spec.ts avec MockEventSource).
Critique
(blocker ou risque sévère — corruption data, sécurité, contrat cassé)
1. Boot fragile sans ANTHROPIC_API_KEY env var
backend/src/main/resources/application.yml:43:key: ${ANTHROPIC_API_KEY}sans default (pas de:), contrairement à${TWELVEDATA_API_KEY:}ligne 61 et${FINNHUB_API_KEY:}ligne 67.AppConfigService.kt:38:@Value("\${anthropic.api.key:}")a un fallback vide, mais Spring résout${ANTHROPIC_API_KEY}en placeholder property avant d'arriver au constructeur — un env var manquant lèveIllegalArgumentException: Could not resolve placeholder 'ANTHROPIC_API_KEY' in value "${ANTHROPIC_API_KEY}".- Toute la philosophie Phase 2.5 v2 (commit
f5ba075) est que la clé Anthropic devienne SECRET runtime éditable via/settings/configurationsans reboot. Un user qui clone à froid sans env var ne peut pas booter pour ouvrir la page Settings et la rentrer. - Symétrique manqué : Twelve Data et Finnhub ont leur fallback
:; le default empty string fait fail-fast au premier appel avec un message clair (requireApiKey()côtéTwelveDataClient/FinnhubClient). C'est aussi ce queClaudeClient.requireApiKey()ligne 67-74 fait. Le YAML doit suivre la même convention.
→ Patcher application.yml:43 en key: ${ANTHROPIC_API_KEY:}. Test à ajouter : un boot avec unsetenv ANTHROPIC_API_KEY réussit, puis ConfigController.GET /api/config retourne hasValue: false sur la clé Anthropic.
Important
(à traiter en priorité)
2. WatchlistService.add fait un appel network sous @Transactional
backend/.../watchlist/application/WatchlistService.kt:60-74:@Transactional fun add(symbol)valide viasymbolSearch.validate(...)(peut hit Twelve Data/symbol_search) puislookupInstrumentType(...)(hittickerService.loadqui appellechartClient.fetchChart→ Twelve Data/time_series+/quote).- Le pattern documenté dans
architecture.md(« LLM call hors transaction — l'appel LLM ne doit pas tenir de connexion Hikari ») est explicitement violé : un cache miss sur le chart fetch tient une connexion 1-3 s pour une salve réseau, plus longtemps en cas de timeout Twelve Data. Le scenario du commita608967(rate-limit upstream sur le mount d'un dashboard avec watchlist froide) déplace le burst des reads vers les writes — c'est mieux qu'avant, mais ce n'est pas pareil que l'éliminer. - Cache hit Caffeine évite le coût en cas de re-add mais Phase 2 a documenté qu'
instrumentTypen'a justement pas de cache préfixé par adapter (audit #3 toujours en dette). - Single-user low-concurrency masque l'impact aujourd'hui. Phase 5 multi-user le rendra visible.
→ Sortir lookupInstrumentType() (et idéalement isKnownSymbol()) de la portée transactionnelle. Patron : un add(symbol): WatchlistEntry non-transactionnel qui fait les 2 appels réseau, puis appelle un persistAdd(normalisedSymbol, instrumentType) privé @Transactional qui lit findBySymbol + save. Le test WatchlistServiceTest couvre déjà la sémantique ; il faudrait y ajouter un assert que tickerService.load est appelé avant le repository.save mais hors transaction (instrumentation Mockito).
3. LlmTimeoutService.millis() est dead code, docstring stale
frontend/src/app/core/llm-timeout.service.ts:7-8: la docstring affirme« Backend mirror : OllamaClient.readTimeout et les deux JobStore.DEDUP_WINDOW_SECONDS (both AnalysisJobStore for portfolio analysis and TickerNarrativeJobStore for ticker narratives) ».AnalysisJobStorea été supprimé en V6 (Phase 0 décommissionné). Le mirror backend exact estOllamaClient.buildClient(timeoutMs)+TickerNarrativeJobStore.pendingForqui litappConfig.getInt(LLM_TIMEOUT_SECONDS).llm-timeout.service.ts:30millis(): aucun consommateur dans la base de code (grep -rn "timeoutService.millis\|LlmTimeoutService.*millis" frontend/srcretourne 0). Le legacy poll-abort qui le consommait a été retiré en PR2 SSE (market.http.tsne contient pluspollNarrativeJob).configuration.ts:281-284commentaire : « Saving the LLM timeout flips a value that the polling adapters read from [LlmTimeoutService] — refresh it so the next poll tick (portfolio analysis or narrative job) picks up the new abort window without a page reload. » — le poll tick portfolio analysis n'existe plus (Phase 0), le narrative job poll non plus (PR2). Lerefresh()ne sert qu'à mettre à jour le label « estimation max » sur la card LLM, ce quearchitecture.mddocumente correctement mais le code lui-même contredit.
→ Soit (a) garder le service uniquement pour le label, supprimer millis(), réécrire la docstring pour pointer la card LLM seule ; soit (b) si on veut garder le hook pour Phase 4 cron / job orchestration, marquer explicitement « unused-but-kept-for-Phase-4 ». Et patcher le commentaire ligne 281-284 dans tous les cas.
4. streamJob SSE endpoint n'authentifie pas le jobId contre le symbol du path
backend/.../analysis/infrastructure/http/TickerNarrativeController.kt:79-81: le contrôleur enregistre simplementjobEventPublisher.register(jobId)sans valider que lejobIdcorrespond bien àsymbol.- Une URL
/api/market/ticker/AAPL/narrative/jobs/{jobIdDeNVDA}/streamretourne avec succès les events deNVDA. Lesymboldu path est ignoré. - Conséquence en single-user no-auth : aucune. Conséquence en Phase 5 multi-user (ticket OAuth2 backlog) : un user A peut sniffer les events d'un job d'un user B juste en intuitionnant son jobId UUID. Pas exploitable en pratique (UUIDv4) mais discipline.
- Le pattern correct serait : valider que
jobStore.get(jobId)?.symbol == symbol.uppercase(), sinon 404. Le testTickerNarrativePendingJobControllerTestet le contrôleur lui-même ont déjà cette discipline ailleurs (/jobs/pendinglitservice.pendingFor(symbol)qui filtre par symbol).
→ Avant Phase 5 OAuth2, ajouter le check :
@GetMapping("/jobs/{jobId}/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamJob(@PathVariable symbol: String, @PathVariable jobId: UUID): SseEmitter {
val job = jobStore.get(jobId) ?: throw NoSuchElementException("Job $jobId not found")
require(job.symbol == symbol.uppercase()) { "Job $jobId does not belong to $symbol" }
return jobEventPublisher.register(jobId)
}
🟡 Moyenne avec le tag « prérequis multi-user ».
5. LlmTimeoutService n'a pas de spec dédié alors que ses voisins en ont
frontend/src/app/core/:theme.service.spec.ts(88 lignes),language.service.spec.ts(109 lignes),ollama-status.service.spec.ts(279 lignes),job-stream.service.spec.ts(230 lignes).llm-timeout.service.ts: 0 spec.- Les conventions du projet (
tests as documentation) sont strictement appliquées partout sauf ici. Le service est petit mais surface non-triviale : prime au boot, refresh sur save, fallback sur exception, signal exposé. - Acceptable v0 mais incohérent avec le ton de la phase.
→ Ajouter llm-timeout.service.spec.ts mirror de ollama-status.service.spec.ts : (a) prime au constructeur retourne le default 400 quand repo.list n'a pas encore résolu ; (b) refresh sur retour du backend met le signal à jour ; (c) refresh sur erreur backend keep le default ; (d) refresh sur retour avec currentValue non-numeric keep le default.
Modérée
(à filer en dette technique)
6. OllamaClient.complete recrée un RestClient par appel
backend/.../analysis/infrastructure/llm/OllamaClient.kt:51:buildClient(timeoutMs).post().... Construction d'unSimpleClientHttpRequestFactoryneuf à chaque narratif.- Acceptable parce que le coût est microsecondes face aux 5-180 s de l'appel Ollama. La docstring documente le trade-off (« cost is microseconds — acceptable on a path that already takes seconds »).
- Mais : aucun cache du
RestClientquandtimeoutMsn'a pas changé entre deux appels. Pattern alternatif : unvolatilefield qui mémoïse le dernier (timeoutMs, restClient) et invalide quand le timeout bouge, ce qui devient l'écrasante majorité des cas (le slider bouge rarement). Cosmétique.
→ À noter en passing si on retouche OllamaClient. Pas urgent.
7. OllamaStatusService.pullModel envoie name plutôt que model dans le body
backend/.../analysis/infrastructure/llm/OllamaStatusService.kt:163:body(mapOf("name" to name, "stream" to false)).- L'API Ollama documente actuellement
model(cf. https://github.com/ollama/ollama/blob/main/docs/api.md#pull-a-model) et marquenamecomme deprecated mais toujours accepté. Le testOllamaStatusServiceTest:273pin explicitement le wire format"name":"mistral:7b". - Risque : un upgrade futur d'Ollama qui drop
namecasse silencieusement le pull. Le test ne lève pas l'alerte (il pin le statu quo, pas la conformité à la dernière version d'Ollama). unloadModel(ligne 117) utilisemodel(correct).deleteModel(ligne 198) utilisename. Inconsistance interne.
→ Aligner pullModel et deleteModel sur model, ré-aligner les tests sur la nouvelle wire.
8. OllamaPullDialog fallback hardcodés en anglais hors i18n
frontend/src/app/features/settings/configuration/ollama-pull-dialog.ts:119, 126, 147:'pull failed','delete failed'sont des fallback string hardcodées sur le patherr instanceof Error ? err.message : 'pull failed'.- Ce path se déclenche quand l'erreur est non-
Error(browser exotique) ou quanderr.messageest vide. Édge mais visible en français à un user FR. - La convention du projet est de tout passer par
TranslateService.instant. Toutes les autres erreurs UI suivent la règle.
→ Remplacer par this.translate.instant('settings.configurationPage.pullDialog.errors.pullFailed') (et ajouter les keys FR/EN).
9. OllamaStatusService.pullModel bloque le request thread Spring 1-3 min sur stream: false
OllamaStatusService.kt:156-177:stream: falseen pull → la requête HTTP backend reste sur le thread Tomcat le temps qu'Ollama termine le download (~1-3 min sur un modèle 4 GB).- Documenté dans la KDoc (« Acceptable single-user »). Tomcat default
max-threads = 200donc 200 pulls concurrents avant saturation, en pratique le user ne lance qu'un pull à la fois. - En revanche : aucun cancel côté backend si le user ferme le dialog (le backend continue le pull, le snapshot revient mais personne ne le consomme). Acceptable — le download Ollama est utile même si le client est parti.
→ À reconsidérer Phase 5 si SaaS multi-user : SSE streaming des progress events (Ollama supporte stream: true qui pousse une ligne JSON par % de download). Filer en dette.
10. OllamaStatusService.pullModel et deleteModel ne désérialisent pas la réponse upstream
OllamaStatusService.kt:165:.body(Map::class.java)pour parser la réponse, mais on n'inspecte pas le body. Si Ollama retourne{status: "error", error: "..."}avec un HTTP 200 (cas rare mais documenté pour certaines situations transitoires), on considère le pull OK et on re-probe. Le re-probe verra effectivement que le modèle n'a pas atterri et le snapshot sera honnête à la fin. Pas un bug, juste une tolérance qu'il faut tracer.
→ Ajouter un test « pull retourne 200 avec status: error → fail-soft snapshot conforme au comportement actuel » pour pin le rationale.
11. JobEventPublisher.pruneStale n'a pas de test direct
JobEventPublisher.kt:134-141: la méthode prune les buckets > 60 s post-terminal. Appelée à chaquepublish()etregister().- Le test
JobEventPublisherTestcouvre 12 scenarii mais aucun ne vérifie que (a) un bucket terminal est effectivement évincé après TTL, (b) un register sur un jobId évincé renvoie un emitter idle plutôt que de replay des events disparus. - L'invariant testé indirectement (« register sur unknown jobId returns non-null idle emitter ») couvre le cas après éviction, mais sans avancer le clock.
- Acceptable v1 :
pruneStaleest très défensif et un leak 60 s ne casse rien. Mais à filer pour si un jour on bumpeTERMINAL_RETENTIONà 5 min, on aura un test prêt.
→ Ajouter un test avec un Clock injectable ou un sleep contrôlé (pénible en JUnit synchrone). Optionnel.
12. ConfigTestClient.probeOllama mentionne le bouton Tilt qui n'existe plus
ConfigTestClient.kt:225: message d'erreur« — try \ollama pull $model` (or the matching Tilt button) »` retourné en plain à l'utilisateur via le banner « Tester ».- Le
local_resource("llm:ensure-model")duTiltfilea été supprimé dans la livraisonde7714c(Phase 2.5, en faveur du dialog Pull UI). - Doc drift dans un message user-visible.
→ Remplacer par « — open /settings/configuration > LLM > Pull… to download it ».
Mineure
(nits, polish, cohérence)
13. Configuration.reset() ne préserve pas le typing des secrets
frontend/src/app/features/settings/configuration/configuration.ts:262-292(save) clear l'edit pour les SECRETs après save. Bien.- Mais
reset()(ligne 295-332) clear aussi l'edit (« delete next[key] »). Si un user a tapé une nouvelle clé puis cliqué Reset par erreur, le typing est perdu. C'est probablement le comportement voulu (Reset retourne au défaut) mais il n'y a pas de confirm dialog côté secrets. - Mineur. Cosmétique.
14. AppConfigService accepte des nombres flottants stockés en BDD via getInt
AppConfigService.kt:61:fun getInt(key: String): Int = getString(key).toInt(). Si la valeur stockée en BDD vaut"15.0"(cas pathologique d'écriture manuelle),"15.0".toInt()lèveNumberFormatExceptionau runtime.- La validation sur
setrejette viavalue.toIntOrNull() ?: throw, donc le flow normal ne peut pas écrire ça. Mais une migration manuelle SQL pourrait. Défensif.
15. OllamaPullDialog.suggestions dupliqué de Configuration.OLLAMA_MODEL_SUGGESTIONS
ollama-pull-dialog.ts:19-26re-déclare la même liste de 6 suggestions. La docstring documente le choix volontaire (« deliberately duplicated rather than extracted to a shared module — the two surfaces are unlikely to diverge often »). Cosmétique acceptable mais dette future.
16. ConfigController tests pin par index positionnel
ConfigController.kt:57:ConfigKeys.KNOWN_KEYS.sorted().map { entryFor(it) }. Les tests pinnent les indexes[0]..[11]en dur.- Si une nouvelle clé est ajoutée et tombe alphabétiquement au milieu (ex.
auth.…), tous les indexes test glissent et le test casse. La discipline de re-numéroter à chaque ajout est documentée nulle part.
→ Optionnel : passer les tests à des jsonPath("$[?(@.key == 'anthropic.api.key')].type") plutôt que des indexes positionnels. Refacto cosmétique 30 min.
17. OllamaStatusService cohabite avec ConfigTestClient.probeOllama — duplication subtile
- Les deux classes hit le daemon Ollama via leur propre RestClient.
OllamaStatusServicepour le panneau live,ConfigTestClientpour le bouton « Tester ». Deux RestClients distincts, deux timeouts différents (3 s probe / 120 s test / 300 s pull). - C'est intentionnel (les 3 surfaces ont des contracts différents) mais 250 lignes de plomberie similaire. À reconsidérer en factor commun si un 4e cas apparaît.
Documentation
(drift doc → code)
18. architecture.md — claim cache prefix toujours faux
- Audit fin Phase 2 finding #21 toujours ouvert.
architecture.mdaffirme toujours« Cache key préfixée par adapter (twelvedata|, mock|) ⇒ pas de collision ». Vrai uniquement pourMARKET_CHART_CACHEcôtéTwelveDataClient. LeMockMarketChartClientn'a pas de@Cacheabledu tout, et les 4 autres caches (news-by-symbol,analyst-recommendations,earnings,sector-by-symbol) cachent au niveau service applicatif sans préfixe provider — un togglemock → finnhubcontinue à servir la valeur cachée du provider précédent jusqu'à expiration. - Le ticket dette technique 🟡 « Stratégie de cache : trancher entre key-prefix par adapter et service-cache sans préfixe » est filed (cf.
backlog.md). Pas un nouveau finding, juste un résiduel.
19. LlmTimeoutService docstring stale (cf. finding #3)
- Référence
AnalysisJobStore for portfolio analysis and TickerNarrativeJobStore for ticker narratives— le premier est décommissionné en V6, le second n'utilise plus le timeout pour un poll mais pour un dedup window backend.
20. developper.md mention cleanup orphelins comme « prévu »
developper.md:195: « Cleanup automatique au boot prévu en dette technique (cf.backlog.md). »OrphanedJobCleanupListener.ktest en place depuis Phase 1 et a été touché en Phase 2.5 (commit984c43c). Le cleanup automatique est déjà là.- Doc drift à corriger.
21. architecture.md > Modèle pipeline d'analyse — vision en avance
- La section « Modèle pipeline d'analyse » (DAG cible Phase 4) est très détaillée, riche en design notes. Bien.
- Mais elle référence des artefacts qui n'existent pas encore (
PortfolioAggregation,MARKET_REFRESH, tablejobunifiée…). Le statut « design cible, non encore implémenté » est explicite. Acceptable. - Risque : un nouveau lecteur peut prendre cette section pour une description de l'existant. Le wording « Statut : design cible » règle le sujet.
→ Pas un finding actionnable, juste un signal qualitatif. La section est bien framée.
Récapitulatif
| Niveau | Count | Exemples |
|---|---|---|
| Critique | 1 | Boot fragile sans ANTHROPIC_API_KEY env (#1) |
| Important | 4 | network call sous @Transactional watchlist (#2), LlmTimeoutService partiellement mort (#3), SSE jobId pas authentifié contre symbol (#4), spec manquante LlmTimeoutService (#5) |
| Modérée | 7 | RestClient recréé par appel Ollama (#6), wire name vs model Ollama pull (#7), i18n manquant dans pull dialog fallback (#8), pull bloque thread Tomcat (#9), réponse upstream pas inspectée (#10), pruneStale pas testé (#11), ConfigTestClient mention Tilt obsolète (#12) |
| Mineure | 5 | reset secrets sans confirm (#13), flottants dans getInt (#14), suggestions dupliquées (#15), test indexes positionnels (#16), duplication RestClient Ollama (#17) |
| Documentation | 4 | cache prefix claim faux (#18), LlmTimeoutService docstring stale (#19), developper.md cleanup orphelins déjà livré (#20), section pipeline DAG en avance — non-actionnable (#21) |
Reco de priorisation :
- Patcher
application.yml:43en${ANTHROPIC_API_KEY:}(5 min) — un fresh clone doit booter sans env var. C'est aussi la convention déjà adoptée pour les 2 autres clés. - Refacto
WatchlistService.addpour sortir les appels network de la transaction (30-60 min) — le pattern « LLM/network call hors transaction » est un invariant documenté du projet, le re-violer après l'avoir explicitement écrit dansarchitecture.mdest mauvais signal architectural. - Sweep
LlmTimeoutService: décider entre supprimermillis()+ retoucher la docstring + retoucher le commentaireconfiguration.ts:282, ou garder pour Phase 4 et marquer explicitement (15 min) — le drift code/doc/usage actuel est gênant à lire. - Ajouter le check
job.symbol == symbol.uppercase()sur le SSE controllerstreamJob(10 min) — discipline avant Phase 5 OAuth2, et c'est un test qu'il sera pénible de rétrofiter plus tard. - Aligner
OllamaStatusService.pullModelsurmodelplutôt quename(15 min, test inclus) — anticipe une rupture future d'Ollama, harmonise avecunloadModel. - Le reste des findings (i18n pull dialog, doc drifts, stale comments, spec
LlmTimeoutService) en cleanup commit dédié, ou groupés avec la prochaine feature dans la même surface.
Le tag v0.4.0 peut être posé après #1 et #4. #2 et #3 idéalement avant pour clore la Phase 2.5 sur une cohérence architecturale propre, mais ne sont pas bloquants pour la release.