Journal des livraisons — PortfolioAI
Historique des features livrées par phase, format reverse-chronological (Phase 2.5 récente en haut, Phase 0 fondation en bas). Pendant des features, ce fichier accueille la note détaillée d'implémentation ; le backlog.md voisin ne garde que les ⏳ À faire et la dette technique courante.
Convention : à chaque livraison, déplacer l'entrée du
⏳ À fairedebacklog.mdvers la section ✅ Livré de la phase correspondante dans ce fichier, en gardant les notes d'implémentation (le contexte historique). Cf.CLAUDE.md > Backlogpour la règle d'ordering détaillée.Pour le changelog du doc-set lui-même (audits doc-maintainer, drift narratives, refactos doc), voir
docs/CHANGELOG.md. Ce fichier-ci est consacré aux livraisons code + UX.Les tags git de clôture de chaque phase (
v0.1.0→v0.6.0, puisv0.7.0à venir Phase 5a) sont publiés en parallèle comme Releases GitHub — cf.docs/devops/release-process.md > Versioningpour la table phase↔tag et les règles de format.
Phase 5 — Déploiement (en cours)
Phase ouverte 2026-05-18. Provider retenu (après révision même journée) : Google Cloud Run + Supabase Postgres, $0/mo durable dans les free tiers, région compute Montréal native (
northamerica-northeast1), DB Supabase Toronto (ca-central-1) en mode Session pooler IPv4. Fly.io ($10/mo) et Oracle A1 Ampere ($0 + sysadmin léger) restent documentés en fallbacks. Détail de l'analyse et de la révision dansdocs/devops/deploiement.md. Tickets⏳ À fairerestants (devops/ skeleton, Provisionner v1, backup nocturne, Cloudflare, monitoring, hardening, releases) au backlog.
| Feature | Notes |
|---|---|
| ✅ Whitelist d'emails autorisés au login (gating accès, runtime-editable, ADMIN-only) | Livré 2026-05-19. Sécurité critique post-1er deploy public : avant ce ticket, l'app Cloud Run était ouverte sur internet (--allow-unauthenticated + Google OAuth sans gating) → n'importe quel compte Google qui découvre l'URL *.run.app complétait le flow, créait un row app_user USER, et accédait au dashboard / dossiers ticker / watchlist. APP_ADMIN_EMAILS ne gateait que l'attribution du rôle ADMIN à la création, pas l'accès en soi. Risque concret : un inconnu spam les LLM (credits Anthropic / Twelve Data / Finnhub gaspillés), pollue les snapshots narratifs cross-user (cohérence + bias scoring Phase 3 dégradés). Cible livrée : whitelist app.allowed.emails éditable runtime via /settings/access-control, ADMIN-only, suit le pattern Phase 2.5 SECRET slots — pas de redeploy nécessaire pour ajouter/retirer un email. Backend : (1) Nouveau slot ConfigKeys.ALLOWED_EMAILS = "app.allowed.emails" ajouté au registry runtime config (Phase 2.5 app_config table), nouveau set EMAIL_LIST_KEYS, ajout à KNOWN_KEYS. (2) ConfigValueType.EMAILS ajouté à l'enum DTO — la UI rend en mat-chip-grid ; la valeur n'est pas masquée (les emails ne sont pas secrets, l'admin doit voir la liste actuelle pour l'éditer). ConfigController.entryFor détecte le type EMAILS et populate currentValue. (3) AppConfigService.getAllowedEmails(): Set<String> — méthode utilitaire qui lit le slot (DB override > YAML default > vide), split sur virgule, trim, lowercase, drop blank, dedup. @Value("\${app.allowed.emails:}") injecté en constructeur (path standalone à la cacheTtlDefault). Helper internal fun parseEmailList(raw) extrait au bottom du fichier pour réutilisation tests. (4) Validation stricte dans AppConfigService.validate : chaque token comma-separated doit contenir @ et être non-blank après trim. Défend contre le typo "alice@x.com bob@y.com" (space-separated) qui serait stocké comme un single 19-char string que le gate ne matche jamais → silently-broken whitelist. (5) CustomOAuth2UserService.assertAuthorized(email) appelée en tête de findOrCreateUser, avant le findByEmail lookup (couvre nouveau ET existant — un row pré-gated ne peut plus relogin sans être ré-ajouté à la liste). Calcule effective = allowed ∪ adminEmails (union avec APP_ADMIN_EMAILS boot-time pour éviter le foot-gun "l'admin se lock out en retirant son email de la UI"). Si allowed est vide → mode laxiste (anyone goes, backward-compat fresh deploy). Sinon, throw OAuth2AuthenticationException(OAuth2Error("not_authorized")) si l'email pas dans effective. Le code "not_authorized" est lu par SecurityConfig failure handler. Injection AppConfigService ajoutée au constructeur (le path OIDC CustomOidcUserService partage findOrCreateUser — couvert automatiquement). (6) SecurityConfig ajoute failureHandler sur oauth2Login — AuthenticationFailureHandler SAM-conversion qui inspecte (exception as? OAuth2AuthenticationException)?.error?.errorCode et redirige /login?error=not_authorized si match, /login?error=oauth_failed sinon. Frontend : (7) Nouvelle page /settings/access-control dédiée (3e entrée sidenav settings, icon lock_person, gated adminGuard héritée du parent /settings). Composant standalone Angular 21 avec mat-chip-grid + chip-input directive (Enter/comma terminator). Lit ConfigRepository.list() au mount, parse le CSV, signal emails: string[]. Add/remove muent le signal localement, Save explicite serialise en CSV → PUT /api/config/app.allowed.emails. Asymmetrie save/reset importante : empty list → repo.reset(KEY) (DELETE, fallback YAML default = open mode), car le backend PUT rejette les valeurs blanches. (8) Banner "open mode" obligatoire quand la liste effective est vide — silently-open serait un foot-gun, la page doit shout. Plus banner info "Les emails ADMIN sont auto-inclus, pas besoin de les ré-ajouter". (9) Validation front mirror : si l'admin tape un token sans @, inline error message i18n invalidEmail avec le value en placeholder, pas d'ajout au chip list — défense côté UI avant la défense backend. (10) LoginPage étendu : lit ?error= queryParam via toSignal(route.queryParamMap), computed errorKey qui mappe not_authorized → auth.errors.notAuthorized et oauth_failed → auth.errors.oauthFailed. Banner inline above le bouton Google CTA, style .login-error (border + bg color-mix sur --color-danger). Code inconnu → degrade silently (jamais d'opaque error banner). (11) i18n FR + EN : nouvelles clés auth.errors.{notAuthorized,oauthFailed} + settings.accessControl (sidenav label) + settings.accessControlPage.{title,intro,adminNote,openModeNote,currentLabel,addPlaceholder,addAriaLabel,removeAriaLabel,save,reset,saving,saved,invalidEmail,loading,errors.{load,save,reset}}. Symétrie complète FR↔EN, copy axée sur "ce qui est en mode laxiste et comment activer le gating". (12) ConfigValueType TS union mise à jour avec EMAILS (alignment avec le DTO backend, ConfigEntry contract préservé). Tests backend : 6 nouveaux dans AppConfigServiceTest (helper newService étendu avec param allowedEmailsDefault, tests : default empty → empty set, parsed CSV lowercased/dedup, DB override over yaml default, malformed token rejected, well-formed accepted, whitespace-only tokens tolérés). 6 nouveaux dans CustomOAuth2UserServiceTest (helper service étendu avec param allowedEmails: Set<String>, mock AppConfigService injecté ; tests : open mode let stranger in, whitelisted email allowed, non-whitelisted rejected avec not_authorized code + 0 save + 0 findByEmail, admin auto-include override la liste, existing user pre-gated rejected quand son email plus dans la liste — invariant critique pin, case-insensitive match). Tests frontend : 4 nouveaux dans login-page.spec.ts (helper setup étendu avec queryParams: Record<string,string>, ActivatedRoute stub avec convertToParamMap + of(map) ; tests : not_authorized banner rendu, oauth_failed banner rendu en fallback, missing param silent, unknown param silent). 12 nouveaux dans access-control.spec.ts (stub ConfigRepository complet avec listResponse / setCalls / resetCalls / shouldError ; tests : open-mode banner conditional, chips render from CSV, parse lowercases + sorts + dedup, load error renders banner, add valid email + clear input, reject token sans @, dedup case-insensitive on add, remove drops chip, save non-empty PUTs CSV, save empty DELETEs via reset (asymetrie pinnée), save error inline, reset button always calls reset + reload). Activation post-deploy : après le merge + redeploy v0.7.0, la whitelist BDD est vide → mode laxiste maintenu, backward-compat. L'admin login passe normalement (toujours dans APP_ADMIN_EMAILS), ouvre /settings/access-control (nouvelle 3e entrée sidenav), pose le 1er email (le sien — auto-redondance OK, l'union avec adminEmails l'inclut déjà). À partir du prochain login d'un inconnu, le flow OAuth se termine par /login?error=not_authorized avec banner inline en clair. Fallback bootstrap documenté : si la liste BDD est vide ET APP_ALLOWED_EMAILS env var est vide, comportement = laxiste. La 1re activation se fait via UI runtime (pas via deploy.yml secret push — c'est l'intérêt du pattern Phase 2.5). Audit log hors v1 : qui a ajouté/retiré quel email + quand reste filed comme follow-up Phase 6 si jamais multi-admin émerge (table access_control_audit). Effort réel : ~3 h cumulé (lecture exhaustive de SecurityConfig + AppConfigService + CustomOAuth2UserService pour scoper la surface, 6 fichiers backend + 8 fichiers frontend + 2 i18n + 4 nouveaux fichiers composant + 2 specs étendues + 1 spec créée + journal + backlog + architecture decision entry). |
| ✅ Industrialiser le versionning — règles SemVer + workflow guard + cleanup AR + backfill 8 Releases historiques | Livré 2026-05-19. Discipline release-triggered formalisée : le namespace SemVer Artifact Registry était pollué (v0.7.0-dev1..dev4, 4 blobs orphelins du 1er bootstrap manuel Phase 5a) avant que les règles ne soient écrites, et les 8 tags git historiques (v0.1.0 → v0.6.0) n'avaient aucune Release GitHub correspondante. Ticket attaqué avant tout tagging post-v0.6.0 (donc avant v0.7.0) pour éviter que le drift devienne permanent. Livrables : (1) Section ## Versioning dans docs/devops/release-process.md — règles strictes (regex ^v[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?$ pour stable + -rcN pour pre-release, pas d'autre format autorisé), table historique phase↔tag (Phase 0 → Phase 5a à venir), convention Docker dev local (jamais de préfixe SemVer dans AR depuis un build local, fallback dev-<short-sha> ou dev-YYYYMMDD-N). Étape 2 du rituel allégée (renvoie sur la section). (2) Guard fail-fast dans .github/workflows/deploy.yml — nouvelle 1ère step Validate release tag format qui regex le tag_name de la Release avant tout build/push, fail avec ::error:: annotation explicite + lien vers la doc si le tag est hors format. Empêche le foot-gun « j'ai créé une Release avec un tag bizarre, le workflow build et push avec ce tag dans Artifact Registry ». (3) Cleanup Artifact Registry — gcloud artifacts docker images delete ...:v0.7.0-dev{1,2,3,4} --delete-tags --quiet. 4 blobs supprimés (les 4 itérations manuelles Docker du 1er bootstrap Phase 5a). v0.7.0-rc1 conservée (image légitime construite par le workflow lors du smoke). L'historique narratif des 5 itérations dev reste consigné dans le journal Phase 5a. (4) Backfill 8 Releases GitHub (v0.1.0 → v0.6.0) — gh release create avec notes hand-crafted : titre vX.Y.Z — Phase N (nom), 1 paragraphe titre + 2-7 bullets-clés tirés du journal + lien vers journal-livraisons.md. --latest=false sur les 7 plus anciennes, --latest=true sur v0.6.0 (plus récente stable, promue Latest). v0.7.0-rc1 reste Pre-release comme attendu (la liste finale a 9 entrées). Lecture utile en archive. (5) Cross-links doc — nouvelle section ## Versioning dans commit-conventions.md (renvoie sur release-process.md > Versioning), note d'intro dans journal-livraisons.md indiquant que les tags Phase X sont publiés comme Releases GitHub. Tests / validation : (i) cleanup AR vérifié via gcloud artifacts docker images list (4 blobs dev1..dev4 disparus, rc1 toujours visible), (ii) backfill vérifié via gh release list (9 releases visibles dont v0.6.0 "Latest" et v0.7.0-rc1 "Pre-release"), (iii) regex bash mentalement testée sur les 9 tags actuels + un dev-20260519-1 (le dernier fail comme attendu). Annotation : décision arbitrée de garder v0.7.0-rc1 (pas seulement parce qu'elle est légitime, mais parce qu'elle constitue le 1er artefact reproducible signature du workflow release-triggered — la supprimer effacerait la preuve concrète que la chaîne tag→Release→workflow→AR fonctionne end-to-end). Effort réel : ~1 h (analyse AR + écriture règles + patch yaml + 4 deletes parallèles + 8 releases hand-crafted en un script bash + cross-links). Pattern de collaboration : Claude crée/modifie les fichiers et exécute les commandes externes (gcloud delete, gh release create) après confirmation explicite via question chip pour les actions irréversibles ; user gère uniquement git add/commit/push. Cf. memory feedback_write_files_user_commits.md + feedback_no_commits.md. |
| ✅ Backup Postgres weekly → Cloudflare R2 (workflow + doc + rituel + restore drill) | Livré 2026-05-18, smoke vert end-to-end. Discipline d'exit posée dès le 1er deploy comme promis dans le ticket. Notre archive pg_dump standard est désormais indépendante de Supabase — restorable sur n'importe quel Postgres (Neon, Fly, VPS, RDS…) sans format proprio. Livrables : (1) Workflow .github/workflows/backup-postgres.yml — cron 0 4 * * 0 (dimanche 4 AM UTC, hebdomadaire) + workflow_dispatch manuel pour spot backups, concurrency backup-postgres non-cancelable. WIF auth → install postgresql-client-16 depuis l'apt repo officiel Postgres (forward-compat vs PG 15 Supabase, +1 majeur de buffer) → fetch supabase-db-url Secret Manager → strip préfixe jdbc: pour libpq → pg_dump --no-owner --no-acl --format=plain | gzip > backup-<ISO-timestamp-UTC>.sql.gz → aws s3 cp vers R2 (S3-compatible) → prune les objets au-delà des 30 plus récents (~7 mois d'historique au rythme weekly) → summary markdown dans le run page. (2) Doc docs/devops/backup-process.md — quick links (R2 bucket UI + API tokens + workflow Actions + CLI listing), rituel automatique, setup pas-à-pas (bucket R2 + 3 GH secrets R2_* + 3 GH vars repo-level GCP_* + grant secretmanager.secretAccessor à github-deploy@ per-secret), procédure restore drill trimestriel manuel (download → gunzip → psql sur Neon free temporaire → sanity SELECT count() → teardown), follow-ups hors v1 (alert staleness >36h, encryption client-side, cross-region replication, restore drill automatisé), section « Pièges rencontrés au 1er setup » (2 gotchas observés et documentés pour épargner du debug à un futur setup). (3) docs/devops/liens-utiles.md enrichi — nouvelle section Cloudflare R2 avec liens directs (bucket / dashboard / API tokens) + bloc CLI aws s3 configuré pour pointer R2, entrée nav backup-postgres.yml côté GitHub Actions, ajout de release-process.md + backup-process.md aux références projet. (4) Entrée mkdocs nav sous Devops. Cadence : démarrage en daily envisagé, finalement weekly retenu (worst-case data loss 7j vs 24h, beaucoup moins de bruit, suffisant pour un solo dev où Supabase free tier fait déjà des snapshots quotidiens 7j en parallèle comme filet court — notre backup R2 est le filet long-terme indépendant). Mitigation manuelle : gh workflow run backup-postgres.yml à la demande avant une opération risquée. Setup GCP : grant roles/secretmanager.secretAccessor au SA github-deploy@ sur le seul secret supabase-db-url (per-secret, principe du moindre privilège — github-deploy@ reste sans accès aux 3 autres secrets OAuth/admin). Setup Cloudflare : compte Cloudflare créé + R2 activé (free tier 10 GB) + bucket portfolioai-backups + API token scopé Object Read & Write sur ce seul bucket + Account ID 8f2780696b5e520f85b5fc80413c4c3f. Setup GitHub : 3 secrets R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY + 3 vars repo-level GCP_PROJECT / GCP_WIF_PROVIDER / GCP_SA_EMAIL (décision : repo-level et pas env-scoped production, parce que le required reviewer de l'environment bloquerait le cron à 4h du matin — les 3 identifiers GCP ne sont pas des secrets, donc no-risk côté sécu). Smoke : 2 itérations pour atteindre le vert end-to-end — (1) workflow_dispatch initial échoue sur auth failed: must specify exactly one of workload_identity_provider parce que les vars ${{ vars.GCP_* }} étaient env-scoped uniquement → fix promoting au repo level, (2) 2e run échoue sur upload R2 avec Credential access key has length 10, should be 32 parce que mauvais paste depuis screenshot Cloudflare (4 champs affichés, Token value ≠ Access Key ID) → re-set des 2 secrets R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY avec les vraies valeurs hex 32/64 chars. Run #3 vert, dump confirmé visible côté UI Cloudflare R2. Annotation observée : warning google-github-actions/auth@v2 + setup-gcloud@v2 runs sur Node 20 (deprecated 2026-09-16, default Node 24 dès 2026-06-02). Déjà filed comme dette technique 🟢 dans le backlog du ticket Workflow Releases — pas bloquant, à traiter en bumpant @v3 quand Google sort la version Node 24. Effort réel : ~2 h cumulé (workflow + doc + setup manuel Cloudflare/GitHub/GCP + 3 itérations smoke + debug 2 pièges + journal). Hygiène post-livraison* : screenshot creds R2 supprimé après confirmation que les secrets GitHub étaient bien posés (sinon leak en attente sur le bureau du Mac). |
✅ Workflow deploy.yml Cloud Run + rituel Release-triggered + release-process.md |
Livré 2026-05-18, smoke v0.7.0-rc1 vert end-to-end. Discipline release-triggered, pas push-triggered (décision figée) : un deploy = un acte conscient via création d'une Release GitHub, pas un effet de bord d'un merge master. La CI continue de tourner sur push (backend / frontend / CodeQL / docs) mais ne touche pas à Cloud Run. Livrables : (1) .github/workflows/deploy.yml — trigger on: release: published (pre-releases -rcN inclus, voulu pour smoke tester une PR à risque avant le tag final), concurrency: deploy-production + cancel-in-progress: false (jamais deux deploys parallèles, jamais d'interruption mid-deploy qui laisserait Cloud Run inconsistent), environment: production avec required reviewer (auto-approve self, mais audit trail naturel), permissions: { contents: read, id-token: write } pour WIF. Pipeline : actions/checkout@v4 → docker/setup-buildx-action@v3 → google-github-actions/auth@v2 via WIF → setup-gcloud@v2 → gcloud auth configure-docker → docker/build-push-action@v6 avec platforms: linux/amd64 (natif amd64 sur ubuntu-latest, pas de QEMU émulation contrairement à un build Mac M1, gain ~5-10×) + labels OCI standard (org.opencontainers.image.source/revision/version) → gcloud run deploy portfolioai --service-account=portfolioai-runtime@... --update-secrets avec 4 secrets mountés (SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_ID/SECRET, APP_ADMIN_EMAILS, SPRING_DATASOURCE_URL) + --set-env-vars SPRING_PROFILES_ACTIVE=prod + flags resources (--memory=1Gi --cpu=1 --max-instances=3 --min-instances=0 --timeout=300 --port=8080) → smoke curl /actuator/health post-deploy + résumé markdown dans $GITHUB_STEP_SUMMARY (release / commit / image / URL). Pas de APP_FRONTEND_URL explicite — le default / du base application.yml marche puisque backend + SPA sont même origine (Angular embarqué dans le jar via static/). (2) docs/devops/release-process.md — rituel pas-à-pas (tag → Draft Release → Publish → approve production environment → workflow part → smoke browser), 3 variantes de tag CLI (gh release create direct, tag séparé puis Release, brouillon avant publish), cas particuliers (hotfix, rollback rapide via UI Cloud Run vs propre via rebuild, release supprimée, failed deploy), section pre-releases / RCs, tableau des inputs déjà câblés (renvoie sur prod/README.md + deploiement.md). (3) Entrée nav mkdocs.yml sous Devops. (4) docs/projet/backlog.md augmenté — 3 tickets ajoutés en session (versionning industrialisation 🟡 — drift git vs Artifact Registry dev1..dev5, DNS analyse 🟢, persister préfs user backend 🟡). Smoke : gh release create v0.7.0-rc1 --target master --prerelease --generate-notes --title "..." → workflow déclenche → required reviewer approve dans Actions UI → build amd64 (~3-5 min) → push AR → gcloud run deploy → smoke /actuator/health vert → service URL retournée dans le summary. End-to-end opérationnel. Effort réel : ~1 h cumulé (workflow + doc + 3 nouveaux tickets backlog + entrée nav). Annotation observée : warning Node.js 20 deprecation sur les actions google-github-actions/auth@v2 + setup-gcloud@v2 — déjà filed comme dette technique 🟢, à traiter en bumpant @v3 quand Google sort la version Node 24. Pas bloquant (June 2 2026 = Node 24 devient default, September 16 2026 = Node 20 retiré). |
✅ Identité visuelle frontend + landing /login + wildcard 404 |
Livré 2026-05-18. Trois chantiers liés sur une même session post-deploy Phase 5a. (1) Brand mark — nouveau logo « Reader » : un P stylisé contenant 3 lignes de paragraphe → métaphore directe du positionnement « the LLM is a writer, not a decider » (CLAUDE.md). Tout vectoriel, deux couleurs paramétriques --logo-tile + --logo-mark définies dans styles.scss sur :root (dark default = tuile blanche + tracé sombre, pour pop sur toolbar mat-toolbar color="primary" violet) et [data-theme='light'] (inversées). Le SVG frontend/public/img/logo/logo.svg consomme les vars via fill="var(--logo-tile, ...)" + stroke="var(--logo-mark, ...)" avec fallback inline ; comme MatIconRegistry inline le SVG dans le DOM, le toggle de thème via ThemeService propage instantanément, sans rebuild. Enregistré une seule fois au boot via provideAppInitializer(() => inject(MatIconRegistry).addSvgIcon('portfolioai', sanitizer.bypassSecurityTrustResourceUrl('img/logo/logo.svg'))) dans app.config.ts, puis consommé partout via <mat-icon svgIcon="portfolioai"> — toolbar App et landing /login, zéro duplication, zéro SVG inline. (2) Favicon — frontend/public/favicon.svg indépendant du logo in-app (tuile arrondie + P), adaptatif via prefers-color-scheme (OS-level, pas app-level — cohérent avec un favicon qui suit le tab du navigateur). Plus de PNG : tout vectoriel, fini les renders multi-tailles favicon-16.png/32.png/192.png/etc. (3) Landing /login — refonte de la page connexion en landing minimaliste : hero (logo + h1 + tagline « L'analyse de marché qui se lit comme une histoire, pas comme un signal d'achat ») + 3 feature cards (Indicateurs calculés avec query_stats, Narration IA avec auto_stories, Portefeuille connecté avec account_balance_wallet) + CTA Google + disclaimer. Plus de card framing centrée — fond plein viewport var(--color-bg), max-width 760 px, grid 3 cols qui stack sous 720 px. Nouvelles clés i18n FR+EN sous auth.login.tagline + auth.login.features.{indicators,narrative,portfolio}.{title,description}, ancien subtitle retiré. Test login-page.spec.ts ajusté sur le nouveau set de keys i18n. (4) <title>PortfolioAI</title> dans index.html (avant, le boilerplate Angular CLI <title>Frontend</title> restait affiché dans l'onglet — Angular Router ne touche document.title que si une route déclare title:, donc inutile de polluer chaque route, le tag HTML suffit). (5) Wildcard 404 (clôture du ticket Phase 5 « Revoir les redirections Angular ») — { path: '**', redirectTo: 'dashboard' } ajouté en fin de app.routes.ts. Les autres points de l'audit étaient déjà couverts par l'existant : '' → dashboard (présent), /settings → /settings/configuration (présent dans les enfants), authGuard + adminGuard câblés sur les routes sensibles (/observability/**, /settings/**), params /ticker/:symbol gérés par les composants. Code modifié : frontend/public/favicon.svg + frontend/public/img/logo/logo.svg (création) + frontend/src/index.html (title + favicon link) + frontend/src/styles.scss (vars --logo-*) + frontend/src/app/app.config.ts (MatIconRegistry registration) + frontend/src/app/app.{html,scss} (toolbar mat-icon) + frontend/src/app/app.routes.ts (wildcard) + frontend/src/app/features/login/login-page.{html,scss,spec.ts} + frontend/public/i18n/{fr,en}.json. Pourquoi ce regroupement : le redesign logo a déclenché le besoin d'un point d'entrée propre (la landing) qui le met en valeur ; le wildcard 404 était un petit fix résiduel cohérent avec la session. |
| ✅ Provisionner et déployer v1 sur Cloud Run + Supabase (Phase 5a bootstrap manuel) | Livré 2026-05-18. Premier deploy public end-to-end fonctionnel. URL : https://portfolioai-912181505110.northamerica-northeast1.run.app/. Coût : $0/mo confirmé (sous tous les free tiers). 5 itérations d'image Docker pour atteindre un état working, avec les bugs réels rencontrés (vs. estimés en deliverable) : (1) dev1 — build ARM64 sur Apple Silicon Mac M1 → Cloud Run refuse parce qu'il ne supporte que linux/amd64. Fix : --platform linux/amd64 au docker build (forcé via QEMU émulation, ~10-15 min de build vs 3-5 natif — non-issue en CI puisque les runners GitHub Actions tournent déjà sur ubuntu-latest amd64). (2) dev2 — cp: target './backend/src/main/resources/static/' is not a directory : le Stage 2 du Dockerfile faisait cp -r ./frontend/dist/*/browser/* ./backend/src/main/resources/static/ sans que static/ existe physiquement dans le repo (Spring Boot le crée à la volée mais pas pendant le build). Fix : mkdir -p ./backend/src/main/resources/static/ avant le cp. Bonus aligné même temps : bump node:20-alpine → node:24-alpine au Stage 1 frontend-builder (anticipe la dette technique 🟢 sur GitHub Actions Node 20 deprecation juin 2026). (3) dev3 — SecurityConfig bean creation failed, Could not resolve placeholder 'APP_FRONTEND_URL' : mon application-prod.yml v1 déclarait app.frontend-url: ${APP_FRONTEND_URL} sans default, alors que le base application.yml avait ${APP_FRONTEND_URL:/} (default /). Mon override prod écrasait le base avec une version cassée. Diagnostic après lecture du base : le placeholder était unresolvable parce que je n'avais volontairement pas posé APP_FRONTEND_URL au deploy (espérant que le base default / prendrait — mais mon override écrasait ce default). Fix structurel : supprimer le bloc app: ET le bloc spring.security.oauth2.client.registration.google: de application-prod.yml — le base les gère déjà et le relaxed binding env vars Cloud Run alimente Spring directement (les 3 env vars APP_ADMIN_EMAILS + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_ID/SECRET mountées depuis Secret Manager auto-câblent la registration via le pattern Spring spring.security.oauth2.client.registration.<name>.<field>). Plus de double déclaration + risque de placeholder unresolvable. Insight au passage : application.yml base a un commentaire ligne 36-55 qui explique exactement pourquoi le bloc OAuth est intentionnellement absent — ClientRegistration.Builder.build() crash sur Assert.hasText(client-id) si une registration est déclarée avec valeur vide. (4) dev3 ✓ smoke — curl /actuator/health retourne HTTP/2 200 {"status":"UP"} + CSRF cookie Secure + HSTS 1 an + headers security tous OK via Google Frontend, mais GET / retourne 401 dans le browser. Cause : SecurityConfig v1 conçu pour l'archi dev Phase 4 où la SPA Angular tourne sur :4201 (origine séparée du backend :8081), donc seuls /actuator/health, /login/**, /oauth2/**, /swagger-ui/** étaient permitAll. En prod avec SPA embarquée dans le jar Spring Boot (src/main/resources/static/ via le Dockerfile multi-stage), il faut permitAll les paths statiques Angular + les routes client-side. (5) dev4 — login Google end-to-end fonctionnel : ajout des permits statiques précis (/, /index.html, /assets/**, /i18n/**, /*.js, /*.css, etc.) dans SecurityConfig. Redirect URI https://portfolioai-912181505110.northamerica-northeast1.run.app/login/oauth2/code/google enregistrée manuellement dans Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client ID. Smoke test browser passé : ouvrir l'URL → auto-redirect SPA /login → click "Se connecter avec Google" → consent → callback → session posée → SPA atterrit sur /settings/configuration (route gated ADMIN parce que venet.julien@gmail.com est dans APP_ADMIN_EMAILS injecté depuis Secret Manager au boot → role ADMIN assigné à la création du User row Supabase par CustomOAuth2UserService.findOrCreateUser). Toute la stack Phase 4 OAuth tient en prod : CSRF cookie-based SPA, OIDC user service avec AppOidcUser carrying userId, app.frontend-url default / corrigé via relaxed binding, multi-tenant user_id FK. (6) dev5 — SPA fallback fix : SecurityConfig simplifié en requestMatchers("/api/**").authenticated() + anyRequest().permitAll() (couvre les routes Angular client-side comme /dashboard, /ticker/AAPL, /settings/configuration qui passaient Security mais 404aient ensuite parce que Spring n'avait pas de controller / fichier statique pour elles). + nouveau fichier backend/src/main/kotlin/com/portfolioai/shared/SpaFallbackConfig.kt (@Profile("prod") WebMvcConfigurer qui câble un resource handler /** avec PathResourceResolver custom : sert les fichiers statiques quand ils existent, retourne null pour les paths réservés api/ / actuator/ / oauth2/ etc. (laisse Spring router naturellement), fallback index.html pour tout le reste — Angular Router prend le relais côté browser). Code en place, deploy v0.7.0-dev5 pending la prochaine session (test refresh sur /dashboard + /settings/configuration + check que /api/me retourne toujours 401 et non index.html). GCP état final : projet trade-496613, service portfolioai Cloud Run région northamerica-northeast1, 5 images Artifact Registry (dev1..dev5), 4 secrets Secret Manager (3 OAuth/admin + supabase-db-url JDBC), 2 service accounts (deploy + runtime), Workload Identity Federation pool github + provider github, OAuth redirect URI enregistrée. Supabase état : projet portfolioai-prod région ca-central-1, schema vide → migration Flyway V1 appliquée par le 1er Spring boot (création des 11 tables : app_user, portfolio, portfolio_snapshot, asset, watchlist_entry, app_config, ticker_narrative_snapshot, ticker_narrative_job, prompt_template, prompt_score, flyway_schema_history interne). Code modifications : 4 fichiers — devops/prod/Dockerfile (Node 24 + mkdir static), backend/src/main/resources/application-prod.yml (suppression blocs app: + OAuth redondants), backend/src/main/kotlin/com/portfolioai/auth/infrastructure/security/SecurityConfig.kt (simplification permitAll), nouveau backend/src/main/kotlin/com/portfolioai/shared/SpaFallbackConfig.kt. Discipline confirmée : zéro SDK Supabase dans build.gradle.kts (juste DATABASE_URL JDBC standard), zéro dépendance Cloud Run-specific côté code, migration sortie testable (pg_dump + redeploy ailleurs ≤ 3 h). Annotation log Supabase Dashboard observée : relation "supabase_migrations.schema_migrations" does not exist (application_name: supabase/dashboard) — bénin, c'est la console Supabase qui essaie de lire SA table de migrations interne (jamais créée parce qu'on utilise Flyway et pas Supabase Migrations CLI). Ignorable. Effort réel : ~3 h sur cette session post-doc-set (5 itérations Docker build/push/deploy + debug log Cloud Run + patches successifs SecurityConfig + application-prod.yml + Dockerfile + SpaFallbackConfig + smoke test browser end-to-end). Suit : ticket Workflow GitHub Releases (🔴 promu pour la session suivante) qui automatise tout ça via on: release: published — l'utilisateur clique « Draft a new release » → tag v0.7.0 → Publish → workflow .github/workflows/deploy.yml se déclenche → docker build --platform linux/amd64 sur runner ubuntu-latest (natif amd64, pas de QEMU émulation, 5-10× plus rapide qu'en local Mac M1) + push Artifact Registry + gcloud run deploy avec les flags exacts validés ce soir. |
✅ Dossier devops/prod/ + commit des Spring profile YAMLs (scope révisé) |
Livré 2026-05-18. Ticket attaqué avec l'idée initiale de bouger Tiltfile + docker-compose.yml dans devops/local/ avec symlinks racine, puis scope révisé deux fois en discussion : (1) les symlinks sont confusants (fichiers visibles 2× dans les recherches IDE) → rollback total, fichiers infra restent à la racine, devops/local/ supprimé entièrement. (2) application-local.yml était gitignored historiquement mais ne contient aucun secret depuis Phase 4 (juste mock providers + Ollama wiring + JPA log overrides + springdoc activé + flyway.repair-on-migrate=true) → un-gitignore + commit à backend/src/main/resources/application-local.yml. Idem pour le stub application-prod.yml qui vivait temporairement dans devops/prod/ : git mv vers backend/src/main/resources/ (la convention Spring native, chargé automatiquement quand SPRING_PROFILES_ACTIVE=prod est posé par Cloud Run). État final : devops/ ne contient plus que devops/prod/ (Dockerfile multi-stage Node 20→Temurin 21→JRE runtime + service.yaml Cloud Run descriptor stub avec annotations scaling/healthchecks/secretKeyRef + README.md check-list). Les 3 YAMLs Spring (application.yml base + application-local.yml dev + application-prod.yml prod) cohabitent dans backend/src/main/resources/, tous committés, tous secret-free. Discipline gravée dans la doc : « ne jamais coller une clé API dans application-local.yml — le fichier est committé ; clés vivent en .env (gitignored) ou /settings/configuration UI runtime ». Doc-set patché : (a) CLAUDE.md Repository Structure + Local Development + Data & secrets + Backend conventions réécrits pour refléter le nouveau modèle, (b) technique/developpement.md tree mis à jour + paragraphes sur application-local.yml (gitignored→committé), (c) technique/developper.md section "Configurer le LLM" réécrite (path UI runtime + path .env boot-time, plus jamais YAML pour les clés Anthropic / TwelveData / Finnhub), (d) devops/prod/README.md check-list raccourcie (l'étape "déplacer application-prod.yml vers backend" disparaît, déjà faite), (e) .gitignore — ligne backend/src/main/resources/application-local.yml retirée + commentaire explicatif posé pour clarifier la nouvelle policy. Pourquoi ce trade-off final était la bonne réponse : (i) la convention Spring native (application-{profile}.yml sur le classpath) marche sans gymnastics --spring.config.additional-location, (ii) symétrie complète des 3 YAMLs dans un même dossier, (iii) fresh-clone-friendly (un nouveau dev a tout dès le git clone, plus de copy-paste depuis .example), (iv) les 2 settings dangereux-en-prod (flyway.repair-on-migrate=true, springdoc.api-docs.enabled=true) restent isolés au profil local par construction → safety préservée. Effort réel : ~1.5 h dont ~30 min de re-cadrage scope en discussion (le symlink-approach puis le rollback) + ~1 h d'exécution + doc-set sync. Pattern de collaboration : Claude écrit les fichiers directement (Dockerfile, service.yaml, README, application-prod.yml header, edits docs), user gère uniquement git add/commit/push — cf. memory feedback_write_files_user_commits.md. |
| ✅ GitHub Secrets + Environments vault (Workload Identity Federation GitHub ↔ GCP, 4 secrets pushés dans Secret Manager) | Livré 2026-05-18. Pipeline secret-management côté CI/CD posé end-to-end + validé via smoke test. Côté GCP trade-496613 (projet déjà existant depuis Phase 4 OAuth, réutilisé) : (1) Billing account lié (0159AE-56FF40-037FC8) — pré-requis bloquant Cloud Run / Artifact Registry. (2) 6 APIs activées : run, artifactregistry, secretmanager, iam, iamcredentials, sts. (3) 2 service accounts créés suivant le pattern GCP recommandé deploy-vs-runtime separation : github-deploy@ (rôles run.admin + artifactregistry.writer au project level + iam.serviceAccountUser sur le runtime SA — pas project level pour scoping strict) + portfolioai-runtime@ (rôles secretmanager.secretAccessor granté per-secret plutôt que project-level, principe du moindre privilège). (4) Workload Identity Pool github + Provider github avec issuer https://token.actions.githubusercontent.com, attribute mapping standard (subject, repository, repository_owner, ref) + attribute condition assertion.repository_owner == 'jv3n' (rejette tout token OIDC d'un autre owner — sécurité critique). Binding roles/iam.workloadIdentityUser sur github-deploy@ scopé via principalSet://.../attribute.repository/jv3n/trade. Aucun service account JSON key émis ni stocké — c'est tout l'intérêt du pattern. (5) Repo Artifact Registry backend créé dans northamerica-northeast1 (même région que Cloud Run = pull instantané au deploy, pas d'egress inter-région facturé). (6) 4 secrets pushés dans Secret Manager via stdin (valeurs jamais en clair dans shell history) : google-oauth-client-id + google-oauth-client-secret (creds OAuth Phase 4), app-admin-emails (whitelist ADMIN role), supabase-db-url (JDBC URL session pooler aws-1-ca-central-1.pooler.supabase.com:5432 avec creds inline + sslmode=require). Le runtime SA portfolioai-runtime@ a secretmanager.secretAccessor sur chacun des 4. Choix mode connexion Supabase : session pooler port 5432 retenu (vs direct IPv6-only ou transaction pooler 6543 qui ne supporte pas les advisory locks Flyway). Côté GitHub jv3n/trade : (7) Environment production créé avec required reviewer (jv3n self-approve documenté) + deployment branch policy master only (anti foot-gun, pas de deploy depuis une feature branch). (8) 3 Environment variables posées via gh variable set --env production : GCP_PROJECT=trade-496613, GCP_WIF_PROVIDER=projects/912181505110/locations/global/workloadIdentityPools/github/providers/github, GCP_SA_EMAIL=github-deploy@trade-496613.iam.gserviceaccount.com. Variables et non secrets parce que ce sont des identifiants publics (pas de credentials). (9) Secret scanning + Push protection activés au repo level (Settings → Code security). (10) Dependabot alerts activé en bonus. Côté Supabase : (11) Projet portfolioai-prod créé en région Toronto ca-central-1 (free tier), schema vide attendant Flyway au 1er boot Spring Boot. Smoke test workflow .github/workflows/smoke-wif.yml créé + déclenché manuel — 5 steps verts en 25 s, validant end-to-end : (a) OIDC token GitHub présenté, (b) GCP accepte (attribute condition matched), (c) impersonation github-deploy@ OK, (d) roles/run.admin exerce gcloud run services list sans error, (e) roles/artifactregistry.writer exerce gcloud artifacts repositories describe backend sans error. Required reviewer protection rule a aussi été exercée (workflow s'est mis en Waiting for review jusqu'au click Approve and deploy). Annotation observée : warning Node.js 20 deprecation sur les actions google-github-actions/auth@v2 + setup-gcloud@v2 (Node 20 default-disabled le 2 juin 2026, supprimé 16 sept 2026) — non bloquant pour le moment, filed comme dette technique 🟢 dans le backlog. Effort réel session entière : ~2 h (Étapes 1-9 du walkthrough step-by-step, 1 fichier créé .github/workflows/smoke-wif.yml). Pattern de collaboration émergent : Claude crée les fichiers directement avec Write/Edit pendant les walkthroughs, le user gère uniquement les git add/commit/push — sauvegardé en memory feedback_write_files_user_commits.md. |
| ✅ Analyse hébergement — choix Cloud Run + Supabase Postgres (révision intra-journée) | Livré 2026-05-18 en une session de cadrage Phase 5. L'utilisateur a demandé une évaluation de prio des tickets Phase 5 + reorder du backlog + ajout d'un ticket devops/ skeleton + attaque immédiate de l'analyse hébergement. Trajectoire de la décision (3 itérations sur la même journée) : (1) Analyse initiale → Fly.io retenu sur l'hypothèse « Ollama dispo en prod requis » (3 questions chip arbitrées : cheap mais migratable, GitOps strict, PaaS sans YAML, Ollama prod). Comparaison Fly/Railway/Render, 4 WebSearches pricing. Différentiateurs Fly : yyz Toronto ~5 ms latence, architecture multi-Machine pour Ollama sidecar, Fly Postgres unmanaged $7/mo. Plan phasé 5a $10/mo → 5b $35/mo (avec Ollama) → 5c $50+/mo. (2) 1ère clarification (contrainte #4) : utilisateur relax « PaaS strict » → « tout l'état infra dans le repo, IaC type Terraform/Kustomize/docker-compose acceptable ». Hetzner CX22 / Oracle A1 redeviennent éligibles. Documenté comme fallbacks dans deploiement.md §2 + §4. Reco Fly maintenue. (3) 2e clarification (LLM prod) : utilisateur tranche « pas d'Ollama en prod, uniquement Mock + Claude API ». La stack passe de ~6 GB RAM (avec Ollama qwen2.5:3b) à ~2 GB. Re-shortlist : Google Cloud Run + Supabase Postgres devient compétitif (free tiers $0/mo durable + région Montréal native côté compute) ; Oracle A1 devient overkill (24 GB pour ~2 GB) avec reclamation 7j idle inevitable ; Fly $10/mo perd son avantage Phase 5b qui ne s'amortit plus. Choix final retenu : Cloud Run + Supabase. Données comparatives finales : Cloud Run free tier (2M req + 360K GB-s + 180K vCPU-s + 1 GB egress N. America) stable depuis 2019 ; Supabase free (500 MB DB + 50K MAU + auto-pause 7j inactivité) globalement en croissance 2020-2026. Plan phasé révisé : (1) Phase 5a $0/mo — Cloud Run service northamerica-northeast1 (scale-to-zero, cold-start 1-3 s) + Supabase free US-East + Angular static embarqué dans le jar + Mock/Claude LLM, Ollama UI 503. (2) Phase 5b $0/mo encore — Cloudflare gratuit devant Cloud Run (custom domain + TLS + cache + bypass egress quota) + Healthcheck.io + Sentry hobby. (3) Phase 5c $0-25/mo — si free tier serre : migration Supabase → Neon free 30 min, ou upgrade Supabase Pro $25/mo, ou bascule Fly $10/mo plan C. Pipeline GitOps avec Workload Identity Federation : trigger on: release: published, GitHub Environment production avec id-token: write permission pour échanger OIDC token court-terme contre access token GCP via google-github-actions/auth@v2 — pas de service account JSON key dans GitHub Secrets, secrets runtime montés via gcloud run deploy --update-secrets depuis GCP Secret Manager. Discipline non-négociable dès le 1er deploy : (a) zéro SDK Supabase dans build.gradle.kts (uniquement DATABASE_URL JDBC standard), (b) zéro dépendance Cloud Run-specific dans le code applicatif, (c) backup pg_dump nocturne via cron GitHub Actions → Cloudflare R2 (free 10 GB) rétention 30 jours — rend la migration sortie indépendante de Supabase. Migration sortie : Dockerfile + Postgres standard, lock-in = Cloud Run service.yaml ~30 lignes uniquement, effort estimé ~2-3 h vers Fly / Neon / Oracle / VPS. Risques assumés documentés : Supabase free tier shrinkage possible (mitigé par backup nocturne + migration Neon en 30 min), Cloud Run egress 1 GB/mo (mitigé par Cloudflare devant), Supabase auto-pause après 7j (invisible quotidien, sensible après vacances), latence DB cross-région ~25 ms (invisible single-user), cold-start ~1-3 s. Livrable : docs/devops/deploiement.md — 10 sections (contexte, short-list, tableau comparatif, recommandation, plan phasé, pipeline GitHub Actions WIF, plan migration, risques, tickets dépendants, résumé décisions). Câblé dans mkdocs.yml sous le bucket Devops. Tickets dépendants filed/révisés : Provisionner v1 Cloud Run+Supabase (🔴), Backup Postgres nocturne pg_dump → R2 (🔴 promu — dès le 1er deploy), Cloudflare devant Cloud Run (🟡), Monitoring uptime + Sentry (🟡), ticket Phase 5b Ollama retiré (caduc). Effort réel session entière : ~4 h cumulé (3 itérations questions chip + 6 WebSearches pricing + 1 deep-dive Oracle A1 + écriture deliverable v1 + révision v2 post-clarification + reorder backlog 2× + journal + architecture + CHANGELOG + memory + mkdocs). |
Phase 4 — Authentification
Phase clôturée 2026-05-17. Sortie du mode single-user no-auth via OAuth2 Google OIDC + Spring Security côté backend,
AuthService+ interceptor +/login+ guards + page/errorstandalone côté frontend, CSRF cookie-based SPA pattern, migration multi-tenantuser_idFK surportfolio+watchlist_entry, provider gating UI, logs redaction (conventionuserIdUUID), Flyway squash V1→V10 en un seulV1__init.sql, DevX toggleBACKEND_AUTH_MODE(no-auth ↔ oauth). Les deux tickets résiduels (GitHub Secrets vault, hardening secret-management OAuth prod,forward-headers-strategy) sont passés en début de Phase 5 parce qu'ils dépendent du choix d'hébergement. Six commits, du563df74(foundation) àf9588c2(code review patches pré-tag).
| Feature | Notes |
|---|---|
| ✅ Auth v1.1 — OIDC fix + SPA-aware OAuth + CSRF + /error page + secrets refactor + DevX toggle | Livré 2026-05-17 (même journée que la foundation, itéré post-bring-up). 8 livrables enchaînés en réaction au flow de test du vrai login Google contre localhost. (1) Frontend /login + interceptor + guards + navbar gating — livré (1ère partie du « Hors-scope v1 » de la foundation) : route /login standalone avec bouton « Se connecter avec Google » (window.location.href = '/oauth2/authorization/google'), core/api/auth/{auth.repository,adapters/auth.http}.ts (getCurrentUser() + logout()), core/app-state/auth.service.ts (signal currentUser + computeds isAuthenticated/isAdmin, primé via provideAppInitializer, expose lastError + clearError()), core/http/auth.interceptor.ts (401 → /login + 5xx → /error, skip /api/me + /api/config), core/router/auth.guards.ts (authGuard + adminGuard), App component avec user menu (display name + logout) + role-gated nav (Observability + Settings cachés pour USER) + toolbar masqué sur /login et /error via isStandaloneRoute(). 4 tests Vitest (auth.service.spec + auth.guards.spec + login-page.spec + app.spec.ts stub). (2) Google OIDC support — bug observé : après le 1er login OAuth réussi, /api/me → 500 avec stack trace Unexpected principal type org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser — expected AppOAuth2User. Cause : Google avec scope openid déclenche Spring's OidcUserService (pas OAuth2UserService), donc mon CustomOAuth2UserService n'était jamais appelé. Fix : (a) nouvelle interface marker AppUserPrincipal { val userId: UUID } implémentée par les deux principal types ; (b) nouveau AppOidcUser extends DefaultOidcUser qui carry userId ; (c) nouveau CustomOidcUserService extends OidcUserService qui réutilise CustomOAuth2UserService.findOrCreateUser(...) (extrait pour le partage) ; (d) câblage des deux services via userInfoEndpoint { ep -> ep.userService(...) ; ep.oidcUserService(...) } dans SecurityConfig ; (e) AuthService.getCurrentUser cast sur l'interface AppUserPrincipal au lieu du type concret. Email volontairement absent de l'interface pour éviter le clash JVM signature avec DefaultOidcUser.getEmail() (compile error « Accidental override »). (3) SPA-aware OAuth dev flow (xfwd + forward-headers + frontend-url) — bug observé : après login Google réussi, atterrissage sur localhost:8081/ (backend) au lieu de localhost:4201/ (SPA) → Whitelabel 404. Pire : le cookie de session était posé sur localhost:8081, donc même en naviguant manuellement sur :4201, /api/me était unauthenticated. Cause : Spring construit le redirect_uri envoyé à Google depuis le Host qu'il voit ; sous proxy CLI avec changeOrigin: true, c'est le port backend. Fix : (a) xfwd: true dans frontend/proxy.conf.js (ajoute X-Forwarded-Host/Port/Proto) ; (b) server.forward-headers-strategy: framework dans application.yml (Spring lit les forwarded headers via ForwardedHeaderFilter) ; (c) app.frontend-url (env APP_FRONTEND_URL, défaut /) consommé par SecurityConfig.defaultSuccessUrl(...) pour override explicite en dev. Conséquence : OAuth dance entier passe par localhost:4201 du point de vue browser + Google, cookie de session scopé sur la bonne origine, SPA /api/me remonte la session naturellement. Redirect URI à enregistrer Google Cloud Console : http://localhost:<FRONTEND_HOST_PORT>/login/oauth2/code/google. (4) CSRF re-enabled (CodeQL finding) — CodeQL flagged csrf { it.disable() } comme « Disabled Spring CSRF protection » sur les deux filter chains. Fix : (a) CookieCsrfTokenRepository.withHttpOnlyFalse() (cookie XSRF-TOKEN lisible par JS, Angular's HttpClient le lit auto et set X-XSRF-TOKEN sur POST/PUT/PATCH/DELETE relatifs) ; (b) CsrfTokenRequestAttributeHandler plain (pas XorCsrfTokenRequestAttributeHandler par défaut Spring 6, qui obfusquerait le token et casserait la lecture SPA) ; (c) nouveau CsrfTokenResponseFilter (OncePerRequestFilter custom, inséré après CsrfFilter) qui touch csrfToken?.token à chaque request pour forcer l'écriture du cookie — sans ça Spring 6 résout le token lazy, le cookie n'est jamais écrit, la SPA n'a rien à forwarder et tout POST 403. CSRF enabled dans les deux filter chains (prod + local-no-auth) pour matcher le shape. 0 changement frontend (Angular's provideHttpClient inclut le XSRF interceptor par défaut avec les bons noms cookie/header). (5) Page /error + AuthService graceful 500 — bug observé : un 500 sur /api/me au boot crashe le bootstrap Angular (provideAppInitializer error = bootstrap failure). Fix : AuthService.refresh catch tous les errors (401 → null + clear lastError, autre → null + record lastError), retourne toujours Observable<void> complet. Page /error standalone créée (features/error/) avec 2 actions (logout + retry, ou retour /login) + détails techniques (HTTP status + URL + lastError). Interceptor route les 5xx sur /api/** (sauf /api/me + /api/config) vers /error avec query params. App.isStandaloneRoute() couvre désormais /login ET /error pour masquer la toolbar. (6) Secrets refactor — application-local.yml → .env — sécurité hygiene : tous les secrets (SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_{CLIENT_ID,CLIENT_SECRET}, APP_ADMIN_EMAILS, APP_FRONTEND_URL) sont déplacés dans .env (gitignored) avec doc dans .env.example. application-local.yml reste vide de credentials — uniquement les overrides de comportement dev (JPA verbose, llm.provider=ollama, springdoc enabled). Tiltfile serve_cmd source .env (set -a ; . ../.env ; set +a) pour exporter au sous-process gradle, Spring lit via relaxed binding. Pattern identique dev → CI → prod : seules les valeurs changent, les YAML committés sont stables. Distinction explicite documentée : les clés API Anthropic/Twelve Data/Finnhub restent runtime-editable via /settings/configuration UI (Phase 2.5 SECRET slots, table app_config) — pas dans .env. (7) DevX toggle BACKEND_AUTH_MODE + Tilt buttons — BACKEND_AUTH_MODE=no-auth|oauth dans .env, lu dans le shell du serve_cmd Tiltfile au runtime (pas au parse Starlark). 2 boutons cmd_button sur la ressource backend (« Mode → OAuth », « Mode → no-auth ») éditent .env + touchent application.yml pour relancer le serve_cmd dans le mode opposé. Le shell calcule les profiles : local,local-no-auth (no-auth, par défaut) ou local (oauth). Remplace l'ancien spring.profiles.group.local: local-no-auth hardcodé dans application.yml (qui aurait empêché le toggle runtime). Tiltfile ajoute aussi proxy.conf.js dans les deps du frontend pour que les modifs déclenchent un restart ng serve (sinon piège silencieux : edit xfwd: true, pas de restart, behavior inchangée). (8) Backlog + GitHub Secrets vault filed — entry « ⏳ GitHub Secrets + Environments comme vault de CI/prod » 🟡 ajoutée dans Phase 4 hardening : 5 axes (Actions Secrets, Environments per-env avec required reviewers, secret scanning + push protection, GitHub OIDC fédéré vers cloud provider, mapping local→CI→prod). Effort réel : ~3 h cumulés (test + diagnostic + 4 fixes successifs + cleanup secrets). Décisions notables : (a) Garder CustomOAuth2UserService même si Google OIDC est le seul provider actuel — facilite l'ajout d'un futur provider non-OIDC (GitHub OAuth sans scope openid) sans refacto. (b) CSRF activé même sous local-no-auth pour matcher le shape de prod — disabling en dev seulement ferait apparaître les bugs uniquement au switch en oauth mode, le pire moment. (c) Email absent de l'interface AppUserPrincipal — préférable au workaround @JvmField ou autre annotation pour le clash signature. (d) app.frontend-url explicite plutôt que de tout déléguer à xfwd — défensif, sert si un dev oublie de redémarrer le frontend après la modif proxy.conf.js. (e) Google OAuth creds restent en .env (pas runtime-editable) parce que Spring Security construit ClientRegistrationRepository au boot. Backlog filed pour rendre ça runtime-editable plus tard si pertinent (~2 h, custom ClientRegistrationRepository qui lit depuis AppConfigService). |
| ✅ Auth foundation backend v1 — User + OAuth2 Google + ADMIN/USER + local-no-auth | Livré 2026-05-17. Sortie : le backend peut désormais accepter un login OAuth2 Google, créer un row app_user avec un rôle (ADMIN via whitelist email, USER sinon), exposer GET /api/me pour l'introspection, et appliquer un gating ADMIN sur trois familles de routes (/api/config/**, /api/prompts/**, /api/narrative/observability/**). Le dev solo continue de bypass tout via le profile local-no-auth (actif par défaut sous local via spring.profiles.group.local: local-no-auth dans application.yml) — tilt up reste 0-friction. Décisions techniques arrêtées avant code : (1) Provider = Google OIDC seul en v1 (pas de multi-provider). (2) Forme = Option A « session cookie backend » (Spring Security oauth2Login() + JSESSIONID HttpOnly + SameSite=Lax) plutôt qu'un JWT bearer côté SPA — single-instance, minimise la surface XSS pour une app qui contient des positions financières, ~3× moins de code que la variante backend-signs-its-own-JWT. (3) Rôles = enum Role { ADMIN, USER }, désignation par email whitelist app.admin.emails (YAML + override possible via app_config runtime à terme), assignation one-shot à la création du user — re-évaluer la whitelist à chaque login écraserait une rétrogradation manuelle (UPDATE app_user SET role='USER') et c'est anti-intuitif. (4) Narratives partagées — pas de user_id FK sur ticker_narrative_snapshot ; coût LLM stable et cohérent avec la sémantique « narrative = digest du jour pour ce ticker, peu importe qui regarde ». (5) Backfill data model = reporté en ticket séparé ; V9 v1 ne crée que app_user, pas de FK ailleurs. Backend : (1) Migration V9 — table app_user (id UUID PK, email UNIQUE, display_name, provider, provider_id, role CHECK (ADMIN|USER), created_at, last_login_at). Nom app_user plutôt que user parce que user est un mot réservé PostgreSQL. (2) Domain auth/domain/User.kt + Role.kt. JPA entity avec var sur les fields mutables (cohérent avec WatchlistEntry), kotlin-plugin-jpa synthétise le no-arg constructor. (3) Persistence auth/infrastructure/persistence/UserRepository.kt — Spring Data JpaRepository<User, UUID> + findByEmail(email). (4) Security infra dans auth/infrastructure/security/ : (a) AppOAuth2User — custom OAuth2User qui carry le userId UUID en plus des attributs Google, pour que AuthService.getCurrentUser résolve le user sans re-lookup sur l'email à chaque request ; (b) CustomOAuth2UserService étend DefaultOAuth2UserService — bridge entre la réponse userinfo Google et le row DB, avec une méthode internal fun processOAuth2User(oauth2User, registrationId) extraite de loadUser pour la testabilité (pas besoin de MockWebServer pour exercer la logique post-HTTP) ; (c) SecurityConfig (@Profile("!local-no-auth")) wire oauth2Login() conditionnellement sur la présence du bean ClientRegistrationRepository (via ObjectProvider.ifAvailable) — sans ça le BackendApplicationTests.contextLoads casserait au boot sans creds OAuth, car Assert.hasText(clientId) jette dans ClientRegistration.Builder.build() ; (d) LocalNoAuthSecurityConfig (@Profile("local-no-auth")) — filter chain permitAll + addFilterBefore(localNoAuthFilter, AnonymousAuthenticationFilter) + FilterRegistrationBean.isEnabled(false) pour empêcher la double-registration globale ; (e) LocalNoAuthFilter (OncePerRequestFilter) injecte un AppOAuth2User synthétique sur chaque request ; (f) LocalNoAuthUserInitializer (CommandLineRunner) seed le row dev@local.test (ADMIN) au boot — idempotent. Routes ADMIN-only câblées : /api/config/**, /api/prompts/**, /api/narrative/observability/**. Le reste = authenticated(). Unauthenticated request → 401 (via HttpStatusEntryPoint) pas 302 — la SPA interceptor a besoin d'un status code propre pour décider du redirect /login. CSRF désactivé en v1 — same-origin SPA + cookie session, à ré-activer avant ouverture multi-origin. (5) Application auth/application/AuthService.kt : getCurrentUser() lit SecurityContextHolder + relit le user en BDD (fresh row pour qu'une modif SQL du rôle soit prise en compte sans relogin) ; isAdmin() trivial dérive ; les paths de wiring-bug (no auth, wrong principal type, deleted user) jettent IllegalStateException plutôt que retourner un faux user. (6) Controller auth/infrastructure/http/AuthController.kt — GET /api/me retourne {email, displayName, role} via CurrentUserDto ; logout = handler natif Spring POST /logout, pas de custom endpoint. YAML : application.yml ajoute spring.profiles.group.local: local-no-auth (la directive spring.profiles.include aurait été plus naturelle mais Boot 2.4+ l'interdit dans un fichier profile-specific) + clé app.admin.emails avec default vide ; pas de bloc OAuth2 client dans application.yml pour ne pas crasher le smoke test sans creds — le dev pose le block dans application-local.yml (gitignored) quand il veut tester le vrai flow Google. Tests : 4 nouveaux fichiers. (a) AuthServiceTest (5 tests) — happy path + wiring-bug paths (no auth, wrong principal, ghost user) + isAdmin true/false sur ADMIN/USER ; tear-down SecurityContextHolder.clearContext() pour éviter le leak inter-tests. (b) CustomOAuth2UserServiceTest (8 tests) — création USER, création ADMIN via whitelist (case-insensitive + tolerant whitespace), update existing (lastLogin/providerId/displayName) sans changement de rôle même si whitelist match, préservation du displayName sur blank Google response, missing email/sub → IllegalStateException. Le refactor processOAuth2User permet de tester sans HTTP. (c) AuthControllerTest (@WebMvcTest + @AutoConfigureMockMvc(addFilters = false)) — JSON shape pour ADMIN avec displayName, USER avec null displayName (Jackson sérialise null par défaut, le frontend tolère). (d) LocalNoAuthIntegrationTest (@SpringBootTest + @ActiveProfiles("local-no-auth") + MockMvc) — pin le triplet initializer/filter/controller : le row dev est seedé en BDD, GET /api/me retourne 200 sans aucune auth, role ADMIN. Patch collateral : 13 @WebMvcTest existants reçoivent @AutoConfigureMockMvc(addFilters = false) parce que spring-boot-starter-security désormais sur le classpath fait que @WebMvcTest charge SecurityAutoConfiguration qui lock down toutes les routes → 401 sur les assertions. Patch mécanique via script Python (idempotent), import ajouté avant WebMvcTest import dans chaque fichier. Hors-scope v1 (à attaquer) : (a) migration FK user_id sur portfolio / watchlist_entry / app_config / portfolio_snapshot + script backfill (user système pré-seedé vs premier-OAuth-claim — à trancher) ; (b) frontend /login + AuthService signal + interceptor HTTP redirect 401 → /login + canActivate guards ADMIN-only sur les routes settings/observability + navbar gating ; (c) redaction email dans log.info/log.warn avant Phase 5 deploy ; (d) CSRF tokens explicites si on ouvre à des origines tiers ; (e) 2e provider (GitHub OAuth) si pertinent. Effort réel : ~2 h sur la session (vs ~1 j estimé au backlog pour la v1 complète backend+front+migration). |
Livré 2026-05-16. Motivation : la racine core/ avait grossi à 14 repositories à plat + 2 services UI + 1 wiring + 1 dossier adapters/ mêlant HTTP et localStorage. Aligner sur la structure du backend (un bucket par module) et clarifier la nature des fichiers (port HTTP vs port persisté navigateur vs service UI signal sans counterpart distant). Réorganisation : (1) core/api/<bucket>/ héberge les 8 bounded contexts HTTP — market/, portfolio/ (regroupe Portfolio + Snapshot, le 2ᵉ étant l'historique du 1ᵉʳ), watchlist/, news/, analyst/, earnings/, config/, analysis/ (Phase 3 narrative + LLM infra : 4 repositories narrative + prompt + ollama-status, plus 3 services bucket-locaux ollama-status.service, job-stream.service SSE, llm-timeout.service). Chaque bucket : port à la racine + adapters/*.http.ts à côté. (2) core/local/annotation/ — seul habitant aujourd'hui, port + adapters/annotation.local.ts (localStorage). (3) core/app-state/ — theme.service.ts + language.service.ts (signal + persist localStorage, parallel shape, pas de split port/adapter parce qu'aucun counterpart distant). core/providers.ts reste à la racine et wire chaque bucket. Cross-bucket : llm-timeout.service (analysis/) importe ConfigRepository (config/) via ../config/config.repository — toujours valide après le move car les deux sont sous api/. Tests : ~27 fichiers patchés côté imports (features/ + app. + providers.ts), 384/384 verts après le refactor, lint clean. Décisions naming retenues : api/ (vs http/ ou remote/) — court, parallel avec local/, mental-model « d'où viennent les données » ; local/ (vs client-state/ ou client/) — matche l'extension .local.ts ; app-state/ (vs ui-state/) — extensible si on ajoute d'autres préférences cross-cutting (sidebar collapsed, etc.). Effets* : (a) un dev qui cherche le port d'un module backend descend directement sous core/api/<même-nom>/ ; (b) l'ajout d'un futur bucket persisté navigateur (cache offline, brouillon de form…) trouve sa place sans churn ; (c) la skill folders-structure-frontend et CLAUDE.md sont alignés. Commit suggéré : refactor(frontend): split core/ on 3 axes — api/, local/, app-state/.
✅ Page /settings/prompt-preview supprimée
Livré 2026-05-14. Friction : la page Phase 1 affichait le system + user prompt interpolé pour un ticker donné, sans appel LLM. Utile au moment de la livraison narrative pour valider la tokenisation et inspecter la sortie sur un cas réel ; depuis la Phase 3 l'éditeur /settings/prompts couvre la lecture / l'édition du template, et l'observability page couvre l'inspection de ce que le LLM a réellement produit. La preview interpolée n'a plus d'usage observé. Code supprimé :
- Frontend : dossier
features/settings/prompt-preview/(composant.ts/.html/.scss), route enfantprompt-previewdansapp.routes.ts, lien sidenav danssettings/settings.html, interfaceNarrativePromptPreview+ méthodegetNarrativePromptPreviewdu portMarketRepository+ implémentationHttpMarketRepository, test associé dansmarket.http.spec.ts. - Backend : endpoint
GET /api/market/ticker/{symbol}/narrative/preview+ méthodepreviewdansTickerNarrativeController, DTONarrativePromptPreviewDto, test sliceTickerNarrativePreviewControllerTest. Le contrôleur perd 3 dépendances injectées (TickerService,TickerNarrativePromptService+ importbuildNarrativeUserMessage) qui ne servaient qu'à cet endpoint —buildNarrativeUserMessagereste exporté carTickerNarrativeExecutorl'utilise. - i18n :
settings.promptPreview+ tout le blocsettings.previewPage.*retirés defr.jsoneten.json(~20 clés). - Docs : références dans
CLAUDE.md,architecture.md(overview Frontend ASCII + endpoints/narrative+ modulesettings/),developpement.md(arborescence repo),fonctionnalites.md(sidenav settings — note historique conservée pour mémoire),commit-conventions.md(scopesettings), etfolders-structure-frontend/SKILL.md(arborescence). Le journal-livraisons et CHANGELOG conservent les mentions historiques.
Hors scope : la fonction buildNarrativeUserMessage et le bean TickerNarrativePromptService restent en place — ils alimentent le pipeline narratif lui-même. Si un futur besoin de preview ressurgit (ex. valider une nouvelle version de prompt sur un ticker avant activation), il pourra se brancher sur l'éditeur Phase 3 en réutilisant ces composants — pas besoin de ressusciter cette page.
Phase 3 — Observabilité narrative
Phase ouverte 2026-05-10 sur la foundation observabilité (prompt management + scoring), étendue 2026-05-13 par la page observabilité narrative qui consomme le corpus, 2026-05-14 par le score de cohérence (#2), puis le même jour par la détection de biais (#3) qui élève le corpus en signal agrégé. Reste à attaquer : la page Jobs (#4) qui dépend du DAG unifié Phase 6.
| Feature | Notes |
|---|---|
| ✅ Détection de biais (Phase 3 #3) | Livré 2026-05-14. Sortie : nouvelle page /observability/bias (lien depuis l'index /observability) qui rend une vue agrégée du corpus narratif en 4 sections — sentiment distribution, calibration sentiment vs prix, couverture thématique des key_points, distribution des thumbs par sentiment. Filtres from / to / promptId mêmes que la timeline (pour comparer un prompt vs un autre). Backend (analysis/) : (1) NarrativeBiasQuery (native SQL) avec 3 round-trips — sentimentCounts (GROUP BY sentiment), thumbsBySentiment (LATERAL pour le dernier prompt_score par snapshot puis GROUP BY × SUM(CASE WHEN ...)), rawSnapshots (cap 2000 — au-delà le fan-out chart sature). (2) NarrativeBiasService (@Service) compose les 3 queries + enrichit côté Kotlin : (a) sentiment distribution zéro-padded sur les 3 buckets BULLISH/NEUTRAL/BEARISH (un bucket vide reste rendu — « zero BEARISH » est lui-même un signal), bias flag déclenché à >= 60 % (seuil produit, pinné dans le test). (b) calibration : group snapshots par symbol → MarketChartClient.fetchChart une fois par unique symbol (cache-friendly via Caffeine — symbols déjà ouverts dans les dossiers tickers sont warm). Pour chaque snapshot, calcule delta 1d/1w/1m vs son prix, puis moyenne par sentiment en filtrant les nulls (window non écoulée / upstream missing). Reporte snapshotsWithDelta1d/1w/1m à côté de la moyenne pour que le user juge la significativité. Dégradation gracieuse MarketUnavailableException par symbol — calibration de ce symbol → null contributions, les 3 autres sections tiennent. (c) topic coverage : tokenise key_points avec regex [a-z][a-z0-9]* (préserve « ma200 » / « rsi62 », exclut bare numbers « 62 »), filtre stopwords (~80 mots EN : articles + filler verbes + finance generic « stock / price / level ») et tokens < 3 chars, compte par snapshot pas par occurrence (« rsi mentionné dans 38/47 snapshots », pas « rsi mentionné 142 fois »), top-15 trié desc. (d) thumbs distribution par sentiment, zéro-padded comme sentiment distribution. (3) DTOs : NarrativeBiasResponse envelope avec les 4 sections + snapshotsConsidered total ; sous-DTOs SentimentDistributionDto (avec BiasFlagDto?), CalibrationBucketDto, TopicCoverageDto + TopicDto, ThumbsBucketDto. (4) Endpoint GET /api/narrative/observability/bias?from=&to=&promptId= ajouté au NarrativeObservabilityController existant — déclaré avant /{symbol} pour que Spring matche le segment littéral en priorité (sinon bias serait bindé comme symbol path-variable). Frontend (features/observability/bias/) : (1) port + adapter NarrativeBiasRepository + HttpNarrativeBiasRepository câblés dans core/providers.ts. (2) route lazy /observability/bias déclarée avant /observability/:symbol dans app.routes.ts (même piège routing). (3) BiasPage standalone signal-based avec : header back-link vers /observability + intro ; filter bar (date range + prompt dropdown + reset) calquée sur la page observability ; computed isEmpty (load OK + snapshotsConsidered === 0), hasActiveFilter, maxThumbsTotal (utilisé pour scaler les segments des bars thumbs au max cross-sentiment — sinon BEARISH avec 20 votes paraîtrait aussi plein que BULLISH avec 100). (4) 4 cards de section : (a) sentiment bars horizontales colorisées (vert/grey/rouge) avec % + count + chip « biais suspecté » optionnel ; (b) calibration table sentiment × delta1d/1w/1m avec cellules colorisées par signe + count contributing snapshots ; (c) topic pills monospace triées desc avec count + % ; (d) thumbs stacked horizontal bars (up vert / neutral grey / down rouge / no-vote outline pointillé) scalées par thumbsBarWidth(value) au max global. i18n : ~30 nouvelles clés FR + EN sous biasPage.* (titre, intro, 4 sections labels + hints, bias flag template avec interpolation sentiment / percent / threshold, ARIA labels pour les bars thumbs). Tests backend : (a) NarrativeBiasServiceTest (10 tests) : sentiment distribution zero-pad (zero BEARISH rendu), bias flag à exactement 60 % vs 59 %, bias flag null sur empty corpus (no division by zero), calibration moy. delta1d skipping nulls (3 snapshots → 2 contributing → +3 % moyenne), one chart fetch par unique symbol (5 snapshots × 2 symbols = 2 calls), graceful degradation MarketUnavailableException sur 1 symbol (autres tiennent), topic count par snapshot pas par occurrence (« rsi » 3× dans 1 snapshot = 1), topic stopwords + filler filtered, thumbs distribution zero-padded. (b) NarrativeObservabilityControllerTest étendu (+3 tests) : route /bias literal résolue avant {symbol}, filtres from / to / promptId round-trip, JSON shape complet (sentimentDistribution.buckets, calibration[].snapshotsWithDelta, topicCoverage.topics[].topic, thumbsDistribution[]). Tests frontend : (a) narrative-bias.http.spec.ts (3 tests) : URL + no-params default + filter wire-up + empty-string omission. (b) bias.spec.ts (12 tests) : init findBias(undefined), prompts dropdown loaded on mount, isEmpty branch, error banner, bias-flag chip présence/absence selon réponse, deltaClass rules (4 cas), thumbsBarWidth cross-sentiment scaling (BULLISH 80/100 = 80 %, BEARISH 10/100 = 10 %), thumbsBarWidth empty-corpus = 0 (pas de NaN sur Math.max), applyFilters wire avec to extended J+1, collapse-to-undefined sur empty, hasActiveFilter reset. Effort réel : ~3 h (vs 3-4 h estimés). Décisions notables : (1) heuristique sur tokenisation plutôt que stemming / lemmatisation (NLP-lib aurait été overkill) — la regex [a-z][a-z0-9]* + stopwords list capture 90 % de la valeur sans dépendance, le 10 % restant (« momentum » vs « momenta » comptent comme 2 tokens) est acceptable pour un signal qualitatif. (2) count by snapshot, not by occurrence — un narratif verbeux qui répète « rsi » 5× ne doit pas écraser la distribution. (3) calibration en mémoire plutôt qu'une nouvelle table snapshot_delta pré-computée : (a) chart cache amortit le coût, (b) éviter une table dérivée tant que les chiffres ne sont pas stables. (4) pas de chart pour stopwords FR — les key_points sont en EN par construction du prompt (cf. NARRATIVE_SYSTEM_PROMPT), un Ollama local qui produirait FR bleed-through serait visible dans la liste et le user pourrait l'ignorer. (5) page séparée /observability/bias plutôt que sous /settings/prompts/:id/bias per-prompt — les biais sont systémiques (prompt-agnostiques v1), le filtre promptId permet le slice per-prompt sans dupliquer la page. Effets cumulés : combinée avec Phase 3 #1 (timeline per-symbol), #2 (chip cohérence par row), et la foundation prompt management (#PR1-6, livrée 2026-05-10), on a maintenant une boucle d'audit narrative complète* : (a) regarder un narratif en isolation (dossier ticker), (b) le comparer à son précédent (chip cohérence sur la timeline), (c) lire la timeline reverse-chrono per-symbol (/observability/:symbol), (d) prendre du recul sur le corpus complet (/observability/bias). Phase 3 #4 (page Jobs DAG) reste filed mais est bloquée par le ticket fondateur Phase 6 (DAG unifié). |
| ✅ Score de cohérence cross-runs (Phase 3 #2) | Livré 2026-05-14. Sortie : chaque card de la timeline /observability/:symbol porte désormais une chip « Cohérence vs précédent » colorisée (OK muted / WARN ambre / HIGH rouge), avec un tooltip qui surface les 3 sous-mesures (sentiment / mots-clés communs / longueur résumé) + le mouvement de prix entre les deux runs qui « excuse » — ou pas — la divergence. La 1ère card de la timeline (la plus ancienne, sans précédent) n'a pas de chip. Décision design : heuristique pure (pas de LLM-as-judge) pour rester (a) gratuit (cost = 0 LLM calls additionnel par snapshot scoré), (b) déterministe (deux runs sur le même corpus produisent le même verdict — utile quand on debug le prompt), (c) transparent (le user lit les 3 sous-mesures dans le tooltip et peut re-dériver le verdict à l'œil). LLM-as-judge reste filed comme évolution v2 si le signal heuristique se révèle insuffisant — Claude Haiku noterait alors la cohérence sémantique au-delà de Jaccard, mais on ne s'y engage pas v1. Backend (analysis/) : (1) Domain CoherenceScore (data class) + enums Verdict ∈ {OK, WARN, HIGH} et SentimentChange ∈ {SAME, PARTIAL, FLIPPED}. PARTIAL = un cran sur l'échelle BULLISH ↔ NEUTRAL ↔ BEARISH ; FLIPPED = traversée complète BULLISH ↔ BEARISH (le signal le plus fort qu'on mesure). (2) CoherenceScorer (@Component, pure function) : score(current, previous, currentPrice, previousPrice) retourne le CoherenceScore. Algorithme : weightedDivergence = 0.55 * sentimentDiv + 0.30 * keypointsDiv + 0.15 * lengthDiv, puis adjusted = (weighted - 0.5 * priceExcuse).coerceAtLeast(0), où priceExcuse = min(|priceMove| / 0.05, 1.0) (un swing de 5 % couvre intégralement la divergence sentiment-only ; en deçà, ramp linéaire). Verdict via thresholds : >= 0.55 → HIGH, >= 0.25 → WARN, sinon OK. Pondérations : sentiment dominant (le signal le plus visible côté UI), mots-clés intermédiaire (writing variance normale entre prompt v2 et v3), longueur la plus faible (verbose ≠ contradictoire). Jaccard sur key_points normalisés (lowercased + trimmed) — deux narratifs qui phrasent « RSI 62 » vs « rsi 62 » comptent comme identiques. Length ratio capé à 9.99 pour éviter les degenerate ratios sur summary 1-char. priceMoveBetween = null quand previousPrice <= 0 (défensif — price est NUMERIC(18,4) NOT NULL mais une corruption ne doit pas NaN la row). (3) DTO CoherenceScoreDto (verdict + sentimentChange + keyPointsJaccard + summaryLengthRatio + priceMoveBetween + previousSnapshotId + previousGeneratedAt) ajouté en champ optionnel à NarrativeObservationDto. (4) Câblage NarrativeObservabilityService : les rows arrivent reverse-chronological, la précédente pour i est i+1 ; la dernière row (oldest) a coherence = null. Ré-parse du keyPointsJson du previous (timeline cap 500, < 1 ms cumulé) plutôt qu'un cache cross-rows. Frontend (features/observability/) : (1) interface CoherenceScore côté repo Angular + champ optionnel coherence sur NarrativeObservation. (2) Chip chip-coherence-{ok|warn|high} rendue dans .card-header-left après le chip prompt et avant l'icône thumbs ; [hidden] quand coherence === null. (3) coherenceTooltip(score) dans observability.ts qui assemble 5 lignes (titre vs narrative from {{date}}, sentiment localisé, % Jaccard arrondi à l'entier, ratio formaté ×1.50, mouvement de prix signé +5.00% ou -3.40%). Branche priceMoveUnknown quand priceMoveBetween === null pour éviter +NaN%. Volontairement [title] natif et pas MatTooltip — chip lightweight, zéro overlay CDK, zéro DI supplémentaire. i18n : 11 nouvelles clés FR + EN sous observabilityPage.coherence.* (label, 3 verdicts, 5 lignes de tooltip dont la priceMoveUnknown, 3 valeurs sentimentChange). Tests backend : (a) CoherenceScorerTest (9 tests) : narratif stable + flat → OK ; flip BULLISH→BEARISH sur tape flat → HIGH (le scénario raison-d-être de la chip) ; flip + 5 % move → OK (price excuse complet) ; key_points disjoints + sentiment SAME → WARN max (writing variance jamais HIGH) ; longueur ×3 + sentiment SAME → OK (length seul ne déclenche pas) ; échelle PARTIAL pour NEUTRAL ↔ BULLISH ; jaccard normalise case + trim ; jaccard vacuously identique sur 2 listes vides ; priceMoveBetween = null sur previous price = 0 ; round-trip previousSnapshotId + previousGeneratedAt. (b) NarrativeObservabilityServiceTest (3 nouveaux tests) : oldest row a coherence = null ; le wiring scorer pair chaque row avec i+1 (regression guard contre une inversion de direction) ; end-to-end pin du scénario flip + flat → HIGH observé via le service. Tests frontend : (a) chip rendue avec la classe chip-coherence-high quand verdict = HIGH (regression guard sur le câblage .toLowerCase() + le rename de verdict) ; (b) chip absente quand coherence = null (pin contre une régression qui testerait la truthiness de coherence?.verdict sur un enum string toujours truthy) ; (c) tooltip surface les 3 sous-mesures + price move signé sur 5 lignes ; (d) tooltip fallback priceMoveUnknown quand priceMoveBetween = null, jamais de NaN. Effort réel : ~2 h (vs ~½ j estimé) ; le découpage minimaliste « domain + scorer + service + DTO + chip + tooltip » s'est tenu en un seul commit cohérent. Effets cumulés : la page /observability/:symbol devient une vraie surface d'audit — au lieu de comparer mentalement les cards reverse-chronological, le user voit un chip rouge sur les rows qui méritent un coup d'œil, ouvre la card, et lit les 3 sous-mesures dans le tooltip pour décider si la divergence est gênante. Combinée avec le filtrage par prompt version livré en Phase 3 #1 PR3, on peut désormais répondre à « le prompt v3 produit-il des narratifs plus cohérents que le v2 ? » qualitativement (compter visuellement les chips rouges par version). Phase 3 #3 (détection de biais) reprendra le même corpus + thumbs pour produire des stats agrégées au-delà du chip per-row. |
| ✅ Page observabilité narrative (Phase 3 #1, 3 sous-PRs PR1→PR3) | Livré 2026-05-13 en 3 commits séquencés (036c7f8 → ba588fa, ~2-3 j estimés, réalisé sur une session). Sortie : timeline narratif vs prix par ticker, page index des symbols avec corpus, filtres (date range + prompt version + thumbs), lien direct depuis le dossier ticker. (PR1 — 036c7f8 backend timeline endpoint) : nouveau module observabilité dans analysis/ : NarrativeObservabilityQuery (native SQL avec LEFT JOIN prompt_template + LATERAL prompt_score, cap 500 rows, filtres conditionnels SQL string-built côté Kotlin), NarrativeObservabilityService (orchestre query + une seule MarketChartClient.fetchChart(symbol, "1y", "1d") par requête — cache hit gratuit si l'utilisateur vient du dossier ticker), NarrativeObservabilityController exposant GET /api/narrative/observability/{symbol}?from=&to=&promptId=. DTOs : NarrativeObservationDto (snapshot + prompt provenance + thumbs + priceAt1d/1w/1m + delta1d/1w/1m), NarrativeObservationsResponse (envelope {symbol, observations[]} pour futures métadonnées). Décisions : (a) deltas calculés en mémoire au render à partir du chart 1Y cached (vs pré-computer dans une nouvelle table) — simple, cohérent avec le warm path dossier ; (b) base du delta = snapshot.price (vs close à T0 du bar) — honnête vis-à-vis du « prix qu'on m'a montré » ; (c) lookup at or after sur les bars — naturellement tolérant weekends/jours fériés ; (d) dégradation gracieuse sur MarketUnavailableException (deltas tous null, narratifs intacts — l'observabilité est primaire, le prix est enrichissement) ; (e) JSONB key_points_json lu via .toString() défensif (PG JDBC peut renvoyer PGobject ou String selon version driver). Tests : 9 service (delta math vs snapshot.price, weekend rollforward at-or-after, fenêtre non écoulée → null, une fetch par requête peu importe N rows, court-circuit upstream sur empty, dégradation MarketUnavailableException, garde base ≤ 0, normalisation symbol, round-trip prompt provenance + thumbs) + 5 controller @WebMvcTest (filtres null par défaut, symbol passé verbatim, binding ISO 8601, wire JSON shape). Découverte mid-PR : org.mockito.kotlin.any() ne matche pas null — il fallait anyOrNull() sur les params nullables de query.find, sinon les stubs retournaient emptyList() par défaut et 8/9 tests échouaient. (PR2 — 76a0662 page Angular per-symbol) : route lazy /observability/:symbol, port abstract NarrativeObservabilityRepository + HttpNarrativeObservabilityRepository, page signal-based avec timeline reverse-chronologique de cards expandables. État : observations, loading, loadError, expandedId + computed isEmpty (load OK + 0 rows) et pricesAllUnavailable (toutes les rows ont les 6 fields prix null — pin every() qui est vacuously true sur [] donc l'empty path n'allume pas la bannière cloud_off). Chaque card carries : date de génération, chip sentiment colorisé (BULLISH vert / BEARISH rouge / NEUTRAL muted), chip prompt version, icône thumbs si vote, 3 cellules delta colorisées (delta-up vert / delta-down rouge / delta-zero text / delta-muted pour null). Click sur header → expand inline avec summary complet + key points + meta-grid (prix génération / +1d / +1w / +1m, model, prompt). Mutual exclusion : une seule card ouverte à la fois. Bouton « Back to {{symbol}} dossier » en haut. Décision : page au top-level /observability/:symbol, pas sous /settings/ — le scope est par-ticker (vs prompt-stats qui est par-prompt). i18n : ~25 clés FR + EN (observabilityPage.*). Tests : 4 adapter HTTP (URL, filtres round-trip, params absents, encodage symbols BRK.B) + 11 page (init upper-case, missing symbol, empty/populated, error banner, toggle mutual exclusion, deltaClass rules pour 0 strict vs > 0, pricesAllUnavailable vacuously-true edge). (PR3 — ba588fa filtres + index + lien dossier) : (1) endpoint backend GET /api/narrative/observability/tickers qui groupe ticker_narrative_snapshot par symbol et retourne {symbol, snapshotCount, lastGeneratedAt} ordonné most-recent-first (cap 200). Déclaré avant /{symbol} dans le controller sinon Spring lie symbol="tickers" et retourne une timeline vide — pinné explicitement dans le test. (2) Page index /observability (sans symbol) : ObservabilityIndexPage lazy, repository findTickers(), liste de cards-link vers /observability/:symbol avec compteur de snapshots et timestamp. (3) Filtres sur la page per-symbol : (a) <input type="date"> pour from/to — date-range traduit en ISO instants UTC, to étendu à J+1 00:00:00Z pour intervalle half-open cohérent avec le contrat backend from inclusive, to exclusive ; (b) <select> peuplé via PromptRepository.list('narrative-default') au mount (fail-silent : dropdown vide en cas d'erreur, pas de banner) ; (c) chips thumbs (all / 👍 / 0 / 👎) — filtre client-side sur le signal filteredObservations parce que la timeline plafonne à 500 rows et refetcher à chaque chip click serait gaspilleur ; pinné dans le KDoc du controller pour expliciter l'asymétrie (from/to/promptId côté serveur, thumbs côté client) ; (d) bouton « Reset » visible quand hasActiveFilter() vrai ; (e) branche isFilteredEmpty distincte de isEmpty — « filtres trop stricts » a sa propre empty-card avec action reset, vs « corpus vide » qui pointe vers le dossier. (4) Lien depuis le dossier ticker : ajouté dans le footer de narrative-card (ticker.html), icône history + texte i18n, [routerLink]="['/observability', symbol()]". i18n : +22 clés FR + EN (observabilityPage.filters.*, observabilityPage.filteredEmpty, observabilityIndexPage.*, ticker.narrative.viewHistory). Tests delta PR3 : +4 backend (2 service round-trip DTO + vide ; 2 controller route literal vs path-variable + empty array) + 1 frontend adapter (findTickers wire) + 4 frontend index page (init + empty + error + ordre verbatim) + 6 frontend page filters (prompts loaded on init, chip thumbs filter, isFilteredEmpty, applyFilters wire avec to extended J+1, collapse-to-undefined sur empty filter, hasActiveFilter reset). Effets cumulés : (a) le user peut désormais ouvrir /observability (ou cliquer « Voir l'historique » depuis n'importe quel dossier) et confronter les narratifs passés à ce que le prix a fait — c'est la première surface qui transforme le corpus accumulé depuis Phase 1 en signal exploitable ; (b) filtrage par prompt version permet déjà la lecture « v3 a-t-il mieux fait que v2 ? » qualitativement, en attendant Phase 3 #2 qui formalisera la mesure ; (c) sortie zero-friction : ouverte par défaut, gratuite côté Twelve Data (la page utilise le chart 1Y déjà cached par le dossier). Verdict implicite gardé pour Phase 3 #2 : la page rend deltas + sentiment côte à côte et laisse l'humain lire « le LLM a dit BULLISH et le prix a chuté — miss », sans label automatique « hit/miss » qui serait subjectif (un narratif « neutre » + prix flat n'est ni miss ni hit — la limite n'est pas tranchable v1). Tests cumulés : ~600 lignes tests Kotlin (1 nouveau service test, 1 nouveau controller test, +query helper) + ~700 lignes tests TS (1 nouveau adapter spec, 1 nouvelle index spec, 1 nouvelle observability spec). Suite frontend passe de 354 → 365 tests verts. Effort réel : ~3-4 h sur une session (vs 2-3 j estimés). |
| ✅ Prompt management + scoring — foundation Phase 3 (6 sous-PRs PR1→PR6) | Livré 2026-05-10 en 6 commits séquencés sur la même journée (578d21a → 52291d5, ~5 j d'effort prévu, réalisé sur ~6 h serrées). Sortie : persistance des prompts narratifs en BDD, édition + activation live depuis l'UI, scoring continu (latency, retry, parse/validator failed) à chaque run, feedback utilisateur 👍/👎 sur la card narrative, page de stats agrégées par prompt. (PR1 — 578d21a schema + service backbone) : Flyway V8 crée les tables prompt_template (id, name, version, system_prompt, user_template nullable, target_model nullable, is_active, created_at, activated_at, deprecated_at, notes) et prompt_score (id, snapshot_id FK SET NULL, prompt_template_id FK RESTRICT, latency_ms, retry_count, parse_failed, validator_failed, user_thumbs SMALLINT CHECK (-1/0/1), llm_judge_score NUMERIC(5,2) nullable, created_at), plus index unique partiel idx_prompt_template_active_per_name (name) WHERE is_active = TRUE pour garantir « au plus 1 ligne active par famille de prompt » + 2 index secondaires sur prompt_score. Colonne prompt_template_id UUID NULL ajoutée sur ticker_narrative_snapshot (FK SET NULL). Seed narrative-default v2 inséré verbatim depuis le NARRATIVE_SYSTEM_PROMPT Kotlin, dollar-quoted pour éviter l'escape des apostrophes ; backfill UPDATE ticker_narrative_snapshot SET prompt_template_id = (SELECT id FROM seeded) WHERE prompt_version = 'v2' dans la même migration. Service TickerNarrativePromptService (lecture cache 1 min via @Cacheable sur la lookup findActive(name), fallback hardcodé du NARRATIVE_SYSTEM_PROMPT si BDD vide pour le bootstrap zéro-downtime, méthodes activateVersion(id) qui flippe l'ancien actif à FALSE + nouveau à TRUE dans une seule transaction, createNewVersion(input) qui pose is_active = FALSE par défaut). TickerNarrativeRunner bascule du NARRATIVE_SYSTEM_PROMPT constant vers promptService.activeFor("narrative-default") ; le prompt_template_id est propagé jusqu'à TickerNarrativePersister qui le persiste à côté du promptVersion string historique (les 2 cohabitent — le string reste autorité de trace, le FK est l'autorité de lookup pour PR6 stats). (PR2 — 1d555f9 score collection silencieuse) : nouveau service PromptScoreRecorder (côté analysis/application/) qui consomme un record(snapshotId, promptTemplateId, latencyMs, retryCount, parseFailed, validatorFailed). Branché dans TickerNarrativeExecutor à 2 endroits : succès run normal + run échec définitif (les 2 attempts ont raté parser ou validator) — pas d'UI, pas de breaking change. Snapshot snapshot_id nullable absorbe les runs entièrement KO (parser + validator failed deux fois) sans INSERT orphelin. (PR3 — 8341622 /settings/prompts v1 list + activate) : nouvelle route /settings/prompts (lazy load, dans app.routes.ts à côté de /settings/prompt-preview historique). Repository frontend PromptRepository (port abstract) + HttpPromptRepository (adapter HTTP par défaut). PromptController backend expose GET /api/prompts?name=narrative-default (liste reverse-chronological par createdAt desc), GET /api/prompts/{id} (un seul), POST /api/prompts/{id}/activate (idempotent ; 200 si déjà actif). Page UI Angular signal-based : liste de versions avec chip active sur la ligne courante, bouton « Activer » sur les autres lignes (désactivé si version déjà active), pas encore d'éditeur. (PR4 — de027b2 éditeur + diff) : la page /settings/prompts étend avec un éditeur inline (textarea Material mat-form-field appearance=outline, monospace, 20 lignes par défaut, autosizable). À la frappe, un diff side-by-side ligne-à-ligne s'affiche en dessous : à gauche la version source (sélectionnable via dropdown — défaut = active), à droite la nouvelle, lignes ajoutées vert sur fond, supprimées rouge barré, modifiées en jaune. Diff algorithm côté frontend (Myers algorithm minimal — pas de dépendance externe, ~80 lignes de TS). Bouton « Sauvegarder comme nouvelle version » POST /api/prompts {name, version, systemPrompt, userTemplate?, targetModel?, notes?} ; le backend pose is_active = FALSE (le user doit explicitement activer la version après preview). Validation : version non vide et unique par famille (la contrainte n'est pas en BDD — pas de cas usage de doublon en pratique — mais la couche service rejette en BadRequestException). Monaco volontairement écarté (~200 KB de bundle, overkill pour un textarea + diff). (PR5 — c702ad6 feedback thumbs 👍👎 sur la card narrative) : nouveau endpoint PATCH /api/narrative/snapshots/{id}/thumbs (body {value: -1 | 0 | 1}) idempotent — set le user_thumbs sur la dernière ligne prompt_score du snapshot (ordre created_at DESC LIMIT 1). Si aucun score n'est lié au snapshot (cas pré-PR2 ou snapshot d'un run KO avec snapshot_id = NULL), 404. Adapter frontend NarrativeFeedbackRepository + adapter HTTP, signal-based. La card narrative dans ticker.html rend 2 icon-button Material avec état thumbsValue (signal local rafraîchi à chaque load snapshot ; idle, up, down — surlignage du choix actif, click sur la même valeur désactive = passe à 0). Latency-tolerant : optimistic update qui rollback sur erreur backend, snackbar d'erreur via MatSnackBar (ad-hoc — sera centralisé via le ticket dette technique « Centraliser la gestion d'erreur » qui doit aussi couvrir le logging back). (PR6 — 52291d5 page stats agrégées par prompt) : nouvelle route /settings/prompts/:id/stats (lazy). Backend GET /api/prompts/{id}/stats?window=30d renvoie PromptStatsDto : agrégats globaux sur la fenêtre (runs, p50LatencyMs, p95LatencyMs, retryRate, parseFailedRate, validatorFailedRate, thumbsUp / thumbsDown / thumbsNeutral counts) + série quotidienne daily: [{day, runs, p50Latency, retryRate, ...}] (group by date_trunc('day', created_at)). Query SQL dédiée dans PromptScoreStatsQuery.kt (pas de JPQL : percentiles + group by + frame WHERE created_at > NOW() - INTERVAL '30 days' plus naturels en SQL natif via @Query(value=..., nativeQuery=true)). UI : sparkline SVG inline (~120 px, latency p50 sur 30 jours, pas de dépendance externe — points=... calculé côté composant) + tableau quotidien Material mat-table avec colonnes (jour, runs, p50/p95 latence, % retry, % parse-failed, % validator-failed, +/-/0 thumbs). Lien retour vers /settings/prompts. Effets cumulés : (a) corpus de scoring qui démarre immédiatement sur tous les futurs runs — vide à T+0, exploitable à ~50 snapshots, base saine pour Phase 3 #2 (page observabilité) et Phase 3 #3 (cohérence cross-runs) ; (b) cycle « propose un prompt v3 → active → laisse tourner ~10 dossiers → consulte stats vs v2 → roll-back si pire » faisable end-to-end sans toucher au code ; (c) découplage prompt ↔ deploy = un fix de prompt n'attend plus une release backend. Décisions reportées au sortir des 6 PRs : (1) pertinence subjective via thumbs user (livré) vs LLM-as-judge (Claude note Ollama — colonne llm_judge_score pré-créée mais nullée) : à arbitrer quand on aura ~30 jours de thumbs pour comparer signal humain vs synthétique ; (2) user_template reste NULL partout (le builder Kotlin buildNarrativeUserMessage interpole les indicateurs avec skip null silently côté code, trop subtil pour Mustache v1) — à reconsidérer si un prompt v3+ veut customiser le user message. Tests : ~1600 lignes de tests Kotlin + TS ajoutées sur ce delta (cf. --stat : TickerNarrativePromptServiceTest 379, PromptControllerTest 303, TickerNarrativeExecutorTest 293, PromptScoreServiceTest 238, PromptScoreRecorderTest 177, NarrativeThumbsControllerTest 120 côté backend ; prompts.spec.ts 386, prompt-stats.spec.ts 268, ticker.spec.ts +154 côté front). Conventions tests-as-documentation respectées : noms de tests = phrases anglaises de spec, fixtures factory + override par scénario. i18n : ~77 nouvelles clés FR + EN ajoutées (settings.promptsPage.*, settings.promptStatsPage.*, ticker.narrative.thumbs.*). Effort réel : ~6 h (vs 5.25 j estimés au backlog). Le découpage en 6 sous-PRs séquentiels — chacun déployable indépendamment et apportant une feature observable — a permis de garder chaque PR sous ~500 lignes de diff utile et de tester chaque incrément end-to-end avant de chaîner le suivant |
Phase 2.5 — Stabilisation et outils
Phase intermédiaire entre la profondeur ticker (Phase 2) et l'observabilité narrative (Phase 3) — accumule l'outillage runtime et les améliorations UX dashboard qui n'ont pas leur place dans une roadmap « ticker-only » ni « narrative-only ». Pas de scope produit nouveau ; on stabilise et on outille.
| Feature | Notes |
|---|---|
✅ Retour audit fin Phase 2.5 — boot fragile, SSE jobId/symbol, watchlist hors txn, sweep LlmTimeoutService |
Livré 2026-05-10, en clôture de la Phase 2.5 et juste avant le tag v0.4.0. Quatre findings de l'audit 2026-05-10-fin-phase-2.5.md traités en un seul commit pour ne pas trainer la dette dans la phase suivante. (1) Finding #1 (🔴 Critique) — boot fragile sans ANTHROPIC_API_KEY : application.yml:43 lisait key: ${ANTHROPIC_API_KEY} sans default, contrairement aux 2 autres SECRETs (${TWELVEDATA_API_KEY:} et ${FINNHUB_API_KEY:}). Spring résout le placeholder avant @Value, donc un fresh clone sans env var crashait à IllegalArgumentException: Could not resolve placeholder — exactement ce que la Phase 2.5 v2 (clé Anthropic en SECRET runtime éditable) cherchait à éviter. Patch : ${ANTHROPIC_API_KEY:} + commentaire qui pointe la convention symétrique. Test : BackendApplicationTests perd son @TestPropertySource(properties = ["anthropic.api.key=test-key-ci-only"]) qui n'avait été ajouté que pour compenser l'absence de default — le contextLoads() exerce désormais directement le path « boot sans env var », régression-guard naturel. (2) Finding #4 (🟡 Important) — SSE streamJob jobId/symbol non corrélés : TickerNarrativeController.streamJob enregistrait jobEventPublisher.register(jobId) sans valider que le jobId du path appartenait bien au symbol du même path. Conséquence : /api/market/ticker/AAPL/narrative/jobs/{jobIdDeNVDA}/stream streamait silencieusement les events de NVDA. Pas exploitable single-user no-auth (UUIDv4 + un seul tenant), mais cross-tenant leak garanti dès Phase 5 OAuth2 — discipline qu'il sera pénible de rétrofiter. Patch : lookup jobStore.get(jobId), 404 unifié (NoSuchElementException) si null OU si job.symbol != symbol.uppercase() — le 404 unique retire aussi l'oracle d'existence sur les UUIDs. Test dédié : nouveau TickerNarrativeStreamControllerTest.kt @WebMvcTest, 3 specs (jobId inconnu → 404 + publisher jamais hit, jobId d'un autre symbol → 404 + publisher jamais hit, lowercase URL contre uppercase job symbol → 200). Le happy path SSE reste couvert par JobEventPublisherTest côté backend et job-stream.service.spec.ts côté frontend. (3) Finding #2 (🟡 Important) — WatchlistService.add viole « pas d'appel réseau dans @Transactional » : la méthode était @Transactional et appelait symbolSearch.validate (Twelve Data /symbol_search) puis tickerService.load (/time_series + /quote) — un cache miss tenait une connexion Hikari 1-3 s, plus en cas de timeout, en violation directe de l'invariant écrit dans architecture.md. Refacto : suppression du @Transactional sur add ; les appels réseau résolvent avant tout accès BDD ; repository.save (déjà @Transactional au niveau Spring Data) ouvre sa propre courte transaction. Re-check findBySymbol post-network dans le helper privé persistNew pour couvrir la fenêtre TOCTOU ouverte par le refacto — un caller concurrent qui aurait inséré le symbol pendant l'aller-retour réseau récupère sa row plutôt que de surfacer une DataIntegrityViolationException. Tests : inOrder(symbolSearch, tickerService, repository) sur le happy path qui pin la séquence validate → load → save (regression guard contre une réintroduction du @Transactional), nouveau test « concurrent insert lands during the network calls » qui simule la fenêtre TOCTOU via willReturn(null, concurrent). (4) Findings #3 + #5 (🟡 Important) — LlmTimeoutService dead code + docstring stale + spec absente : millis() n'avait plus aucun consommateur (le legacy poll-abort qui s'en servait a été retiré en PR2 SSE Phase 2.5), la docstring référençait AnalysisJobStore (décommissionné en V6) et le narrative job poll (remplacé par SSE), configuration.ts:281-284 parlait de « next poll tick » qui n'existe plus, et le service n'avait pas de spec alors que ses 4 voisins core/ (ThemeService, LanguageService, OllamaStatusService, JobStreamService) en ont. Sweep : suppression de millis() + de la constante MILLIS_PER_SECOND, réécriture de la docstring pour pointer la card LLM seule (« source of truth pour le label estimation max »), patch des 2 commentaires configuration.ts:281-284 et :317-318 (save + reset paths) qui mentionnaient les polling adapters. Spec dédiée : llm-timeout.service.spec.ts (6 tests, mirror de ollama-status.service.spec.ts — pattern StubRepository avec queue de réponses) qui pin (a) default 400 avant refresh, (b) refresh success met le signal à jour, (c) refresh keep current value sur erreur backend, (d) refresh keep current value sur entry manquant, (e) refresh keep current value sur null/blank/non-numeric, (f) refresh keep current value sur 0/négatif. Effet : tag v0.4.0 posable proprement, dette technique 🔴 Critique + 3× 🟡 Moyenne retirée du backlog, regression guards CI sur les 4 paths. Effort réel : ~1 h |
✅ Persister instrumentType sur watchlist_entry (drop le lazy-lookup burst Twelve Data) |
Livré 2026-05-09. Friction observée : après un switch mock → twelvedata, le mount du dashboard a immédiatement déclenché un rate-limited: 12 API credits used, current limit 8 côté Twelve Data, bloquant l'app. Diagnostic : Dashboard.enrichWatchlistInstrumentTypes (livré 2026-05-07) firait un getTicker(symbol) parallèle par entrée watchlist au mount pour récupérer le instrumentType du chip. Chaque getTicker côté backend = 1 fetchChart = 2 credits Twelve Data (/time_series + /quote). 5 entrées watchlist + cache vide = 10 credits en burst < 1 sec, plus le getTicker du dossier ouvert = 12 credits exact, ban immédiat sur free tier (8/min). Le trade-off avait été acté à la livraison du lazy-lookup avec la mention « Si plus tard les credits deviennent un sujet, on bascule sur le persist BDD » — « plus tard » est arrivé. Backend : (1) Flyway V7 watchlist_entry ADD COLUMN instrument_type VARCHAR(20) nullable. Pas de backfill auto — les enums AssetType (portefeuille) ≠ InstrumentType (market) ont des valeurs distinctes (BOND/COMMODITY/CRYPTO côté AssetType vs INDEX côté InstrumentType), mapping risqué. Les rows pré-V7 restent avec instrument_type = NULL, le user peut re-add s'il veut le chip. (2) WatchlistEntry domain : nouveau champ var instrumentType: InstrumentType? = null avec @Enumerated(EnumType.STRING). (3) WatchlistService.add : injection TickerService, après la validation symbolSearch.validate + avant repository.save, appel lookupInstrumentType(normalised) qui hit tickerService.load(symbol).quote.instrumentType avec fail-open try/catch (Exception) — n'importe quelle erreur (rate-limit, 404, unreachable) yield null plutôt que de bloquer l'add. Mirror exact du fail-open existant sur isKnownSymbol. (4) WatchlistEntryDto étend avec instrumentType: InstrumentType? (nullable). Frontend : (1) WatchlistEntry interface étend avec instrumentType: 'STOCK' \| 'ETF' \| 'INDEX' \| 'OTHER' \| null. (2) Dashboard : suppression du signal watchlistInstrumentTypes (16 lignes), de la méthode enrichWatchlistInstrumentTypes (16 lignes), de watchlistInstrumentTypeFor (8 lignes), et des appels au mount (loadWatchlist et addToWatchlist). (3) dashboard.html : watchlistInstrumentTypeFor(entry.symbol) → entry.instrumentType directement. Tests : WatchlistServiceTest étendu — injection TickerService mock, helper snapshot(symbol, instrumentType) partagé entre tests, 2 nouveaux tests sur la fail-open path (rate-limit upstream → null saved, null instrumentType retourné par le snapshot → null saved), assertions ajoutées sur les tests existants (saved.instrumentType == STOCK, verify(tickerService, never()).load(...) sur les paths qui ne doivent pas appeler). dashboard.spec.ts : helper sample(symbol, instrumentType?) étend la signature (default null), 3 nouveaux tests dans Instrument-type chip block (chip rendu directement depuis entry.instrumentType, null entry → no chip, dashboard mount fires zero getTicker enrichment), suppression des 3 anciens tests sur le lazy lookup. ticker.spec.ts fixture watched: WatchlistEntry étend avec instrumentType: 'STOCK'. Effet : 0 credit Twelve Data brûlé au dashboard mount (vs 10+ avant). Le coût est déplacé sur le POST add — 1 lookup par add = 2 credits une fois pour la vie de l'entrée. Migration risk pour le user : les watchlist existantes rendront sans chip jusqu'à re-add. Acceptable single-user (~5-10 entrées max). Effort réel : ~50 min |
✅ Drop du local_resource("llm:ensure-model") Tilt — couvert par le panneau Pull UI |
Livré 2026-05-09, dans la foulée de la livraison Pull modèle. Friction observée : le bouton Tilt llm:ensure-model faisait docker exec portfolioai-ollama ollama pull qwen2.5:3b à chaque tilt up. Idempotent (no-op si déjà pull, ~100 ms) mais payait 2 GB de download au premier lancement, même pour un user qui n'utilise pas Ollama (Claude est le défaut Phase 2.5). Décision : retirer le resource — le panel Pull UI livré juste avant couvre le besoin organiquement (le user qui clique sur le toggle Ollama dans /settings/configuration voit le panneau État Ollama, click Pull → suggestions hardcodées → modèle pull en un clic). Container ollama Docker conservé (le daemon doit être up pour que le panel puisse le probe), seul le pre-pull saute. Code : 17 lignes retirées du Tiltfile (ligne 73-92 — header section LLM + bloc local_resource). Doc : (1) developpement.md table « Commandes Tilt utiles » — ligne llm:ensure-model retirée, ne reste que Purge ; (2) developper.md paragraphe « Premier lancement Ollama » réécrit pour pointer vers /settings/configuration > LLM > Pull… au lieu du bouton Tilt ; tableau « Configurer le LLM » ligne ollama mise à jour de la même façon ; (3) providers.md ligne « Modèle par défaut » + commentaire YAML — réf au bouton Tilt remplacée par la procédure UI. Effet collatéral : le label "llm" dans le panneau Tilt n'a plus de resource et disparaît tout seul (cosmétique). Effort réel : ~10 min |
| ✅ Désinstaller un modèle Ollama depuis l'UI (bouton trash dans le dialog Pull) | Livré 2026-05-09, dans la foulée du Pull. Friction observée : pour libérer du disque (un user typique accumule plusieurs Go de modèles testés), il fallait sortir un terminal et docker exec portfolioai-ollama ollama rm <name>. Backend : (1) OllamaStatusService.deleteModel(name) mirroir unloadModel — DELETE /api/delete upstream avec {name} dans le body via rest.method(HttpMethod.DELETE) (RestClient supporte le body sur DELETE). Re-probe + snapshot frais retourné en un round-trip. Fail-soft : 404 wrap en « Model not pulled locally : unloadModel). (2) Nouveau DTO DeleteModelRequest(model). (3) Endpoint POST /api/config/llm/delete-model (POST côté front pour symétrie avec unload/pull, traduit en DELETE côté Ollama). Frontend : (1) port OllamaStatusRepository.delete(model) + adapter HTTP. (2) OllamaStatusService.delete(model) mirror exact unload — silent swallow OK (feedback via signal). (3) Dialog Pull : trash button mat-icon-button conditionnel sur isPulled(name), sibling de chaque chip (suggestions + other-pulled), tooltip + aria-label paramétriques. Méthode delete(name) qui delegue au service + clear l'inline error. (4) Bordure verte sur .pulled retirée — seul le check icon signale (feedback user : « la coche verte serait juste suffisant »). i18n FR + EN : pullDialog.deleteAriaLabel. Tests : 4 sur OllamaStatusServiceTest (DELETE wire + body + re-probe, 404 → not-pulled hint, blank → short-circuit, daemon down → fail-soft) + 1 sur ConfigControllerTest ; côté front 2 tests service (forward + signal update, swallow on fail) + 1 URL contract HTTP + 3 tests dialog (forward + clear error, no-op pendant busy, error inline sur reject). Bug fix collatéral : ConfigDto.kt KDoc qui mentionnait `/llm/*-model` → le /* ouvrait un nested block comment Kotlin que le */ du KDoc fermait au mauvais niveau, laissant le fichier comme un commentaire géant non clos. Reformulé en unload-model and pull-model endpoints. Effort réel : ~40 min (incluant le bug fix KDoc + le polish bordure verte) |
✅ Pull d'un modèle Ollama depuis l'UI + drop dep zombie com.rometools:rome |
Livré 2026-05-09. Friction de départ : pour tester un autre modèle (ex. mistral:7b) que le défaut, le user tapait le tag dans le champ « Modèle Ollama » et cliquait Tester — Ollama renvoyait 404 model not found avec le hint « try ollama pull mistral:7b », forçant à sortir un terminal et faire le pull à la main, cassant le flow. Backend : (1) OllamaStatusService.pullModel(name) mirroir de unloadModel — POST /api/pull avec stream: false (Ollama répond une fois à la fin avec {status: "success"}, pas besoin de SSE v1), pullRest RestClient dédié avec read timeout 5 min (dimensionné pour mistral:7b ~4 GB sur réseau honnête, sans bloquer indéfiniment si le daemon hang), re-probe + snapshot frais retourné en un seul round-trip. Fail-soft contract identique à unload : 5xx (Ollama renvoie 500 avec {"error": "model 'foo' not found"} pour les noms inconnus, pas 404) wrap en daemonReachable: false + errorMessage populé. Blank input court-circuite vers une probe() plain. (2) Nouveau DTO PullModelRequest(model) dans config/application/dto/ConfigDto.kt. (3) Endpoint POST /api/config/llm/pull-model ajouté à ConfigController. (4) Tests : OllamaStatusServiceTest étendu avec 4 nouveaux tests (POST /api/pull + stream: false + re-probe → fresh snapshot, blank name short-circuite, 5xx → fail-soft avec code dans message, daemon down → fail-soft) ; ConfigControllerTest 1 nouveau test (POST trim + forward + JSON shape post-pull avec le nouveau modèle dans availableModels). Frontend : (1) port OllamaStatusRepository.pull(model) + adapter HttpOllamaStatusRepository.pull qui POST /api/config/llm/pull-model. (2) OllamaStatusService.pull(model) mirror partiel de unload — différence clé : rethrow les transport errors au lieu de les swallow silencieusement (le user vient de cliquer Pull, un silent no-op laisserait un spinner stuck ; le dialog veut pouvoir afficher une erreur inline). Snapshot daemon-unreachable côté retour (= fail-soft backend) traité comme erreur user-visible côté dialog. (3) Composant OllamaPullDialog standalone (270 lignes TS+HTML+SCSS) avec MatDialogRef, ReactiveFormsModule, input texte + 6 suggestions chips hardcodées (qwen2.5:3b, qwen2.5:7b, llama3.2:3b, llama3.1:8b, mistral:7b, phi4-mini — re-utilisé du OLLAMA_MODEL_SUGGESTIONS de configuration.ts, dupliqué pour ne pas coupler les deux surfaces prématurément), spinner inline « Pulling mistral:7b… » pendant le pull, error banner inline si le pull échoue (le dialog reste ouvert pour permettre au user de corriger un typo sans perdre la state). Cancel disabled en busy state (Ollama ne supporte pas l'abort clean, le user doit attendre la fin). (4) Bouton « Pull… » dans OllamaStatusPanel à côté de Refresh, conditionnel sur daemonReachable (cacher le bouton sur un daemon mort plutôt que laisser cliquer un endpoint qui va échouer). i18n FR + EN : nouvelle clé pullCta + sous-objet pullDialog.{title,hint,inputLabel,pulling,confirm,cancel}. Tests : 7 tests sur ollama-pull-dialog.spec.ts (suggestion click, pull success → close avec model, trim whitespace, blank short-circuite, transport error → inline error + dialog ouvert, fail-soft snapshot → idem, cancel close avec null, cancel no-op pendant busy) ; ollama-status-panel.spec.ts étendu avec 2 tests (Pull button hidden quand daemon unreachable, click → dialog.open invoqué) + ajout du MatDialog mock dans le TestBed. ollama-status.service.spec.ts 2 nouveaux tests (forward + signal update, rethrow transport errors). ollama-status.http.spec.ts 1 nouveau test (URL contract POST {model} body wrapping). Hors scope v1 : pas de progress bar (stream: true exposerait un feed per-percent mais demande SSE plumbing — filed pour plus tard si le besoin se confirme), pas de cancel d'un pull en cours (Ollama ne supporte pas), pas d'auto-promotion du 404 model not found du bouton Tester vers ce dialog (alternative plus contextuelle mais demande un DTO test result structuré, v2 si l'usage le justifie). Trade-off principal documenté : avec stream: false on bloque le request thread Spring 1-3 min. Acceptable single-user, à reconsidérer Phase 5 SaaS. Cleanup collatéral : dépendance com.rometools:rome:2.1.0 retirée de backend/build.gradle.kts — seul consommateur historique était le module ingestion/ Phase 0 supprimé en V6 (cf. journal Phase 2.5 décommissionnement Phase 0). Vérification grep -rn "rometools" backend/src retournait vide avant la suppression. Effort réel : ~50 min (légèrement au-dessus de l'estimation 45 min, à cause du fail-soft snapshot côté front qui demandait un cas en plus dans le dialog) |
| ✅ Remplacer le polling jobs par du push SSE avec événements per-phase | Livré 2026-05-09 en 3 PR séquentielles sur la branche events. Friction de départ : le poller HTTP pollNarrativeJob (toutes les 3 s) interrogeait le backend pour un statut binaire PENDING / DONE / ERROR — latence jusqu'à 3 s en fin de job, granularité pauvre (spinner muet pendant qu'Ollama mouilne 60-300 s sans feedback intermédiaire, frustration vécue le 2026-05-07 quand un appel Ollama a tourné 5 min sans qu'on sache si le LLM répondait, validait ou parsait). Cible atteinte : passage en Server-Sent Events (pas WebSocket — la communication est unidirectionnelle back→front pendant un job ; SSE c'est SseEmitter natif Spring + EventSource natif browser, zéro lib, zéro handshake, traverse tout proxy HTTP/1.1 ou HTTP/2). PR1 — backend SSE (13ad6f0) : 4 nouveaux fichiers (domain/JobPhase.kt enum 9 valeurs, domain/JobEvent.kt data class, application/JobEventPublisher.kt pub/sub in-memory avec replay-on-reconnect TTL 60 s post-terminal, JobEventPublisherTest.kt 12 tests via test seam internal open fun createEmitter() + RecordingEmitter qui override send(SseEventBuilder)) ; 4 fichiers modifiés (Executor signature execute(symbol, jobId) + emit per-phase, Runner emit DONE/ERROR autour try/catch, Controller endpoint GET /jobs/{jobId}/stream, PreviewControllerTest @MockitoBean JobEventPublisher ajouté pour le slice). PR2 — bascule frontend (72e5dbd) : nouveau core/job-stream.service.ts qui wrappe EventSource et expose un Observable<JobEvent> (complète sur DONE/ERROR, error sur close prématuré, teardown sur unsubscribe via observable), spec dédié avec MockEventSource + vi.stubGlobal('EventSource', ...) (9 tests), retrait de pollNarrativeJob du port + adapter + interval/takeWhile/switchMap/NARRATIVE_POLL_INTERVAL_MS/LlmTimeoutService du market.http.ts, ticker.ts consomme la SSE au lieu du poll dans generateNarrative, mock JobStreamService en remplacement du pollNarrativeJob mock dans le spec. PR2.5 — reattach on revisit (30d7a9b, ajouté après friction observée : navigate-away → return-to-page perdait le job en cours) : nouveau TickerNarrativeService.pendingFor(symbol) qui uppercase + délègue au store, nouvel endpoint GET /jobs/pending (Spring route le segment littéral en priorité sur /jobs/{jobId} UUID), 2 tests slice TickerNarrativePendingJobControllerTest, port getPendingNarrativeJob + adapter (404→null), helper privé subscribeToNarrativeStream(symbol, jobId) qui mutualise le SSE entre generateNarrative et reattachPendingNarrative, hook depuis loadLatestNarrative, 3 tests reattach + 2 tests endpoint. PR3 — UX phase visible : nouveaux signals narrativePhase: JobPhase \| null + computed narrativeElapsedSeconds (compteur live qui tick toutes les 1 s via un signal narrativeNow mis à jour par setInterval, anchor wall-clock + event.elapsedMs du dernier event reçu — survit aux phases longues style CALLING_LLM 85 s sur Ollama M1 sans figer le compteur), bandeau de progression <div class="narrative-progress"> sous le header narratif (mat-spinner 14px + label phase i18n + counter Ns), 9 nouvelles clés i18n FR + EN sous ticker.narrative.phase.*, 2 tests dédiés (transition phases via Subject + dérivation elapsedSeconds via vi.spyOn(Date, 'now')). Décisions notables : (1) Replay-on-reconnect 60 s post-terminal plutôt que "GET initial + SSE pour la suite" — single API surface, plus propre. (2) Test seam createEmitter() open plutôt que reflection/mockito sur SseEmitter — coût prod 0, fiabilité tests +++. (3) /jobs/pending literal path plutôt que query string — Spring routing handle la précédence sur /jobs/{jobId} UUID nativement. (4) narrativePhase reset au début de generateNarrative plutôt que sur DONE — laisse le label "Done (8s)" visible le temps que fetchNarrativeAfterCompletion remplisse la card. Effort réel : ~1 jour comme estimé, splitté en 4 commits pour rendre la review humaine traçable. Prérequis débloqué : la page Jobs DAG view (Phase 3+ backlog) peut maintenant consommer le même JobEventPublisher pour streamer les transitions des leaves TickerAnalysis + parents PortfolioAggregation (Phase 6) — autant ne pas refactor le canal de transport deux fois |
| ✅ Décision design : stratégie déploiement Ollama — option 3 retenue | Livré 2026-05-09. Ferme le ticket 🚧 ouvert depuis le 2026-05-08. Décision : statu quo Claude-first, Ollama reste containerisé dans docker-compose.yml même sur Mac malgré la dégradation CPU (Metal inaccessible via Docker Desktop VM → 60–180 s par narratif qwen2.5:3b au lieu de 5-10 s en natif). Pourquoi pas option 1 (sortie de Compose + install Ollama natif via brew) : aurait débloqué Metal mais cassé le « clone + tilt up = tout marche » qu'on vient de polir avec le panneau État Ollama et l'eject from VRAM. Coût onboarding non justifié tant qu'Ollama reste occasionnel — surtout que l'arrivée imminente d'une clé Anthropic va tirer 95 %+ de l'usage vers Claude (latence 2-5 s, qualité narrative supérieure). Pourquoi pas option 2 (override Compose Mac vs cible Linux GPU) : over-engineering pour une cible serveur hypothétique — Phase 5 hosting (OVH / Hetzner / Scaleway / Lightsail dans la fourchette 5-15 €/mois) n'a pas de GPU dans cette gamme. Philosophie utilisateur formalisée : « pas envie de sortir des services hors Docker tant qu'on n'y est pas obligé ». Re-trigger pour réévaluer : machine dédiée (Linux + GPU ou Mac Studio serveur), usage Ollama > 20 % des sessions sur 2-3 semaines consécutives, ou distribution du repo à des contributeurs externes (Linux + Windows + Mac mélangés). Implémentation (~20 min, dans la même session) : (1) docs/devops/decision-ollama-deploiement.md flippé de 🟡 brouillon à ✅ tranchée, ajout d'un bloc « Décision retenue » en tête + section « Trace historique » qui préserve l'analyse des 3 options pour la prochaine fois. (2) docs/technique/developpement.md étendu d'un paragraphe « Performance Ollama sur Mac » sous le bloc Phase 1 LLM, qui documente la limite Metal-via-Docker et redirige vers Claude pour le quotidien. (3) docs/devops/commandes-pratiques.md nouvelle sous-section « Diagnostic narratif lent / fan qui hurle » avec 3 commandes pour confirmer le trait connu (docker stats, /api/ps, docker compose logs). (4) docs/technique/architecture.md > Décisions techniques notables nouvelle entrée « Ollama containerisé même sur Mac, malgré la dégradation CPU » qui formalise le statu quo et liste les conditions de re-trigger. Effort réel : ~20 min comme estimé |
| ✅ Bouton « décharger » par modèle chargé en VRAM (panel État Ollama) | Livré 2026-05-08, suite directe du panel État Ollama. Cible : permettre de décharger un modèle de la VRAM en un clic sans sortir un terminal. Use cases : (a) switcher de modèle et libérer la VRAM tout de suite, (b) forcer un cold-start pour comparer la latence. Backend : nouvelle méthode OllamaStatusService.unloadModel(model) qui hit POST ollama/api/chat avec keep_alive: 0 (pattern documenté Ollama, pas d'endpoint /api/stop officiel) puis re-probe immédiatement et retourne le snapshot frais — la panel met à jour la VRAM libérée en un seul round-trip. Fail-soft identique à probe() : 404 d'Ollama (modèle pas pull localement) wrap en daemonReachable: false avec hint « Model not pulled locally : ResourceAccessException wrap en unreachable. Edge case : unloadModel("") (binding form parasite) court-circuite vers une probe() plain — pas de POST sur le wire avec un model vide. Nouvelle DTO UnloadModelRequest(model) dans config/application/dto/. Endpoint POST /api/config/llm/unload-model ajouté à ConfigController. Frontend : (1) port OllamaStatusRepository.unload(model) + adapter HttpOllamaStatusRepository.unload qui POST {model} à /api/config/llm/unload-model. (2) méthode OllamaStatusService.unload(model) mirror exact de refresh() (signal updaté avec le snapshot retourné, conserve previous value sur erreur — même contract anti-flicker). (3) bouton mat-icon-button avec icône eject sur chaque entrée loaded-model du panel, à droite du countdown, aria-label + matTooltip paramétriques (Décharger qwen2.5:3b de la VRAM). Click → service.unload → signal updaté → la liste se re-render avec le modèle absent. Style : 28×28 px (plus petit que le défaut Material 40 px pour rester proportionné aux pill chips), couleur --color-text-muted au repos, --color-danger au hover (signale l'action destructive). i18n FR + EN : nouvelle clé settings.configurationPage.ollamaStatus.unloadAriaLabel paramétrique avec {{model}}. Tests : OllamaStatusServiceTest étendu (4 nouveaux tests : POST keep_alive: 0 + re-probe → fresh snapshot, 404 Ollama → message « not pulled locally », blank model → short-circuit pas de POST chat, daemon unreachable → fail-soft) ; ConfigControllerTest 1 nouveau test (POST trim + forward + JSON shape) ; ollama-status.http.spec.ts 1 nouveau test (URL contract POST + body wrappé) ; ollama-status.service.spec.ts étendu avec une unloadQueue séparée + 2 tests (forward + signal update, failure preserves) ; ollama-status-panel.spec.ts 1 nouveau test (click 2ᵉ bouton → service appelé avec le bon model). SCSS bonus : promu .config-card (background / border / radius / padding / margin-bottom + descendants .card-header + .description) du configuration.scss vers le global styles.scss pour qu'il survive l'emulated view encapsulation Angular — sans ça, le panel OllamaStatusPanel rendait sans frame visible et son bouton « Rafraîchir » était collé à la card Provider LLM en dessous (friction reportée par le user). Effet de bord positif : le panel a maintenant une vraie card frame visible. Hors scope v1 : pas de confirmation modal (l'unload est non-destructif, le modèle reste pull localement, juste déchargé de VRAM), pas de progress indicator (opération ~10 ms côté Ollama). Effort réel : ~30 min comme estimé + 15 min pour le fix de la marge globale .config-card |
✅ Panneau « État Ollama » sur /settings/configuration > LLM |
Livré 2026-05-08. Cible : surfacer la santé du daemon Ollama (up/down, modèle chargé en VRAM avec countdown, modèles pull localement, latence du dernier ping) directement dans /settings/configuration > LLM, conditionnel à llm.provider === 'ollama'. Résout la friction « pourquoi mon narratif renvoie Connection refused ? » sans que le user ait à sortir un terminal. Backend : (1) nouveau DTO OllamaStatusDto(daemonReachable, baseUrl, latencyMs, loadedModels, availableModels, errorMessage) + LoadedModelDto(name, expiresAt, sizeVramBytes) dans analysis/application/dto/. (2) nouveau OllamaStatusService dans analysis/infrastructure/llm/ qui hit GET /api/tags (modèles dispos) + GET /api/ps (modèles loaded avec expires_at) avec read timeout 3 s (volontairement court — la panel veut feedback rapide). Fail-soft contract : ResourceAccessException / HttpClientErrorException n'est jamais propagé — wrap vers daemonReachable: false + errorMessage populé. La panel poll toutes les 10 s, propager une 503 mettrait toute la page settings en error state à chaque hiccup, ce qui défait le but du panneau. (3) endpoint GET /api/config/llm/status ajouté à ConfigController (proxy mince vers OllamaStatusService.probe()). Pas de POST /probe séparé — le bouton « Tester » existant sur la card llmProvider couvre déjà le round-trip de bout en bout via POST /api/config/test/llm. (4) tests : OllamaStatusServiceTest avec MockWebServer (happy path, daemon down, 5xx upstream, schema drift sur /api/tags + /api/ps, expires_at manquant ou unparseable, models triés alphabétiquement par le service) ; ConfigControllerTest étendu avec un @MockitoBean sur OllamaStatusService + 2 nouveaux tests (snapshot complet round-trip + fail-soft 200 avec daemonReachable: false). Frontend : (1) port OllamaStatusRepository + adapter HttpOllamaStatusRepository câblé dans core/providers.ts (10 repositories au total désormais). (2) OllamaStatusService providedIn: 'root', signal-based, mirror exact de LlmTimeoutService — méthode refresh() (signal update sur succès, conserve le previous value sur erreur — pas de flicker via spinner sur hiccup transitoire), méthodes startPolling() / stopPolling() idempotentes (défensives contre double-mount), interval par défaut 10 s. (3) composant OllamaStatusPanel standalone dans features/settings/configuration/ avec card Material : daemon chip vert/rouge + latence inline, base URL en <code>, section « Modèles chargés en mémoire » avec liste + size GB + countdown live (signal now qui tick toutes les 1 s, format 4m 32s ou 15s ou expired), section « Modèles pull localement » avec chips, bouton Refresh manuel. Lifecycle : startPolling au ngOnInit, stopPolling au ngOnDestroy, setInterval cleanup pour le ticker now. (4) wire dans configuration.html au-dessus de la card llmProvider dans la section LLM, conditionnel sur isOllamaActive() (computed sur llmProvider().currentValue === 'ollama'). Quand provider === 'claude', note inline « État non applicable au provider Claude — utiliser le bouton Tester de la card Modèle Claude ». i18n FR + EN : 13 nouvelles clés sous settings.configurationPage.ollamaStatus.* (title, loading, daemonReachable/Unreachable, baseUrlLabel, loadedModelsLabel, noLoadedModels, availableModelsLabel, noAvailableModels, expiresIn paramétrique, expired, refresh, notApplicableForClaude). Tests : ollama-status.http.spec.ts (URL contract GET /api/config/llm/status), ollama-status.service.spec.ts (initial null state, refresh success/failure preserves previous value, polling start/stop avec vi.useFakeTimers(), idempotence des deux méthodes), ollama-status-panel.spec.ts (lifecycle start/stop, daemon up render, daemon down render avec error message, empty loaded list avec friendly empty-state, refresh button click, spinner pendant le first load), configuration.spec.ts étendu avec mock OllamaStatusService + nested describe('Ollama status panel visibility') (panel rendu en ollama, note Claude rendue en claude, selectProvider flip via mockImplementationOnce pour préserver le typage ENUM). Hors scope v1 : pas de logs ligne-par-ligne (exposer le Docker socket = risque sécu, sidecar log-shipper = lourd), pas de click-to-set sur les chips Available models (juste affichage). Effort réel : ~2 h comme estimé |
| ✅ Swagger UI / OpenAPI exposé en local via Tilt | Livré 2026-05-08. Cible : option 1 du ticket — accès Tilt-only à la surface REST sans toucher au front, désactivé en prod par défaut. Backend : (1) dépendance org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0 ajoutée à build.gradle.kts (compat Spring Boot 3.5.x — auto-génère le schéma OpenAPI 3.0 depuis les controllers + DTOs Jackson). (2) application.yml (versionné) fixe springdoc.api-docs.enabled: false + springdoc.swagger-ui.enabled: false — aucun env qui n'opte pas in n'expose la surface. (3) application-local.yml (gitignored) override les deux flags à true — l'UI est servi à http://localhost:${BACKEND_HOST_PORT}/swagger-ui.html et le JSON brut à /v3/api-docs. (4) Annotation @Tag(name, description) posée sur les 11 controllers (Market, News, Analyst, Earnings, Watchlist, Portfolio, Snapshot, CSV Import, Ticker Narrative, Symbol Search, Config) pour grouper proprement dans l'UI — auto-gen Kotlin suffit pour les paramètres / réponses, pas de @Operation / @Schema v1 (« ne PAS sur-annoter » conformément au ticket). Tilt : nouvelle entrée link("http://{host}:{backend_port}/swagger-ui.html", "Swagger UI") ajoutée à la liste links du local_resource("backend") — apparaît comme bouton cliquable dans le panneau Tilt à côté de Health. Doc : nouvelle sous-section « Swagger UI — explorer la surface REST » dans developpement.md sous « Commandes Tilt utiles » qui rappelle l'URL, le pattern par tag, et la propriété d'opt-in profile-only. Hors scope v1 : pas d'@Operation / @ApiResponse(404/503) exhaustif (l'auto-gen Kotlin avec data classes nullables + KDoc en place est déjà bonne — à enrichir au cas par cas si un endpoint le mérite vraiment), pas d'intégration back-office iframe (option 2 du ticket — l'option 1 Tilt-only suffit pour le besoin actuel « voir clair sur la surface REST »), pas de profile Spring @Profile("local") sur la config Swagger (le double opt-in YAML couvre déjà le risque d'exposition). Effort réel : ~30 min |
✅ Ports stack locale configurables via .env |
Livré 2026-05-08. Friction observée : un proche a cloné le repo pour tester l'app et tilt up a échoué sur « port already allocated » parce que son Postgres système occupait déjà 5432. Cible : permettre à n'importe quel port de la stack locale d'être surchargé sans toucher au code versionné. Code : (1) nouveau .env.example à la racine (gitignored via le .env* + !.env.example déjà en place) listant les 4 vars POSTGRES_HOST_PORT (défaut 5432), OLLAMA_HOST_PORT (11434), BACKEND_HOST_PORT (8080), FRONTEND_HOST_PORT (4200). (2) docker-compose.yml : substitution ${POSTGRES_HOST_PORT:-5432}:5432 côté Postgres et idem Ollama — seul le port hôte change, le container reste sur son port natif en interne. Compose lit .env automatiquement. (3) application.yml : server.port: ${BACKEND_HOST_PORT:8080} ajouté explicitement (auparavant implicite par défaut Spring), datasource.url: jdbc:postgresql://localhost:${POSTGRES_HOST_PORT:5432}/portfolioai. (4) application-local.yml : ollama.base-url: http://localhost:${OLLAMA_HOST_PORT:11434}. (5) Tiltfile : nouveau helper Starlark load_env_file(path) qui parse .env si présent (skip comments/blank lines, gère KEY="value" avec quotes optionnelles), populated dans un dict env. Les 4 ports sont (a) injectés dans le serve_cmd du backend via prefix shell POSTGRES_HOST_PORT={pg} OLLAMA_HOST_PORT={ol} BACKEND_HOST_PORT={be} cd backend && ... pour que Spring les voie, (b) propagés au serve_cmd frontend via npm start -- --port {}, (c) substitués dans les link() UI Tilt et dans readiness_probe.port = int(backend_port). Doc : nouvelle section « Conflit de port » dans developper.md (onboarding, le bon endroit pour la friction observée) avec le pattern cp .env.example .env + édition + tilt up, et tableau des 4 vars dans developpement.md sous « Démarrage ». Hors scope : pas de migration vers un fichier .env officiel pour les secrets API (les clés Anthropic / Twelve Data / Finnhub restent dans application-local.yml ou la BDD app_config runtime — leur cycle de vie est différent et le runtime config v2 couvre déjà la rotation). Effort réel : ~30 min |
| ✅ Sidebar dashboard — drag-drop des portfolios + persistance accordéons | Livré 2026-05-08. Sidebar dashboard : la liste des portefeuilles devient draggable via Angular CDK DragDropModule (handle cdkDragHandle dédié, drag uniquement depuis l'icône drag_indicator pour ne pas voler le clic sur le <li> qui déclenche selectPortfolio). Persistance localStorage clé portfolio-order = JSON string[] d'IDs ; appliqué au boot via applyPersistedOrder(portfolios) qui hydrate l'ordre saved-first et place les portfolios inconnus (CSV fraîchement importé) en queue plutôt que de les laisser déplacer silencieusement les pinned. Robustesse : IDs périmés (portfolio supprimé) skipped, JSON corrompu fallback transparent sur l'ordre serveur. A11y : aria-label sur le bouton handle, focus-visible style. CDK : cdkDropList sur la <ul>, cdkDrag sur les <li>, classes cdk-drag-preview (shadow + surface-2) + cdk-drag-placeholder (opacity 0.3) pour la fluidité visuelle ; transition 0.18s sur les items adjacents pendant le drag. Code : nouveau applyPersistedOrder + readPersistedOrder + persistOrder + onPortfolioDrop(event: CdkDragDrop) dans dashboard.ts, hook depuis loadPortfolios.next() avant portfolios.set(). Template : <li> enveloppe désormais la pile name/count/total dans un .portfolio-content column-flex à droite du handle. SCSS : .portfolio-item passe en flex row, handle opacity: 0 puis 1 au hover du row (ne clutter pas la sidebar pour les users qui ne réordonnent jamais), cursor: grab / grabbing natifs. i18n FR + EN : nouvelle clé dashboard.reorderPortfolio. Tests : 6 nouveaux dans dashboard.spec.ts > describe('portfolio reordering') : (1) ordre persisté hydraté à l'init, (2) IDs inconnus skipped, (3) nouveaux portfolios en queue, (4) onPortfolioDrop réordonne le signal + persiste, (5) drop avec previousIndex === currentIndex est no-op (pas de write parasite), (6) JSON corrompu fallback ordre serveur. Hors scope du drag-drop : pas de drag inter-section (la sidebar a 3 sections collapsables Portefeuilles / Tickers détenus / Watchlist — le drag est local à la liste portfolios uniquement, le reste sera traité par le ticket Phase 2.5 « Sidebar modulaire ») ; pas d'a11y clavier custom (CDK le gère via tabindex="0" déjà posé + flèches haut/bas natives quand le handle a focus). Persistance accordéons sidebar : ajoutée dans la même session — l'état ouvert/fermé des 3 sections (Portefeuilles / Tickers détenus / Watchlist) est désormais persisté en localStorage clé dashboard-sidebar-open = JSON {portfolios, ownedTickers, watchlist: boolean}. Pattern miroir de ThemeService / LanguageService : free function readSidebarOpenState() hydrate les 3 signaux à la construction (per-key fallback true pour gérer un objet partiel sauvé avant l'ajout d'un futur 4ème accordéon), effect() enregistré dans le constructor() Dashboard écrit la nouvelle state à chaque toggle (try/catch pour absorber un quota localStorage exceeded). Aucun changement de template ni i18n — les bindings (click)="portfoliosOpen.update(v => !v)" existants déclenchent automatiquement l'effect. Tests : 4 nouveaux dans describe('sidebar accordion persistence') : hydration depuis localStorage, fallback per-key sur objet partiel, fallback all-open sur JSON corrompu, persistance après toggle (via await fixture.whenStable()). Effort réel : ~1 h pour le drag-drop + ~15 min pour la persistance accordéons |
| ✅ Clé API Anthropic (Claude) éditable au runtime comme SECRET | Livré 2026-05-08. Cible : aligner la clé Claude sur le pattern déjà en place pour market.twelvedata.api-key et market.finnhub.api-key — éditable depuis /settings/configuration > LLM, password input masqué + bouton Tester, rotation sans reboot. Backend : (1) ConfigKeys — nouvelle constante ANTHROPIC_API_KEY = "anthropic.api.key", ajoutée à SECRET_KEYS (le DTO sortira currentValue=null + defaultValue=null masqués) et KNOWN_KEYS (12 clés au total). (2) AppConfigService — nouveau @Value("\${anthropic.api.key:}") anthropicApiKeyDefault: String dans le constructor + case dans defaultFor(). (3) ClaudeClient — @Value("\${anthropic.api.key}") retiré du constructor au profit d'un property accessor apiKey: String get() = appConfig.getString(ANTHROPIC_API_KEY) (mirror exact de TwelveDataClient / FinnhubClient). Le header x-api-key passe de defaultHeader() builder-side (figé à la construction) à header() per-request — la rotation prend effet immédiatement. Nouveau requireApiKey() qui throw IllegalStateException("Anthropic API key is not configured") plutôt que de poster avec une clé vide et obtenir un 401 cryptique. (4) ConfigTestClient — @Value("\${anthropic.api.key:}") retiré, injection de AppConfigService ajoutée. probeClaude(model) refactoré en probeClaude(model, apiKey) ; testLlm("claude", model) lit la clé depuis appConfig, nouveau testAnthropicKey(candidateKey) mirror exact de testTwelveData / testFinnhub qui valide une clé candidate non encore sauvée. (5) ConfigController — nouvel endpoint POST /api/config/test/anthropic calque sur /test/twelvedata et /test/finnhub. Frontend : (1) port ConfigRepository.testAnthropic(value) ajouté + adapter HttpConfigRepository.testAnthropic qui POST /api/config/test/anthropic. (2) Configuration component — nouvelle constante ANTHROPIC_KEY, computed anthropicKey(), dispatch dans save() (blank l'input après succès, mêmes raisons que les deux autres SECRETs) et test() (route vers repo.testAnthropic). (3) Nouvelle card <section class="config-card"> dans /settings/configuration > LLM placée entre le toggle llm.provider et la card Ollama model — input type="password", badge Set/Unset, boutons Tester / Sauvegarder / Réinitialiser, bandeau d'erreur inline. i18n FR + EN : nouvelles entrées settings.configurationPage.anthropicKey.{title,description,inputLabel} ; descriptions de claudeModel mises à jour pour ne plus dire « clé configurée en YAML » mais « clé actuellement configurée plus haut ». Tests : AppConfigServiceTest étendu (constructor inclut anthropicApiKeyDefault), ConfigControllerTest étendu — count 11 → 12 clés, layout alphabétique recalculé (anthropic.api.key insère en position [1], les indices [2..11] décalent de +1), nouveau test POST test anthropic returns the result from the test client mirror des deux autres testers, ConfigTestClientTest réécrit pour mocker AppConfigService (au lieu de @Value) et ajoute un test testAnthropicKey rejects a blank candidate before any network call, configuration.spec.ts : nouvelle fixture ANTHROPIC SECRET, mock testAnthropic ajouté, loads the entries on init count 11 → 12 + assertions sur anthropicKey(), test routing étendu pour vérifier que test('anthropic.api.key') appelle repo.testAnthropic, config.http.spec.ts : nouveau test testAnthropic POSTs the candidate value to /test/anthropic. Hors scope : pas de migration BDD (la table app_config V4 supporte n'importe quelle key arbitraire), pas de chiffrement (cohérent avec le statu quo des deux autres SECRETs). Effort réel : ~1 h comme estimé |
✅ Scinder backlog.md : extraction du journal des livraisons vers un fichier dédié |
Livré 2026-05-07. Ferme le ticket Phase 2.5 du même nom. Cible : docs/projet/backlog.md mélangeait ⏳ À faire (planning) et ✅ Livré (historique) sur 233 lignes — chaque session de planning scrollait à travers des trimestres de livré. Fait : (1) nouveau fichier docs/projet/journal-livraisons.md au format reverse-chronological par phase (Phase 2.5 → 2 → 1 → 0 → Dette technique) avec une section frontmatter par phase rappelant le tag de clôture (v0.1.0, v0.2.0, v0.3.0). Toutes les notes d'implémentation détaillées migrées intactes — c'est le contexte historique qui fait la valeur du fichier. (2) backlog.md rétréci de 233 → 125 lignes : ne garde que ⏳/🚧/🧊/❌ et la section Dette technique (⏳ only). Chaque phase clôturée (0, 1, 2, 2.5) ouvre par un pointeur explicite vers la section correspondante du journal — pas de duplication de contenu, juste un lien. Le ❌ Décommissionné Phase 0 reste dans le backlog parce qu'il documente l'état courant du code (mêmes raisons que 🧊) ; il est aussi dans le journal pour la chronologie. (3) Mises à jour collatérales : mkdocs.yml nav Projet: étend avec une nouvelle entrée Journal des livraisons ; developper.md > Pour aller plus loin scinde la ligne backlog en deux (planning vs historique) ; CLAUDE.md section ### Backlog réécrite pour expliquer le split + workflow « après livraison : nouvelle entrée dans journal-livraisons.md, ligne ⏳ retirée de backlog.md », tableau Documentation étendu d'une ligne dédiée au journal. Bénéfice principal : une session de planning ouvre backlog.md et lit uniquement ce qui reste à faire ; une revue rétrospective ouvre journal-livraisons.md et lit du plus récent au plus vieux comme un Keep-a-Changelog. Non automatisé : la migration future (déplacer une entrée ⏳ vers le journal après livraison) reste manuelle — pas de script v1, l'inertie va dans le bon sens (nouvelles livraisons écrivent dans le journal, le backlog rétrécit naturellement). Effort réel : ~30 min comme estimé |
| ✅ Type d'instrument — chip dans la sidebar tickers détenus (phase 2/3 — backend extend) | Livré 2026-05-07. Boucle l'éventail du chip d'instrument type avec phase 1 (header dossier) et phase 3 (watchlist) — toutes les surfaces où le user voit un ticker affichent désormais son bucket. Pattern différent des phases 1 et 3 : pas de lookup HTTP (lazy ou direct) parce que OwnedTicker est déjà un DTO d'une query backend qui agrège la table asset ; la BDD a déjà l'info via asset.asset_type. Donc on étend la query plutôt que d'ajouter un fetch côté front. Backend : OwnedTickerRow (data class JPQL constructor) gagne un assetType: AssetType, query JPQL étendue pour le ramener via a.assetType dans le SELECT et le GROUP BY (ajouter au GROUP BY a.ticker, a.assetType plutôt que MAX(a.assetType) parce qu'un même ticker partage toujours le même type — les ajouter au GROUP BY collapse les rows naturellement, et si une CSV-import inconsistency créait un même ticker avec deux types, on verrait deux rows séparées plutôt que de masquer le bug). OwnedTickerDto étendu avec le même champ. PortfolioQueryService.findOwnedTickers mappe le champ. Frontend : interface OwnedTicker étendue avec assetType: AssetType, template dashboard.html enveloppe chaque entry dans un <div class="owned-ticker-item"> (inline-flex pour que la paire chip-symbol + chip-type ne se sépare pas dans le wrap) et ajoute le chip <span class="sidebar-instrument-type instrument-{{ assetType }}">. SCSS — refactor mineur en passant : la classe .watchlist-instrument-type (introduite en phase 3) renommée en .sidebar-instrument-type puisque les deux listes sidebar (watchlist + owned-tickers) partagent désormais les mêmes styles. 3 nouvelles variantes .instrument-CRYPTO (rouge danger, fits volatile vibe), .instrument-BOND (neutre quiet via surface-2 + text-muted), .instrument-COMMODITY (warning soft — même teinte qu'INDEX puisque les deux ne cohabitent pas sur la même surface : INDEX sort uniquement de InstrumentType market = watchlist, COMMODITY sort uniquement de AssetType portfolio = owned-tickers). i18n FR + EN étendus avec CRYPTO/BOND/COMMODITY (Crypto/Obligation/Matière première en FR, Crypto/Bond/Commodity en EN). Tests : PortfolioControllerTest mis à jour — fixture OwnedTickerDto reçoit AssetType.STOCK / CRYPTO / ETF, assertions JSON sur le nouveau champ assetType (avec un BTC en CRYPTO pour pin un type non-STOCK et exercer la sérialisation enum→string). dashboard.spec.ts ajoute (a) le champ assetType aux fixtures ownedTickers existantes et (b) un nouveau test qui rend 3 entrées (STOCK / ETF / CRYPTO) et vérifie que chaque chip porte la classe instrument-{{ type }} correcte. Effort réel : ~30 min |
| ✅ Type d'instrument — chip dans la watchlist (phase 3/3 — lazy lookup) | Livré 2026-05-07. Décision design : pivoté de l'approche backend originale (V7 migration + colonne asset_type sur watchlist + lookup Twelve Data au POST add, ~60 min) vers une lazy lookup côté frontend (~30 min) qui réutilise le cache Caffeine 15 min déjà en place sur getTicker. Trade-off : ~10-20 credits Twelve Data au premier dashboard de la journée (≈ 2-3 % du free tier 800/jour), zero credit sur les reloads suivants dans la fenêtre de cache. Pas de migration BDD, pas d'historique pré-migration à backfill. Si plus tard les credits deviennent un sujet ou qu'on veut le type avant le rendu (e.g. tri par type), on bascule sur le persist BDD ; pour le single-user actuel, lazy suffit. Code : nouveau signal privé watchlistInstrumentTypes: Record<symbol, type> dans Dashboard, méthode publique watchlistInstrumentTypeFor(symbol) consommée par le template, méthode privée enrichWatchlistInstrumentTypes(symbols) qui boucle, skip les symboles déjà dans la map, et fire un marketRepository.getTicker(symbol) parallèle pour chaque inconnue. Hook depuis loadWatchlist (success) et addToWatchlist (success, pour la nouvelle entrée). Pas de cleanup sur removeFromWatchlist — entrées stales sont harmless et un re-add hit le cache hot. Erreurs swallowed : un 404 ou 503 sur getTicker laisse l'entrée absente de la map → chip non rendu (degrade closed, mirror du Sector toggle / Fondamentaux / chip header dossier). Template : chip <span class="watchlist-instrument-type instrument-{{ type }}"> rendu dans .watchlist-item entre le .ticker-chip et le .watchlist-remove-btn, conditionnel sur @if (watchlistInstrumentTypeFor(symbol); as type) (falsy check exclude null et undefined sans cas spécial). SCSS : nouvelle classe .watchlist-instrument-type dans dashboard.scss avec 4 variantes adossées aux mêmes tokens que la version dossier, mais taille 0.55 rem (vs 0.65 rem) parce qu'on est dans une sidebar étroite. Note dans le commentaire SCSS : si phase 2 (Tickers détenus) ajoute le même chip, considérer lift à styles.scss pour mutualiser. Tests : 3 nouveaux dans le describe('watchlist') de dashboard.spec.ts — (1) watchlistInstrumentTypeFor populated avec le type retourné par le mock pour 2 symboles distincts (AAPL → STOCK, VOO → ETF), (2) lookup absent quand getTicker 503 (degrade closed, pas de stale STOCK qui leak), (3) re-add d'un symbole déjà dans la map ne refait pas l'appel getTicker (évite la burst-of-credits sur re-add). Mock mockMarketRepository.getTicker ajouté avec helper buildSnapshot(symbol, instrumentType) ; reset dans le beforeEach du watchlist describe pour l'isolation. Effort réel : ~30 min |
| ✅ Type d'instrument — chip dans le header dossier ticker (phase 1/3) | Livré 2026-05-07. Phase 1 du ticket « Surfacer le type d'instrument » qui a 3 phases livrables indépendamment ; les phases 2 (sidebar tickers détenus) et 3 (watchlist) restent en ⏳. Code : nouveau wrapper <div class="ticker-meta"> dans le .ticker-id du header /ticker/:symbol qui contient l'exchange existant + le nouveau chip <span class="ticker-instrument-type instrument-{{type}}"> rendu conditionnellement quand snap.quote.instrumentType n'est pas null (degrade closed, même posture que le toggle Sector et la section Fondamentaux). SCSS : .ticker-meta flex row + 4 variantes de chip .instrument-STOCK/ETF/INDEX/OTHER adossées à la palette tokens existante (--color-accent-soft STOCK, --color-success-soft ETF, --color-warning-soft INDEX, neutre --color-surface-2 OTHER). Pill arrondi --radius-pill, taille 0.65rem uppercase. i18n : nouvelle sous-clé common.instrumentLabel.{STOCK,ETF,INDEX,OTHER} en FR (Action / ETF / Indice / Autre) et EN (Stock / ETF / Index / Other) — placée sous common pour que les phases 2 et 3 (qui afficheront aussi les buckets CRYPTO/BOND/COMMODITY côté AssetType portfolio) réutilisent la même clé. Tests : nouveau bloc describe('instrument type chip') dans ticker.spec.ts avec 3 assertions miroir des tests Sector/Fondamentaux existants — (1) variant STOCK rendu par défaut, (2) classe swap vers instrument-ETF quand le snapshot l'indique, (3) chip absent quand instrumentType: null. Sélecteur data-testid="ticker-instrument-type". Hors scope : pas d'extension du vocabulaire instrumentLabel aux buckets CRYPTO/BOND/COMMODITY — sera ajoutée en phase 2 quand le DTO portfolio les expose. Effort réel : ~15 min (estimé 10 min, légèrement plus long parce qu'on a aussi posé le wrapper .ticker-meta pour héberger l'exchange + le chip côte-à-côte au lieu d'empiler en colonne) |
✅ CacheTtlListener : passer en @TransactionalEventListener(AFTER_COMMIT) |
Livré 2026-05-07. Ferme finding #5 audit 2026-05-06. Code : annotation @EventListener remplacée par @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) sur CacheTtlListener.onConfigChanged (1 ligne + import swap dans MarketConfig.kt). Docstring de la classe enrichie pour expliquer le why (le rebuild se fait après commit, pas avant — sinon une transaction qui rollback aurait déjà flushé le cache pour rien). Tests : nouveau CacheTtlListenerIntegrationTest (@SpringBootTest + @MockitoSpyBean CaffeineCacheManager + TransactionTemplate) avec 3 tests qui pin chacun un invariant : (1) commit → setCaffeine appelé, (2) rollback → setCaffeine jamais appelé, (3) event sur une autre clé que cache.ttl-minutes → ignoré. Test écrit en intégration (et pas unitaire) parce que le branchement commit-vs-rollback de @TransactionalEventListener est wired par le PlatformTransactionManager Spring — pas exerçable sans un vrai contexte transactionnel. Hors scope assumé : le test ne passe pas par AppConfigService.set directement, il publie l'event via ApplicationEventPublisher parce que c'est le contrat event-side qu'on pin, indépendamment de qui publie. Effort réel : ~25 min (10 min annotation + 15 min test) |
| ✅ Décommissionner Phase 0 (ingestion RSS + analysis legacy + tables associées) | Livré 2026-05-07 en 3 PR séquentielles. PR1 — backend : migration Flyway V6 droppant recommendation, recommendation_action, recommendation_score, analysis_job, feed_article, feed_source (FK-aware ordering, pas de CASCADE pour révéler les dépendances). Module ingestion/ complet supprimé (7 fichiers : RssFetcherService, IngestionDto, FeedArticle, FeedSource, IngestionController, 2 repositories). 16 fichiers Phase 0 supprimés sous analysis/ : AnalysisService, AnalysisExecutor, AnalysisRunner, AnalysisJobStore, AnalysisContextLoader, ArticleRelevanceScorer, RecommendationPersister, RecommendationValidator, LlmResponseParser, AnalysisDto, entities AnalysisJob + Recommendation, AnalysisController, RecommendationHistoryController, AnalysisJobRepository, RecommendationRepository. Enum JobStatus extraite dans son propre fichier (avant suppression de AnalysisJob.kt) parce que TickerNarrativeJob Phase 1 en dépend toujours. OrphanedJobCleanupListener simplifié pour ne sweep que ticker_narrative_job (la branche analysis_job retirée), test mis à jour. BackendApplication perd @EnableScheduling (plus aucun @Scheduled après suppression de RssFetcherService). YAML : bloc ingestion: retiré de application.yml + application-local.yml. PR2 — frontend : core/analysis.repository.ts + core/adapters/analysis.http.ts + analysis.http.spec.ts supprimés ; core/settings.repository.ts + core/adapters/settings.http.ts + settings.http.spec.ts supprimés (port purement Phase 0 RSS sources backoffice, plus aucun consommateur). features/recommendations/, features/history/, features/settings/sources/, features/settings/test-sources/ supprimés (4 dossiers). app.routes.ts : routes /recommendations, /history, /settings/sources, /settings/test-sources retirées ; settings default redirect passe de sources à configuration. app.config.ts : providers AnalysisRepository + SettingsRepository retirés. app.html : entrée navbar /history retirée. dashboard.ts + .html + .scss + .spec.ts purgés de toute la machinerie analyse Phase 0 : signals analyzing/analyzeElapsed/lastRecommendation + méthodes runAnalysis()/stopAnalyzing()/actionClass()/actionAmounts() + bouton « Analyser » + section recommandation + ~200 lignes de CSS mort (.btn-ai-*, @keyframes glow-pulse/spin, .recommendation-*, .action-*, .amount-*) + tests actionClass* / actionAmounts* ; les tests grandTotalCad, ownedTickers, watchlist conservés intacts. settings.html : entrées sub-sidenav Sources + Tester un flux retirées (reste Configuration + Prompt preview). i18n FR + EN purgés des clés orphelines (nav.recommendations, dashboard.{analyze,analyzing,recommendation}, dashboard.errors.{startAnalysis,ai,polling}, history, settings.{sources,testSources,sourcesPage,testPage}). PR3 — docs : architecture.md (vue d'ensemble ASCII allégée, sections analysis/ legacy + ingestion/ retirées, table BDD passée de 8 à 5 lignes actives, V6 référencée, bloc « Gelé Phase 0 (référence) » des décisions techniques retiré, vision pipeline DAG ré-orientée vers ticker_narrative_job seul), fonctionnalites.md (Phase 0 → « partiellement décommissionnée », sections « Gelé » → « Décommissionné Phase 2.5 » avec contexte), CLAUDE.md (tableau backend modules retire ingestion/, repositories frontend passés de 11 à 9, settings sub-sidenav remis à jour, blurb analysis/ allégé), ddd.md (contexte ingestion retiré du tableau bounded contexts, dépendances Phase 0 retirées), developpement.md (arborescence retirée des modules supprimés), sources.md (section Phase 0 RSS gelée → décommissionnée, listings RSS / macro / crypto retirés). Hors scope assumé : pas de dump JSON archive avant le drop des tables (single-user local, le V1 d'origine reste dans git pour la genèse, et le replacement Phase 6 ne réutilise rien de la plomberie). Replacement : la Phase 6 « Réintégration Phase 0 » (toujours en backlog) sera un parent PortfolioAggregation au-dessus des snapshots ticker existants — pas un re-prompt LLM portfolio-wide, pas de scraping nouveau. Effort réel : ~1 jour effectif (3 PR séquentielles aux scopes propres) |
| ✅ Config runtime v2 (v1.5) : timeout LLM éditable + sub-sidenav Providers / LLM | Livré 2026-05-07 dans la foulée de v1. Timeout slider : nouvelle clé runtime llm.timeout-seconds (INT 60..900, défaut 400, validation côté AppConfigService.validate), unifie les trois fenêtres précédemment hardcodées : OllamaClient.readTimeout (RestClient reconstruit per-call avec le timeout courant, le SimpleClientHttpRequestFactory ne supportant pas la mutation in-place — cost négligeable), AnalysisJobStore.DEDUP_WINDOW_SECONDS (lu via appConfig.getInt(LLM_TIMEOUT_SECONDS) à chaque dedup), TickerNarrativeJobStore.DEDUP_WINDOW_SECONDS (idem). Côté frontend, nouveau LlmTimeoutService (providedIn: 'root') qui expose un signal<number> primé au boot via provideAppInitializer — les deux pollers (analysis.http.ts pour le portfolio analysis + market.http.ts pour le narrative ticker) lisent la valeur per-tick (slider drag mid-poll prend effet à la prochaine tick au lieu d'être figé à la construction de l'observable). Configuration.save(LLM_TIMEOUT_KEY) appelle timeoutService.refresh() pour propager instantanément. UI : slider Material 60..900 step 60 (1 min) sur la section LLM, affichage en minutes (X min) avec hint « défaut : 7 min ». Hors scope : ClaudeClient.readTimeout reste à 60 s hardcoded — Anthropic résout en 1-3 s, le slider serait du bruit pour ce chemin. Sub-sidenav Providers / LLM : /settings/configuration passe d'une liste plate à un layout 2-col avec sub-nav signal-driven (activeSection: 'providers' \| 'llm', persistée localStorage runtime-config-section). Section Providers de données : market / news / analyst / earnings + Twelve Data + Finnhub + Cache TTL. Section LLM : llm.provider + Ollama model + Claude model + LLM timeout. Pas de sub-routes — single-user, pas de bénéfice partage-d'URL ; la migration vers [routerLink] reste triviale si besoin un jour. Responsive < 720 px : sub-nav passe en horizontal au-dessus du contenu. Tests : AppConfigServiceTest étendu (range 60..900), ConfigControllerTest mis à jour pour 11 clés, configuration.spec.ts : fixture LLM_TIMEOUT, prime / dirty / refresh-on-save / pas-refresh-sur-clé-non-LLM, plus 5 tests sub-nav (default providers, setSection flip + persist, no-op same section, hydratation localStorage, Twelve Data card invisible en section LLM). Docs : architecture.md (11 clés + paragraphe LLM timeout runtime + LlmTimeoutService), CLAUDE.md (timeout-seconds remplace les 3 hardcoded values), backlog ce ticket en ✅ |
| ✅ Config runtime v2 (v1) : LLM provider + model éditable depuis l'UI | Livré 2026-05-07. Trois nouvelles clés runtime ajoutées à ConfigKeys : llm.provider (ENUM claude / ollama, défaut claude), ollama.model (STRING libre, défaut qwen2.5:3b, l'UI suggère qwen2.5:3b/7b, llama3.2:3b, llama3.1:8b, mistral:7b, phi4-mini via mat-autocomplete mais le backend ne whitelist pas — l'écosystème Ollama bouge trop vite), anthropic.api.model (STRING libre, défaut claude-opus-4-6, suggestions claude-opus-4-7 / 4-6, claude-sonnet-4-6 / 4-5, claude-haiku-4-5-20251001). Backend : nouveau RoutingLlmClient (@Primary) dans analysis/infrastructure/llm/ qui délègue per-call à ClaudeClient ou OllamaClient selon appConfig.getString(LLM_PROVIDER). Les deux adapters perdent leur @ConditionalOnProperty et sont toujours instanciés ; le model name est lu per-call via appConfig.getString(...). modelId() route lui aussi pour que la snapshot capture l'identifiant exact du modèle qui a répondu — switcher de provider au runtime garde l'historique honnête. Endpoint POST /api/config/test/llm + ConfigTestClient.testLlm(provider, model) qui envoie le prompt fixe « Reply with exactly the word OK. » à /v1/messages (Claude) ou /api/chat (Ollama, pas de format=json pour le probe), mesure latence + vérifie que la réponse contient « OK », retourne un TestConfigResult(ok, message) (OK — Ollama (qwen2.5:3b) replied in 1.4s en succès, message contextualisé sur 401 / 404 model-not-found / unreachable en échec). Read timeout dédié 120 s pour absorber un cold-start Ollama sans bloquer 400 s côté request thread. Frontend : 3 cards Material sur /settings/configuration insérées entre Earnings provider et Twelve Data (toggle LLM provider) et entre Finnhub key et Cache TTL (Ollama model + Claude model). Toggle save instantané (mirror MARKET_PROVIDER). Cards model = input texte avec mat-autocomplete, suggestions hardcoded TS, primé au load depuis currentValue (mirror TTL slider). Boutons Save / Reset / Tester par card ; Tester probe toujours le provider de la card (indépendant de llm.provider actif — l'utilisateur veut savoir « ce modèle répond-il ? », pas « la stack live tourne-t-elle ? »). i18n FR + EN : llmProvider, ollamaModel, claudeModel + providers.claude / providers.ollama ajoutés sous settings.configurationPage. Tests : AppConfigServiceTest étendu (rejet openai/claude-api sur llm.provider, acceptation modèle Ollama arbitraire, défauts exposés), nouveau RoutingLlmClientTest miroir de RoutingNewsClientTest (dispatch claude/ollama, dispatch modelId(), rejet provider inconnu), nouveau ConfigTestClientTest sur les branches input-validation (model blank, provider inconnu, Anthropic key manquante), ConfigControllerTest mis à jour pour 10 clés et test POST /test/llm avec trim. Côté front : config.http.spec.ts étendu (POST /test/llm body {provider, model}), configuration.spec.ts étendu (count 10, fixtures LLM, prime des inputs model, modelDirty true/false, testLlmModel route ollama/claude). v1.5 (timeout slider) restée à faire dans une entrée séparée — nécessite de transformer la const compile-time POLL_ABORT_SECONDS en lookup runtime, coût 3-4 h, mérite sa propre PR |
✅ /settings/configuration : exposer analyst.provider et earnings.provider côté UI |
Ferme le finding #1 de l'audit 2026-05-06 (drift contrat doc ↔ code). Backend déjà câblé (ConfigKeys.KNOWN_KEYS, ENUM_KEYS, défaut YAML, retour dans GET /api/config) — il manquait juste les deux cards Material. Côté configuration.ts : ajout des constantes ANALYST_PROVIDER_KEY / EARNINGS_PROVIDER_KEY et de deux computed() accesseurs (analystProvider, earningsProvider). Côté configuration.html : deux nouvelles <section class="config-card"> modelées sur le bloc news.provider existant, insérées entre News provider et Twelve Data — chaque card a son mat-button-toggle-group mock ↔ finnhub, son badge default/override, un bouton Reset conditionnel, et la même bannière d'erreur inline. i18n FR + EN : 4 nouvelles entrées sous settings.configurationPage.analystProvider.{title,description} et earningsProvider.{title,description}. Description rappelle que la clé Finnhub plus bas est requise et que le changement s'applique au prochain dossier ouvert. Tests configuration.spec.ts étendus : fixtures ANALYST_PROVIDER / EARNINGS_PROVIDER ajoutées au repo.list() mocké, assertions ajoutées dans loads the entries on init (count 7, allowedValues), et deux nouveaux tests miroir des selectProvider existants (routes analyst.provider to the right key, routes earnings.provider to the right key). Docstring du composant mise à jour (n'évoque plus « three keys » mais la liste complète : 2 API keys + TTL + 4 toggles provider). Aucun changement backend, aucun changement de schéma, l'endpoint REST PUT /api/config/analyst.provider qui marchait déjà via curl est maintenant cliquable. Embarque le rappel pour le finding #4 (sector benchmark Finnhub-dependent) qui reste en ⏳ À faire séparé |
| ✅ Sidenav outils chart (Dossier ticker) | Refonte du chart-toolbar inline — les contrôles (timeframe / benchmark + autocomplete custom / overlays MA/Bollinger/52w / outils annotation+measure+reset zoom) sont déportés dans une sidenav gauche persistante façon Amazon, foldable via chevron, état persisté global en localStorage ticker-sidenav-open. La page ticker passe en grid 2-col (sidenav 240 px ouvert / 48 px collapsé + 1fr contenu), sidenav position: sticky top: 1rem pour rester visible pendant que l'utilisateur scrolle dans les indicateurs / fundamentals / news / narratif. Sections dans la sidenav (top→bottom) : (1) Période (mat-button-toggle-group vertical 1D/5D/1M/3M/1Y/5Y), (2) Benchmark (toggle-group vertical Off/SPY/QQQ/IWM/Sector + autocomplete custom sidecar), (3) Overlays (multi-select MA50/MA200/Bollinger/52w hi/lo, désactivé en mode benchmark %), (4) Outils (« + Annotation » toggle, « Clear anchor » conditionnel, « Reset zoom » conditionnel), (5) Annotations posées (liste avec valeur + bouton supprimer mat-icon-button par item, empty state si vide). Embarque la fiabilisation de suppression d'annotations (ticket Phase 2 « Chart analyse v3 — fiabiliser la suppression d'annotations » clôturé en passant) : la liste explicite remplace le handle × inline comme chemin discoverable ; le handle reste pour interaction directe sur le SVG mais n'est plus le seul moyen. Responsive : breakpoint max-width: 720px collapse la sidenav en static (full-width, courte) au-dessus du contenu pour les petits écrans / mobile. Tests : 6 nouveaux dans ticker.spec.ts (default open, hydration localStorage, toggle persiste, empty annotations, hydration list, removeAnnotation optimistic via la liste). i18n FR + EN (titre sidenav, libellés sections, tooltips fold/expand, empty state annotations) |
Phase 2 — Profondeur ticker (clôturée 2026-05-06, tag v0.3.0)
✅ Prérequis — provider de marché alternatif
| Feature | Notes | Priorité |
|---|---|---|
✅ TwelveDataClient (nouveau provider primaire) |
Yahoo rate-limitait agressivement les IPs résidentielles (ban observé sur résidentiel + VPN + cellulaire) — validation live impossible. Twelve Data prend le relais : REST documenté, free tier 800 credits/jour, TSX natif (XTSE), JSON simple. Deux endpoints (/time_series + /quote), parser tolérant aux quirks (numériques en strings, erreurs en HTTP 200 avec status: error). Refactor du port MarketChartClient au passage : retour en types domaine (MarketChart = TickerQuote + List<OhlcBar>). Clé de config market.provider (mock | twelvedata). Défaut application.yml = mock (pas de clé requise pour l'onboarding et la CI), application-local.yml bascule sur twelvedata avec clé via TWELVEDATA_API_KEY. Code Yahoo supprimé en suivant (jamais opérationnel pour le user) — historique git si besoin |
🔴 Critique |
✅ Settings & config runtime + Profondeur ticker
| Feature | Notes |
|---|---|
✅ /settings/configuration : signaler la dépendance Finnhub du Sector benchmark |
Ferme le finding #4 de l'audit 2026-05-06 (option (b) recommandée — avertissement i18n dans la card Twelve Data, sans modifier benchmarkChoicesForCurrentTicker). Asymétrie de routing : RoutingSectorClassifier route le mode market.provider=twelvedata vers Finnhub parce que /profile Twelve Data est paid-tier — un user qui n'a renseigné que sa clé Twelve Data se voyait proposer le toggle « Sector » qui retournait silencieusement un 503 inline. HTML configuration.html : nouveau bloc <p class="dependency-hint"> avec icône info_outline ajouté dans la card Twelve Data juste après la description, affiché uniquement quand finnhub() && !finnhub()!.hasValue — quand la clé est posée le hint disparaît pour ne pas devenir du bruit. Marqué data-testid="twelvedata-sector-hint" pour l'attache des tests. SCSS configuration.scss : nouvelle classe .dependency-hint (flex, color-text-muted, font-size 0.8rem, mat-icon 1rem 1×1, marge négative en haut pour coller à la description). i18n FR + EN : nouvelle clé settings.configurationPage.twelveData.sectorHint qui rappelle que le Sector benchmark utilise Finnhub même en mode Twelve Data et invite à renseigner la clé Finnhub plus bas. Tests configuration.spec.ts : 2 nouveaux specs DOM-level qui pin (a) le hint est rendu quand FINN.hasValue=false (default fixture), (b) le hint disparaît quand on rebascule l'entrée FINN à hasValue=true via repo.list.mockReturnValueOnce + component.load() + fixture.detectChanges(). Docstring de classe étendue avec un nouveau bullet « Sector dependency hint ». Aucun changement backend, aucun changement de schéma. Option (a) (filtrer le toggle Sector côté benchmarkChoicesForCurrentTicker via un champ DTO hasFinnhubKey) reste en réserve si quelques sessions montrent que le hint passe inaperçu |
| ✅ Lifecycle de position (OPEN / CLOSED) dans l'import CSV | Bug observé : le portfolio "live" du dashboard ne reflétait plus la réalité du courtier — un ticker vendu (XAU dans l'export Wealthsimple suivant) restait visible parce que CsvImportService.import faisait un upsert pur sans cleanup. Fix V5 : ajout d'un lifecycle explicite sur la table asset (status ∈ {OPEN, CLOSED}, opened_at, closed_at). Choix architecturé pour cohérence avec la philo observabilité du projet (snapshots Phase 3, croisement Phase 4) — au lieu de hard-delete on flippe en CLOSED, valeurs figées à la dernière snapshot connue. Comportement import : par account, après upsert des rows présentes, identifier to-close = existing OPEN − csvTickers → flip CLOSED + closedAt = importedAt ; si un asset CLOSED réapparaît dans le CSV → reopen (status OPEN, closedAt null, opened_at conservé). Query : findByPortfolioIdAndStatus(OPEN) côté PortfolioQueryService.findAssets et filtre WHERE a.status = OPEN côté findOwnedTickerRows. UI : compteurs "N positions fermées / réouvertes" surfacés dans la confirmation d'import (single + batch agrégé), i18n FR + EN. DTO : CsvImportResult enrichi de positionsClosed + positionsReopened. Tests : 3 tests Mockito-Kotlin sur le lifecycle (close, reopen, no-op si CSV identique). Snapshot Suivi inchangé — il capture toujours la vérité par batch indépendamment du status. Future page "Positions historiques" (Phase 6) lira les CLOSED |
✅ Section Configuration runtime dans /settings |
Nouvelle page /settings/configuration (4ème onglet sidenav, icône tune) qui édite en direct cinq clés sans reboot : market.twelvedata.api-key, market.finnhub.api-key, market.cache.ttl-minutes, market.provider (mock ↔ twelvedata) et news.provider (mock ↔ finnhub). Backend : nouveau module config/ (AppConfigService avec cache mémoire ConcurrentHashMap primé au boot, surcharge BDD au-dessus du défaut YAML, émet ConfigChangedEvent sur changement effectif). Migration V4 app_config (key PK / value TEXT / updated_at). Les deux gotchas du backlog ont été traités : (1) TwelveDataClient et FinnhubClient lisent appConfig.getString(...) à chaque appel (le @Value figé à la construction du bean est remplacé par une property get() qui délègue) ; (2) MarketConfig ne hard-code plus le TTL — CacheTtlListener (composant séparé) écoute l'event et appelle setCaffeine(...) sur le CaffeineCacheManager pour rebuild la spec sans reboot (trade-off accepté : invalide les entrées en cours, négligeable sur un changement rare). Endpoints : GET /api/config (liste, secrets masqués — currentValue/defaultValue à null, hasValue bool), PUT /api/config/{key} (set, trim côté serveur), DELETE /api/config/{key} (reset au défaut), POST /api/config/test/twelvedata + /test/finnhub (probe live d'une clé candidate sans la sauver — ConfigTestClient dédié, RestClient interne pour fonctionner même si market.provider=mock). Frontend : nouveau port ConfigRepository + adapter HTTP, page Material avec 3 cards (password input + bouton "Tester" pour les deux secrets, mat-slider 5–60 step 5 pour le TTL avec disable du Save quand ttlDirty()=false). État per-key isolé (signaux edits / saving / testing / testResults indexés par clé). Save d'un secret blanke l'input après succès — la valeur est désormais masquée côté serveur, la laisser à l'écran serait une fuite. Tests : 11 unitaires AppConfigServiceTest (read layered, cache prime, set/reset, validation TTL 5–60, event publication conditionnelle), 7 slice MVC ConfigControllerTest (mask des secrets, trim, 400 sur blank, delete idempotent, dispatch test endpoint). Les tests existants TwelveDataClientTest + FinnhubClientTest patchés pour mocker AppConfigService. Côté front : 5 tests adapter HTTP config.http.spec + 11 tests configuration.spec (load, dirty slider, test routing, error path, fresh edit invalide le résultat de test). i18n FR + EN. Note sécu : clés API stockées en clair en BDD locale — acceptable projet perso, à chiffrer si on déploie un jour. v1 = un seul TTL pilote market-chart + news-by-symbol (single Caffeine spec partagée) ; on splittera si on veut des fraîcheurs différentes. Switch provider à chaud : les anciens @ConditionalOnProperty(market.provider=…) / news.provider=… sont retirés sur les 4 adapters + 2 HttpConfig, les deux providers de chaque type sont désormais toujours instanciés (un RestClient Twelve Data et un RestClient Finnhub coexistent ; les clients sont qualifiés par @Qualifier("twelveDataRestClient") / @Qualifier("finnhubRestClient")). Deux beans @Primary RoutingMarketChartClient et RoutingNewsClient lisent appConfig.getString(...) à chaque appel et délèguent au bon adapter — bascule visible au prochain dossier ouvert. UI : mat-button-toggle-group qui sauve à chaque clic (pas de bouton Save séparé pour les enums). Les keys ENUM portent un champ allowedValues côté DTO + frontend ConfigEntry ; nouveau type ConfigValueType.ENUM. 6 tests supplémentaires (RoutingMarketChartClientTest, RoutingNewsClientTest) + extensions du AppConfigServiceTest (validation enum) et du ConfigControllerTest (5 keys, ordre alphabétique, allowedValues sur les ENUM) |
| ✅ Multi-timeframe + axes + crosshair de hover | Toggle 6 boutons (1D / 5D / 1M / 3M / 1Y / 5Y) au-dessus du graphe. Nouveau endpoint back GET /api/market/ticker/{symbol}/chart?timeframe= retournant ChartDto (bars seuls, pas d'indicateurs ni narratif — ceux-ci restent ancrés sur 1Y). Enum Timeframe côté domain (Yahoo-style intervals 1d / 1wk / 5m / 30m pour aligner les clés Caffeine entre dossier et chart endpoint). IllegalArgumentException mappée à HTTP 400 dans GlobalExceptionHandler pour les codes inconnus. Front : mat-button-toggle-group + signaux chartBars / chartLoading / chartError séparés du dossier (erreur 5Y ne casse pas les chips). Mock MockMarketChartClient honore désormais (range, interval) (seed = symbol+range+interval). Chart enrichi en v1 : 5 lignes de grille horizontales + labels prix axe Y (gauche), 4 dates axe X (bas, format calé sur le timeframe), crosshair pointillé + dot au survol, tooltip HTML date+prix. Locale du label tirée de LanguageService. 9 tests couvrent le flux (slice MVC MarketControllerTest, adapter market.http.spec, ticker.spec, 3 nouveaux tests mock pour intraday/weekly/seed). Voir entrée "Chart : analyse + sélection" plus bas pour la suite (zoom drag-select, overlays MA, annotations) |
| ✅ Watchlist persistée | Nouveau module backend watchlist/ — port WatchlistService avec normalisation symbole (uppercase + trim) + add idempotent + remove non-idempotent (404 propre). Table watchlist_entry (V3) id UUID / symbol VARCHAR(20) UNIQUE / added_at. 3 endpoints REST GET / POST / DELETE /api/watchlist[/symbol]. Front : port WatchlistRepository + adapter HTTP, deux entrées d'ajout — input rapide dans la sidebar dashboard + bouton "Suivre / Suivi" sur le header du Dossier ticker (icône bookmark filled/outlined, optimistic toggle avec rollback sur erreur). Section sidebar avec liste cliquable et icône poubelle, message empty si vide. Optimistic remove côté dashboard (rollback sur erreur). 14 tests : slice MVC WatchlistControllerTest (7), adapter spec (3), dashboard.spec watchlist (6 tests : init, idempotent dédup, error 400/generic, optimistic remove, rollback, silent failure), ticker.spec toggle (5 tests). i18n FR + EN. Pas de gestion multi-user (table sans user_id). Doublon owned ↔ watch volontairement accepté visuellement |
| ✅ Sidebar dashboard collapsable + scrollbar custom | Trois sections sidebar (Portefeuilles, Tickers détenus, Watchlist) deviennent indépendamment foldables via 3 signaux *Open + bouton header avec chevron qui pivote au clic. Le grand total CAD reste visible quand "Portefeuilles" est fermé. Scrollbar custom 8px (vs ~15px par défaut) appliquée globalement dans styles.scss — tokens couleur du thème, support Webkit (::-webkit-scrollbar*) + Firefox (scrollbar-width / scrollbar-color). Pas de persistance localStorage des états ouvert/fermé pour l'instant |
| ✅ News par ticker | Nouveau module backend news/ — port NewsClient. Deux adapters cohabitent, sélectionnés par news.provider : FinnhubClient (finnhub, REST + apikey, fenêtre roulante 30 jours sur /company-news) et MockNewsClient (mock, défaut sans clé — feed synthétique déterministe par symbole, ~10 % de tickers "quiet" qui renvoient vide pour exercer l'empty-state UI, ~25 % d'items sans summary pour exercer la null-handling path). Twelve Data ne couvrant pas /news (testé live → 404), Finnhub est ajouté comme provider news séparé (clé market.finnhub.api-key, gratuit 60 calls/min sans cap quotidien). Cache Caffeine news-by-symbol 15 min, key #symbol.toUpperCase() + '\|' + #limit (toUpperCase Java, pas uppercase Kotlin — SpEL ne voit que les méthodes JVM). Endpoint GET /api/market/ticker/{symbol}/news?limit=10. Erreurs upstream mappées sur MarketUnavailableException partagée → 503 unifié avec le market provider. Front : nouveau port NewsRepository + adapter HTTP, section dédiée sur le Dossier ticker entre la 52w range et le narratif IA, liste 10 headlines avec source + date relative localisée. États empty / loading / error scopés. 17 tests : FinnhubClientTest (8), FinnhubMappersTest (4), MockNewsClientTest (5), NewsControllerTest (4), news.http.spec (3), ticker.spec (3). i18n FR + EN |
| ✅ Watchlist v2 : autocomplete + validation des tickers existants | Bug d'origine : l'input watchlist acceptait n'importe quelle chaîne (10 caractères max), l'utilisateur pouvait taper XXXXX et le voir s'ajouter, le 404 ne tombait qu'à l'ouverture du dossier ticker. v2 livrée : (1) Backend — nouveau port SymbolSearchClient (méthode search(query, limit)) avec deux adapters sélectionnés par market.provider : TwelveDataSymbolSearchClient (REST /symbol_search, 1 credit/call) et MockSymbolSearchClient (~30 symbols seedés US + TSX, prefix match symbol + substring match name, paths réservés RATELIMIT et UNKNOWN). RoutingSymbolSearchClient @Primary qui délègue à l'adapter actif. SymbolSearchService avec @Cacheable sur le nouveau cache symbol-search (TTL partagé avec market-chart / news pour rester aligné), méthode validate(symbol) qui exige un match exact case-insensitive. Endpoint GET /api/market/symbols/search?q=&limit= retournant [{symbol, name, exchange}]. WatchlistService.add branche symbolSearch.validate(normalised) après le check d'idempotence (un symbole déjà sur la liste skip la revalidation pour épargner un credit) — rejet → IllegalArgumentException → 400 avec message "not recognised". (2) Frontend — MarketRepository.searchSymbols(query, limit) + adapter HTTP. Sidebar dashboard refacto : input plain remplacé par mat-autocomplete avec debounce 300 ms, FormControl<string | SymbolMatch | null> (la valeur flippe entre la string en cours de frappe et l'objet SymbolMatch post-pick), dropdown affichant <strong>SYMBOL</strong> — Name (EXCHANGE). Bouton "+" désactivé tant que watchlistSelectedMatch() est null. Gestion d'erreur 503 sur la search : dropdown collapse à vide silencieusement (la search est best-effort, l'error banner reste réservée aux add/remove). 400 sur l'add → message i18n "Ticker non reconnu par le fournisseur de marché". Décision design : durci comme prévu — pas d'override "j'insiste" v1, un symbole rejeté en search ne donnerait pas de dossier exploitable. Bonus reporté : enrichir le display sidebar avec name + tooltip exchange (demande une migration watchlist_entry denorm V6) — peut se faire en v3 si l'envie pousse. Tests : 10 backend (MockSymbolSearchClientTest, TwelveDataSymbolSearchClientTest, SymbolSearchServiceTest, SymbolSearchControllerTest, WatchlistServiceTest neuf), 9 frontend (market.http.spec 3 nouveaux + dashboard.spec watchlist refondue avec 9 tests dont 3 nouveaux pour le pipeline autocomplete). i18n FR + EN |
| ✅ Comparaison vs benchmark (v1) | Overlay d'un indice (SPY / QQQ / IWM) sur le chart du dossier ticker pour comparer la perf relative. Front-only, zéro changement back — réutilise GET /api/market/ticker/{symbol}/chart?timeframe= pour fetch le benchmark comme un ticker normal (le cache Caffeine market-chart est partagé sur la clé (symbol, range, interval), donc tous les dossiers ouverts sur le même benchmark se partagent l'entrée 15 min). UX : opt-in (mat-button-toggle-group Off / SPY / QQQ / IWM dans la chart-toolbar, Off par défaut → zéro credit Twelve Data tant qu'aucun click). Quand un benchmark est sélectionné, le Y-axis flippe instantanément du prix absolu (173.50) vers le % return signé depuis la première bar (+5.20%) — sinon SPY à 500 $ écraserait visuellement un small-cap à 15 $. La 2ᵉ ligne (dashed, semi-transparente, --color-text-dim) apparaît dès que le fetch résout. Tooltip enrichi avec un swatch + symbol + valeur pour chaque série. Geometry : le chartGeometry computed devient bi-mode (price absolu vs percent normalisé), aligne les deux séries par index — assumé OK pour SPY/QQQ/IWM qui suivent le calendrier US partagé avec les tickers US-listed. Pour un dual-listed (RY.TO) on truncate silencieusement à la longueur commune (drift de 1-2 jours / an acceptable). Cohérence multi-timeframe : selectTimeframe refetch le benchmark en parallèle quand actif, les deux séries restent en sync. Erreur scoping : 503 sur le benchmark surface un banner inline ticker.benchmark.errors.fetch mais ne touche pas chartBars ni snapshot() — même règle d'isolation que la news panel. Tests : 6 nouveaux tests ticker.spec.ts (default off / fetch on toggle / no-op re-click / sync timeframe / error scope / off clears bars). i18n FR + EN. Le sector ETF déduit + benchmark custom sont livrés dans l'entrée v2 ci-dessous |
| ✅ Chart : analyse + sélection v1+v2+v3 (zoom + overlays + brush + annotations + measure) | v1 zoom drag-select : pointerdown → pointermove → pointerup sur la chart-canvas trace un rectangle SVG semi-transparent ; au release, si la distance dépasse ZOOM_DRAG_THRESHOLD_PX = 10 les coords X sont converties en bar indices (via getScreenCTM().inverse() et points.length) puis traduites en indices full-array (offset par zoomRange()?.startIdx ?? 0 pour le zoom-in-zoom). Reset via bouton toolbar (visible uniquement quand zoomé) ou double-clic sur le SVG. selectTimeframe clear le zoom — les indices ne traversent pas les timeframes. chartGeometry slice chartBars() ET benchmarkBars() symétriquement ; en mode benchmark, le baseline % est re-derivé sur la première bar visible (zoom = re-baseline naturel). pointerCapture sur la chart-canvas pour survivre à une sortie temporaire du curseur. v2 overlays : multi-select mat-button-toggle-group — MA50, MA200, Bollinger (BB), 52w hi, 52w lo. Tout calculé front-side depuis chartBars() : rollingMean(values, n) linéaire (warmup null sur les n-1 premiers), bollinger(values, n=20, k=2) naïf O(n²) sur n=20. 52w hi/lo lus de snapshot.quote.fiftyTwoWeekHigh/Low. allValues du Y-range étendu pour couvrir les overlays. Désactivé en mode benchmark (Y axis en % return space). Couleurs : MA50 --color-info (bleu), MA200 --color-orange, Bollinger --color-purple (bandes dashed), 52w hi --color-success, 52w lo --color-danger. v3 brush mini-chart : SVG séparé 52px de haut sous le main, mirroir de la full series (jamais sliced) avec rectangle draggable indiquant la zone zoomée. Trois modes de drag détectés par la zone du grab : pan (drag du body), resize-left / resize-right (zone BRUSH_HANDLE_WIDTH_PX = 8 autour des bords), reset du zoom si click hors rectangle. BRUSH_MIN_BARS = 2 empêche de collapser le rectangle. Snap automatique sur "no zoom" quand le rectangle couvre la full series. v3 annotations : nouveau port AnnotationRepository (core/annotation.repository.ts) + adapter LocalStorageAnnotationRepository (core/adapters/annotation.local.ts, key ticker-annotations:{SYMBOL}, crypto.randomUUID avec fallback). Bouton toolbar "+ Annotation" arme annotationMode ; le prochain click sub-threshold sur le chart commit une h-line au prix cliqué (inverse-yAt via geom.yMin + yRange). Désarmement auto après placement. Annotations rendues en SVG : line dashed --color-warning, label gauche, handle × à droite révélé au hover de l'annotation parent. Click sur le handle → optimistic remove avec rollback sur erreur. Out-of-range → clamp top/bottom + suffixe ↑/↓ sur le label. v3 measure tools : un click sub-threshold (sans annotation mode) pose un anchor au bar le plus proche (signal measureAnchor: {index, price, timestamp}). Re-cliquer le même bar → toggle off. Bouton "Clear anchor" visible quand anchor actif. Anchor recovered par timestamp à chaque geometry pass — robuste au zoom qui shift les indices. Hover montre delta % et delta time (formatDeltaTime adaptatif min/h/j) dans une ligne distincte du tooltip avec swatch purple + border-top de séparation. Désactivé en benchmark mode (le % axis fait déjà ce travail). selectTimeframe et resetZoom clear l'anchor. Click vs drag distinguishment partagé entre les 3 features : threshold 10px, sub-threshold = click (route vers annotation OU anchor selon le mode), au-delà = zoom. onChartPointerDown skip si le event.target est dans .annotation-delete pour ne pas voler le click au handle de suppression. Wiring : app.config.ts provide AnnotationRepository → LocalStorageAnnotationRepository. Tests : 17 nouveaux dans ticker.spec.ts (7 v1+v2 : zoom commit slice, drag below threshold, timeframe clear, resetZoom, MA/Bollinger 5 paths, 52w h-lines, overlays inert en benchmark + 9 v3 : annotations hydration on init, annotation mode + click commits, removeAnnotation optimistic, measure anchor on click, toggle off on re-click, clearMeasureAnchor, hoverInfo delta, benchmark mode disables both, brush geometry mirrors zoom, brush click outside resets zoom). i18n FR + EN (zoom, overlays, annotation, measure, brush) |
| ✅ News inline : accordéon de contenu sans navigation externe (v1 minimaliste) | Aujourd'hui chaque headline du panneau News du Dossier ticker est un accordéon — clic sur la row toggle un body qui surface le summary Finnhub + un lien Lire l'article complet → explicite vers la url (target=_blank). Le default action « row click » garde le user sur le dossier ; le lien sortant n'est plus la première chose qui se déclenche. Décision design v1 : on n'introduit ni scraping (ToS Reuters/Bloomberg fragiles, paywall, JS-heavy sites) ni LLM-as-summarizer (hallucination + ~10 calls Claude par dossier ouvert) — on affiche ce que Finnhub fournit déjà dans le payload, point. Honnête (zéro contenu inventé), zéro nouvelle dépendance, zéro coût marginal. Le pain point principal résolu : « le user perd le contexte du dossier en cliquant sur un article » devient « le user lit le résumé inline et sort uniquement quand il veut le contenu intégral ». Si à l'usage le résumé Finnhub (~150-200 chars) se révèle trop court, v2 filed en dette pour basculer sur LLM-as-summarizer (option 2 du brief original) avec cache 24 h+. Frontend — nouveau signal expandedNews = signal<Set<string>>(new Set()) (clé = news.id, default empty). Helpers isNewsExpanded(id) / toggleNews(id). Multiple items ouvrables simultanément (le user peut comparer deux dépêches sans avoir à re-cliquer). État reset explicite dans loadNews(symbol) pour que les ids stales d'un précédent ticker n'errent pas même si la collision est improbable (ids upstream, ticker-scoped). HTML — chaque <li> devient <button class="news-header-row"> (headline + source + date + chevron rotatif) + <div class="news-body" [hidden]> (summary ou message i18n noSummary si null + lien news-read-full avec icône open_in_new). SCSS — chevron rotate 180° via .news-item.expanded, transition douce, lien external avec icône inline. i18n — nouvelles clés ticker.news.readFull et ticker.news.noSummary FR + EN. Tests — 4 nouveaux specs dans describe('news') : default closed, toggleNews flip, multiples ouverts simultanément, reset sur loadNews. Pourquoi pas v2 LLM tout de suite : l'effet « narratif IA » (lecture creuse, halluciné) qu'on a observé Phase 1 risque de se reproduire sur un summarizer naïf — mieux vaut tester v1 honnête puis voir si le besoin justifie la complexité. Aussi : Phase 3 introduit l'observabilité narrative + prompt management, ce qui couvrira mieux le coût/qualité tradeoff d'un summarizer LLM |
| ✅ Earnings dates et derniers résultats | 2ᵉ sous-bloc « Résultats » de la section « Fondamentaux » sur le Dossier ticker, sous le sous-bloc analyste. Backend — nouveau module earnings/ avec port EarningsClient et deux adapters sélectionnés par earnings.provider : FinnhubEarningsClient (REST /stock/earnings requis pour l'historique 4 derniers Q + /calendar/earnings optionnel pour la prochaine date — fail-soft à null sur 401/403/5xx parce que le calendrier sit derrière un paid tier sur certains comptes Finnhub, fenêtre 90 j en avant pour capturer la prochaine annonce sans burn de quota sur du long terme ; cache key SpEL #symbol.toUpperCase() Java méthode) et MockEarningsClient (synthétique déterministe par symbole, EPS dans la bande $0.30–$3.50, surprise ±15 % autour de l'estimé, next-date 1–60 j en avant, history 4 trimestres calés sur les fins de Q standard, symboles réservés UNKNOWN 404 / RATELIMIT 503 / NOCALENDAR nextEarningsDate=null pour reproduire la dégradation Finnhub). RoutingEarningsClient @Primary qui délègue per-call (provider switch s'applique au prochain dossier ouvert sans rétention de cache stale puisque la clé n'inclut pas le provider). EarningsService avec @Cacheable("earnings", key = "#symbol.toUpperCase()") 15 min — Finnhub publie au rythme trimestriel et la calendar move daily mais lentement, 15 min de staleness sont invisibles. Cache earnings ajouté à MarketConfig (6e cache, partage le TTL market.cache.ttl-minutes). Nouvelle clé runtime earnings.provider (mock ↔ finnhub) ajoutée à ConfigKeys / KNOWN_KEYS / ALLOWED_VALUES_BY_KEY — séparée de news.provider et analyst.provider pour pouvoir flipper les trois indépendamment (live news + mock recos + mock earnings pendant l'itération, par exemple). Endpoint GET /api/market/ticker/{symbol}/earnings (404 sur empty data via NoSuchElementException quand reports ET calendar sont vides ; 503 sur upstream unavailable via MarketUnavailableException partagée). Domain EarningsSnapshot provider-neutre ({symbol, nextEarningsDate?, nextEarningsTime?, lastReports[]}), EarningsReport ({period, epsEstimate?, epsActual?, surprisePercent?}), enum EarningsTime (BEFORE_MARKET / AFTER_MARKET / UNSPECIFIED), helper pur computeSurprisePercent qui gère null + zero estimate (évite la div-by-zero) + estimate négatif via abs() au dénominateur (un beat sur une perte attendue garde un sign positif). Mappers Finnhub purs (FinnhubEarningsMappers) testables sans MockWebServer : tri défensif period ASC, cap reports à 4 trimestres, recalcul surprisePercent côté code (Finnhub round inconsistemment sur small caps), filtre calendar par symbol + epsActual == null (cleanest "did it happen yet" signal vs un date >= today qui race avec la matinée du print), pick the earliest, mapping bmo/amc/""/dmh → enum (collapse les inconnus à UNSPECIFIED). Frontend — nouveau port EarningsRepository + adapter HTTP, signaux earnings / earningsLoading / earningsNotCovered / earningsError scopés à la panel (404 → empty state, 503 → inline error, mêmes règles d'isolation que news / analyst). Helper earningsCountdownDays (computed, anchored UTC pour éviter le drift timezone sur le boundary day), earningsSurpriseSign(report) (beat/miss/inline/null pour null surprise). UI : ligne next-earnings avec icône event + date + tag horaire (BMO/AMC/"") + countdown pill ("aujourd'hui" / "demain" / "dans N jours"), warning-tinted quand ≤ 7 jours pour signaler une publication imminente sans bloquer ; tableau 4 lignes (période / estimé / réel / surprise % colorée beat-vert / miss-rouge / inline-gris), reports affichés newest-first dans le DOM (oldest-first sur le wire). i18n FR + EN (col headers, time labels BMO/AMC, today/tomorrow/inDays/daysAgo, notCovered, loading). Tests : 26 backend (EarningsSnapshotTest 7 — surprise calc avec sign + null + zero + negative estimate, FinnhubEarningsMappersTest 9 — sort, cap 4, empty→404, calendar fail-soft, only-reported entries → null next-date, multiple future entries → earliest, foreign symbols ignored, hour mapping, null actual handling, MockEarningsClientTest 9 — déterminisme, reserved symbols, history sort, next-date band, surprise consistency, RoutingEarningsClientTest 3 — mock/finnhub/unknown, EarningsControllerTest 4 — happy path / 404 / 503 / null calendar) + 7 frontend dans ticker.spec.ts (hydration, 404, 503, null calendar = no countdown, countdown days computation, surprise sign helper, countdown null before snapshot lands). i18n FR + EN |
| ✅ Recommandations analystes | Sous-bloc « Recommandations analystes » de la nouvelle section « Fondamentaux » sur le Dossier ticker, entre les chips d'indicateurs et la section News. Backend — nouveau module analyst/ avec port AnalystRecommendationClient et deux adapters sélectionnés par analyst.provider : FinnhubAnalystClient (REST /stock/recommendation requis + /stock/price-target optionnel — fail-soft à null sur 401/403/5xx parce que le price-target est derrière un paid tier sur certains comptes Finnhub, le snapshot reste utile sans target ; cache key SpEL #symbol.toUpperCase() Java méthode) et MockAnalystClient (synthétique déterministe par symbole, biais ~50 % bullish / ~30 % mixed / ~20 % bearish, drift mois-sur-mois pour une trend line non plate, symboles réservés UNKNOWN 404 / RATELIMIT 503 / NOTARGET priceTarget=null). RoutingAnalystClient @Primary qui délègue per-call (provider switch s'applique au prochain dossier ouvert sans rétention de cache stale puisque la clé n'inclut pas le provider). AnalystRecommendationService avec @Cacheable("analyst-recommendations", key = "#symbol.toUpperCase()") — Finnhub stamp les snapshots mensuellement, 15 min de staleness sont invisibles mais épargnent le quota free tier sur les re-clics. Cache analyst-recommendations ajouté à MarketConfig (5e cache, partage le TTL market.cache.ttl-minutes). Nouvelle clé runtime analyst.provider (mock ↔ finnhub) ajoutée à ConfigKeys/KNOWN_KEYS/ALLOWED_VALUES_BY_KEY — séparée de news.provider pour pouvoir flipper indépendamment (live news + mock recos pendant l'itération, par exemple). Endpoint GET /api/market/ticker/{symbol}/analyst-recommendations (404 sur empty coverage, 503 sur upstream unavailable via MarketUnavailableException partagée). Domain AnalystSnapshot provider-neutre ({symbol, asOf, strongBuy, buy, hold, sell, strongSell, totalAnalysts, consensus, priceTarget?, history[]}), enum AnalystConsensus (BUY/HOLD/SELL/MIXED), helper pur deriveConsensus (60 % bullish/bearish, 50 % hold, MIXED sinon — choix conservateur, on préfère MIXED à un BUY trompeur sur 55/45). Mappers Finnhub purs (FinnhubAnalystMappers) testables sans MockWebServer : tri défensif period ASC (Finnhub documente newest-first mais on ne fait pas confiance), cap history à 6 mois, all-zero target → null (Finnhub renvoie le shell zéro pour les symbols sans target — afficher « $0 » serait trompeur). Frontend — nouveau port AnalystRepository + adapter HTTP, signaux analyst / analystLoading / analystNotCovered / analystError scopés à la panel (404 → empty state distinct du 503 → inline error, mêmes règles d'isolation que la news panel). Helper analystBucketPct(bucket) pour la segmented bar (sum 100 %, fallback 0 si snapshot null), computed analystTrend qui lit le delta bullish-minus-bearish entre la première et la dernière history (epsilon 5 %, default flat → pas d'éditorialisation sur stable). UI : chip consensus coloré (BUY vert / SELL rouge / HOLD-MIXED gris, palette alignée sur le sentiment narrative), segmented bar 5 buckets avec tooltip et legend chips, target box (mean / range), trend arrow icon (up/down/flat) avec tooltip, footer asOf. Tests : 31 backend (AnalystSnapshotTest 6 — boundary 60 %, FinnhubAnalystMappersTest 6 — sort, cap, empty→404, all-zero→null, MockAnalystClientTest 8 — déterminisme, reserved symbols, history sort, RoutingAnalystClientTest 3 — mock/finnhub/unknown, AnalystControllerTest 4 — happy path / 404 / 503 / null target) + 8 frontend dans ticker.spec.ts (hydration, 404, 503, bucket pct sum, bucket pct 0 sans snapshot, trend up/flat/null). i18n FR + EN (chip consensus, bucket labels, tooltips par segment, trend tooltips, target labels). Connu / Coutures post-livraison filed en dette : (1) pas de FinnhubAnalystClientTest MockWebServer (mapping erreurs HTTP non pinné, news side a l'équivalent), (2) fetchPriceTargetOrNull swallow 5xx + network identique à 401/403, (3) consensus: String côté DTO perd la sécurité d'enum, (4) MockAnalystClient utilise LocalDate.now() direct, (5) nits SCSS (segments width=0 affichent 1 px de border-left, height 12px hardcodé) |
| ✅ Comparaison vs benchmark v2 — Sector + Custom | Étend l'overlay v1 avec deux nouveaux modes : (1) Sector auto-détecte le SPDR sector ETF qui couvre le secteur GICS du ticker (AAPL → XLK Technology, JPM → XLF Financials, etc.), (2) Custom laisse l'utilisateur taper n'importe quel ticker via un mat-autocomplete sidecar dans la toolbar, fallback sur searchSymbols du provider de marché. Backend — nouveau port SectorClassifier (méthode classify(symbol): SectorBenchmark) avec deux adapters sélectionnés par market.provider : initialement TwelveDataSectorClassifier (REST /profile, mais paid-tier only — remplacé 2026-05-06 par FinnhubSectorClassifier qui hit /stock/profile2 free tier ; cf. CHANGELOG suite 4) et MockSectorClassifier (~25 tickers populaires US/TSX hand-curated + paths réservés UNKNOWN/RATELIMIT). RoutingSectorClassifier @Primary. Mapping hardcodé sector → SPDR ETF dans SpdrSectorEtfs.kt (11 entrées GICS : Technology→XLK, Financials→XLF, Healthcare→XLV, Energy→XLE, Consumer Discretionary→XLY, Consumer Staples→XLP, Communication Services→XLC, Industrials→XLI, Materials→XLB, Real Estate→XLRE, Utilities→XLU) + table de synonymes pour les variations provider ("Information Technology" / "Health Care" / "Consumer Cyclical" → forme canonique). Sector hors SPDR (Conglomerates, crypto, etc.) → NoSuchElementException → 404 → message inline gracieux côté front. SectorClassifierService avec @Cacheable("sector-by-symbol") (TTL partagé market.cache.ttl-minutes, ajouté à MarketConfig). Endpoint GET /api/market/ticker/{symbol}/sector-benchmark retournant {tickerSymbol, sector, etfSymbol, etfName}. Frontend — MarketRepository.getSectorBenchmark(symbol) ajouté + adapter HTTP. Refacto ticker.ts : BenchmarkChoice étendu à 'off' \| 'SPY' \| 'QQQ' \| 'IWM' \| 'sector' \| 'custom'. Nouveaux signaux resolvedBenchmark: {symbol, label} (ce qui est plotté, source de vérité pour la geometry et le legend) et customBenchmarkControl (FormControl pour l'autocomplete). Helper resolveSectorBenchmark() enchaîne getSectorBenchmark puis getChart du résolu. Helper wireCustomBenchmarkSearch() mirroir du dashboard (debounce 300 ms + filter string + switchMap searchSymbols, catchError → []). selectTimeframe utilise désormais resolvedBenchmark()?.symbol (pas selectedBenchmark()) → un changement de timeframe avec sector actif refetch l'ETF résolu sans re-résoudre (épargne 1 credit Twelve Data par click). UI option C — toggle group à 5 boutons (Off/SPY/QQQ/IWM/Sector) + mat-form-field avec mat-autocomplete sidecar "or pick…" dans la toolbar. Picker via autocomplete switche selectedBenchmark à 'custom' → toggle deselect visuel. Légende du tooltip enrichie pour afficher resolvedBenchmark()?.label (ex. "Technology (XLK)" en mode Sector, "MSFT" en custom). Erreurs 404 sector → message i18n distinct (sectorNotMapped) du fetch generic. Tests : 35 backend (MockSectorClassifierTest 9, SpdrSectorEtfsTest 9 — dont une qui pinne les 11 sectors SPDR en assertions internes, TwelveDataSectorClassifierTest 10, RoutingSectorClassifierTest 3, extension MarketControllerTest 4) + 5 frontend nouveaux dans ticker.spec.ts (sector resolve + fetch / sector 404 scope / custom pick / sync timeframe sans re-résoudre / switch sector → preset). i18n FR + EN |
Phase 1 — Pivot ticker (clôturée 2026-05-02, tag v0.2.0)
Backend — module market/ (nouveau)
| Feature | Description | Priorité |
|---|---|---|
✅ MarketChartClient (port) + MockMarketChartClient |
Fetch par ticker : quote, OHLC 1y, 52w high/low. Mock déterministe par symbole pour itérer sans clé / réseau. Cache Caffeine 15 min, 503 propre sur erreurs upstream. Sélection via market.provider. Initialement implémenté avec YahooClient (cookie+crumb) — supprimé en cleanup post-Phase-1 (Yahoo bannit les IPs résidentielles, validation live impossible). Code Yahoo consultable dans l'historique git (commit b993440) |
🔴 Critique |
✅ IndicatorCalculator |
Kotlin pur, sans Spring : RSI(14), MA50, MA200, momentum 30j/90j, perf 1m/3m/1y, drawdown 52w, volume relatif, distance vs MA. 20+ tests unitaires | 🔴 Critique |
✅ Endpoint REST market/ |
GET /api/market/ticker/{symbol} retourne quote + indicateurs + bars OHLC (pour le graphe inline). Pas de /history séparé — un seul payload sert le dossier |
🔴 Critique |
| ✅ Migration Flyway V2 | Tables ticker_narrative_snapshot (output LLM + indicateurs JSONB + provenance modèle) et ticker_narrative_job (état async) |
🔴 Critique |
Backend — pipeline narratif
| Feature | Description | Priorité |
|---|---|---|
| ✅ Nouveau prompt par ticker | System prompt strict + user message construit depuis indicateurs (skip silently les nuls). Output {summary, sentiment: BULLISH\|NEUTRAL\|BEARISH, keyPoints: string[3..5]}. Pas de targetWeight, pas de BUY/SELL |
🔴 Critique |
✅ TickerNarrativeService + Runner + Executor |
@Async sur bean séparé. Service → Runner async → Executor (parse + validate + 1 retry) → Persister. Cache snapshot 30 min, dedup job 5 min |
🔴 Critique |
✅ TickerNarrativeParser + TickerNarrativeValidator |
Parse JSON tolérant aux fences markdown / prose alentour / sentiment mixed-case. Valide 3-5 keyPoints, ≤15 mots/bullet, summary 2-3 phrases | 🔴 Critique |
| ✅ Bascule Claude par défaut | llm.provider: claude dans application.yml. Mistral activable via application-local.yml pour offline. LlmClient.modelId() tracé sur chaque snapshot pour comparer plus tard |
🔴 Critique |
✅ Endpoint REST narrative/ |
POST /api/market/ticker/{symbol}/narrative (kick async), GET .../jobs/{id} (poll), GET .../latest (snapshot le plus récent) |
🔴 Critique |
Frontend — page Dossier ticker
| Feature | Description | Priorité |
|---|---|---|
✅ Route features/ticker/:symbol |
Page dossier ticker. En-tête : symbole, nom, prix, plage 52w, sentiment via badge ajouté avec le narratif | 🔴 Critique |
| ✅ Graphique des prix | SVG inline (pas de dep ajoutée), 1y daily. Pas de toggle ni d'overlay MA pour l'instant — suffisant pour le MVP | 🔴 Critique |
| ✅ Indicateurs en chips | 10 chips avec color-coding (RSI > 70 warning, drawdown profond rouge, etc.) | 🔴 Critique |
| ✅ Narratif LLM | Section dédiée : sentiment chip (BULLISH/NEUTRAL/BEARISH coloré), summary, bullets keyPoints, footer modèle+date. Bouton Générer/Régénérer avec spinner, polling 3 s, abort 300 s. Cache hit DONE direct (snapshot < 30 min) sans polling. 7 tests (init avec snapshot, init vierge, cache hit, kick fresh, error, poll abort, sentiment class) | 🔴 Critique |
| ✅ Lien Dashboard → Dossier ticker | Ticker cliquable dans la table du dashboard → /ticker/:symbol |
🟡 Moyenne |
| ✅ Liste des tickers détenus | Section "Tickers détenus" dans la sidebar du dashboard sous la liste des portefeuilles. Endpoint dédié GET /api/portfolios/owned-tickers avec agrégation JPQL (distinct ticker + portfolioCount) — pas de N+1. Chips cliquables → /ticker/:symbol. Best-effort : échec backend → liste vide sans banner |
🟡 Moyenne |
Settings — adaptation Phase 1
| Feature | Description | Priorité |
|---|---|---|
| ✅ Test source ticker | Section "Tester un ticker" ajoutée à /settings/test-sources (séparée par border-top du test RSS). Input ticker libre + suggestions cliquables depuis owned tickers. Réutilise MarketRepository.getTicker(symbol) donc respecte le market.provider configuré. Result block : prix, bars OHLC, RSI(14), MA200, drawdown 52w. Erreurs 404 / 503 surfacées via i18n |
🟢 Basse |
| ✅ Aperçu du prompt par ticker | /settings/prompt-preview adaptée Phase 1 narratif. Input ticker libre + suggestions cliquables depuis les owned tickers. Endpoint back GET /api/market/ticker/{symbol}/narrative/preview réutilise NARRATIVE_SYSTEM_PROMPT + buildNarrativeUserMessage sans appel LLM. 2 tests slice MVC + 1 test adapter HTTP |
🟢 Basse |
Tests prioritaires Phase 1
| Sujet | Description | Priorité |
|---|---|---|
✅ IndicatorCalculatorTest |
20+ tests unitaires Kotlin purs : RSI sur série monotone, MA sur fenêtre, drawdown, volumes, edge cases (1 bar, séries trop courtes) | 🔴 Critique |
✅ MockMarketChartClientTest |
Mock provider validé : forme, déterminisme, divergence inter-symbole, 52w cohérent avec la série, paths réservés UNKNOWN/RATELIMIT (6 tests) |
🟡 Moyenne |
✅ TickerNarrativeParserTest + TickerNarrativeValidatorTest + TickerNarrativePromptTest |
17 tests : JSON valide / fences / prose / sentiment mixed-case / unknown sentiment, validation 3-5 keyPoints + longueur, prompt skip silently nulls | 🟡 Moyenne |
✅ TickerNarrativeServiceTest |
8 tests Mockito-Kotlin sur la décision tree : pending dedup → reuse / fresh snapshot ≤ 30 min → cache hit avec job DONE synchrone / stale ou absent → kick runner. Plus normalisation casse symbole et délégation latestSnapshot. Le test borderline 30 min est volontairement à 29 min — le cas exact dépend d'un Clock injectable |
🟡 Moyenne |
✅ TwelveDataClientTest + TwelveDataMappersTest (HTTP) |
14 tests avec okhttp3.mockwebserver:4.12.0 : happy path /time_series + /quote mergés, request URL avec apikey/outputsize/order=ASC, fallback bar-derived 52w, mappings d'erreur (200 avec status=error code=404/429/401, HTTP 429/500), blank API key détecté avant l'appel. Mappers : halted bars (empty strings), DESC→ASC re-sort, intraday datetime, parseTimestamp edge cases |
🟢 Basse |
Phase 0 — Fondation (terminée, tag v0.1.0 ; partiellement décommissionnée en Phase 2.5 / V6)
✅ Conservé et utilisé
| Feature | Notes |
|---|---|
| Navigation (header + sidenav settings) | mat-toolbar Material sticky, sidenav latérale dans /settings, theme toggle (sun/moon) |
| Theme dark/light | Tokens CSS sur :root + [data-theme='light'], Material dual-theme, default dark, toggle persistance localStorage, script anti-FOUC dans index.html |
| Frontend ports & adapters | core/<name>.repository.ts (port abstract class) + core/adapters/<name>.http.ts (adapter HTTP). 4 repositories : Portfolio, Analysis, Settings, Snapshot |
Frontend features/ |
Toutes les pages UI sous features/ (dashboard, history, import, recommendations, settings, suivi). Routes dans app.routes.ts |
| Import CSV Wealthsimple | Parse 21 colonnes FR (NFD, BOM, délimiteur auto), upsert par compte, multi-fichiers (drag & drop) avec extraction de date depuis le nom |
| Portefeuille read-only | Lecture seule depuis l'UI ; CSV = seule source de vérité |
| Snapshots historiques | PortfolioSnapshot + SnapshotPosition par compte, regroupés via batch_id. Page Suivi : timeline + expand par compte |
| Settings back-office | Sidenav /settings initial Phase 0 : sources (activer/désactiver), test-sources (RSS), prompt-preview (aperçu du prompt sans appel LLM). Les entrées sources/ + test-sources/ ont été supprimées en Phase 2.5 ; Phase 2 a ajouté configuration/ (config runtime) qui est devenu l'entrée par défaut |
| Devise & valeur de marché par actif | Colonnes currency, book_value_cad, market_value, unrealized_gain, gain_currency ajoutées à asset dans V1__init.sql (consolidé après refactor Phase 1/2). Affichage P&L par position |
| Persistance des jobs d'analyse | Table analysis_job (incluse dans V1__init.sql), dédup des jobs concurrents (DEDUP_WINDOW_SECONDS). Table droppée en Phase 2.5 / V6. Pattern réutilisé en Phase 1 par ticker_narrative_job |
| Infra Tilt + CI | Tilt + Docker Compose (postgres, ollama, backend, frontend). GitHub Actions backend (Gradle + postgres) / frontend (Vitest) / docs |
@Async sur bean séparé |
Pattern Service → Runner (@Async) → Executor (@Transactional) — réutilisé en Phase 1 |
| Adapter specs HTTP | 4 specs core/adapters/*.http.spec.ts (portfolio, analysis, settings, snapshot) |
❌ Décommissionné — code supprimé en Phase 2.5
| Feature | Notes |
|---|---|
| ❌ Ingestion RSS | Module ingestion/ (Rome, scheduler 15 min, déduplication par guid, parsing robuste DOCTYPE / & nus, 25 sources seedées) supprimé en Phase 2.5. Tables feed_source + feed_article droppées (V6) |
| ❌ Pipeline analyse portfolio LLM | AnalysisExecutor, AnalysisContextLoader, ArticleRelevanceScorer, LlmResponseParser, RecommendationValidator, RecommendationPersister supprimés en Phase 2.5. Tables recommendation* + analysis_job droppées (V6). Replacement Phase 6 = PortfolioAggregation au-dessus des snapshots ticker |
| ❌ Pages Recommendations / History | features/recommendations/ + features/history/ supprimées en Phase 2.5 (PR2 frontend). Repository AnalysisRepository + adapter retirés, navbar /history retirée |
| 🧊 Bascule Mistral local + timeouts 400 s | OllamaClient configuré initialement pour mistral (7B Instruct Q4), timeouts alignés sur 400 s. Le défaut local a depuis basculé sur qwen2.5:3b (Mistral trop lent sur M1, 30-60 s/narratif → timeouts répétés). Le timeout 400 s est aujourd'hui éditable au runtime via le slider llm.timeout-seconds (Phase 2.5 v1.5). Reste activable via llm.provider: ollama mais Claude est le défaut Phase 1 |
Dette technique — items livrés
Items de la section « Dette technique » qui ont été clôturés. Les
⏳ouverts vivent dansbacklog.md.
| Sujet | Description | Priorité |
|---|---|---|
✅ Agent code-reviewer + slash command /code-review — pre-commit review en contexte isolé |
Livré 2026-05-16. Ferme le ticket dette 🟡 « Agent Claude code-reviewer pré-commit (à l'image de doc-maintainer) ». Constat origine : la code review en fin de feature était jusqu'ici faite ad hoc dans la session principale — pollution du contexte (gros diffs lus + relus) + mélange des rôles (l'agent qui a écrit le code revient le juger). Cible livrée : (1) Subagent code-reviewer dans .claude/agents/code-reviewer.md avec tools Read, Glob, Grep, Bash ; Bash restreint en dur dans le system prompt aux commandes git lecture-seule (status / diff / diff --cached / diff master..HEAD / diff --stat / log --oneline / show / blame) ; interdictions explicites sur commit / push / reset / checkout / branch / tag / rebase / merge / rm / add / restore / stash apply / gh pr* ; interdiction aussi de remplacer Glob / Grep / Read par des commandes Bash équivalentes. (2) Slash command /code-review dans .claude/skills/code-review/SKILL.md qui spawne l'agent via le Agent tool en passant un prompt par défaut self-contained (périmètre : git diff HEAD pour uncommitted + git diff master..HEAD pour branche, fichiers du diff exhaustivement, pas un échantillon). (3) 3 capacités encodées dans le system prompt : (a) Cohérence avec le projet — tableau cross-référencé CLAUDE.md + architecture.md + ddd.md + 10 skills (kotlin-idioms, spring-boot, hexagonal-ddd, folders-structure-backend, angular-component, angular-di, angular-signals, angular-testing, folders-structure-frontend, code-review-excellence) avec une liste de drifts typiques à signaler (port outbound mal placé, composant @Input() vs input(), effect() set-site, repository sans Resource builder, @SpringBootTest sur controller, wildcard imports Kotlin, hardcoded user-facing string, mock useValue sur port à builders). (b) Invariants techniques transverses — sécurité (clés API), AOP Spring (@Async séparé), formatage Spotless, Conventional Commits EN, no mock DB sur tests d'intégration, cache key SpEL Java pas Kotlin, i18n keys obligatoires, Flyway numbering, doc trigger (statut backlog ↔ journal). (c) Régression et angles morts — tests manquants sur nouveau code-path, paths d'erreur non couverts, TODOs / @Suppress / @Deprecated neufs, diff cross-bounded-context, backlog sync, skill drift. (4) Format de sortie : punch-list structurée Bloquants / À discuter / Mineurs avec extrait de diff cité + suggestion concrète + verdict global mergeable | needs-fix | reject (avec règle « si tu hésites entre needs-fix et reject, choisis needs-fix » pour limiter les rejects abusifs). Doc CLAUDE.md : 2 nouvelles rows ajoutées à la table « Skills and hooks » pour skills/code-review/ et skills/doc-maintainer/ (la seconde était manquante de la table — drift préexistant corrigé en passant). Volontairement read-only comme doc-maintainer — l'autoapply viendra quand on aura confiance, et probablement jamais sur la code review (les arbitrages sont trop contextuels). Différence code-review-excellence : le skill existant est une checklist destinée à l'agent principal et au reviewer humain ; le nouveau skill /code-review spawne un subagent qui exécute la review en contexte isolé — les deux sont complémentaires. Différence /ultrareview : ultrareview = review cloud multi-agent facturée pour les jalons ; /code-review = review locale gratuite au quotidien. Trigger d'usage : /code-review invoqué manuellement avant git commit, ou en fin de chaque feature structurelle. Effort réel : ~50 min (lecture template doc-maintainer.md + doc-maintainer/SKILL.md 5 min, écriture code-reviewer.md agent ~150 lignes 25 min, écriture code-review/SKILL.md ~50 lignes 10 min, update CLAUDE.md + closure backlog/journal 10 min) |
🟡 Moyenne |
✅ Refonte .claude/ — 4 skills backend (kotlin-idioms, spring-boot, hexagonal-ddd, folders-structure-backend) + audit refresh des skills Angular |
Livré 2026-05-15 (gros du travail) → 2026-05-16 (closure formelle). Ferme le ticket dette 🟡 « Refonte .claude/ — audit structure + skills Kotlin/Spring + hexagonal/DDD ». Constat origine : .claude/skills/ couvrait bien Angular (4 skills : component, di, signals, testing) + folders-structure-frontend + code-review-excellence + doc-maintainer, rien côté backend. Les sessions backend reconstruisaient les conventions à chaque fois depuis CLAUDE.md + architecture.md. Livré : (1) 4 nouveaux skills backend (commit 5aae398 chore(.claude): overhaul skills + drop effect() from frontend services, 2026-05-15) — kotlin-idioms/ (data class, sealed, scope functions, null safety, no-wildcard imports, extension functions, immutables), spring-boot/ (constructor injection, @Service/@Component/@Configuration, profils YAML, @Async séparé, Caffeine cache, @Transactional, tests @WebMvcTest/@SpringBootTest), hexagonal-ddd/ (ports en domain/, adapters en infrastructure/<capability>/, @Primary routing, fail-soft + UpstreamUnavailableException, séparation domain ↔ application ↔ infrastructure), folders-structure-backend/ (jumeau de folders-structure-frontend/, layout par bounded context, conventions de packages). (2) Audit + refresh des skills Angular existants (même commit) — angular-signals/SKILL.md réécrit pour reclasser effect() en dernier recours et promouvoir le set-site side-effect (cf. ticket dette « effect() retiré du code main » clos 2026-05-15, entrée 199 du journal). (3) Raffinements drift-driven au fil des sessions backend qui ont consommé les skills (et révélé qu'ils décrivaient parfois faux le code) : c6b78b8 chore(.claude): align spring-boot test section with reality + add perf guidance (le skill spring-boot prétendait que tous les controller tests étaient @SpringBootTest ; les 13 sont déjà @WebMvcTest — section Tests réécrite + nouvelle sous-section perf), 9f8f828 (hexagonal-ddd aligné sur la promotion MarketUnavailableException → shared/UpstreamUnavailableException), 2517735 (spring-boot aligné sur le split SymbolSearchService/SymbolValidator — pattern @Lazy self reclassé « no longer used, replaced by two-bean split »), d203ede (spring-boot enrichi d'une sous-section « Grouped @Value via @Component data class »), 16feff4 (hexagonal-ddd + folders-structure-backend réécrits pour mettre les ports outbound dans domain/ au lieu de infrastructure/). (4) Point (4) du ticket — « Revoir agents/ » reste ouvert via son propre ticket #2 🟡 « Agent Claude code-reviewer pré-commit ». L'agent doc-maintainer existe déjà ; le code-reviewer est prévu en sibling read-only. État courant .claude/ : 13 skills (4 Angular : component, di, signals, testing ; 4 backend : kotlin-idioms, spring-boot, hexagonal-ddd, folders-structure-backend ; folders-structure-frontend ; code-review-excellence ; doc-maintainer ; git-commit ; github-create-pull-request) + 1 agent (doc-maintainer). Méta-leçon : la valeur d'un skill se mesure au moment où on l'utilise pour la première fois — chaque refacto backend des 7 derniers jours (MarketUnavailableException, SymbolValidator, @Value grouping, ports outbound en domain/) a soit validé soit fait drifter les skills, et chaque cycle a produit un raffinement. Pas de code écrit dans la session du 2026-05-16 — closure formelle d'un ticket largement délivré en cumul sur 2026-05-15. Effort réel : ~5 min (audit présence + écriture journal + retrait backlog) |
🟡 Moyenne |
✅ Backend — ports outbound déplacés de infrastructure/<capability>/ vers <context>/domain/ (hexagonal strict) |
Livré 2026-05-15. Ferme analyse critique 2026-05-15 finding #B1, mais avec une cible révisée mid-session. Le ticket initial visait infrastructure/ → application/ ; l'utilisateur a recadré sur infrastructure/ → domain/ (hexagonal strict — « les ports sont les interfaces à placer dans le domaine »), en s'appuyant sur la mention « Classical hexagonal puts ports in domain/ or application/ » du skill hexagonal-ddd. Périmètre étendu à 7 ports (les 5 du ticket + SymbolSearchClient et SectorClassifier côté market/ qui suivaient le même pattern) : MarketChartClient, SymbolSearchClient, SectorClassifier, NewsClient, AnalystRecommendationClient, EarningsClient, LlmClient. Code : (1) git mv des 7 fichiers <context>/infrastructure/<capability>/<Port>.kt vers <context>/domain/<Port>.kt. Package déclaration mis à jour ; les import com.portfolioai.<ctx>.domain.<Type> deviennent inutiles puisque les types domaine sont désormais dans le même package. (2) KDoc des ports nettoyée : les bracket-refs [MockNewsClient], [FinnhubClient], [RoutingNewsClient], etc. — qui pointaient vers des classes d'infrastructure — dégradées en plain text (RoutingNewsClient) pour que le domaine ne référence pas l'infrastructure même en KDoc. Les refs cross-context conservées en FQN ([com.portfolioai.market.domain.MarketChartClient] dans analyst/domain/AnalystRecommendationClient.kt). (3) 28 adapter/router files (Mock*, Finnhub*, Twelve*, Claude*, Ollama*, Routing* + leurs tests Routing*ClientTest) — qui étaient dans le même package que le port et y accédaient sans import — gagnent un import com.portfolioai.<ctx>.domain.<Port> chacun. Batch via script Python (Edit tool refuse l'edit sur fichier non-read, le sed manuel via Bash gère mieux 28 insertions identiques). (4) 18 consumer files (services applicatifs + tests + 4 domain files avec bracket-refs KDoc) — qui importaient le port via son ancien FQN infrastructure.<cap>.<Port> — ont leur import rewritten en domain.<Port> via sweep Python sur src/**/*.kt. (5) Adapters restent en infrastructure/<capability>/ — Mock*, Finnhub*, Twelve*, Claude*, Ollama*, Routing* (@Primary) conservent leur emplacement et leur @Component. Les wire models (Finnhub*Models.kt) et mappers (Finnhub*Mappers.kt) aussi. Doc : (a) hexagonal-ddd/SKILL.md — glossary Port réécrit (« live in <context>/domain/ » au lieu de « live in <context>/infrastructure/<capability>/ »), section canonique du tree port + adapter group restructurée (port sous domain/, adapters sous infrastructure/<capability>/), section « Why ports live in infrastructure/, not domain/ » entièrement remplacée par « Why ports live in domain/, not infrastructure/ or application/ » qui motive l'inversion de dépendance stricte + la pureté Kotlin du port + le pluggability infrastructure. Bloc « Historical note (B1 refactor) » ajouté pour tracer l'archéologie. (b) folders-structure-backend/SKILL.md — tree ASCII actualisé (port <Capability>Client.kt sous domain/), section domain/ étoffée d'un bullet « Outbound ports », section « Port + adapter naming » réécrite. (c) docs/technique/ddd.md — structure de chaque contexte revue (port mentionné sous domain/, adapter mention sous chaque sous-section infrastructure/<capability>/ clarifiée), section domain/ étoffée d'un bullet sur les ports outbound, sections infrastructure/llm/ + infrastructure/market/ + infrastructure/analyst/ + infrastructure/earnings/ reformulées pour pointer vers <context>/domain/ comme home du port. Bloc « Note (B1, 2026-05-15) » ajouté en tête de la section pour expliciter la migration. (d) architecture.md — entrée « Ports outbound dans domain/ » ajoutée sous « Décisions techniques notables > Patterns transverses backend ». Méta-note : le périmètre initial (5 ports listés dans TODO.md vs 7 ports observés) a été élargi par cohérence — laisser SymbolSearchClient et SectorClassifier en infrastructure/ aurait créé une exception incohérente avec le reste. Décision tranchée en cours de session via AskUserQuestion. Effort réel : ~1 h 10 (cartographie des consumers 5 min, git mv + package updates 10 min, batch imports adapters 15 min, sweep consumer FQN 5 min, skills + ddd.md + architecture.md + journal/backlog 30 min, vérifs grep + compile 5 min) — supérieur à l'estimation 1 h du ticket parce que le périmètre est passé de 5 → 7 ports en cours de session |
🟡 Moyenne |
✅ Backend — section « Tests » du skill spring-boot corrigée (déjà-conforme + leviers perf) |
Livré 2026-05-15. Ferme analyse critique 2026-05-15 finding #B7 — mais par découverte plutôt que par refacto. Constat : le ticket #B7 partait du diagnostic « tous les controller tests utilisent @SpringBootTest, on peut basculer en @WebMvcTest pour gain CI 30-40 % ». Vérification factuelle : 13 controller tests sont déjà en @WebMvcTest (NewsControllerTest, AnalystControllerTest, EarningsControllerTest, MarketControllerTest, SymbolSearchControllerTest, PortfolioControllerTest, WatchlistControllerTest, ConfigControllerTest, NarrativeThumbsControllerTest, NarrativeObservabilityControllerTest, PromptControllerTest, TickerNarrativeStreamControllerTest, TickerNarrativePendingJobControllerTest). @SpringBootTest est utilisé exactement 2 fois : BackendApplicationTests (smoke context-boots) et CacheTtlListenerIntegrationTest (@TransactionalEventListener(AFTER_COMMIT) exige un vrai PlatformTransactionManager). Le projet appliquait déjà la règle ; c'est mon skill spring-boot qui décrivait l'inverse. La section « Integration tests — real PostgreSQL, no DB mocks » prétendait que « Controller tests use @SpringBootTest (full context) — not @WebMvcTest slice tests » — verbatim faux. Code : aucun (rien à refactor). Skill : spring-boot/SKILL.md section Tests complètement réécrite : (a) sous-section @WebMvcTest(<Controller>::class, GlobalExceptionHandler::class) documentée comme le défaut avec un exemple verbatim depuis NewsControllerTest (incluant l'astuce GlobalExceptionHandler::class dans le slice pour que le mapping 503 / 404 / 400 soit wired — sans ça, les tests d'erreur tomberaient sur un 500 générique). (b) sous-section @SpringBootTest reclassée comme « only for genuinely integration cases » avec exemple CacheTtlListenerIntegrationTest, contre-règle explicite « Don't reach for @SpringBootTest on a controller — if you find yourself wanting it, it usually means a @MockitoBean would do the same job at ~10× the speed ». (c) sous-sections « Plain JUnit » + « MockWebServer » conservées. (d) Nouvelle sous-section « Test performance — the leverage points, ranked » ajoutée en réponse à la question utilisateur « qu'est-ce qui est le mieux pour des tests performants ». 4 leviers ROI-rankés : (1) mesurer avant tout (./gradlew test --info / --scan), (2) partager la configuration entre @SpringBootTest pour préserver le context caching Spring (1 boot pour N classes au lieu de N), (3) parallélisation au niveau classe via junit-platform.properties (~×4 sur M1 8 cœurs), (4) Gradle build cache --build-cache. Liste explicite des pas-rentables (kotest migration, Testcontainers, splits de beans). Trigger explicite : « start mesuring + intervening when the suite passes 1 min in CI on the test step alone ». Méta-leçon : le skill peut décrire faux le code projet sans validation grandeur nature. Les 3 cycles précédents (B2/B3/B6) avaient raffiné le skill par confrontation à un refacto ; B7 inversait la dynamique — c'est le code qui était déjà bon et le skill qui drift'ait. À chaque ticket de validation skill, vérifier l'état observé avant de présumer l'état décrit. Effort réel : ~20 min (sondage 5 min, skill rewrite section Tests 10 min, ajout sous-section perf 5 min) |
🟡 Moyenne |
✅ Backend — MockLlmClient ajouté pour faire tourner l'app sans clé ni daemon Ollama |
Livré 2026-05-15. Ferme la sous-partie « MockLlmClient » du ticket dette « Onboarding doc + MockLlmClient » (les autres parties — testeur.md + choix Dockerfile dev — restent ouvertes). Constat : la stack mock était parité-incomplète. MockMarketChartClient, MockNewsClient, MockAnalystClient, MockEarningsClient permettaient à l'app de tourner sans aucune clé API côté providers de données — mais le narratif LLM exigeait soit une clé Anthropic, soit un daemon Ollama avec un modèle pull. Un onboardé sans aucune des deux clés se retrouvait avec un dossier ticker peuplé (chart + news + analyst + earnings synthétiques) mais une narrative card cassée. Asymétrie qui rendait l'onboarding "demo-friendly" promu dans developper.md partiellement faux. Code : (1) analysis/infrastructure/llm/MockLlmClient.kt (~120 lignes) — @Component implémentant LlmClient, extrait le symbole du userMessage via regex ^Ticker:\s*([A-Za-z0-9.\-]+) (le prompt builder TickerNarrativePrompt.buildNarrativeUserMessage le préfixe en première ligne), génère un narratif JSON déterministe par symbole (seed = symbol.hashCode()) parser-compatible (champs summary / sentiment ∈ BULLISH|NEUTRAL|BEARISH / keyPoints[]), distribution sentiment ~33 % BULLISH / ~44 % NEUTRAL / ~22 % BEARISH (bucket list de 9 entries pour mécaniquement éviter une équiprobabilité 1/3 inutile), 3 keyPoints templated par narratif depuis un pool de 8. Symbole réservé RATELIMIT lève UpstreamUnavailableException pour exercer le path 503 sans provider réel — mirror de MockMarketChartClient/MockAnalystClient/MockEarningsClient. modelId() = "mock:narrative-v1" sentinel stable pour permettre au filtre observability de séparer mock vs real runs. (2) ConfigKeys.LLM_PROVIDER enum étendu : listOf(PROVIDER_CLAUDE, PROVIDER_OLLAMA) → listOf(PROVIDER_MOCK, PROVIDER_CLAUDE, PROVIDER_OLLAMA) — la validation par AppConfigService.validate accepte désormais mock. (3) RoutingLlmClient constructor étendu d'un @Qualifier("mockLlmClient") private val mock: LlmClient, branche when PROVIDER_MOCK -> mock ajoutée. Décision : claude reste le défaut, pas mock. Argument pour mock (alignement avec les 4 autres providers, fresh clone keyless) écarté — le narratif sur un vrai modèle est un trait distinctif de l'app, le passer en mock par défaut dégraderait silencieusement l'expérience d'un nouveau user qui n'aurait pas lu la doc. Le mock reste 1 ligne YAML loin (llm.provider: mock dans application-local.yml). Tests : (a) MockLlmClientTest.kt (~90 lignes) — 7 tests qui pinnent : parser-compat (round-trip via le vrai TickerNarrativeParser, contrat assertion plutôt que duplication), déterminisme cross-call, variété cross-symbol, sentiment toujours dans le triad enum (sweep de 8 symboles), RATELIMIT → 503, modelId exact, fallback UNKNOWN si le prompt ne contient pas de ligne Ticker:. (b) RoutingLlmClientTest.kt étendu — 1 nouveau test dispatches complete to mock when provider is mock (couvrait l'absence de branche mock qui aurait fait tomber dans else → IllegalArgumentException silencieusement), constructor adapté aux 3 deps (mock, claude, ollama), 4 tests existants mis à jour pour passer le mock comme 1er param + assertions verify(mock, never()) ajoutées pour défendre contre une régression vers une dispatch incorrecte. Doc + skill : architecture.md 3 mentions actualisées (sub-section analysis/ + sub-section config/ switch provider à chaud + Décision « Tracking du modèle LLM par snapshot »). .claude/CLAUDE.md section analysis/ étoffée pour citer les 3 adapters avec leur rôle. Impact skills : aucun pivot — hexagonal-ddd/SKILL.md documentait déjà le pattern Mock comme attendu (« reach for the port when 2 adapters materialise — mock + real »). Ce ticket est l'application directe de cette règle, pas son raffinement ; les skills sont validés par l'absence de surprise pendant l'exécution. Effort réel : ~45 min (MockLlmClient 15 min, ConfigKeys + RoutingLlmClient 5 min, tests 15 min, docs 10 min) |
🟡 Moyenne |
✅ Backend — allowlist no-wildcard-imports Spotless complètement supprimée |
Livré 2026-05-15. Ferme dette ticket #10. Constat : backend/build.gradle.kts portait une allowlist de 14 packages pour le step Spotless no-wildcard-imports — censée matcher les wildcards historiquement présents et rétrécir au fil du temps. Sondage exhaustif (grep -rEn "^import [^ ]+\.\*( \|$)" backend/src --include='*.kt') : zéro wildcard import dans 100+ fichiers du projet. Les 14 entries étaient toutes vestigiales. Pendant ce temps .editorconfig racine pinnait déjà ij_kotlin_name_count_to_use_star_import = Int.MAX_VALUE et ij_kotlin_packages_to_use_import_on_demand = unset — IntelliJ ne réintroduira jamais de wildcards spontanément même au-delà de 5 imports d'un même package. Code : (1) backend/build.gradle.kts step no-wildcard-imports : allowlist setOf(…) de 14 entries supprimée, la logique du check simplifiée (plus de .filterNot { it in allowed }). Commentaire au-dessus réécrit pour expliquer le contexte historique + l'invariant maintenant strict (« no allowlist »). (2) Aucun fichier .kt touché (rien à expand puisque rien n'était wildcardé). Impact skill : kotlin-idioms/SKILL.md section « Imports — no wildcards » reformulée : le « phased out » devient « dropped 2026-05-15 », explicite la combinaison .editorconfig + Spotless comme défense en profondeur, donne le geste de récupération si une régression apparaît (« open the file in IntelliJ and ⌘+⌥+O will expand it correctly »). La section « When NOT to follow these patterns > Test fixtures » corrigée : les wildcards JUnit/MockMvc qui étaient autorisés ne le sont plus (allowlist retirée), explicite import partout. Effort réel : ~15 min (sondage 5 min, suppression allowlist + KDoc 5 min, skill rewrite 5 min) |
🟢 Basse |
✅ Backend — AppConfigService : 12 @Value regroupés en 3 @Component data classes |
Livré 2026-05-15. Ferme analyse critique 2026-05-15 finding #B6. Constat : le constructor de AppConfigService injectait 12 @Value (3 API keys + 5 provider switches + 3 LLM defaults + 1 cache TTL) + 2 deps = 14 params total. Detekt LongParameterList.constructorThreshold bumpé à 16 pour absorber, avec un commentaire qui anticipait justement le split. Décision sur le pattern — pas @ConfigurationProperties : la solution canonique Spring Boot est @ConfigurationProperties(prefix = "…"), mais les 14 clés du projet sont réparties sur 6 racines YAML (market.*, news.*, analyst.*, earnings.*, anthropic.*, llm.*, ollama.*) qui ont grandi organiquement et back des env-vars documentées (TWELVEDATA_API_KEY, ANTHROPIC_API_KEY, etc.). Un @ConfigurationProperties propre demanderait soit (a) restructurer le YAML sous un préfixe partagé (casse les env-vars), soit (b) plusieurs @ConfigurationProperties minuscules avec préfixes hétérogènes. Le compromis pragmatique retenu : @Component data class avec @Value constructor params — lit identique au consommateur, préserve les clés existantes, groupe explicitement par concern. Code : (1) SecretsDefaults.kt (3 keys : twelvedata + finnhub + anthropic API keys, KDoc qui explique pourquoi pas @ConfigurationProperties). (2) DataProvidersDefaults.kt (4 keys : market + news + analyst + earnings providers — le frontend les groupe sous le même sub-section « Providers de données »). (3) LlmDefaults.kt (4 keys : llm.provider + ollama.model + anthropic.api.model + llm.timeout-seconds — bundle alignée sur la card LLM frontend, llm.provider regroupé ici plutôt qu'avec les autres *.provider parce qu'il toggle main-dans-la-main avec model+timeout). (4) AppConfigService constructor passe de 14 params à 6 (2 deps + 3 defaults groups + cache TTL standalone). defaultFor() lit désormais via les groupes (secrets.twelveDataApiKey, dataProviders.marketProvider, llm.llmTimeoutSeconds.toString(), etc.) ; comportement identique. KDoc class-level mise à jour pour pointer vers les 3 groupes. (5) AppConfigServiceTest.newService() helper réécrit pour instancier les 3 data classes inline (les data classes sont des value carriers — pas de logique, pas besoin de mock). (6) backend/config/detekt/detekt.yml : constructorThreshold: 16 → 8 (default Detekt). Commentaire actualisé pour pointer vers le split #B6 et recommander le même pattern de groupage si un futur bean dépasse 8. Impact skill : spring-boot/SKILL.md section « Configuration injection » étoffée d'une nouvelle sous-section « Grouped @Value via @Component data class — for ≥3 related keys » qui documente le pattern, cite les 3 fichiers réels, explique pourquoi @ConfigurationProperties n'a pas été retenu (YAML keys spread across roots, env-var contract à préserver), et arbitre clairement : « Use @ConfigurationProperties when the YAML does share a prefix. Use grouped @Value data classes when consolidating existing scattered keys. » Effort réel : ~40 min (3 data classes 10 min, refacto service + test helper 10 min, Detekt + KDoc 5 min, skill rewrite 10 min, journal + backlog 5 min) |
🟢 Basse |
✅ Backend — SymbolSearchService : @Lazy self retiré au profit d'un SymbolValidator dédié |
Livré 2026-05-15. Ferme analyse critique 2026-05-15 finding #B3. Constat : SymbolSearchService portait search(@Cacheable) ET validate(symbol). Le validate avait besoin que search traverse le proxy AOP pour que @Cacheable s'applique — un this.search(...) direct burnait un crédit Twelve Data à chaque watchlist.add(). La parade en place : @Autowired @Lazy private var self: SymbolSearchService? = null + fallback (self ?: this).search(...) pour que les tests unitaires hors Spring fonctionnent. Hack documenté mais code-smell pinné dans spring-boot/SKILL.md (« use only when extracting a separate bean would be heavier »). Code : (1) nouveau SymbolValidator.kt (@Component dédié dans market/application/, dépend de SymbolSearchService, expose exists(symbol): Boolean — méthode renommée du validate historique parce qu'exists lit mieux pour « does this ticker exist in the upstream catalog »). (2) SymbolSearchService.kt réduit au pur cache layer : validate() retiré, @Autowired @Lazy self retiré, plus aucune dépendance Spring AOP autre que le @Cacheable standard. KDoc class-level réécrite : la section « AOP gotcha » devient « Validation lives in SymbolValidator, a separate @Component » qui pointe explicitement vers le split. (3) WatchlistService.kt : champ symbolSearch: SymbolSearchService → symbolValidator: SymbolValidator, appel symbolSearch.validate(symbol) → symbolValidator.exists(symbol), 3 KDocs class-level + private method mises à jour. (4) Tests : SymbolSearchServiceTest ne garde que le test de clamping (le reste — exact-match, blank short-circuit, no-results — migré vers le nouveau SymbolValidatorTest). SymbolValidatorTest ajouté avec les 4 tests sémantiques + class-level docstring qui explicite le rôle du split. WatchlistServiceTest : mock SymbolSearchService → mock SymbolValidator, 6 sites symbolSearch.validate(...) → symbolValidator.exists(...), narration mise à jour. Impact skill : spring-boot/SKILL.md section « @Cacheable — @Lazy self-inject if you must » → « @Cacheable — split into two beans, never self-inject ». Le pattern @Lazy self est explicitement classé « no longer used in the codebase, replaced by the two-bean split during ticket #B3 » avec instruction « if you're tempted to reach for it, that's the signal that a split is what you actually want ». Section @Transactional alignée sur la même règle. Effort réel : ~30 min (création SymbolValidator + cleanup SymbolSearchService 10 min, refacto WatchlistService 5 min, split tests + adaptation WatchlistServiceTest 10 min, skill rewrite 5 min) |
🟢 Basse |
✅ Backend — MarketUnavailableException promu en shared/UpstreamUnavailableException |
Livré 2026-05-15. Ferme analyse critique 2026-05-15 finding #B2. Constat : l'exception vivait en market/domain/ et était importée par news/, analyst/, earnings/, analysis/, watchlist/. Couplage cross-context implicite, documenté comme « shared kernel » dans hexagonal-ddd/SKILL.md — défensible mais pas honnête : 5 contexts dépendaient de market/ juste pour cette exception, sans que ça apparaisse dans le diagramme d'architecture. Code : (1) nouveau shared/UpstreamUnavailableException.kt (signature identique : class UpstreamUnavailableException(message, cause? = null) : RuntimeException, KDoc qui motive le déplacement et trace le MarketUnavailableException historique pour archéologie). Placé flat dans shared/ (cohérent avec GlobalExceptionHandler.kt voisin — pas de subfolder tant que shared/ reste petit ; à promouvoir en shared/domain/ quand un 5ᵉ fichier joint). (2) Suppression de market/domain/MarketUnavailableException.kt. (3) Sweep mécanique sed -i 's/MarketUnavailableException/UpstreamUnavailableException/g; s/com\.portfolioai\.market\.domain\.MarketUnavailableException/com.portfolioai.shared.UpstreamUnavailableException/g' sur 21 fichiers main + 8 fichiers test : FinnhubAnalystClient, MockAnalystClient, AnalystController, FinnhubEarningsClient, MockEarningsClient, EarningsController, FinnhubSectorClassifier, MockSectorClassifier, MockMarketChartClient, MockSymbolSearchClient, SectorClassifier, SymbolSearchClient, TwelveDataClient, TwelveDataSymbolSearchClient, MarketController, FinnhubClient (news), NarrativeBiasService, NarrativeObservabilityService, WatchlistService, GlobalExceptionHandler, plus les *Test.kt correspondants (assertions assertThrows<UpstreamUnavailableException>, KDoc class-level docstrings, noms de tests en backticks). (4) GlobalExceptionHandler.handleMarketUnavailable → handleUpstreamUnavailable, message HTTP 503 inchangé (« Données momentanément indisponibles »). Doc : architecture.md section shared/ étoffée pour expliciter le rôle de l'exception cross-context (« vit ici plutôt que dans market/domain/ parce que le contrat 503 est identique pour les six providers — pas de raison que news/ importe une exception de market/ ») + 2 mentions Phase 2 « partagées avec le reste de la stack market » reformulées en « partagées avec tous les providers externes ». sources.md aligné. CLAUDE.md section shared/ mise à jour pour mentionner l'exception. Skills : hexagonal-ddd/SKILL.md section « Fail-soft vs fail-hard » reformulée (« lives in market/domain/ but is imported by … » → « defined in shared/ because the same 503 contract applies to every external integration ») + section « Cross-context dependencies » reclassée (le terme « shared kernel » remplacé par « shared exception in shared/ qui ne crée pas de dépendance cross-context »). folders-structure-backend/SKILL.md tree ASCII et conventions domain/ actualisés pour clarifier que les exceptions context-specific vivent en <context>/domain/ mais que les exceptions cross-context vont en shared/. Mention historique préservée : la KDoc du nouveau fichier référence explicitement « replaced 2026-05-15 — see backlog dette ticket #B2 » pour l'archéologie git. Effort réel : ~50 min (sweep mécanique 5 min, restauration des références historiques voulues 5 min, audit + nettoyage des claims stales dans skills et docs 25 min, journal + backlog 15 min) |
🟢 Basse |
✅ Frontend — effect() retiré du code main au profit du set-site side-effect |
Livré 2026-05-15. Sortie de l'analyse critique post-refonte .claude/. Constat : ThemeService, LanguageService et Dashboard utilisaient effect() pour « signal change → write to localStorage / DOM », pattern que l'équipe Angular décourage hors cas génuinement réactifs. Conséquences : écriture redondante dans localStorage au boot (l'effect re-fire avec la valeur fraîchement lue de localStorage), aucune composition possible (pas de debounce), tests qui exigeaient un TestBed.tick() après chaque mutation. Code : (1) ThemeService.ts — l'effect() du constructor a été remplacé par 2 méthodes privées applyDom() + persist() appelées par set() ; toggle() délègue à set() ; constructor garde un applyDom(this._theme()) initial pour synchroniser <html data-theme> au boot sans réécrire dans localStorage. (2) LanguageService.ts — même pattern via une méthode privée apply(lang, persist: boolean) ; constructor appelle apply(_, false) pour translate.use() initial sans réécrire ; set() appelle apply(_, true). (3) Dashboard.ts — 3 signals (portfoliosOpen, ownedTickersOpen, watchlistOpen) accédés en mutation depuis le template via .update((v) => !v) (anti-pattern : signal writable exposé au template) ; refacto en une seule méthode toggleSidebar(section: keyof SidebarOpenState) qui flip + persiste, template passe par toggleSidebar('portfolios') au lieu de portfoliosOpen.update(...). Import effect retiré des 3 fichiers. (4) theme.service.spec.ts + language.service.spec.ts — TestBed.tick() retiré des tests post-set() (devient inutile, la side effect est sync), docstrings actualisés (Effect side-effects → Set-site side-effects, etc.). (5) angular-signals/SKILL.md — section « Side effects » réécrite : effect() reclassé comme dernier recours, set-site comme défaut, toObservable() + takeUntilDestroyed() comme alternative pour les flux composés (debounce, distinct), 3 cas où effect() reste justifié documentés (signals que tu ne possèdes pas, coordination N signaux, DOM imperatif). Sweep : grep -rn "effect(() =>" frontend/src/app --include='*.ts' \| grep -v spec.ts retourne 0 hit après refacto. Effort réel : ~50 min (refacto 30 min + skill rewrite 15 min + sweep + tests docstrings 5 min) |
🟡 Moyenne |
✅ NewsClient.kt — KDoc obsolète sur la sélection d'adapter |
Livré 2026-05-15. Sortie de l'analyse critique post-refonte .claude/. Constat : NewsClient.kt:8 claim « the active adapter is selected by Spring's @ConditionalOnProperty on news.provider » alors que depuis la livraison Phase 2 « Settings & config runtime », c'est RoutingNewsClient (@Primary) qui dispatche par-call. Drift cosmétique mais induisait en erreur (l'agent lisant cette KDoc aurait cru pouvoir basculer un adapter via @ConditionalOnProperty). Code : 1 paragraphe de KDoc réécrit pour pointer vers RoutingNewsClient + AppConfigService, expliciter le « both adapters always wired, no @ConditionalOnProperty ». Effort réel : ~2 min |
🟢 Basse |
| ✅ Stratégie de cache — option (b) documenter explicitement les deux modèles | Livré 2026-05-14. Ferme la moitié « doc-only » du ticket dette « Stratégie de cache : trancher entre key-prefix par adapter et service-cache sans préfixe » (audit 2026-05-06 finding #3). L'option (a) homogénéisation reste ouverte dans le backlog. Constat : architecture.md > Décisions techniques notables > Caching côté serveur affirmait sans nuance « Le cache key préfixe par adapter (twelvedata|) » comme si la règle valait pour les 6 caches, alors qu'en pratique seul MARKET_CHART_CACHE (côté TwelveDataClient) préfixe par adapter ; news-by-symbol, analyst-recommendations, earnings, sector-by-symbol cachent au niveau service applicatif sans inclure le provider dans la clé (un toggle mock → finnhub sert la valeur du précédent provider jusqu'à expiration TTL ~15 min). Drift doc↔code irritant à chaque relecture. Code : (1) architecture.md ligne 244 réécrite — section « Caching côté serveur » décrit désormais les deux modèles côte-à-côte (« Modèle A clé préfixée » sur le chart, « Modèle B clé sans préfixe » sur les 4 services applicatifs) + paragraphe « Pourquoi cette hétérogénéité ? » qui assume le compromis et trace l'option (a) restée ouverte. La section ligne 196 « Stratégie de cache hétérogène à connaître » (qui était déjà honnête) reste en place. (2) RoutingSectorClassifier KDoc étendu d'un paragraphe « Cache lives one layer up on SectorClassifierService » qui aligne sa doc sur celle de RoutingNewsClient / RoutingAnalystClient / RoutingEarningsClient (les trois autres explicitaient déjà le choix « provider not in key »). (3) Backlog narrowed à l'option (a) homogénéisation seule. Pas de code Kotlin runtime touché — comme convenu pour la moitié doc-only du ticket. Effort réel : ~25 min |
🟡 Moyenne |
✅ Bar-lookup priceAtOrAfter — guard la borne basse pour ne pas advertise des deltas de 12 mois comme « delta1d » |
Livré 2026-05-14. Ferme audit 2026-05-14 finding « À discuter » de la review Phase 3. Constat : NarrativeObservabilityService.priceAtOrAfter et son jumeau NarrativeBiasService.priceAtOrAfter retournaient bars.firstOrNull { date >= target } ; la borne haute était implicite (null quand la série ne va pas assez loin → la timeline rend « — »), mais la borne basse ne l'était pas. Si le snapshot était généré avant le 1er bar du chart 1Y (typique d'un narratif de l'année passée visualisé sur un chart 1Y récent), target.plusDays(1) restait antérieur au 1er bar et on retournait le 1er bar du chart — soit ~12 mois après le snapshot — présenté comme « delta1d ». Affichage trompeur, observable une fois le corpus à 1+ an. Code : guard target.isBefore(bars.first().date) → null ajouté aux deux helpers (volontairement dupliqué — extraction commune candidate pour le ticket « Coutures Phase 3 » qui regroupe les duplications). KDoc des deux fonctions étendue d'un paragraphe « Lower-bound guard » qui explique la symétrie avec la borne haute. Tests : 1 dans NarrativeObservabilityServiceTest (« snapshot dated before the chart's earliest bar yields three null deltas ») + 1 dans NarrativeBiasServiceTest (« snapshot dated before the chart's earliest bar is excluded from the calibration averages » — vérifie que le snapshot stale ne tire pas la moyenne vers 0). Docstrings des deux classes étendues d'un bullet « Lower-bound guard ». Effort réel : ~30 min comme estimé |
🟡 Moyenne |
✅ Tests défensifs sur JobEventPublisher.pruneStale (TTL eviction) et OllamaStatusService.pullModel (status:error fail-soft) |
Livré 2026-05-14. Ferme audit 2026-05-10 findings #10 + #11. (a) Ollama status:error fail-soft — pullModel n'inspecte pas le body upstream : si Ollama retourne 200 avec {status: "error", ...}, le service traite la réponse comme un HTTP-success et confie au re-probe immédiat la livraison de l'état réel. Comportement correct mais non pinné. Nouveau test pullModel trusts the re-probe when Ollama returns 200 with status error in the body dans OllamaStatusServiceTest qui injecte 3 réponses (pull 200 status:error, /api/tags re-probe avec 2 modèles, /api/ps vide) et asserte daemonReachable=true + availableModels.size==2 + errorMessage==null. Class-level docstring étendue d'une mention « 200 status:error → trust the re-probe » pour documenter cet invariant. (b) JobEventPublisher TTL eviction — pruneStale drop les buckets > 60 s post-terminal, couvert indirectement mais aucun test n'avançait le clock. Seam de test : JobEventPublisher accepte désormais un Clock en paramètre primaire (Clock.systemUTC() par défaut → Spring autowiring inchangé). 3 sites internes passent de Instant.now() à clock.instant() (compteur d'elapsedMs sur publish, stamp terminalAt, cutoff sur pruneStale) ; JobBucket.startedAt perd son défaut et reçoit clock.instant() au construct. Nouveau test pruneStale evicts terminal buckets past retention so a late register replays nothing qui utilise un MutableClock interne (sous-classe Clock qui retourne un Instant mutable via advance(Duration)) pour téléporter le publisher 61 s après le DONE, déclencher pruneStale via un register sur un jobId tiers, puis vérifier qu'un re-register sur le jobId original retourne un emitter idle (sendCount=0, completed=false). ClockedTestablePublisher ajouté en sub-class de test pour câbler le clock. Pas de change runtime — le clock par défaut reste systemUTC(). Effort réel : ~35 min comme estimé |
🟢 Basse |
✅ ConfigTestClient — hint « Tilt button » obsolète remplacé par le pointeur vers le dialog Pull UI |
Livré 2026-05-14. Ferme audit 2026-05-10 finding #12. Constat : ConfigTestClient.kt:225 renvoyait à l'utilisateur via le banner « Tester » Ollama : « — try ollama pull $model (or the matching Tilt button) ». Le local_resource("llm:ensure-model") du Tiltfile a été supprimé en faveur du dialog Pull UI ; le message restait obsolète. Code : 1 ligne du hint remplacée par « — open /settings/configuration > LLM > Pull… to download it » + commentaire au-dessus actualisé (CLI command → in-app Pull dialog). Aucun test ne pinant l'ancienne chaîne. Effort réel : ~5 min comme estimé |
🟢 Basse |
✅ OllamaPullDialog — fallback strings hardcodées passées par TranslateService |
Livré 2026-05-14. Ferme audit 2026-05-10 finding #8. Constat : ollama-pull-dialog.ts:119, 126, 147 utilisaient des fallback strings en anglais ('pull failed', 'delete failed') sur les paths err instanceof Error ? err.message : 'pull failed' et le snap.errorMessage ?? 'pull failed'. Cas d'occurrence : erreur non-Error (browser exotique, throw d'un primitive) ou errorMessage absent côté snapshot fail-soft. Visible en français pour un user FR, en contradiction avec la convention du projet (tout passer par TranslateService.instant). Code : injection TranslateService dans OllamaPullDialog, 3 fallback remplacés par translate.instant(...) sur 2 nouvelles clés settings.configurationPage.ollamaStatus.pullDialog.errors.{pullFailed,deleteFailed}, ajoutées à fr.json (« Échec du pull » / « Échec de la suppression ») et en.json (« Pull failed » / « Delete failed »). Effort réel : ~10 min comme estimé |
🟢 Basse |
✅ OllamaStatusService.pullModel et deleteModel alignés sur model plutôt que name |
Livré 2026-05-14. Ferme audit 2026-05-10 finding #7. Constat : pullModel (ligne 163) et deleteModel (ligne 198) envoyaient body(mapOf("name" to name, ...)) alors que l'API Ollama documente model comme champ courant et marque name comme deprecated. unloadModel (ligne 117) utilisait déjà model — inconsistance interne. Le test OllamaStatusServiceTest pinait "name":"mistral:7b" au statu quo, donc un upgrade futur d'Ollama qui dropperait name aurait cassé silencieusement le pull. Code : 2 changements de body ("name" → "model") dans OllamaStatusService.kt, plus 2 assertions ajustées dans le test + un commentaire au-dessus de chaque body pointant vers la doc Ollama upstream. Effort réel : ~10 min comme estimé |
🟢 Basse |
✅ Frontend dev-server proxy lit .env (fin du 502 sur /api/** quand BACKEND_HOST_PORT est customisé) |
Livré 2026-05-09, dans la foulée du fix gradle test. Friction observée : le user a customisé tous les ports .env (POSTGRES_HOST_PORT=5444, OLLAMA_HOST_PORT=11435, BACKEND_HOST_PORT=8081, FRONTEND_HOST_PORT=4201) pour exercer le mécanisme livré 2026-05-08. Le frontend ne savait plus appeler le backend — l'Angular dev server retournait 502 Bad Gateway sur tous les /api/** parce que frontend/proxy.conf.json hardcodait target: http://localhost:8080, statique. Code : (1) ancien frontend/proxy.conf.json supprimé, remplacé par frontend/proxy.conf.js (Angular CLI / webpack-dev-server supporte les deux formats nativement, le .js permet un require('fs') pour lire .env). (2) Parser hand-rolled (~10 lignes JS), mirroir exact des deux parsers déjà en place (Starlark dans Tiltfile, Kotlin DSL dans backend/build.gradle.kts) — pas de package npm dotenv ajouté pour rester cohérent avec « zero-deps externe pour un parser de 10 lignes ». (3) Résolution du port par ordre de priorité : process.env.BACKEND_HOST_PORT (Tilt-injecté gagne) > clé BACKEND_HOST_PORT dans .env > défaut '8080'. Marche dans les 3 paths d'entrée : tilt up, npm start direct, fresh clone sans .env. (4) frontend/angular.json : proxyConfig: proxy.conf.json → proxy.conf.js. Doc : un 3ᵉ paragraphe ajouté dans docs/technique/developpement.md sous le bloc .env, mentionne que le proxy frontend lit aussi .env et n'exige plus de toucher angular.json quand le port backend bouge. Le fichier .env mentionné dans la doc liste désormais 5 fichiers qui retombent sur les défauts au lieu de 4 (ajout de frontend/proxy.conf.js). Hors scope : pas d'injection de BACKEND_HOST_PORT dans le serve_cmd frontend du Tiltfile — le proxy.conf.js lit déjà .env par lui-même, l'injection serait redondante. Effort réel : ~10 min |
🟡 Moyenne |
✅ ./gradlew test lit .env automatiquement (fin du Connection refused sur Postgres remappé) |
Livré 2026-05-09. Friction de départ : un dev qui customise POSTGRES_HOST_PORT dans .env (cas légitime quand 5432 est occupé localement, cf. la feature .env livrée 2026-05-08) voyait ses tests d'intégration @SpringBootTest planter en cascade — BackendApplicationTests.contextLoads, CacheTtlListenerIntegrationTest etc. — avec une stack Flyway → Postgres Connection refused, parce que application.yml retombait sur le défaut localhost:5432 quand l'env var n'était pas exportée. Workaround actuel : préfixer chaque appel POSTGRES_HOST_PORT=5444 ./gradlew test. Décision design : pivoté de la reco initiale du backlog (option (c) doc seule en v1, (b) wrapper script en v2) vers une 4ᵉ option non listée — mirroir Kotlin DSL du load_env_file() Starlark déjà en place dans le Tiltfile. Pourquoi : (1) la doc seule laissait la friction, (2) un wrapper script scripts/test.sh cassait l'idiome ./gradlew et obligeait à le documenter / le mémoriser, (3) un plugin Gradle externe (dotenv-kotlin, node-gradle-dotenv) ajoutait une dépendance pour un fichier .env de 10 lignes au format dead-simple. Le mirroir Kotlin est ~15 lignes, zero deps externe, cohérent avec ce que fait déjà le Tiltfile pour bootRun / npm start. Code (backend/build.gradle.kts) : nouvelle val dotenv: Map<String, String> qui lit file("../.env") si présent, parse KEY=value (skip blank/#, strip optional surrounding "/'), et tasks.withType<Test> boucle sur la map pour appeler environment(k, v) sur chaque clé. Fallback transparent : .env absent (CI, fresh clone) → map vide → tests retombent sur les défauts d'application.yml exactement comme avant. Pas de régression CI. Doc : paragraphe ajouté dans docs/technique/developpement.md sous le bloc .env, mentionne explicitement que ./gradlew test lit aussi .env et que le préfixage manuel n'est plus nécessaire. Hors scope : (a) injecter aussi sur bootRun — le Tiltfile s'en charge déjà via serve_cmd ; un dev qui lance ./gradlew bootRun direct (sans Tilt) reste responsable de poser ses env vars manuellement, cas marginal. (b) charger les SECRETs .env (clés API) — elles vivent dans application-local.yml ou la BDD app_config runtime, cycle de vie différent. Effort réel : ~15 min (parser + test config + doc + housekeeping backlog/journal) |
🟡 Moyenne |
| ✅ Coutures post-livraison benchmark v2 (sector + custom) | Livré 2026-05-08. Trois micro-points relevés en code review v2 traités en bloc. (1) Single normalisation sector benchmark — le triple trim().uppercase() (controller / SpEL key / adapter) collapse en une seule normalisation au boundary controller. MarketController.getSectorBenchmark calcule val upper = symbol.trim().uppercase() une fois et le passe à la fois à sectorClassifierService.classify(upper) et à .toDto(upper). La SpEL key = "#symbol.trim().toUpperCase()" reste défensive (cheap, cache-boundary belt-and-suspenders pour callers programmatiques futurs). MockSectorClassifier et FinnhubSectorClassifier perdent leur trim().uppercase() interne (ne gardent qu'un isBlank() check défensif). KDoc de l'interface SectorClassifier étendue avec un bullet « Input is trimmed + uppercase » qui formalise le contrat. Tests : MockSectorClassifierTest retire les 2 tests qui pinaient l'ancien comportement (case-insensitive lookup, whitespace trim) avec un commentaire explicatif sur leur déplacement vers le boundary ; FinnhubSectorClassifierTest lowercase input is uppercased in the URL réécrit en forwards the pre-normalised symbol verbatim into the URL (passthrough); MarketControllerTest étendu avec un verify(sectorClassifierService).classify("AAPL") qui pin le contrat boundary. (2) Reset explicite customBenchmarkSearching dans selectBenchmark côté front — sans le reset, un click sur un toggle preset (SPY/Off) au milieu d'une recherche custom-benchmark en vol laissait le spinner stuck true jusqu'au retour de la subscription (auto-correctif via takeUntilDestroyed mais le spinner clignote dans un dropdown invisible entre-temps). Test ajouté : selectBenchmark resets customBenchmarkSearching so a stale spinner does not linger. (3) displayCustomBenchmark en arrow property — la méthode est passée par mat-autocomplete [displayWith] qui appelle sans bind, donc this doit jamais être référencé dans le body. La conversion en arrow property displayCustomBenchmark = (value) => … capture this à field-init time et blind le foot-gun pour de futurs callers même si aucun n'existe aujourd'hui. Effort réel : ~35 min |
|
✅ ThemeService + LanguageService SSR-safe via isPlatformBrowser |
Livré 2026-05-08. Le ticket d'origine ciblait uniquement ThemeService.document.documentElement (le seul appel non protégé par un try/catch — les writes localStorage y étaient déjà absorbés) ; LanguageService traité dans la même session par symétrie (cf. CLAUDE.md « parallel shape » entre les deux). Code : injection PLATFORM_ID + champ isBrowser calculé via isPlatformBrowser(this.platformId) ; gating précis sur les 3 surfaces browser-only — document.documentElement.setAttribute() (theme + lang attributes), localStorage.{get,set}Item() (persistance) et navigator.language (fallback locale). Le loadInitial() retourne le défaut neutre ('dark' / 'fr') en SSR sans toucher localStorage ; l'effect() du constructor early-return sur isBrowser === false après l'appel translate.use(l) (qui reste server-safe pour LanguageService — ngx-translate gère lui-même l'absence de navigateur). Le try/catch autour des writes localStorage est conservé pour les cas runtime browser-side (quota exceeded, mode privé). Tests : 2 nouveaux fichiers, theme.service.spec.ts (5 tests : SSR no-touch, SSR toggle in-memory, browser hydrate from localStorage, browser corrupt fallback to dark, browser writes via effect) et language.service.spec.ts (5 tests : SSR no-touch, SSR toggle in-memory, browser hydrate from localStorage, browser navigator.language fallback en-US → en, browser writes via effect). Tests injectent {provide: PLATFORM_ID, useValue: 'server' \| 'browser'} via TestBed.configureTestingModule. Les effects sont flushés en mode zoneless via TestBed.tick() (API stable Angular 21). Hors scope : annotation.local.ts (adapter SECRET) reste sans guard — c'est un adapter explicitement client-only qui ne serait pas wired en SSR de toute façon (on swapperait sur un autre adapter). Pas de tentative SSR aujourd'hui — l'app n'a pas de provideServerRendering() ; le ticket prépare le terrain pour une future bascule sans rien activer. Effort réel : ~25 min |
🟢 Basse |
| ✅ Tickets Phase 0 obsolètes — fermés sans code | Clos 2026-05-08 sans nouveau code. Deux tickets de la dette technique étaient devenus stales suite au décommissionnement Phase 0 (V6, livré 2026-05-07) : (1) « Doublon recommendations/ vs history/ » — les deux dossiers features/recommendations/ et features/history/ ont été supprimés en V6 / PR2 frontend (cf. journal Phase 2.5). Plus de doublon parce que plus de pages. (2) « Tests sur le module analysis/ (legacy) » — le code Phase 0 sous analysis/ (AnalysisService, AnalysisExecutor, RecommendationValidator, ArticleRelevanceScorer, ...) a été supprimé en V6 / PR1 backend (16 fichiers). Le analysis/ actuel ne contient plus que la pipeline narrative ticker Phase 1, qui est bien testée (TickerNarrativeServiceTest, TickerNarrativeRunnerTest, TickerNarrativeParserTest, TickerNarrativeValidatorTest). Plus de legacy à tester. Pas de code modifié, juste fermeture des deux rows backlog avec trace ici |
🟢 Basse |
✅ FinnhubAnalystClientTest via MockWebServer |
Livré 2026-05-08. Ferme le sous-finding #1 prioritaire de l'audit 2026-05-06 fin Phase 2 finding #2 — l'absence de couture observable sur l'adapter HTTP analyst (les voisins news / earnings / sector / twelvedata avaient tous l'équivalent). Code : nouveau FinnhubAnalystClientTest.kt sous analyst/infrastructure/analyst/, calque sur FinnhubEarningsClientTest (même structure de fixtures, mêmes helpers mockAppConfig + jsonOk, même MockWebServer + RestClient.builder().build() + base-url overridé). 11 tests : (a) happy path ×3 — merge recommendations + price-target en snapshot, normalisation lowercase → uppercase sur les 2 URLs, présence du token sur les 2 URLs ; (b) price-target fail-soft ×2 — 401 et 5xx sur /stock/price-target swallowed en priceTarget=null avec recommandations toujours présentes (le distinctif de cet adapter, explicitement documenté côté FinnhubAnalystClient parce que l'endpoint est gated paid plan sur certains comptes Finnhub) ; (c) error mapping ×4 — 401/403 → auth-failed, 429 → rate-limited, 500 → upstream, tous via MarketUnavailableException partagée avec le reste du stack Finnhub pour un 503 unifié côté front ; (d) guards ×1 — clé API blanche short-circuite avant tout HTTP call (server.requestCount == 0) ; (e) wire-to-mapper hand-off ×1 — 200 [] sur recommendations bubble NoSuchElementException via le mapper, distinct du 503 upstream et mappé en HTTP 404 par le GlobalExceptionHandler. Fixtures JSON minimales (2 monthly snapshots newest-first pour vérifier le sort défensif, price-target populé à 41 analysts). Pas de change côté composant — les comportements existaient, on pin les invariants. Effort réel : ~40 min (lecture du calque earnings + adapter + write + ajustement des assertions sur les types domain MonthlyRecommendation.period: LocalDate) |
🟡 Moyenne |
✅ Triplets loading / 404 / 503 pinnés sur analyst + earnings |
Livré 2026-05-08. Ferme finding #7 audit 2026-05-06 fin Phase 2. Constat à l'audit du fichier : sur les 6 specs cibles (analyst loading / 404 / 503, earnings loading / 404 / 503), 4 étaient déjà pinnées dans ticker.spec.ts — les 404 et 503 des deux modules. Manquant : les 2 loading state in-flight — la transition entre subscribe() et la première émission, où *Loading() doit être true et le snapshot/error/notCovered encore dans leur état initial. Code : 2 nouveaux it() ajoutés en tête des describe('analyst recommendations') et describe('earnings'), mockent getForSymbol avec un Subject<> qui n'émet pas, posent fixture.detectChanges(), et asserent les 4 signaux du panel pendant que la requête est en vol. Pattern aligné sur les tests voisins (sub-threshold, populated, 404, 503), commentaire motivationnel qui explique le why (« catches a refactor that drops the eager *Loading.set(true) in loadAnalyst / loadEarnings »). Pas de change côté composant — le comportement existait déjà, on pin juste l'invariant. Effort réel : ~25 min (audit grep + lecture des deux describe + 2 tests) |
🟢 Basse |
✅ Audit 2026-05-06 finding #6 — WatchlistService.add fail-open + DTO unverified |
Clos 2026-05-08 sans nouveau code. Constat à la relecture : le ticket avait deux volets, (a) écrire un test Mockito qui pin le comportement fail-open quand SymbolSearchService.validate throw MarketUnavailableException, (b) exposer un champ unverified: boolean sur le DTO pour que le front puisse signaler l'entrée passée par le fail-open. (a) déjà couvert depuis le commit fondateur b31a9de feat(watchlist): autocomplete + symbol validation via Twelve Data /symbol_search — le test WatchlistServiceTest.add fails open when the symbol search provider is unreachable pin précisément le path 503 → save sans erreur. L'audit du 2026-05-06 a manqué la présence du test (déposé avant l'audit). (b) écarté : pour un single-user local, le coût (migration BDD V7 + colonne unverified + mécanique de re-validation — soit cron quotidien soit ré-enclenchement sur idempotent re-add) dépasse largement le bénéfice (cas observé zéro fois ; un symbole mort se manifeste de toute façon par un 404 sur l'ouverture du dossier ticker). À ré-arbitrer si l'app passe en multi-user / SaaS ou si le cas devient observable. Pas de code modifié, juste fermeture du ticket et trace écrite |
🟢 Basse |
✅ provideRepositories() côté frontend |
Livré 2026-05-08. Extraction des 9 lignes { provide: XxxRepository, useClass: <impl> } de app.config.ts vers un nouveau frontend/src/app/core/providers.ts qui exporte provideRepositories(): EnvironmentProviders (via makeEnvironmentProviders). app.config.ts appelle désormais provideRepositories() au même titre que provideRouter() / provideHttpClient() / provideTranslateService() — l'app.config.ts se concentre sur les providers globaux (zoneless, router, http, animations, i18n) et les bindings ports/adapters vivent dans core/. Refs doc mises à jour : CLAUDE.md (paragraphe core/), architecture.md > Frontend > Wiring, docstring portfolio.repository.ts. Pas de test à toucher — l'API publique du DI est inchangée, les composants continuent d'injecter XxxRepository sans rien savoir du wiring. Effort réel : ~15 min comme estimé |
🟢 Basse |
| ✅ Cleanup des jobs orphelins au démarrage | OrphanedJobCleanupListener (@EventListener(ApplicationReadyEvent)) sweep au boot la table ticker_narrative_job — flippe tout PENDING en ERROR avec un marqueur "Job orphaned at backend boot — the previous instance crashed or hot-reloaded mid-execution.". JPQL @Modifying UPDATE … SET status=…, error=… WHERE status=:oldStatus (1 round-trip SQL, pas d'hydratation d'entité). Choix ApplicationReadyEvent plutôt que @PostConstruct : garantit que Flyway + JPA + DataSource sont prêts. 3 tests Mockito-Kotlin pinnent les invariants : (1) la table est sweepée avec le bon marqueur, (2) cas no-op quand 0 PENDING, (3) défense contre un futur élargissement à DONE/ERROR comme statut source. La validité du JPQL est exercée end-to-end par BackendApplicationTests qui boote le full context et fait tourner le listener sur PostgreSQL réel. Au moment du livré, le listener sweepait aussi analysis_job (Phase 0 gelée) — la branche a été retirée en Phase 2.5 quand la table a été droppée par V6. |
🟡 Moyenne |
| ✅ Linter ESLint côté frontend | Flat config eslint.config.js (Angular ESLint 21.3.1, idiomatic Angular 21) posée via ng add @angular-eslint/schematics. Extends : eslint:recommended + tseslint:recommended + tseslint:stylistic + angular-eslint:tsRecommended côté TS, templateRecommended + templateAccessibility côté HTML. eslint-config-prettier appliqué en dernier pour désactiver les règles formatage qui chevauchent Prettier (qui reste seul format). Volontairement pas de recommended-type-checked (5-10× plus lent, à activer plus tard en session dédiée). Premier pass = 21 erreurs (estimation backlog 30-50 plutôt généreuse), traitées dans le même commit : 2 auto-fixées par --fix, 5 next: () => {} vides retirés des specs (RxJS subscribe avec uniquement error: est valide), 2 ternaires utilisés en statement convertis en if/else, 1 prefer-inject migré sur Import component (constructor(private router) → inject(Router)), 1 computed import unused supprimé, 2 <label> non associés convertis en <span class="filter-label"> + SCSS adapté, 6 a11y click-events-have-key-events + interactive-supports-focus corrigés sur les divs/li cliquables (role="button" tabindex="0" (keydown.enter)). CI : step Lint ajoutée à frontend.yml avant le build (lint qui pète tôt évite de cramer le build pour rien). Tests + build verts (130 tests, 18 fichiers) |
🟡 Moyenne |
| ✅ Agent Claude spécialiste doc avec ses propres skills | Subagent doc-maintainer posé dans .claude/agents/doc-maintainer.md avec whitelist d'outils lecture seule (Read, Glob, Grep ; pas de Bash, pas d'Edit). Slash command /doc-maintainer dans .claude/skills/doc-maintainer/SKILL.md qui spawne l'agent via le Agent tool — l'audit tourne en contexte isolé, ne pollue pas la session principale. Trois capacités encodées dans le system prompt de l'agent : (1) cross-check factuel confronte les claims docs (modules backend listés, repositories frontend, providers, workflows CI, migrations Flyway, commandes, statut des phases) à la réalité du repo via Glob/Read ; (2) ton vérifie titres bas-de-casse, tirets cadratin "—" à la française, narratif > bullets pour les arguments, mélange FR/EN cohérent ; (3) cross-link vérifie que chaque lien relatif résout et que toute doc nouvellement créée est référencée depuis un point d'entrée. Sortie = punch-list structurée par capacité avec priorités HIGH/MED/LOW + verdict global, jamais d'edit appliqué — c'est le user qui décide ce qu'on patche dans le main thread. Volontairement read-only pour rester safe et économique en contexte ; on autoapply quand on aura confiance. Trigger : /doc-maintainer, ou en fin de feature structurellement significative (nouveau module, migration, provider, repository, workflow CI) |
🟢 Basse |
| ✅ Provider de marché alternatif (Twelve Data) | Implémenté en prérequis Phase 2 — voir section dédiée plus haut. TwelveDataClient actif via market.provider: twelvedata, clé via TWELVEDATA_API_KEY |
🔴 Critique |
| ✅ Refacto "tests as documentation" sur les tests existants | Pass d'audit appliqué : docstrings de classe + commentaires motivationnels sur IndicatorCalculatorTest, MockMarketChartClientTest, CsvImportServiceTest, PortfolioControllerTest côté back, et ticker.spec, dashboard.spec, suivi.spec, csv-import.spec, analysis.http.spec + 4 HTTP adapter specs côté front. Stub-only specs (should create seul) laissés tels quels. Les tests narratif Phase 1 ont servi de modèle |
🟢 Basse |