From a963d3fbe5347326c9f3eeb4596756504eb10ecd Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 16:34:04 +0200 Subject: [PATCH 01/16] docs(adr): add ADR-001-strategy-evolution-lab-openevolve --- .../001--strategy-evolution-lab-openevolve.md | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 .samourai/docai/decisions/001--strategy-evolution-lab-openevolve.md diff --git a/.samourai/docai/decisions/001--strategy-evolution-lab-openevolve.md b/.samourai/docai/decisions/001--strategy-evolution-lab-openevolve.md new file mode 100644 index 0000000..b8c2655 --- /dev/null +++ b/.samourai/docai/decisions/001--strategy-evolution-lab-openevolve.md @@ -0,0 +1,178 @@ +--- +id: ADR-001 +status: draft +date: 2026-06-21 +deciders: [simodev25] +--- + +# ADR-001 — Introduire un Strategy Evolution Lab offline basé sur OpenEvolve + +## Statut +draft + +## Contexte + +Kairos Mesh dispose déjà d'un Strategy Engine, d'un Backtest Engine, d'un monitoring de stratégies et d'un système de benchmark récemment livré. Le produit sait donc déjà générer, valider, monitorer et promouvoir des stratégies, avec une séparation claire entre recherche et exécution. + +Le besoin actuel n'est pas d'automatiser davantage le trading live, mais d'améliorer la qualité et la diversité des stratégies candidates produites par le système, en particulier sur trois surfaces : + +- les prompts des agents de stratégie et de décision (`strategy-designer`, `trader-agent`, `bullish-researcher`, `bearish-researcher`), +- les paramètres et templates de stratégies, +- à terme, du code de stratégie sandboxé, hors du chemin critique. + +OpenEvolve apporte un cadre pertinent pour ce besoin : évolution pilotée par LLM, MAP-Elites / quality-diversity, populations en îles, optimisation multi-objectifs, et boucle de feedback par artefacts. + +Contraintes structurantes du système : + +- le pipeline de décision live en 4 phases (Analyse → Débat → Décision → Gouvernance) ne doit pas être déstabilisé, +- le moteur de risque déterministe est une zone critique et **ne sera pas impacté**, +- le trading live doit rester isolé de tout mécanisme exploratoire ou auto-évolutif, +- les promotions vers `PAPER` ou `LIVE` doivent rester manuelles et auditables. + +Les drivers principaux sont donc : + +- augmenter la qualité et la robustesse OOS des stratégies candidates, +- augmenter la diversité des approches explorées, +- préserver l'isolation du live trading, +- limiter le couplage avec le pipeline temps réel, +- conserver une gouvernance humaine forte sur la promotion. + +## Options considérées +1. ALT-0 — Ne pas intégrer (statu quo) +2. ALT-1 — Prompt evolution only (offline) +3. ALT-2 — Strategy Evolution Lab découplé (RECOMMANDÉ) +4. ALT-3 — Intégration forte dans le pipeline + +## Décision +Option ALT-2 retenue. + +## Justification + +### Pourquoi ALT-2 + +ALT-2 introduit un **lab d'évolution offline, découplé du pipeline live**, consommant des snapshots de marché, prompts versionnés, templates de stratégie et évaluations par backtests multi-fenêtres. Cette option maximise l'apprentissage sans contaminer le chemin d'exécution critique. + +Le lab exécute OpenEvolve comme service/job séparé, avec son propre stockage d'artefacts et sa propre cadence d'exploration. Il produit des **candidats versionnés** et des rapports d'évaluation, mais ne modifie jamais directement les prompts ni les stratégies actives en production. + +### Comparatif synthétique + +- **ALT-0** + - Avantage : zéro risque d'intégration. + - Limite : aucune amélioration systématique de la recherche de stratégies. + +- **ALT-1** + - Avantage : périmètre réduit, faible coût initial. + - Limite : n'optimise qu'une partie du problème ; ignore les paramètres/templates et réduit la valeur de MAP-Elites. + +- **ALT-2** + - Avantage : bon équilibre entre capacité d'exploration, isolation opérationnelle, auditabilité et montée en charge progressive. + - Limite : nécessite une couche d'orchestration offline, un schéma de versionning des candidats et une discipline d'évaluation. + +- **ALT-3** + - Avantage : boucle potentiellement plus rapide entre évolution et exploitation. + - Limite : couplage fort avec le pipeline live, risque de dérive opérationnelle, complexité de rollback plus élevée, frontière de confiance dégradée. + +### Drivers de décision couverts par ALT-2 + +1. **Sécurité opérationnelle** — le lab reste hors du chemin live. +2. **Préservation des frontières critiques** — aucun impact direct sur `backend/app/services/risk/` ni sur la couche d'exécution broker. +3. **Qualité de recherche** — OpenEvolve peut optimiser plusieurs objectifs simultanément et explorer des niches non triviales. +4. **Auditabilité** — chaque candidat peut être tracé à ses prompts, paramètres, jeux de fenêtres et scores. +5. **Évolutivité** — l'évolution de prompts peut commencer seule, puis s'étendre aux templates/paramètres, puis au code sandboxé. +6. **Réversibilité** — désactiver le lab n'affecte pas le comportement live existant. + +### Évaluation proposée des candidats + +L'évaluateur du lab repose sur le Backtest Engine avec fenêtres multiples, validation out-of-sample et score de robustesse. Les métriques minimales à calculer et stocker sont : + +- **return** (`total_return_pct`), +- **max drawdown** (`max_drawdown_pct`), +- **profit factor**, +- **stabilité OOS** : dispersion des scores entre fenêtres IS/OOS et pénalisation des candidats instables, +- **pénalité de complexité** : plus un prompt, template ou code devient complexe/fragile, plus son score diminue. + +Le score de sélection doit être multi-objectif, pas un simple tri par rendement : + +- rendement ajusté du risque, +- drawdown maîtrisé, +- stabilité inter-fenêtres, +- robustesse OOS, +- complexité contenue. + +### Invariants imposés + +- **Aucun impact sur le risk engine** : le lab ne lit éventuellement que ses contraintes comme référence, mais ne modifie ni logique ni seuils du moteur déterministe. +- **Isolation du live trading** : aucun candidat ne peut être auto-promu, aucun job d'évolution n'appelle MetaAPI pour exécuter des ordres, aucun résultat d'évolution n'entre automatiquement dans `Strategy Monitor` ou `ExecutionService`. +- **Promotion manuelle obligatoire** : un humain valide explicitement tout candidat avant intégration aux prompts actifs, stratégies `PAPER` ou stratégies `LIVE`. + +## Conséquences + +### Positives + +- Amélioration attendue de la qualité et de la diversité des stratégies explorées. +- Meilleur usage du benchmark et du backtest existants comme évaluateurs structurés. +- Réduction du risque architectural par découplage fort entre exploration et exploitation. +- Cadre compatible avec une adoption progressive : prompts d'abord, stratégies ensuite, code sandboxé plus tard. +- Traçabilité renforcée des expérimentations et des raisons de promotion/rejet. + +### Négatives + +- Nouveau sous-système à opérer : orchestration, files, stockage d'artefacts, quotas compute. +- Coût de calcul potentiellement élevé selon la taille des populations et des fenêtres de backtest. +- Risque d'overfitting si la discipline d'évaluation OOS est insuffisante. +- Besoin d'un contrat clair entre candidats du lab et objets déjà gérés par Kairos Mesh (prompts, templates, stratégies validées). + +### Trade-offs assumés + +- On accepte une boucle d'apprentissage plus lente qu'une intégration live pour gagner en sûreté. +- On accepte un coût d'infrastructure supplémentaire pour préserver les frontières critiques. +- On retarde l'évolution de code de stratégie à une phase ultérieure afin de limiter le rayon d'impact initial. + +## Plan d'implémentation progressif +- **Phase 1 : Foundations — Prompt Evolution Offline** + - Périmètre : faire évoluer offline les prompts de `strategy-designer`, `trader-agent`, `bullish-researcher`, `bearish-researcher`. + - Livrables : job/service séparé OpenEvolve, versionning des candidats, connecteur d'évaluation vers backtests multi-fenêtres, stockage des scores et artefacts. + - Garde-fous : aucune écriture automatique dans les prompts actifs ; revue humaine obligatoire. + - Effort estimé : **5 à 7 jours**. + +- **Phase 2 : Strategy Template / Parameter Evolution** + - Périmètre : faire évoluer templates sélectionnés, paramètres bornés et variantes de configuration. + - Livrables : représentation canonique des candidats, score multi-objectif, leaderboard offline, workflow de promotion vers stratégie validable. + - Garde-fous : bornes strictes de paramètres, evaluation OOS obligatoire, promotion manuelle vers `DRAFT` ou `VALIDATED` seulement après revue. + - Effort estimé : **7 à 10 jours**. + +- **Phase 3 : Sandbox Code Evolution (optionnel, ultérieur)** + - Périmètre : autoriser l'évolution de code de stratégie dans un environnement sandboxé, sans accès au chemin live. + - Livrables : sandbox d'exécution, contrôles statiques, politiques de sécurité, corpus de tests/backtests obligatoires avant revue. + - Garde-fous : aucun chargement direct en production ; revue humaine et validation étendue obligatoires. + - Effort estimé : **10 à 15 jours**. + +## Risques et mitigations + +- **Risque : overfitting sur l'historique** + - Mitigation : multi-fenêtres, séparation IS/OOS, pénalisation de variance, seuil minimal de robustesse avant promotion. + +- **Risque : explosion du coût de calcul** + - Mitigation : quotas par campagne, populations bornées, arrêt anticipé, priorisation des niches prometteuses. + +- **Risque : dérive qualitative des prompts** + - Mitigation : règles de scoring explicites, benchmark de régression, comparaison au baseline, revue humaine avant activation. + +- **Risque : couplage implicite avec le pipeline live** + - Mitigation : service séparé, stockage séparé, API de promotion explicite, aucune écriture automatique dans les stratégies actives. + +- **Risque : contamination des frontières critiques** + - Mitigation : exclusion explicite du moteur de risque et de l'exécution broker du périmètre d'évolution ; contrôles d'architecture à la review. + +- **Risque : dette de gouvernance sur les candidats générés** + - Mitigation : versionning complet, métadonnées de provenance, conservation des scores, historique de décisions de promotion/rejet. + +## Liens + +- Architecture : `docs/architecture.md` +- Pipeline de décision : `docs/decision-pipeline.md` +- Gouvernance et risque : `docs/risk-and-governance.md` +- Implémentation existante du benchmark : `backend/app/services/benchmark/engine.py` +- Implémentation existante du strategy designer : `backend/app/services/strategy/designer.py` +- Gestion des stratégies et promotion : `backend/app/api/routes/strategies.py` +- OpenEvolve : `https://github.com/algorithmicsuperintelligence/openevolve` From 0c93a6931f6c5ec591250d5e048f618aa8e1ebc8 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 16:45:03 +0200 Subject: [PATCH 02/16] docs(adr): refine ADR-0001-strategy-evolution-lab-openevolve --- .../001--strategy-evolution-lab-openevolve.md | 178 ------------------ ...-0001-strategy-evolution-lab-openevolve.md | 165 ++++++++++++++++ 2 files changed, 165 insertions(+), 178 deletions(-) delete mode 100644 .samourai/docai/decisions/001--strategy-evolution-lab-openevolve.md create mode 100644 .samourai/docai/decisions/ADR-0001-strategy-evolution-lab-openevolve.md diff --git a/.samourai/docai/decisions/001--strategy-evolution-lab-openevolve.md b/.samourai/docai/decisions/001--strategy-evolution-lab-openevolve.md deleted file mode 100644 index b8c2655..0000000 --- a/.samourai/docai/decisions/001--strategy-evolution-lab-openevolve.md +++ /dev/null @@ -1,178 +0,0 @@ ---- -id: ADR-001 -status: draft -date: 2026-06-21 -deciders: [simodev25] ---- - -# ADR-001 — Introduire un Strategy Evolution Lab offline basé sur OpenEvolve - -## Statut -draft - -## Contexte - -Kairos Mesh dispose déjà d'un Strategy Engine, d'un Backtest Engine, d'un monitoring de stratégies et d'un système de benchmark récemment livré. Le produit sait donc déjà générer, valider, monitorer et promouvoir des stratégies, avec une séparation claire entre recherche et exécution. - -Le besoin actuel n'est pas d'automatiser davantage le trading live, mais d'améliorer la qualité et la diversité des stratégies candidates produites par le système, en particulier sur trois surfaces : - -- les prompts des agents de stratégie et de décision (`strategy-designer`, `trader-agent`, `bullish-researcher`, `bearish-researcher`), -- les paramètres et templates de stratégies, -- à terme, du code de stratégie sandboxé, hors du chemin critique. - -OpenEvolve apporte un cadre pertinent pour ce besoin : évolution pilotée par LLM, MAP-Elites / quality-diversity, populations en îles, optimisation multi-objectifs, et boucle de feedback par artefacts. - -Contraintes structurantes du système : - -- le pipeline de décision live en 4 phases (Analyse → Débat → Décision → Gouvernance) ne doit pas être déstabilisé, -- le moteur de risque déterministe est une zone critique et **ne sera pas impacté**, -- le trading live doit rester isolé de tout mécanisme exploratoire ou auto-évolutif, -- les promotions vers `PAPER` ou `LIVE` doivent rester manuelles et auditables. - -Les drivers principaux sont donc : - -- augmenter la qualité et la robustesse OOS des stratégies candidates, -- augmenter la diversité des approches explorées, -- préserver l'isolation du live trading, -- limiter le couplage avec le pipeline temps réel, -- conserver une gouvernance humaine forte sur la promotion. - -## Options considérées -1. ALT-0 — Ne pas intégrer (statu quo) -2. ALT-1 — Prompt evolution only (offline) -3. ALT-2 — Strategy Evolution Lab découplé (RECOMMANDÉ) -4. ALT-3 — Intégration forte dans le pipeline - -## Décision -Option ALT-2 retenue. - -## Justification - -### Pourquoi ALT-2 - -ALT-2 introduit un **lab d'évolution offline, découplé du pipeline live**, consommant des snapshots de marché, prompts versionnés, templates de stratégie et évaluations par backtests multi-fenêtres. Cette option maximise l'apprentissage sans contaminer le chemin d'exécution critique. - -Le lab exécute OpenEvolve comme service/job séparé, avec son propre stockage d'artefacts et sa propre cadence d'exploration. Il produit des **candidats versionnés** et des rapports d'évaluation, mais ne modifie jamais directement les prompts ni les stratégies actives en production. - -### Comparatif synthétique - -- **ALT-0** - - Avantage : zéro risque d'intégration. - - Limite : aucune amélioration systématique de la recherche de stratégies. - -- **ALT-1** - - Avantage : périmètre réduit, faible coût initial. - - Limite : n'optimise qu'une partie du problème ; ignore les paramètres/templates et réduit la valeur de MAP-Elites. - -- **ALT-2** - - Avantage : bon équilibre entre capacité d'exploration, isolation opérationnelle, auditabilité et montée en charge progressive. - - Limite : nécessite une couche d'orchestration offline, un schéma de versionning des candidats et une discipline d'évaluation. - -- **ALT-3** - - Avantage : boucle potentiellement plus rapide entre évolution et exploitation. - - Limite : couplage fort avec le pipeline live, risque de dérive opérationnelle, complexité de rollback plus élevée, frontière de confiance dégradée. - -### Drivers de décision couverts par ALT-2 - -1. **Sécurité opérationnelle** — le lab reste hors du chemin live. -2. **Préservation des frontières critiques** — aucun impact direct sur `backend/app/services/risk/` ni sur la couche d'exécution broker. -3. **Qualité de recherche** — OpenEvolve peut optimiser plusieurs objectifs simultanément et explorer des niches non triviales. -4. **Auditabilité** — chaque candidat peut être tracé à ses prompts, paramètres, jeux de fenêtres et scores. -5. **Évolutivité** — l'évolution de prompts peut commencer seule, puis s'étendre aux templates/paramètres, puis au code sandboxé. -6. **Réversibilité** — désactiver le lab n'affecte pas le comportement live existant. - -### Évaluation proposée des candidats - -L'évaluateur du lab repose sur le Backtest Engine avec fenêtres multiples, validation out-of-sample et score de robustesse. Les métriques minimales à calculer et stocker sont : - -- **return** (`total_return_pct`), -- **max drawdown** (`max_drawdown_pct`), -- **profit factor**, -- **stabilité OOS** : dispersion des scores entre fenêtres IS/OOS et pénalisation des candidats instables, -- **pénalité de complexité** : plus un prompt, template ou code devient complexe/fragile, plus son score diminue. - -Le score de sélection doit être multi-objectif, pas un simple tri par rendement : - -- rendement ajusté du risque, -- drawdown maîtrisé, -- stabilité inter-fenêtres, -- robustesse OOS, -- complexité contenue. - -### Invariants imposés - -- **Aucun impact sur le risk engine** : le lab ne lit éventuellement que ses contraintes comme référence, mais ne modifie ni logique ni seuils du moteur déterministe. -- **Isolation du live trading** : aucun candidat ne peut être auto-promu, aucun job d'évolution n'appelle MetaAPI pour exécuter des ordres, aucun résultat d'évolution n'entre automatiquement dans `Strategy Monitor` ou `ExecutionService`. -- **Promotion manuelle obligatoire** : un humain valide explicitement tout candidat avant intégration aux prompts actifs, stratégies `PAPER` ou stratégies `LIVE`. - -## Conséquences - -### Positives - -- Amélioration attendue de la qualité et de la diversité des stratégies explorées. -- Meilleur usage du benchmark et du backtest existants comme évaluateurs structurés. -- Réduction du risque architectural par découplage fort entre exploration et exploitation. -- Cadre compatible avec une adoption progressive : prompts d'abord, stratégies ensuite, code sandboxé plus tard. -- Traçabilité renforcée des expérimentations et des raisons de promotion/rejet. - -### Négatives - -- Nouveau sous-système à opérer : orchestration, files, stockage d'artefacts, quotas compute. -- Coût de calcul potentiellement élevé selon la taille des populations et des fenêtres de backtest. -- Risque d'overfitting si la discipline d'évaluation OOS est insuffisante. -- Besoin d'un contrat clair entre candidats du lab et objets déjà gérés par Kairos Mesh (prompts, templates, stratégies validées). - -### Trade-offs assumés - -- On accepte une boucle d'apprentissage plus lente qu'une intégration live pour gagner en sûreté. -- On accepte un coût d'infrastructure supplémentaire pour préserver les frontières critiques. -- On retarde l'évolution de code de stratégie à une phase ultérieure afin de limiter le rayon d'impact initial. - -## Plan d'implémentation progressif -- **Phase 1 : Foundations — Prompt Evolution Offline** - - Périmètre : faire évoluer offline les prompts de `strategy-designer`, `trader-agent`, `bullish-researcher`, `bearish-researcher`. - - Livrables : job/service séparé OpenEvolve, versionning des candidats, connecteur d'évaluation vers backtests multi-fenêtres, stockage des scores et artefacts. - - Garde-fous : aucune écriture automatique dans les prompts actifs ; revue humaine obligatoire. - - Effort estimé : **5 à 7 jours**. - -- **Phase 2 : Strategy Template / Parameter Evolution** - - Périmètre : faire évoluer templates sélectionnés, paramètres bornés et variantes de configuration. - - Livrables : représentation canonique des candidats, score multi-objectif, leaderboard offline, workflow de promotion vers stratégie validable. - - Garde-fous : bornes strictes de paramètres, evaluation OOS obligatoire, promotion manuelle vers `DRAFT` ou `VALIDATED` seulement après revue. - - Effort estimé : **7 à 10 jours**. - -- **Phase 3 : Sandbox Code Evolution (optionnel, ultérieur)** - - Périmètre : autoriser l'évolution de code de stratégie dans un environnement sandboxé, sans accès au chemin live. - - Livrables : sandbox d'exécution, contrôles statiques, politiques de sécurité, corpus de tests/backtests obligatoires avant revue. - - Garde-fous : aucun chargement direct en production ; revue humaine et validation étendue obligatoires. - - Effort estimé : **10 à 15 jours**. - -## Risques et mitigations - -- **Risque : overfitting sur l'historique** - - Mitigation : multi-fenêtres, séparation IS/OOS, pénalisation de variance, seuil minimal de robustesse avant promotion. - -- **Risque : explosion du coût de calcul** - - Mitigation : quotas par campagne, populations bornées, arrêt anticipé, priorisation des niches prometteuses. - -- **Risque : dérive qualitative des prompts** - - Mitigation : règles de scoring explicites, benchmark de régression, comparaison au baseline, revue humaine avant activation. - -- **Risque : couplage implicite avec le pipeline live** - - Mitigation : service séparé, stockage séparé, API de promotion explicite, aucune écriture automatique dans les stratégies actives. - -- **Risque : contamination des frontières critiques** - - Mitigation : exclusion explicite du moteur de risque et de l'exécution broker du périmètre d'évolution ; contrôles d'architecture à la review. - -- **Risque : dette de gouvernance sur les candidats générés** - - Mitigation : versionning complet, métadonnées de provenance, conservation des scores, historique de décisions de promotion/rejet. - -## Liens - -- Architecture : `docs/architecture.md` -- Pipeline de décision : `docs/decision-pipeline.md` -- Gouvernance et risque : `docs/risk-and-governance.md` -- Implémentation existante du benchmark : `backend/app/services/benchmark/engine.py` -- Implémentation existante du strategy designer : `backend/app/services/strategy/designer.py` -- Gestion des stratégies et promotion : `backend/app/api/routes/strategies.py` -- OpenEvolve : `https://github.com/algorithmicsuperintelligence/openevolve` diff --git a/.samourai/docai/decisions/ADR-0001-strategy-evolution-lab-openevolve.md b/.samourai/docai/decisions/ADR-0001-strategy-evolution-lab-openevolve.md new file mode 100644 index 0000000..9bc580f --- /dev/null +++ b/.samourai/docai/decisions/ADR-0001-strategy-evolution-lab-openevolve.md @@ -0,0 +1,165 @@ +--- +id: ADR-0001 +decision_type: adr +created: 2026-06-21 +decision_date: null +last_updated: 2026-06-21 +status: Proposed +summary: "Créer un Evolution Lab découplé du pipeline live, piloté par OpenEvolve et réutilisant benchmark/backtest comme évaluateurs." +owners: ["simodev25"] +service: "benchmark/evolution-lab" +links: + related_changes: ["GH-24"] + supersedes: [] + superseded_by: [] + spec: [] + contracts: ["docs/api-contracts-backend.md"] + diagrams: [] + decisions: [] +--- + +# ADR-0001: Introduire un Evolution Lab découplé basé sur OpenEvolve + +## Context + +Kairos Mesh dispose déjà d'un backend benchmark asynchrone (`/api/v1/benchmark`, `BenchmarkEngine`, queue Celery dédiée), d'un Backtest Engine, de prompts versionnés en base (`prompt_templates`) et d'un frontend multi-pages sans page dédiée au benchmark ni à l'évolution. Le pipeline live reste gouverné par des frontières fortes : moteur de risque déterministe, exécution broker séparée, trading live désactivé par défaut et promotion manuelle des stratégies. + +Le besoin actuel est d'ajouter un "Strategy Evolution Lab" permettant d'évaluer puis d'améliorer des prompts/modèles d'agents sans toucher au chemin live. L'intégration d'OpenEvolve ajoute un nouveau sous-système, de nouvelles tables, une nouvelle API publique FastAPI et une nouvelle page frontend. Le changement est donc cross-service, introduit une dépendance structurante et modifie le modèle de données. + +## Problem Framing (Clarified) + +La décision à prendre n'est pas "faut-il un écran de plus" mais "où loger un moteur d'évolution coûteux et potentiellement risqué sans contaminer le runtime live". + +Le lab doit permettre : + +- de sélectionner un agent et un couple provider/modèle, +- de lancer une campagne d'évolution/backtest sur des prompts candidats, +- d'observer la progression par génération et la comparaison au baseline, +- de promouvoir manuellement un candidat sans auto-activation implicite, +- de conserver traçabilité, budgets et garde-fous. + +Le problème est donc un problème de frontières d'architecture, de gouvernance et d'opérabilité avant d'être un problème UI. + +## Decision Drivers + +1. **Isolation du live trading** — aucun impact sur `risk/`, `metaapi/` et le pipeline live. +2. **Réutilisation maximale de l'existant** — benchmark, backtest, prompts versionnés, logs LLM. +3. **Auditabilité** — provenance complète des campagnes, candidats, coûts, promotions. +4. **Contrôle des coûts** — budget campagne, plafond d'appels, arrêt anticipé. +5. **Évolutivité** — démarrer par l'évolution de prompts puis étendre aux templates/paramètres. +6. **Simplicité opératoire** — queue dédiée, annulation, lecture claire depuis le frontend. +7. **Réversibilité** — désactiver le lab sans effet sur les surfaces existantes. + +## Mental Models & Techniques Used + +- **First Principles** : séparer exploration offline et exécution live. +- **Systems Thinking** : considérer backend, DB, queue, API, frontend et gouvernance comme un tout. +- **Second-Order Thinking** : gérer les effets futurs sur coût, concurrence Celery et promotion de prompts. +- **KISS** : ne pas enfouir OpenEvolve dans le moteur benchmark existant. +- **Opportunity Cost** : éviter une intégration "rapide" mais coûteuse à maintenir dans le pipeline temps réel. + +## Alternatives Considered + +| Alternative | Description | Avantages | Inconvénients | +|---|---|---|---| +| ALT-0 | Statu quo | Zéro coût d'intégration | Aucun lab, aucune amélioration structurée | +| ALT-1 | Étendre directement le sous-système benchmark pour piloter l'évolution | Réutilisation apparente forte | Mélange orchestration d'évolution et moteur de benchmark, couplage excessif | +| ALT-2 | Nouveau sous-système `evolution` réutilisant benchmark/backtest comme évaluateurs | Isolation, lisibilité, extensibilité, gouvernance claire | Nouveau schéma DB et nouvelles routes à opérer | +| ALT-3 | Brancher OpenEvolve dans le pipeline live ou dans la gestion active des prompts | Boucle plus courte | Inacceptable sur le plan des frontières de confiance et du risque opérationnel | + +## Decision + +Adopter **ALT-2**. + +Concrètement : + +- créer un sous-système backend dédié `services/evolution/` avec ses routes `api/routes/evolution.py` et sa task Celery `tasks/evolution_task.py`, +- exécuter OpenEvolve dans une **queue Celery séparée** (`evolution`) avec limites de concurrence dédiées, +- utiliser le benchmark existant comme **évaluateur de prompts d'agents** via un adapter "shadow prompt" qui injecte un prompt candidat sans modifier `prompt_templates`, +- préparer un second adapter d'évaluation pour la **stratégie/backtest** utilisant `validation_scoring.py` et le Backtest Engine, +- stocker les campagnes/candidats/évaluations dans des tables dédiées ; les prompts actifs restent dans `prompt_templates`, +- imposer une **promotion manuelle en deux temps** : créer une nouvelle version de prompt depuis un candidat, puis activation explicite. + +Schéma logique minimal retenu : + +- `evolution_campaigns` : campagne, baseline, modèle, budget, statut, configuration d'évaluation, +- `evolution_candidates` : candidats générés, contenu de prompt, génération, parent, scores agrégés, provenance, +- `evolution_candidate_evaluations` : scores détaillés par run/fenêtre/split, liens vers `benchmark_runs` ou `backtest_runs`, +- `evolution_promotions` : trace de promotion vers `prompt_templates`. + +Extension recommandée pour la traçabilité de coût : ajouter à `llm_call_logs` des références facultatives `evolution_campaign_id`, `evolution_candidate_id` et `phase` (`mutation`/`evaluation`). + +## Trade-offs & Consequences + +### Positive Outcomes + +- Frontière claire entre expérimentation et production. +- Réutilisation disciplinée du benchmark et du backtest au lieu de les dupliquer. +- Promotion humaine et auditée des prompts. +- Architecture extensible vers templates/paramètres sans rework majeur. +- Lecture produit simple : page dédiée, API dédiée, états dédiés. + +### Negative Outcomes + +- Nouveau domaine backend à maintenir. +- Besoin de migrations SQLAlchemy/Alembic supplémentaires. +- Surcoût de stockage si les artefacts/candidats sont conservés longtemps. +- Complexité d'observabilité plus élevée qu'un simple écran frontend. + +### Unresolved Questions + +- Quels agents sont autorisés dans le lot initial : les 9 agents, ou un sous-ensemble priorisé ? +- Faut-il autoriser la promotion directe avec activation, ou forcer "create version" puis activation séparée ? +- Quel budget par campagne est acceptable par défaut en solo-dev (`$`, appels LLM, durée) ? +- Quel jeu de fixtures benchmark doit devenir le baseline officiel pour l'évolution des prompts ? + +## Implementation Plan + +1. **Lot C — Fondations backend Evolution Lab** (5 à 7 j) + - Tables `evolution_*`, schémas Pydantic, routes CRUD/list/detail/cancel. + - Queue Celery `evolution`, service `OpenEvolveCampaignService`, adapter évaluateur benchmark. + - Budgets durs : `budget_usd_limit`, `max_iterations`, `max_candidates`, `max_llm_calls`, timeout. +2. **Lot D — Frontend Evolution Lab MVP** (3 à 4 j) + - Nouvelle route lazy-loadée `/evolution-lab`. + - Formulaire lancement campagne, liste des campagnes, détail sélectionné, courbe fitness, leaderboard candidats. +3. **Lot E — Promotion & gouvernance** (2 à 3 j) + - Endpoint de promotion depuis candidat vers nouvelle `prompt_template`. + - Confirmation explicite d'activation, journalisation et garde-fous de rôle. +4. **Lot F — Évaluateur stratégie/backtest et optimisation multi-objectif avancée** (5 à 8 j) + - Adapter Backtest Engine, scoring OOS, comparaison baseline stratégie. + +## Verification Criteria + +- Une campagne d'évolution n'écrit jamais dans `prompt_templates` tant qu'aucune promotion explicite n'est faite. +- Aucune route ni task d'évolution n'appelle `ExecutionService` ni MetaAPI. +- L'annulation d'une campagne stoppe la task Celery et marque la campagne `CANCELLED`. +- Le détail campagne expose baseline, meilleur candidat, coût cumulé et progression par génération. +- La promotion crée une nouvelle version de prompt traçable vers `campaign_id` et `candidate_id`. +- Les workers `benchmark` et `evolution` peuvent être configurés séparément en concurrence. + +## Confidence Rating + +**0.86 / 1.00** — forte confiance sur la séparation des responsabilités et la réutilisation de l'existant ; confiance moyenne sur le calibrage initial des budgets et du scoring fitness, qui devra être validé empiriquement. + +## Lessons Learned (Retrospective) + +- Le benchmark récemment livré réduit fortement le coût d'entrée pour un lab d'évolution, mais il ne doit pas devenir un "god service". +- La vraie difficulté n'est pas OpenEvolve ; c'est la gouvernance des promotions et la maîtrise du coût. +- La présence de prompts versionnés en base permet une promotion propre si l'on garde les candidats hors de `prompt_templates` jusqu'au dernier moment. + +## Examples & Usage (Optional) + +- **Flux recommandé** : créer campagne `technical-analyst` → exécuter générations offline → comparer au baseline benchmark → promouvoir un candidat en nouvelle version de prompt → activer explicitement après revue. +- **Flux interdit** : campagne d'évolution qui active automatiquement un prompt ou qui branche un candidat sur le pipeline live sans validation humaine. + +## References + +- `docs/architecture.md` +- `docs/decision-pipeline.md` +- `docs/api-contracts-backend.md` +- `backend/app/services/benchmark/engine.py` +- `backend/app/services/benchmark/runs_service.py` +- `backend/app/api/routes/benchmark.py` +- `backend/app/api/routes/prompts.py` +- `backend/app/db/models/prompt_template.py` +- `backend/app/services/strategy/validation_scoring.py` From e28209a5de96a67ccd4217399e377a6d5c3485a7 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 17:18:08 +0200 Subject: [PATCH 03/16] docs(change-spec): add spec for GH-29 --- .../chg-GH-29-spec.md | 594 ++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 .samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-spec.md diff --git a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-spec.md b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-spec.md new file mode 100644 index 0000000..1f59c19 --- /dev/null +++ b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-spec.md @@ -0,0 +1,594 @@ +--- +change: + ref: GH-29 + type: refactor + status: Proposed + slug: agent-skills-versioning + title: "Versionnage des skills d'agents dans une table dédiée (pattern prompt_templates)" + owners: [engineering] + service: agent-skills + labels: [type:chore, priority:high, change] + version_impact: minor + audience: internal + security_impact: low + risk_level: medium + dependencies: + internal: [model-selector, agent-pipeline, connectors-api, frontend-connectors] + external: [] +--- + +# CHANGE SPECIFICATION + +> **PURPOSE** : Migrer le stockage des skills des agents de trading depuis un champ JSON non versionné (`connector_configs.settings.agent_skills`) vers une table dédiée `agent_skills` dotée d'un versioning complet, afin de permettre le suivi des mutations, le rollback, et d'établir le socle technique requis par l'Evolution Lab (GH-28). + +--- + +## 1. SUMMARY + +Les skills des 12 agents de trading sont actuellement stockées dans un champ JSON non structuré (`connector_configs.settings`), sans historique ni versioning. Ce changement crée une table dédiée `agent_skills` reprenant exactement le même patron de versioning que `prompt_templates`, y compris une migration Alembic des données existantes, l'adaptation de tous les sites d'accès en lecture, une API REST dédiée (CRUD + activation) et l'adaptation du frontend `ConnectorsPage`. Le mécanisme de bootstrap existant (`skill_bootstrap.py`) est supprimé et remplacé par un seed de démarrage dans la nouvelle table. + +--- + +## 2. CONTEXTE + +### 2.1 État actuel + +- Les skills des agents de trading sont définies dans des fichiers `SKILL.md` sous `backend/config/skills//SKILL.md`. +- Au démarrage, `skill_bootstrap.py` lit ces fichiers et les pousse dans `connector_configs.settings.agent_skills` (champ JSON libre) pour chaque connecteur. +- La lecture des skills se fait principalement via `AgentModelSelector.resolve_skills()` dans `model_selector.py`, qui interroge `connector_configs.settings`. +- Il existe 12 agents couverts par le JSON existant : les 9 du pipeline principal plus `strategy-designer`, `schedule-planner-agent` et `order-guardian`. +- Le module `AgentScope toolkit.py` dispose d'un fallback de lecture directe sur le fichier `SKILL.md` si le champ JSON est vide. +- Les prompts des agents bénéficient d'un système de versioning complet via la table `prompt_templates` (version, `is_active`, `created_by_id`, historique). +- Aucun mécanisme équivalent n'existe pour les skills. + +### 2.2 Points de douleur / Lacunes + +- **Absence de versioning** : toute modification d'une skill écrase la version précédente sans traçabilité. +- **Pas d'historique** : il est impossible de savoir quelle skill était active à un instant T, ni de la restaurer. +- **Asymétrie architecturale** : les prompts sont versionnés et auditables ; les skills ne le sont pas. Cela génère une incohérence de modèle cognitive pour les développeurs. +- **Bloqueur GH-28** : l'Evolution Lab a besoin de suivre les mutations de skills et de les rollback ; l'absence de versioning rend cette fonctionnalité impossible. +- **Couplage fort au connecteur** : les skills sont stockées dans `connector_configs.settings`, un champ qui concerne la configuration LLM, pas les capacités comportementales des agents. +- **Bootstrap fragile** : `skill_bootstrap.py` utilise des variables d'environnement et un mécanisme ad-hoc difficile à maintenir et à tester. + +--- + +## 3. ÉNONCÉ DU PROBLÈME + +Parce que les skills des agents de trading sont stockées dans un champ JSON sans versioning dans la table de configuration des connecteurs LLM, il est impossible pour l'équipe engineering de tracer, d'auditer ou de rollback une modification de skill — ce qui bloque le développement de l'Evolution Lab (GH-28) et crée une incohérence structurelle avec le système de versioning des prompts, déjà opérationnel. + +--- + +## 4. OBJECTIFS + +- **G-1** : Créer une table dédiée `agent_skills` avec versioning complet (version, activation, horodatages, auteur) selon le même patron que `prompt_templates`. +- **G-2** : Migrer les données existantes depuis `connector_configs.settings.agent_skills` vers la nouvelle table via une migration Alembic data migration. +- **G-3** : Adapter tous les sites de lecture des skills dans le backend pour utiliser la nouvelle table comme source canonique. +- **G-4** : Fournir une API REST dédiée permettant la gestion complète des skills par agent (liste, détail, nouvelle version, activation). +- **G-5** : Adapter le frontend `ConnectorsPage` pour interagir avec la nouvelle API plutôt qu'avec le champ JSON du connecteur. +- **G-6** : Supprimer le mécanisme de bootstrap existant (`skill_bootstrap.py`, variables d'environnement associées) et le remplacer par un seed de démarrage dans la nouvelle table. +- **G-7** : Préserver le fallback sur les fichiers `SKILL.md` si la table est vide pour un agent donné (continuité de service). +- **G-8** : Garantir la backward compatibility de `GET /api/v1/connectors` en servant les skills depuis la nouvelle table. + +### 4.1 Métriques de succès / KPIs + +| Métrique | Cible | +|----------|-------| +| Agents couverts par la table `agent_skills` | 12 / 12 (les 9 du pipeline + 3 additionnels) | +| Sites d'accès aux skills adaptés | 100 % (aucun accès direct à `connector_configs.settings.agent_skills` en production) | +| Régression du pipeline d'analyse | 0 régression (tous les tests existants passent) | +| Couverture de tests du nouveau service | ≥ 80 % du service `AgentSkillsService` | +| `skill_bootstrap.py` supprimé | Oui | +| Rollback d'une version de skill opérationnel | Oui (via activation d'une version antérieure) | + +### 4.2 Non-Goals + +- **NG-1** : Modification du contenu des skills (les textes des skills restent inchangés dans cette livraison). +- **NG-2** : Création d'une page UI dédiée à la gestion des versions de skills (reporté à GH-28, Evolution Lab). +- **NG-3** : Versioning au niveau d'une skill individuelle (la granularité retenue est le jeu complet de skills par agent). +- **NG-4** : Support multi-tenants ou multi-organisations pour les skills. +- **NG-5** : Intégration à un système de feature flags externe. + +--- + +## 5. CAPACITÉS FONCTIONNELLES + +| ID | Capacité | Justification | +|----|----------|---------------| +| F-1 | Stockage versionné des skills par agent dans une table dédiée | Permet le suivi historique, l'audit et le rollback — prérequis GH-28 | +| F-2 | Migration automatique des données existantes lors du déploiement | Garantit la continuité sans perte de données et sans intervention manuelle | +| F-3 | Lecture des skills depuis la nouvelle table par le sélecteur de modèles | Source canonique unique pour tous les agents consommateurs | +| F-4 | API REST de gestion des skills (CRUD + activation) | Permet aux opérateurs de gérer les versions via l'interface ou des scripts | +| F-5 | Adaptation du frontend pour l'affichage et l'édition via la nouvelle API | Cohérence de l'expérience utilisateur dans `ConnectorsPage` | +| F-6 | Seed de démarrage remplaçant le bootstrap existant | Mécanisme de démarrage fiable, testable et cohérent avec les prompts | +| F-7 | Fallback fichier `SKILL.md` si table vide pour un agent | Résilience : le pipeline ne tombe pas si la table est vide pour un agent donné | +| F-8 | Backward compatibility sur `GET /api/v1/connectors` | Aucune régression côté client existant sans migration de l'appelant | + +### 5.1 Détail des capacités + +**F-1 — Stockage versionné** +La table `agent_skills` stocke, pour chaque agent, des versions numérotées séquentiellement du jeu de skills (tableau de chaînes). Une seule version est active à la fois par agent (`is_active = true`). Chaque version est immuable une fois créée : pour modifier les skills d'un agent, on crée une nouvelle version. + +**F-2 — Migration des données existantes** +La migration Alembic en charge de la création de la table effectue simultanément un transfert des valeurs JSON présentes dans `connector_configs.settings.agent_skills` vers des entrées de version 1 dans la nouvelle table, marquées `is_active = true`. Si aucune valeur n'est présente pour un agent, aucune ligne n'est créée (le fallback F-7 prend le relais). + +**F-3 — Lecture des skills** +`AgentModelSelector.resolve_skills(agent_name)` interroge la table `agent_skills` pour retourner la version active de l'agent. En l'absence d'entrée active, le fallback sur le fichier `SKILL.md` est déclenché. Le cache interne `_settings_cache` est adapté pour ne plus contenir de skills (séparation des responsabilités). + +**F-4 — API REST** +Quatre endpoints dédiés permettent : lister toutes les versions pour un agent, récupérer le détail d'une version, créer une nouvelle version (avec optionnellement activation immédiate), et activer une version existante. La création d'une version n'est pas destructive : toutes les versions antérieures restent accessibles. + +**F-5 — Adaptation frontend** +`ConnectorsPage` utilise la nouvelle API pour afficher la skill active et permettre la mise à jour (création d'une nouvelle version). La liste déroulante des versions disponibles est présentée si plusieurs versions existent. L'écriture dans `connector_configs.settings.agent_skills` via `PUT /connectors` est désactivée côté frontend. + +**F-6 — Seed de démarrage** +Au premier démarrage (table vide), un mécanisme de seed charge les contenus des fichiers `SKILL.md` de référence et crée la version 1 de chaque agent dans la table. Ce mécanisme est idempotent : si des entrées existent déjà, il ne crée rien. + +**F-7 — Fallback fichier SKILL.md** +Si `resolve_skills()` ne trouve aucune version active dans la table pour un agent donné, le contenu du fichier `SKILL.md` correspondant est retourné. Ce comportement est identique à l'actuel `toolkit.py`. Un avertissement de log est émis pour signaler le fallback. + +**F-8 — Backward compatibility GET /connectors** +La réponse du endpoint `GET /api/v1/connectors` continue d'inclure le champ `agent_skills` dans la section `settings` de chaque connecteur, en lisant désormais la valeur depuis la version active dans la table `agent_skills` (et non depuis `connector_configs.settings`). + +--- + +## 6. FLUX UTILISATEURS & SYSTÈME + +### Flux 1 : Lecture des skills par le pipeline d'analyse + +``` +Pipeline d'analyse + → AgentScope registry.py / governance/registry.py / strategy/designer.py + → AgentModelSelector.resolve_skills(agent_name) + → [Requête DB] agent_skills WHERE agent_name=X AND is_active=true + → [Si non trouvé] Lecture fichier SKILL.md (fallback) + → Retourne la liste de skills actives pour l'agent +``` + +### Flux 2 : Création d'une nouvelle version de skill (via API) + +``` +Opérateur / Frontend ConnectorsPage + → POST /api/v1/agents/{name}/skills + Body: { skills: [...], notes: "...", activate: true } + → AgentSkillsService.create_version(agent_name, skills, notes, user_id, activate) + → [Si activate=true] Désactive la version précédente (is_active=false) + → Insère nouvelle ligne dans agent_skills (version N+1, is_active=activate) + → Retourne la version créée (201 Created) +``` + +### Flux 3 : Activation d'une version existante + +``` +Opérateur / Frontend + → POST /api/v1/agents/{name}/skills/{version}/activate + → AgentSkillsService.activate_version(agent_name, version) + → Désactive toutes les versions actives pour cet agent + → Active la version demandée + → Retourne 200 OK avec version activée +``` + +### Flux 4 : Démarrage du système (seed) + +``` +Application startup (main.py) + → AgentSkillsSeedService.seed_if_empty() + → Pour chaque agent : agent_skills WHERE agent_name=X → COUNT + → [Si COUNT = 0] Lit SKILL.md → Insère version 1 (is_active=true, created_by=SYSTEM) + → [Si COUNT > 0] No-op +``` + +### Flux 5 : GET /connectors (backward compatibility) + +``` +Frontend (appel existant) + → GET /api/v1/connectors + → ConnectorsService.list_connectors() + → Pour chaque connecteur : AgentModelSelector.resolve_skills(agent_name) + → [Lecture table agent_skills, version active] + → Injecte agent_skills dans settings.agent_skills de la réponse + → Retourne la réponse enrichie (format inchangé) +``` + +--- + +## 7. PÉRIMÈTRE & LIMITES + +### 7.1 In Scope + +- Création de la table `agent_skills` (DDL Alembic). +- Migration des données existantes depuis `connector_configs.settings.agent_skills` (data migration dans le même script Alembic). +- Nouveau service `AgentSkillsService` (requêtes CRUD + logique d'activation). +- Nouveau service `AgentSkillsSeedService` (seed idempotent au démarrage). +- Adaptation de `AgentModelSelector.resolve_skills()` pour lire depuis la nouvelle table. +- Adaptation du cache `_settings_cache` pour ne plus inclure les skills. +- Adaptation de tous les sites d'appel identifiés dans le backend (agentscope, governance, strategy, prompts). +- Adaptation du fallback `toolkit.py` pour appeler `resolve_skills()` plutôt que lire directement le fichier. +- Nouveaux endpoints API REST dédiés aux skills (`/api/v1/agents/{name}/skills`). +- Adaptation de `GET /api/v1/connectors` pour servir les skills depuis la nouvelle table. +- Désactivation de l'écriture des skills via `PUT /api/v1/connectors` (retour 400 ou ignoré silencieusement avec un log d'avertissement). +- Adaptation de `ConnectorsPage.tsx` pour afficher et éditer les skills via la nouvelle API. +- Suppression de `skill_bootstrap.py` et des variables d'environnement associées dans `config.py`. +- Suppression de l'appel au bootstrap dans `main.py`. +- Mise à jour des tests existants impactés. +- Nouveaux tests unitaires pour `AgentSkillsService`. +- Couverture des 12 agents : `technical-analyst`, `news-analyst`, `market-context-analyst`, `bullish-researcher`, `bearish-researcher`, `trader-agent`, `risk-manager`, `execution-manager`, `governance-trader`, `strategy-designer`, `schedule-planner-agent`, `order-guardian`. + +### 7.2 Out of Scope + +- [OUT] Modification du contenu textuel des skills (les textes restent inchangés). +- [OUT] Nouvelle page UI dédiée à la gestion des versions de skills (reporté GH-28). +- [OUT] Versioning au niveau d'une skill individuelle (la granularité est le jeu complet par agent). +- [OUT] Intégration de l'historique des skills dans les dashboards d'observabilité. +- [OUT] API d'export/import de skills entre environnements. +- [OUT] Suppression des fichiers `SKILL.md` de référence (conservés comme source du seed et fallback). +- [OUT] Modification du moteur de risque (`backend/app/risk/`). + +### 7.3 Différé / Peut-être plus tard + +- Interface UI dédiée pour naviguer dans l'historique des versions et comparer deux versions de skills (GH-28, Evolution Lab). +- Notation/évaluation des versions de skills (liée à GH-28). +- Diff visuel entre deux versions de skills. +- Webhooks ou événements lors d'un changement de version active. +- Promotion de skills entre environnements (dev → staging → prod). + +--- + +## 8. INTERFACES & CONTRATS D'INTÉGRATION + +### 8.1 REST / HTTP Endpoints + +| Méthode | Path | Description | Réponse | +|---------|------|-------------|---------| +| `GET` | `/api/v1/agents/{name}/skills` | Liste toutes les versions de skills pour un agent | `200` liste de versions | +| `GET` | `/api/v1/agents/{name}/skills/{version}` | Détail d'une version spécifique | `200` version détaillée | +| `POST` | `/api/v1/agents/{name}/skills` | Crée une nouvelle version (optionnellement active) | `201` version créée | +| `POST` | `/api/v1/agents/{name}/skills/{version}/activate` | Active une version existante | `200` version activée | + +**Schéma de réponse (version) :** + +``` +{ + id: integer, + agent_name: string, // identifiant de l'agent (ex. "technical-analyst") + version: integer, // numéro de version séquentiel par agent + is_active: boolean, + skills: string[], // tableau des skills de l'agent + notes: string | null, // note libre de l'auteur + created_by_id: integer | null, + created_at: datetime (ISO 8601), + updated_at: datetime (ISO 8601) +} +``` + +**Évolution de `GET /api/v1/connectors` :** +Le champ `settings.agent_skills` continue d'être retourné dans chaque connecteur, sa valeur étant désormais issue de la version active de la table `agent_skills` (lecture via `AgentModelSelector.resolve_skills()`). Le format du champ est inchangé. + +**Comportement de `PUT /api/v1/connectors/{id}` :** +Si le corps de la requête inclut `settings.agent_skills`, ce champ est ignoré (avec un log d'avertissement) et une erreur `400 Bad Request` est retournée avec le message : `"agent_skills must be managed via /api/v1/agents/{name}/skills"`. + +### 8.2 Événements / Messages + +N/A — aucun événement ou message asynchrone n'est introduit par ce changement. + +### 8.3 Impact sur le modèle de données + +| ID | Élément | Description | +|----|---------|-------------| +| DM-1 | Table `agent_skills` (nouvelle) | Table dédiée au versioning des skills par agent | +| DM-2 | `agent_skills.id` | Clé primaire entière auto-incrémentée | +| DM-3 | `agent_skills.agent_name` | Identifiant de l'agent (varchar, ex. `"technical-analyst"`) | +| DM-4 | `agent_skills.version` | Numéro de version séquentiel par agent (integer) | +| DM-5 | `agent_skills.is_active` | Indique si cette version est la version active (boolean, défaut false) | +| DM-6 | `agent_skills.skills` | Tableau JSON de chaînes représentant les skills (JSONB) | +| DM-7 | `agent_skills.notes` | Note optionnelle de l'auteur de la version (text nullable) | +| DM-8 | `agent_skills.created_by_id` | Référence à l'utilisateur créateur (FK vers `users.id`, nullable) | +| DM-9 | `agent_skills.created_at` | Horodatage de création (timestamp with timezone) | +| DM-10 | `agent_skills.updated_at` | Horodatage de dernière modification (timestamp with timezone) | +| DM-11 | Contrainte unicité | `UNIQUE(agent_name, version)` — une seule version N par agent | +| DM-12 | Index | `INDEX(agent_name, is_active)` — optimise `resolve_skills()` | +| DM-13 | `connector_configs.settings.agent_skills` | Champ dépréciée : les données existantes sont migrées et le champ n'est plus alimenté | + +**Pattern de versioning (référence `prompt_templates`) :** +Une seule ligne par agent peut avoir `is_active = true`. Lors de l'activation d'une version N, toutes les lignes avec `agent_name = X AND is_active = true` sont passées à `false`, puis la ligne de la version N est passée à `true`. Ce pattern est atomique (transaction). + +### 8.4 Intégrations externes + +N/A — ce changement est purement interne. Aucune API externe n'est introduite. + +### 8.5 Backward Compatibility + +| Aspect | Impact | Détail | +|--------|--------|--------| +| `GET /api/v1/connectors` | Aucune rupture | Le champ `settings.agent_skills` est toujours présent, alimenté depuis la nouvelle table | +| `PUT /api/v1/connectors` (écriture `agent_skills`) | Rupture intentionnelle | Retourne `400` si `settings.agent_skills` est présent dans le corps — changement signalé et documenté | +| Lecture interne `resolve_skills()` | Transparent | Même interface de méthode, source de données changée | +| Fichiers `SKILL.md` | Inchangés | Conservés comme source du seed et fallback de résilience | +| Variables d'environnement bootstrap | Supprimées | `SKILL_BOOTSTRAP_*` supprimées de `config.py` — à retirer des fichiers `.env` de déploiement | + +--- + +## 9. EXIGENCES NON FONCTIONNELLES (NFRs) + +| ID | Exigence | Seuil | +|----|----------|-------| +| NFR-1 | Latence de `resolve_skills()` | P95 ≤ 10 ms (requête indexée sur `agent_name, is_active`) | +| NFR-2 | Idempotence du seed | Zéro doublons créés si le seed est appelé plusieurs fois | +| NFR-3 | Atomicité de l'activation | L'opération d'activation est exécutée dans une transaction SQL unique — aucun état intermédiaire incohérent | +| NFR-4 | Régression pipeline | 0 test existant en échec après migration | +| NFR-5 | Couverture du nouveau service | ≥ 80 % des branches du service `AgentSkillsService` couvertes par des tests unitaires | +| NFR-6 | Compatibilité Alembic | La migration est réversible (`downgrade`) sans perte de données (re-écriture dans `connector_configs.settings`) | +| NFR-7 | Taille des skills | Les skills d'un agent ne dépassent pas 50 entrées et 64 KB au total (validé à la création) | + +--- + +## 10. TÉLÉMÉTRIE & OBSERVABILITÉ + +| Élément | Type | Description | +|---------|------|-------------| +| `agent_skills.fallback_triggered` | Log WARNING | Émis quand le fallback `SKILL.md` est déclenché pour un agent (indicateur de table vide) | +| `agent_skills.version_activated` | Log INFO | Émis à chaque activation de version (agent_name, version, user_id) | +| `agent_skills.seed_executed` | Log INFO | Émis lors de l'exécution du seed (agents créés, agents ignorés car déjà présents) | +| `agent_skills.resolve_duration_ms` | Métrique Prometheus | Histogramme de la durée de `resolve_skills()` par agent (labels: `agent_name`, `source=db|fallback`) | + +--- + +## 11. RISQUES & MITIGATIONS + +| ID | Risque | Impact | Probabilité | Mitigation | Risque résiduel | +|----|--------|--------|-------------|------------|-----------------| +| RSK-1 | Régression du pipeline d'analyse due à un changement de comportement de `resolve_skills()` | H | M | Tests de non-régression sur tous les sites d'appel identifiés avant merge ; tests d'intégration du pipeline | Faible si tests passent | +| RSK-2 | Données existantes incomplètes ou malformées dans `connector_configs.settings.agent_skills` | M | M | Script de migration avec validation des données ; fallback SKILL.md en cas d'absence | Négligeable | +| RSK-3 | Cache `_settings_cache` de `model_selector.py` retournant des skills périmées après migration | M | H | Invalidation du cache lors de l'activation d'une nouvelle version ; adaptation de la logique de cache dans cette livraison | Faible | +| RSK-4 | Coexistence temporaire entre l'ancien champ JSON et la nouvelle table pendant le déploiement | L | M | Déploiement atomique avec migration Alembic incluse ; aucune demi-migration possible | Négligeable | +| RSK-5 | Frontend envoyant encore `agent_skills` via `PUT /connectors` après mise à jour | M | L | Le backend retourne `400` avec un message explicite ; adaptation du frontend dans ce même ticket | Faible | +| RSK-6 | Suppression de `skill_bootstrap.py` crée un démarrage cassé si le seed échoue | H | L | Seed avec gestion d'erreur robuste ; log d'erreur critique ; fallback SKILL.md toujours présent | Moyen — à monitorer en déploiement | + +--- + +## 12. HYPOTHÈSES + +- La table `prompt_templates` est une référence directe pour le patron de versioning à adopter (même colonnes, même logique d'activation). +- Les 12 agents identifiés couvrent l'ensemble des agents actifs dans le système au moment de la livraison. +- Les données dans `connector_configs.settings.agent_skills` sont des tableaux de chaînes valides (ou absentes), sans valeurs corrompues. +- Le système peut être redéployé avec une migration Alembic atomique sans fenêtre de maintenance. +- L'utilisateur `SYSTEM` (ou un utilisateur technique dédié) est disponible pour `created_by_id` lors du seed. +- Les fichiers `SKILL.md` de référence dans `backend/config/skills/` restent présents et valides après la suppression de `skill_bootstrap.py`. + +--- + +## 13. DÉPENDANCES + +| Direction | Élément | Notes | +|-----------|---------|-------| +| Bloque | GH-28 (Evolution Lab) | GH-29 est le prérequis de GH-28 — la table versionnée est le socle de l'Evolution Lab | +| Dépend de | `prompt_templates` (pattern) | Le patron de versioning est recopié depuis la table et le service existants | +| Dépend de | PostgreSQL + Alembic | Migration DDL + data dans une transaction Alembic standard | +| Dépend de | `connector_configs` (source) | Les données à migrer sont dans `connector_configs.settings` | +| Dépend de | Fichiers `SKILL.md` | Utilisés comme source du seed et fallback | + +--- + +## 14. QUESTIONS OUVERTES + +| ID | Question | Contexte | Statut | +|----|----------|---------|--------| +| OQ-1 | Que faire si la migration Alembic détecte un agent dans `connector_configs.settings` qui ne figure pas dans la liste des 12 agents connus ? | Agents créés dynamiquement ou agents legacy non référencés | À trancher — migrer quand même (recommandé) ou ignorer ? | +| OQ-2 | Le retour `400` sur `PUT /connectors` avec `agent_skills` est-il acceptable pour les intégrations externes connues ? | Backward compat intentionnellement rompue | Confirmer qu'aucun script externe n'écrit dans ce champ | + +--- + +## 15. JOURNAL DES DÉCISIONS + +| ID | Décision | Justification | Date | +|----|----------|---------------|------| +| DEC-1 | Granularité : jeu complet de skills par agent, pas skill individuelle | Cohérence avec `prompt_templates` (le prompt est un bloc, pas une phrase) ; simplicité de la migration | 2026-06-21 | +| DEC-2 | Migration Alembic data migration dans le même script que la DDL | Déploiement atomique : créer la table et migrer les données en une seule transaction évite tout état intermédiaire incohérent | 2026-06-21 | +| DEC-3 | Supprimer `skill_bootstrap.py` et le remplacer par un seed idempotent au démarrage | Alignement avec le mécanisme des prompts ; suppression de la dette technique et du couplage aux variables d'environnement | 2026-06-21 | +| DEC-4 | `GET /connectors` continue de servir `agent_skills` (source = nouvelle table) | Backward compatibility stricte côté client existant — aucune migration du frontend requise pour la lecture | 2026-06-21 | +| DEC-5 | Frontend adapté dans ce même ticket (pas un ticket séparé) | La `ConnectorsPage` écrit actuellement dans `connector_configs.settings.agent_skills` — sans adaptation, la fonctionnalité serait cassée dès le merge backend | 2026-06-21 | +| DEC-6 | Couvrir les 12 agents (9 pipeline + 3 additionnels) | La migration doit être exhaustive pour éviter un état partiel post-déploiement | 2026-06-21 | +| DEC-7 | `PUT /connectors` avec `agent_skills` retourne `400` | Rupture explicite préférable à une ignorance silencieuse — facilite le débogage et force la migration des appelants | 2026-06-21 | + +--- + +## 16. COMPOSANTS AFFECTÉS (HAUT NIVEAU) + +| Composant | Impact | +|-----------|--------| +| Table `agent_skills` | Nouveau | +| Migration Alembic (DDL + data) | Nouveau | +| `AgentSkillsService` | Nouveau | +| `AgentSkillsSeedService` | Nouveau | +| API routes `/api/v1/agents/{name}/skills` | Nouveau | +| `AgentModelSelector.resolve_skills()` | Modifié (source de données) | +| `AgentModelSelector._settings_cache` | Modifié (séparation skills/config) | +| `AgentScope registry.py` | Modifié (4 sites d'appel) | +| `AgentScope toolkit.py` | Modifié (fallback via `resolve_skills()`) | +| `Governance registry.py` | Modifié (2 sites d'appel) | +| `Strategy designer.py` | Modifié (1 site d'appel) | +| `Prompts registry.py` | Modifié (1 site d'appel) | +| `Connectors API routes` | Modifié (backward compat, rejet écriture) | +| `main.py` (startup) | Modifié (suppression bootstrap, ajout seed) | +| `config.py` | Modifié (suppression vars d'env bootstrap) | +| `skill_bootstrap.py` | Supprimé | +| `ConnectorsPage.tsx` | Modifié (lecture + écriture via nouvelle API) | +| `test_agent_model_selector.py` | Modifié | +| `test_skill_bootstrap.py` | Supprimé ou converti | +| `test_connectors_settings_sanitization.py` | Modifié | +| `test_prompt_registry.py` | Modifié (si couplage `resolve_skills`) | +| `test_no_french_in_production.py` | Modifié (si couverture bootstrap) | +| `test_agentscope_registry.py` | Modifié | +| Nouveaux tests `test_agent_skills_service.py` | Nouveau | + +--- + +## 17. CRITÈRES D'ACCEPTATION + +| ID | Critère | Lié à | +|----|---------|-------| +| AC-DM1-1 | **Étant donné** un déploiement sur une base vierge, **quand** la migration Alembic s'exécute, **alors** la table `agent_skills` existe avec toutes les colonnes définies en DM-1 à DM-12. | DM-1 | +| AC-DM1-2 | **Étant donné** une base avec des données dans `connector_configs.settings.agent_skills`, **quand** la migration s'exécute, **alors** chaque entrée est créée dans `agent_skills` en version 1 avec `is_active = true`. | DM-1, F-2 | +| AC-F3-1 | **Étant donné** un agent avec une version active dans `agent_skills`, **quand** `resolve_skills(agent_name)` est appelé, **alors** le tableau de skills de la version active est retourné en ≤ 10 ms (P95). | F-3, NFR-1 | +| AC-F7-1 | **Étant donné** un agent sans aucune entrée dans `agent_skills`, **quand** `resolve_skills(agent_name)` est appelé, **alors** le contenu du fichier `SKILL.md` correspondant est retourné et un avertissement est loggué. | F-7 | +| AC-F4-1 | **Étant donné** un agent existant, **quand** `POST /api/v1/agents/{name}/skills` est appelé avec un tableau de skills valide, **alors** une nouvelle version est créée (201 Created) et, si `activate=true`, devient la version active. | F-4 | +| AC-F4-2 | **Étant donné** un agent avec plusieurs versions, **quand** `POST /api/v1/agents/{name}/skills/{version}/activate` est appelé, **alors** seule cette version a `is_active=true` et la version précédemment active passe à `false`. | F-4, DEC-1 | +| AC-F4-3 | **Étant donné** une requête `GET /api/v1/agents/{name}/skills`, **quand** l'agent existe, **alors** toutes ses versions sont retournées avec leur numéro de version, statut `is_active`, et horodatages. | F-4 | +| AC-F5-1 | **Étant donné** la page `ConnectorsPage`, **quand** un utilisateur modifie les skills d'un agent, **alors** l'appel part vers `POST /api/v1/agents/{name}/skills` et non vers `PUT /api/v1/connectors/{id}`. | F-5 | +| AC-F8-1 | **Étant donné** un client appelant `GET /api/v1/connectors`, **quand** la requête est reçue, **alors** chaque connecteur retourne `settings.agent_skills` avec les skills de la version active issue de la table `agent_skills`. | F-8 | +| AC-F8-2 | **Étant donné** un `PUT /api/v1/connectors/{id}` avec `settings.agent_skills` dans le corps, **quand** la requête est reçue, **alors** le serveur retourne `400 Bad Request` avec un message explicite. | F-8, DEC-7 | +| AC-F6-1 | **Étant donné** un démarrage sur une base avec `agent_skills` vide, **quand** l'application démarre, **alors** les 12 agents ont chacun une version 1 dans la table, issue de leurs fichiers `SKILL.md`. | F-6 | +| AC-F6-2 | **Étant donné** un démarrage avec `agent_skills` déjà peuplée, **quand** l'application démarre, **alors** aucune nouvelle ligne n'est créée (idempotence). | F-6, NFR-2 | +| AC-NFR4-1 | **Étant donné** la suite de tests existante, **quand** toutes les migrations et adaptations sont appliquées, **alors** 0 test en échec supplémentaire. | NFR-4 | +| AC-NFR5-1 | **Étant donné** le nouveau service `AgentSkillsService`, **quand** les tests unitaires sont exécutés, **alors** ≥ 80 % des branches sont couvertes. | NFR-5 | +| AC-NFR6-1 | **Étant donné** une migration appliquée, **quand** `alembic downgrade -1` est exécuté, **alors** la table est supprimée et les données sont ré-écrites dans `connector_configs.settings.agent_skills`. | NFR-6 | + +--- + +## 18. DÉPLOIEMENT & GESTION DU CHANGEMENT (HAUT NIVEAU) + +**Stratégie de merge** : feature branch → `main` avec PR après validation de la CI. + +**Ordre de livraison recommandé** : +1. Migration Alembic (DDL + data) — exécutée au déploiement. +2. Seed idempotent au démarrage (remplace bootstrap). +3. Nouveau service + API REST — déployé avec le backend. +4. Adaptation des sites de lecture (`resolve_skills()`, registries). +5. Adaptation de `ConnectorsPage.tsx` — déployée avec le frontend. +6. Suppression de `skill_bootstrap.py` et des variables d'environnement associées. + +**Communication** : +- Les fichiers `.env` de déploiement doivent supprimer les variables `SKILL_BOOTSTRAP_*`. +- La rupture sur `PUT /connectors` avec `agent_skills` est documentée dans la description de PR et la release note. + +--- + +## 19. MIGRATION DE DONNÉES / SEEDING + +La migration de données est incluse dans la migration Alembic. Lors du `upgrade` : +1. La table `agent_skills` est créée. +2. Pour chaque connecteur dans `connector_configs`, si `settings.agent_skills` est non nul et non vide, une ligne est insérée dans `agent_skills` (version 1, `is_active=true`, `notes='Migrated from connector_configs'`). + +Lors du `downgrade` : +1. Pour chaque agent dans `agent_skills` avec `is_active=true`, la valeur est ré-écrite dans `connector_configs.settings.agent_skills` du connecteur correspondant. +2. La table `agent_skills` est supprimée. + +Le seed de démarrage (distinct de la migration Alembic) opère uniquement si la table est vide pour un agent donné, en lisant les fichiers `SKILL.md` de référence. + +--- + +## 20. REVUE CONFIDENTIALITÉ / CONFORMITÉ + +N/A — les skills sont des descriptions comportementales des agents, sans donnée personnelle. Aucune exigence RGPD applicable à ce changement. + +--- + +## 21. POINTS DE SÉCURITÉ + +| Aspect | Niveau | Note | +|--------|--------|------| +| Injection via champ `skills` | Faible | Les skills sont du texte libre envoyé aux LLMs — une validation de la taille et du type est recommandée (NFR-7) | +| Accès à l'API de gestion des skills | Faible | Les endpoints doivent être soumis à la même authentification que les autres endpoints `/api/v1/` | +| Suppression de `skill_bootstrap.py` | Neutre | Réduit la surface d'attaque liée aux variables d'environnement bootstrap | + +--- + +## 22. IMPACT MAINTENANCE & OPÉRATIONS + +- **Suppression de la dette technique** : `skill_bootstrap.py` et ses variables d'environnement associées sont retirés, réduisant la complexité opérationnelle du démarrage. +- **Cohérence** : les skills et les prompts suivent désormais le même patron — la courbe d'apprentissage pour les nouveaux développeurs est réduite. +- **Rollback opérationnel** : un opérateur peut restaurer une version précédente de skills via l'API sans redéploiement. +- **Observabilité** : le fallback sur `SKILL.md` est désormais loggué, permettant de détecter une table incohérente. + +--- + +## 23. GLOSSAIRE + +| Terme | Définition | +|-------|------------| +| Agent de trading | Un des 12 agents du pipeline Kairos Mesh ayant un rôle fonctionnel spécifique dans le pipeline d'analyse ou de gouvernance | +| Skill | Capacité comportementale déclarative d'un agent de trading, exprimée sous forme de chaîne de texte (ex. `"Analyse les patterns de chandeliers japonais"`) | +| Jeu de skills | L'ensemble des skills d'un agent à un instant donné, stocké comme un tableau de chaînes | +| Version active | La version du jeu de skills effectivement utilisée par le pipeline à un instant donné (`is_active = true`) | +| `prompt_templates` | Table existante de versioning des prompts système des agents — référence architecturale pour ce changement | +| `skill_bootstrap.py` | Module existant de chargement initial des skills depuis les fichiers `SKILL.md` vers `connector_configs.settings` — supprimé dans ce changement | +| Seed | Mécanisme idempotent de peuplement initial de la table `agent_skills` depuis les fichiers `SKILL.md` de référence, exécuté au démarrage | +| Fallback | Comportement de résilience : si la table `agent_skills` ne contient pas d'entrée active pour un agent, le fichier `SKILL.md` correspondant est lu directement | +| Evolution Lab | Feature GH-28 — système d'expérimentation et d'évolution des agents, prérequis de ce changement | + +--- + +## 24. ANNEXES + +### Annexe A — Schéma DDL de la table `agent_skills` (référence) + +```sql +CREATE TABLE agent_skills ( + id SERIAL PRIMARY KEY, + agent_name VARCHAR(100) NOT NULL, + version INTEGER NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT FALSE, + skills JSONB NOT NULL DEFAULT '[]', + notes TEXT, + created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_agent_skills_agent_version UNIQUE (agent_name, version) +); + +CREATE INDEX idx_agent_skills_active ON agent_skills (agent_name, is_active); +``` + +### Annexe B — Liste des 12 agents couverts + +| Identifiant | Rôle | Pipeline | +|-------------|------|---------| +| `technical-analyst` | Analyse technique | Principal | +| `news-analyst` | Analyse sentiment | Principal | +| `market-context-analyst` | Contexte macro | Principal | +| `bullish-researcher` | Argumentation haussière | Principal | +| `bearish-researcher` | Argumentation baissière | Principal | +| `trader-agent` | Décision de trading | Principal | +| `risk-manager` | Validation du risque | Principal | +| `execution-manager` | Exécution des ordres | Principal | +| `governance-trader` | Supervision / gouvernance | Principal | +| `strategy-designer` | Conception de stratégie | Additionnel | +| `schedule-planner-agent` | Planification | Additionnel | +| `order-guardian` | Garde-fou des ordres | Additionnel | + +### Annexe C — Correspondance avec le patron `prompt_templates` + +| Élément | `prompt_templates` | `agent_skills` (ce changement) | +|---------|--------------------|-------------------------------| +| Granularité | Prompt système complet | Jeu de skills complet | +| Version | Séquentielle par agent | Séquentielle par agent | +| Activation | `is_active = true` | `is_active = true` | +| Auteur | `created_by_id` | `created_by_id` | +| Seed | Seed au démarrage | Seed au démarrage (nouveau) | +| Fallback | Valeur par défaut hardcodée | Fichier `SKILL.md` | + +--- + +## 25. HISTORIQUE DU DOCUMENT + +| Version | Date | Auteur | Modifications | +|---------|------|--------|---------------| +| 1.0 | 2026-06-21 | @spec-writer | Spécification initiale — GH-29 | + +--- + +## DIRECTIVES D'AUTEUR + +Ce document a été rédigé à partir du résumé de planification fourni par `@pm` à l'issue de la phase `clarify_scope` du ticket GH-29. Les décisions structurantes (granularité, migration, bootstrap, backward compat) ont été capturées dans `chg-GH-29-pm-notes.yaml` et sont reflétées fidèlement dans les sections DEC-* et DM-* de ce document. Les informations manquantes sont capturées en OQ-*. Aucun détail d'implémentation (chemins de fichiers, code) n'a été inclus dans ce document conformément aux règles du rôle `@spec-writer`. + +## LISTE DE VALIDATION + +- [x] `change.ref` correspond au `workItemRef` fourni (GH-29) +- [x] `owners` contient au moins une entrée +- [x] `status` est "Proposed" +- [x] Toutes les sections présentes dans l'ordre (1 à 25 + directives + validation) +- [x] Préfixes d'ID cohérents et uniques (F-, AC-, NFR-, RSK-, DEC-, DM-, OQ-) +- [x] Les critères d'acceptation référencent au moins un ID F-/NFR-/DM- et utilisent Given/When/Then +- [x] Les NFRs incluent des valeurs mesurables +- [x] Les risques incluent Impact & Probabilité +- [x] Aucun détail d'implémentation (pas de chemins de fichiers, pas de tâches pas-à-pas) +- [x] Front matter valide selon les règles `front_matter_rules` From 3c2d655765629a31b388e1fd0ef80782dc049120 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 17:20:29 +0200 Subject: [PATCH 04/16] docs(GH-29): add test plan and implementation plan for agent skills versioning --- .../chg-GH-29-plan.md | 226 ++++++++++++++++++ .../chg-GH-29-pm-notes.yaml | 35 +++ .../chg-GH-29-test-plan.md | 221 +++++++++++++++++ 3 files changed, 482 insertions(+) create mode 100644 .samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md create mode 100644 .samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml create mode 100644 .samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-test-plan.md diff --git a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md new file mode 100644 index 0000000..c67ccdd --- /dev/null +++ b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md @@ -0,0 +1,226 @@ +--- +change: + ref: GH-29 + type: implementation-plan + status: Proposed +--- + +# PLAN D'IMPLÉMENTATION — GH-29 : Versionnage des skills d'agents + +## Contraintes + +- Max 2h par tâche +- Max 3 fichiers modifiés par commit +- Commande test : `cd backend && pytest -q` +- Commande build frontend : `cd frontend && npm run build` + +--- + +## Phase 1 — Modèle et schéma DB (effort : ~1h30) + +- [ ] **1.1** Créer le modèle SQLAlchemy `AgentSkill` + - Fichier : `backend/app/db/models/agent_skill.py` + - Colonnes : id, agent_name, version, is_active, skills (JSON), notes, created_by_id, created_at, updated_at + - Contrainte unique : (agent_name, version) + - Index : agent_name + is_active + - Effort : 30 min + +- [ ] **1.2** Enregistrer le modèle dans `__init__.py` + - Fichier : `backend/app/db/models/__init__.py` + - Effort : 10 min + +- [ ] **1.3** Créer les schemas Pydantic + - Fichier : `backend/app/schemas/agent_skill.py` + - Schemas : `AgentSkillCreateRequest`, `AgentSkillOut`, `AgentSkillListOut` + - Validation : max 12 skills, max 500 chars par skill + - Effort : 30 min + +--- + +## Phase 2 — Service CRUD + versioning (effort : ~2h) + +- [ ] **2.1** Créer le service `AgentSkillsService` + - Fichier : `backend/app/services/skills/__init__.py` + - Fichier : `backend/app/services/skills/service.py` + - Méthodes : `get_active()`, `list_versions()`, `create_version()`, `activate()`, `seed_defaults()` + - Pattern identique à `PromptTemplateService` + - Effort : 1h30 + +- [ ] **2.2** Écrire les tests unitaires du service + - Fichier : `backend/tests/unit/test_agent_skills_service.py` + - Couvre : T-SVC-01 à T-SVC-10 + - Effort : 1h + +--- + +## Phase 3 — Migration Alembic + data migration (effort : ~1h30) + +- [ ] **3.1** Créer la migration Alembic + - Fichier : `backend/alembic/versions/0014_agent_skills_table.py` + - Opérations : CREATE TABLE + data migration depuis connector_configs.settings.agent_skills + - Pour chaque agent dans le JSON : INSERT version=1, is_active=True + - Effort : 1h + +- [ ] **3.2** Tester la migration (up et down) + - Vérifier : 12 rows créées, données correctes + - Effort : 30 min + +--- + +## Phase 4 — API REST (effort : ~1h30) + +- [ ] **4.1** Créer les routes REST + - Fichier : `backend/app/api/routes/agent_skills.py` + - Endpoints : + - `GET /api/v1/agents/{agent_name}/skills` — liste versions (filtre active_only) + - `POST /api/v1/agents/{agent_name}/skills` — nouvelle version (Admin) + - `POST /api/v1/agents/{agent_name}/skills/{skill_id}/activate` — activer (Admin) + - `GET /api/v1/agents/catalog` — liste agents avec info skills + - Effort : 1h + +- [ ] **4.2** Enregistrer les routes dans le router + - Fichier : `backend/app/api/router.py` + - Effort : 10 min + +- [ ] **4.3** Écrire les tests API + - Fichier : `backend/tests/unit/test_agent_skills_api.py` + - Couvre : T-API-01 à T-API-08 + - Effort : 1h + +--- + +## Phase 5 — Adaptation de `resolve_skills()` (effort : ~1h30) + +- [ ] **5.1** Modifier `AgentModelSelector.resolve_skills()` + - Fichier : `backend/app/services/llm/model_selector.py` + - Changement : lire depuis table `agent_skills` au lieu de `settings.agent_skills` + - Conserver le fallback SKILL.md si table vide pour l'agent + - Adapter le cache (TTL séparé ou invalidation) + - Effort : 1h + +- [ ] **5.2** Adapter les tests de `model_selector` + - Fichier : `backend/tests/unit/test_agent_model_selector.py` + - Mocker la nouvelle table au lieu de `settings.agent_skills` + - Effort : 30 min + +--- + +## Phase 6 — Seed au startup (effort : ~1h) + +- [ ] **6.1** Modifier `main.py` : remplacer bootstrap par seed + - Fichier : `backend/app/main.py` + - Supprimer l'appel à `bootstrap_agent_skills_into_settings()` + - Ajouter `AgentSkillsService.seed_defaults(db)` au startup + - Le seed lit les fichiers SKILL.md et crée version 1 si la table est vide pour l'agent + - Effort : 30 min + +- [ ] **6.2** Écrire les tests de seed + - Fichier : `backend/tests/unit/test_agent_skills_seed.py` + - Couvre : T-SEED-01, T-SEED-02 + - Effort : 30 min + +--- + +## Phase 7 — Backward compatibility connectors (effort : ~45 min) + +- [ ] **7.1** Adapter GET /connectors pour servir skills depuis nouvelle table + - Fichier : `backend/app/api/routes/connectors.py` + - Dans le GET : lire les skills actives depuis `agent_skills` et les injecter dans `settings.agent_skills` de la réponse + - Retirer la normalisation `_normalize_agent_skills()` et le bootstrap dans cette route + - Effort : 30 min + +- [ ] **7.2** Adapter les tests connectors + - Fichier : `backend/tests/unit/test_connectors_settings_sanitization.py` + - Retirer les assertions sur `agent_skills` dans settings comme source de vérité + - Effort : 15 min + +--- + +## Phase 8 — Suppression du bootstrap (effort : ~45 min) + +- [ ] **8.1** Supprimer `skill_bootstrap.py` + - Fichier à supprimer : `backend/app/services/llm/skill_bootstrap.py` + - Effort : 5 min + +- [ ] **8.2** Supprimer les variables d'env bootstrap + - Fichiers : `backend/app/core/config.py`, `backend/.env`, `backend/.env.example`, `.env.prod.example` + - Retirer : `AGENT_SKILLS_BOOTSTRAP_FILE`, `AGENT_SKILLS_BOOTSTRAP_MODE`, `AGENT_SKILLS_BOOTSTRAP_APPLY_ONCE` + - Effort : 15 min + +- [ ] **8.3** Supprimer/adapter les tests du bootstrap + - Fichier à supprimer : `backend/tests/unit/test_skill_bootstrap.py` + - Effort : 5 min + +- [ ] **8.4** Nettoyer les imports et références + - Fichiers : tout import de `skill_bootstrap` dans le codebase + - Effort : 15 min + +--- + +## Phase 9 — Adaptation frontend (effort : ~2h) + +- [ ] **9.1** Ajouter le service API skills dans le frontend + - Fichier : `frontend/src/services/api.ts` (ou nouveau fichier dédié) + - Fonctions : `getAgentSkills()`, `createSkillVersion()`, `activateSkill()` + - Effort : 30 min + +- [ ] **9.2** Adapter ConnectorsPage pour utiliser la nouvelle API + - Fichier : `frontend/src/pages/ConnectorsPage.tsx` + - Remplacer la lecture de `settings.agent_skills` par appel à la nouvelle API + - Remplacer l'écriture via PUT connector par POST nouvelle version + - Effort : 1h30 + +- [ ] **9.3** Vérifier le build frontend + - Commande : `cd frontend && npm run build` + - Effort : 10 min + +--- + +## Phase 10 — Tests de non-régression et cleanup (effort : ~1h) + +- [ ] **10.1** Exécuter la suite de tests complète backend + - Commande : `cd backend && pytest -q` + - Corriger les échecs résiduels + - Effort : 30 min + +- [ ] **10.2** Vérifier le lint `test_no_french_in_production.py` + - Adapter si `agent-skills.json` est conservé ou supprimé + - Effort : 15 min + +- [ ] **10.3** Cleanup final + - Supprimer `agent_skills_bootstrap_meta` du JSON connector si plus utilisé + - Vérifier qu'aucun import cassé ne subsiste + - Effort : 15 min + +--- + +## Résumé des phases + +| Phase | Description | Effort estimé | +|-------|-------------|---------------| +| 1 | Modèle et schéma DB | 1h30 | +| 2 | Service CRUD + versioning | 2h | +| 3 | Migration Alembic + data | 1h30 | +| 4 | API REST | 1h30 | +| 5 | Adaptation resolve_skills() | 1h30 | +| 6 | Seed au startup | 1h | +| 7 | Backward compat connectors | 45 min | +| 8 | Suppression bootstrap | 45 min | +| 9 | Adaptation frontend | 2h | +| 10 | Non-régression et cleanup | 1h | +| **TOTAL** | | **~14h (2-3 jours)** | + +--- + +## Ordre des commits recommandé + +1. Phase 1 (modèle + schemas) +2. Phase 2 (service + tests) +3. Phase 3 (migration Alembic) +4. Phase 4 (API + tests) +5. Phase 5 (adaptation model_selector + tests) +6. Phase 6 (seed startup + tests) +7. Phase 7 (backward compat connectors) +8. Phase 8 (suppression bootstrap) +9. Phase 9 (frontend) +10. Phase 10 (non-régression + cleanup) diff --git a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml new file mode 100644 index 0000000..f0ec29e --- /dev/null +++ b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml @@ -0,0 +1,35 @@ +change_id: GH-29 +title: "Refactoring : versionner les skills des agents (table dédiée comme prompt_templates)" +phases: + clarify_scope: { started: "2026-06-21T14:10:00Z", completed: "2026-06-21T14:25:00Z" } + specification: { started: null, completed: null } + test_planning: { started: null, completed: null } + delivery_planning: { started: null, completed: null } + delivery: { started: null, completed: null } + system_spec_update: { started: null, completed: null } + review_fix: { started: null, completed: null } + quality_gates: { started: null, completed: null } + dod_check: { started: null, completed: null } + pr_creation: { started: null, completed: null, url: null } +decisions: + - text: "Granularité : un JEU de skills par agent versionnée (pas skill individuelle). Même pattern que prompt_templates." + date: "2026-06-21" + - text: "Migration : Alembic data migration dans le même script (créer table + migrer données depuis connector_configs.settings)." + date: "2026-06-21" + - text: "Bootstrap : supprimer skill_bootstrap.py et le mécanisme env vars. Remplacer par seed dans la table au premier startup (comme prompt seed)." + date: "2026-06-21" + - text: "Backward compat : le backend continue de servir agent_skills dans GET /connectors pour le frontend existant (lecture seule, source = nouvelle table). Le PUT connector ignore agent_skills (redirige vers la nouvelle table ou erreur)." + date: "2026-06-21" + - text: "Frontend adapté dans ce même ticket : ConnectorsPage utilise la nouvelle API /api/v1/agents/{name}/skills au lieu du PUT connector." + date: "2026-06-21" + - text: "Couvrir les 12 agents du JSON existant (pas seulement les 8 du pipeline principal). Inclut strategy-designer, schedule-planner-agent, order-guardian." + date: "2026-06-21" +open_questions: [] +blockers: [] +notes: + - text: "Pré-requis de GH-28 (Evolution Lab). Skills actuellement dans connector_configs.settings JSON sans versioning." + type: info + date: "2026-06-21" + - text: "Points d'accès identifiés : model_selector.py (resolve_skills), skill_bootstrap.py, toolkit.py, registry.py (4 sites), governance/registry.py, strategy/designer.py, ConnectorsPage.tsx (lecture+écriture). 6 fichiers de tests impactés." + type: info + date: "2026-06-21" diff --git a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-test-plan.md b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-test-plan.md new file mode 100644 index 0000000..8ab9025 --- /dev/null +++ b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-test-plan.md @@ -0,0 +1,221 @@ +--- +change: + ref: GH-29 + type: test-plan + status: Proposed +--- + +# TEST PLAN — GH-29 : Versionnage des skills d'agents + +## 1. Stratégie de test + +### Approche +- **Tests unitaires** : couvrir le nouveau service `AgentSkillsService`, le modèle, les schemas, et l'adaptation de `resolve_skills()` +- **Tests d'intégration API** : valider les endpoints REST (CRUD + activation) +- **Tests de non-régression** : s'assurer que le pipeline d'analyse existant fonctionne sans modification +- **Tests de migration** : valider la migration Alembic (création table + data migration) + +### Commande d'exécution +```bash +cd backend && pytest -q +``` + +### Couverture cible +- ≥ 80 % du nouveau service `AgentSkillsService` +- 100 % des endpoints REST +- 100 % des critères d'acceptation couverts + +--- + +## 2. Matrice de couverture AC → Tests + +| AC | Description | Tests | +|----|-------------|-------| +| AC-1 | Table créée avec bonnes colonnes et contraintes | T-MIG-01, T-MOD-01 | +| AC-2 | Migration crée table + migre données | T-MIG-01, T-MIG-02, T-MIG-03 | +| AC-3 | `resolve_skills()` lit depuis nouvelle table | T-SVC-01, T-SVC-02, T-SVC-03 | +| AC-4 | API REST fonctionne | T-API-01 à T-API-08 | +| AC-5 | Frontend build OK | T-FE-01 | +| AC-6 | Seed fonctionne | T-SEED-01, T-SEED-02 | +| AC-7 | Backward compat GET /connectors | T-BWC-01 | +| AC-8 | Tests unitaires service | T-SVC-* | +| AC-9 | Pipeline sans régression | T-REG-01, T-REG-02, T-REG-03 | +| AC-10 | Fallback SKILL.md | T-SVC-04 | + +--- + +## 3. Cas de test détaillés + +### 3.1 Modèle et migration (T-MIG-*) + +#### T-MIG-01 — Table `agent_skills` existe avec les bonnes colonnes +- **Given** : migration Alembic exécutée +- **When** : inspecter la table `agent_skills` +- **Then** : colonnes `id`, `agent_name`, `version`, `is_active`, `skills` (JSON), `notes`, `created_by_id`, `created_at`, `updated_at` présentes +- **Then** : contrainte unique `(agent_name, version)` active + +#### T-MIG-02 — Data migration : skills existantes transférées +- **Given** : `connector_configs.settings.agent_skills` contient des données pour 12 agents +- **When** : migration exécutée +- **Then** : 12 rows dans `agent_skills`, chacune version=1, is_active=True +- **Then** : le contenu `skills` (JSON array) correspond aux données source + +#### T-MIG-03 — Data migration idempotente +- **Given** : migration déjà exécutée +- **When** : re-exécution tentée +- **Then** : pas de duplication, pas d'erreur + +### 3.2 Service AgentSkillsService (T-SVC-*) + +#### T-SVC-01 — get_active retourne la version active +- **Given** : table contient versions 1 (inactive) et 2 (active) pour `technical-analyst` +- **When** : `get_active(db, "technical-analyst")` +- **Then** : retourne version 2 avec is_active=True + +#### T-SVC-02 — get_active retourne None si aucune version active +- **Given** : table vide pour `unknown-agent` +- **When** : `get_active(db, "unknown-agent")` +- **Then** : retourne None + +#### T-SVC-03 — resolve_skills utilise la nouvelle table +- **Given** : `agent_skills` contient skills pour `trader-agent` version 1 active +- **When** : `AgentModelSelector.resolve_skills(db, "trader-agent")` +- **Then** : retourne les skills de la table (pas du connector JSON) + +#### T-SVC-04 — Fallback SKILL.md si table vide +- **Given** : aucune row dans `agent_skills` pour `news-analyst` +- **Given** : fichier `backend/config/skills/news-analyst/SKILL.md` existe +- **When** : `resolve_skills(db, "news-analyst")` +- **Then** : retourne les skills parsées depuis le fichier SKILL.md + +#### T-SVC-05 — create_version incrémente le numéro +- **Given** : version max pour `trader-agent` est 3 +- **When** : `create_version(db, "trader-agent", skills=[...], notes="test")` +- **Then** : nouvelle row version=4, is_active=False + +#### T-SVC-06 — activate désactive les autres versions +- **Given** : `trader-agent` a versions 1 (active), 2, 3 +- **When** : `activate(db, skill_id_v3)` +- **Then** : version 1 is_active=False, version 3 is_active=True + +#### T-SVC-07 — activate avec id inexistant → erreur +- **When** : `activate(db, 99999)` +- **Then** : raise NotFoundError + +#### T-SVC-08 — Validation skills format +- **Given** : skills = "pas un array" +- **When** : `create_version(db, agent, skills)` +- **Then** : raise ValidationError + +#### T-SVC-09 — Validation max skills par agent (12) +- **Given** : skills = [13 éléments] +- **When** : `create_version(db, agent, skills)` +- **Then** : raise ValidationError + +#### T-SVC-10 — Validation max length par skill (500 chars) +- **Given** : skills = ["a" * 501] +- **When** : `create_version(db, agent, skills)` +- **Then** : raise ValidationError ou troncature documentée + +### 3.3 API REST (T-API-*) + +#### T-API-01 — GET /api/v1/agents/{name}/skills → liste versions +- **Given** : 3 versions pour `technical-analyst` +- **When** : `GET /api/v1/agents/technical-analyst/skills` +- **Then** : 200, array de 3 objets triés par version desc + +#### T-API-02 — GET /api/v1/agents/{name}/skills?active_only=true +- **When** : `GET /api/v1/agents/technical-analyst/skills?active_only=true` +- **Then** : 200, array avec uniquement la version active + +#### T-API-03 — POST /api/v1/agents/{name}/skills → créer version +- **Given** : user authentifié admin +- **When** : `POST /api/v1/agents/trader-agent/skills` body={"skills": [...], "notes": "v2"} +- **Then** : 201, nouvelle version créée, is_active=False + +#### T-API-04 — POST /api/v1/agents/{name}/skills/{id}/activate +- **Given** : version 2 existe pour `trader-agent` +- **When** : `POST /api/v1/agents/trader-agent/skills/2/activate` +- **Then** : 200, version 2 active, anciennes désactivées + +#### T-API-05 — POST skills → erreur 403 si non-admin +- **Given** : user avec rôle ANALYST +- **When** : `POST /api/v1/agents/trader-agent/skills` +- **Then** : 403 Forbidden + +#### T-API-06 — GET skills agent inexistant → 200 array vide +- **When** : `GET /api/v1/agents/nonexistent/skills` +- **Then** : 200, array vide (pas 404) + +#### T-API-07 — POST activate id inexistant → 404 +- **When** : `POST /api/v1/agents/trader-agent/skills/99999/activate` +- **Then** : 404 + +#### T-API-08 — GET /api/v1/agents/catalog → liste tous les agents avec info skills +- **When** : `GET /api/v1/agents/catalog` +- **Then** : 200, array des 12 agents avec version active, skills count + +### 3.4 Backward compatibility (T-BWC-*) + +#### T-BWC-01 — GET /connectors inclut agent_skills depuis nouvelle table +- **Given** : skills dans table `agent_skills` pour 12 agents +- **When** : `GET /api/v1/connectors` +- **Then** : response.settings.agent_skills contient les skills (source = nouvelle table) + +### 3.5 Seed (T-SEED-*) + +#### T-SEED-01 — Seed au startup crée les skills si table vide +- **Given** : table `agent_skills` vide +- **When** : startup application +- **Then** : 12 rows créées (une par agent), version=1, is_active=True +- **Then** : contenu = données des fichiers SKILL.md + +#### T-SEED-02 — Seed idempotent +- **Given** : table déjà peuplée (12 rows) +- **When** : startup application +- **Then** : aucune modification, aucune duplication + +### 3.6 Non-régression (T-REG-*) + +#### T-REG-01 — Pipeline analyse complet fonctionne +- **Given** : skills migrées dans la nouvelle table +- **When** : exécuter un cycle d'analyse single-agent (via benchmark) +- **Then** : l'agent reçoit ses skills correctement, output valide + +#### T-REG-02 — Tests existants passent sans modification fonctionnelle +- **When** : `cd backend && pytest -q` +- **Then** : tous les tests passent (certains adaptés mais pas de changement de comportement) + +#### T-REG-03 — Frontend build OK +- **When** : `cd frontend && npm run build` +- **Then** : build réussi sans erreur + +### 3.7 Frontend (T-FE-*) + +#### T-FE-01 — ConnectorsPage affiche skills depuis nouvelle API +- **Given** : skills dans table `agent_skills` +- **When** : ouvrir ConnectorsPage, sélectionner un agent +- **Then** : skills affichées correspondent à la version active de la nouvelle table + +--- + +## 4. Tests existants à adapter + +| Fichier | Action | +|---------|--------| +| `test_agent_model_selector.py` | Adapter pour mocker la nouvelle table au lieu de `settings.agent_skills` | +| `test_skill_bootstrap.py` | **Supprimer** — mécanisme n'existe plus | +| `test_connectors_settings_sanitization.py` | Retirer les assertions sur `agent_skills` dans settings | +| `test_prompt_registry.py` | Pas de changement (reçoit toujours une liste de skills) | +| `test_no_french_in_production.py` | Adapter si `agent-skills.json` est supprimé | + +--- + +## 5. Nouveaux fichiers de test à créer + +| Fichier | Contenu | +|---------|---------| +| `backend/tests/unit/test_agent_skills_service.py` | T-SVC-01 à T-SVC-10 | +| `backend/tests/unit/test_agent_skills_api.py` | T-API-01 à T-API-08, T-BWC-01 | +| `backend/tests/unit/test_agent_skills_seed.py` | T-SEED-01, T-SEED-02 | +| `backend/tests/unit/test_agent_skills_migration.py` | T-MIG-01 à T-MIG-03 (optionnel si couvert par intégration) | From 5969d77c28a29af9836e6d77788e13bbc5ebbe66 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 17:31:27 +0200 Subject: [PATCH 05/16] refactor(GH-29): phase 1 add AgentSkill model and schemas Add the AgentSkill SQLAlchemy model and corresponding Pydantic schemas for creating, listing and returning agent skill versions. This is phase 1 of GH-29: introduces the DB model, registration in models package, and input/output validation. Verified by running static type checks and ensuring no secrets or large binaries are included. Files: backend/app/db/models/agent_skill.py, backend/app/db/models/__init__.py, backend/app/schemas/agent_skill.py --- .opencode/opencode.jsonc | 10 ++-- .../chg-GH-29-plan.md | 6 +-- .../chg-GH-29-pm-notes.yaml | 8 +-- backend/app/db/models/__init__.py | 2 + backend/app/db/models/agent_skill.py | 29 ++++++++++ backend/app/schemas/agent_skill.py | 53 ++++++++++++++++++ .../bench-1-summary-20260514T120204Z.json | 44 +++++++++++++++ ...cal-analyst-attempt1-20260514T120204Z.json | 54 +++++++++++++++++++ 8 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 backend/app/db/models/agent_skill.py create mode 100644 backend/app/schemas/agent_skill.py create mode 100644 backend/debug-benchmark/bench-1-summary-20260514T120204Z.json create mode 100644 backend/debug-benchmark/bench-1-technical-analyst-attempt1-20260514T120204Z.json diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index fdd86b5..28bf0d2 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -9,7 +9,7 @@ // Fallback model used by any agent without an explicit "model" in the "agent" section. // Format: "provider/model-id" - "model": "github-copilot/gpt-5.2", + "model": "github-copilot/gpt-5.4", // Lightweight model for non-critical utility tasks: // session title generation, short summaries, metadata. @@ -429,7 +429,7 @@ // @review-feedback-applier: applies review feedback after human validation. "review-feedback-applier": { - "model": "github-copilot/gpt-5.2-codex", + "model": "github-copilot/gpt-5.4-codex", "steps": 30 // To enable GitHub MCP (read PR comments): // "tools": { "github*": true } @@ -468,14 +468,14 @@ // @test-plan-writer: writes the test plan (chg-xxx-test-plan.md). "test-plan-writer": { - "model": "github-copilot/gpt-5.2", + "model": "github-copilot/gpt-5.4", "steps": 25, "tools": { "socraticode*": true } // find existing test patterns and coverage }, // @plan-writer: writes the implementation plan (chg-xxx-plan.md). "plan-writer": { - "model": "github-copilot/gpt-5.2", + "model": "github-copilot/gpt-5.4", "steps": 50, "tools": { "socraticode*": true } // understand codebase structure before planning }, @@ -542,7 +542,7 @@ // @designer: UI/UX design, aligned with the design system. "designer": { - "model": "github-copilot/gpt-4o", // GPT-4o: vision + design + "model": "github-copilot/gpt-5", // GPT-4o: vision + design "steps": 20, "tools": { "puppeteer*": true } // enables Puppeteer MCP for UI screenshots }, diff --git a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md index c67ccdd..2cfafaf 100644 --- a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md +++ b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md @@ -18,18 +18,18 @@ change: ## Phase 1 — Modèle et schéma DB (effort : ~1h30) -- [ ] **1.1** Créer le modèle SQLAlchemy `AgentSkill` +- [x] **1.1** Créer le modèle SQLAlchemy `AgentSkill` (ajout `backend/app/db/models/agent_skill.py`, contraintes+index implémentés) - Fichier : `backend/app/db/models/agent_skill.py` - Colonnes : id, agent_name, version, is_active, skills (JSON), notes, created_by_id, created_at, updated_at - Contrainte unique : (agent_name, version) - Index : agent_name + is_active - Effort : 30 min -- [ ] **1.2** Enregistrer le modèle dans `__init__.py` +- [x] **1.2** Enregistrer le modèle dans `__init__.py` (import + `__all__` mis à jour) - Fichier : `backend/app/db/models/__init__.py` - Effort : 10 min -- [ ] **1.3** Créer les schemas Pydantic +- [x] **1.3** Créer les schemas Pydantic (ajout `backend/app/schemas/agent_skill.py`, validations max 12/max 500) - Fichier : `backend/app/schemas/agent_skill.py` - Schemas : `AgentSkillCreateRequest`, `AgentSkillOut`, `AgentSkillListOut` - Validation : max 12 skills, max 500 chars par skill diff --git a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml index f0ec29e..55053d8 100644 --- a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml +++ b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml @@ -2,10 +2,10 @@ change_id: GH-29 title: "Refactoring : versionner les skills des agents (table dédiée comme prompt_templates)" phases: clarify_scope: { started: "2026-06-21T14:10:00Z", completed: "2026-06-21T14:25:00Z" } - specification: { started: null, completed: null } - test_planning: { started: null, completed: null } - delivery_planning: { started: null, completed: null } - delivery: { started: null, completed: null } + specification: { started: "2026-06-21T14:25:00Z", completed: "2026-06-21T14:35:00Z" } + test_planning: { started: "2026-06-21T14:25:00Z", completed: "2026-06-21T14:40:00Z" } + delivery_planning: { started: "2026-06-21T14:25:00Z", completed: "2026-06-21T14:40:00Z" } + delivery: { started: "2026-06-21T14:40:00Z", completed: null } system_spec_update: { started: null, completed: null } review_fix: { started: null, completed: null } quality_gates: { started: null, completed: null } diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index 8c812ed..0f92949 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -2,6 +2,7 @@ from app.db.models.agent_step import AgentStep from app.db.models.agent_runtime_message import AgentRuntimeMessage from app.db.models.agent_runtime_session import AgentRuntimeSession +from app.db.models.agent_skill import AgentSkill from app.db.models.audit_log import AuditLog from app.db.models.backtest_run import BacktestRun from app.db.models.backtest_trade import BacktestTrade @@ -29,6 +30,7 @@ 'AgentRuntimeEvent', 'AgentRuntimeMessage', 'AgentRuntimeSession', + 'AgentSkill', 'ExecutionOrder', 'AuditLog', 'PromptTemplate', diff --git a/backend/app/db/models/agent_skill.py b/backend/app/db/models/agent_skill.py new file mode 100644 index 0000000..1ca3c0a --- /dev/null +++ b/backend/app/db/models/agent_skill.py @@ -0,0 +1,29 @@ +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AgentSkill(Base): + __tablename__ = 'agent_skills' + __table_args__ = ( + UniqueConstraint('agent_name', 'version', name='uq_agent_skills_agent_version'), + Index('ix_agent_skills_agent_name_is_active', 'agent_name', 'is_active'), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + agent_name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + version: Mapped[int] = mapped_column(Integer, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + skills: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by_id: Mapped[int | None] = mapped_column(ForeignKey('users.id'), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) diff --git a/backend/app/schemas/agent_skill.py b/backend/app/schemas/agent_skill.py new file mode 100644 index 0000000..91d5647 --- /dev/null +++ b/backend/app/schemas/agent_skill.py @@ -0,0 +1,53 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, field_validator + + +MAX_AGENT_SKILLS_PER_AGENT = 12 +MAX_AGENT_SKILL_LENGTH = 500 + + +class AgentSkillCreateRequest(BaseModel): + skills: list[str] = Field(min_length=1, max_length=MAX_AGENT_SKILLS_PER_AGENT) + notes: str | None = None + activate: bool = False + + @field_validator('skills') + @classmethod + def validate_skills(cls, value: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for item in value: + cleaned = str(item or '').strip() + if not cleaned: + continue + if len(cleaned) > MAX_AGENT_SKILL_LENGTH: + raise ValueError(f'each skill must be <= {MAX_AGENT_SKILL_LENGTH} chars') + key = cleaned.lower() + if key in seen: + continue + seen.add(key) + normalized.append(cleaned) + if len(normalized) > MAX_AGENT_SKILLS_PER_AGENT: + raise ValueError(f'max {MAX_AGENT_SKILLS_PER_AGENT} skills allowed') + if not normalized: + raise ValueError('at least one non-empty skill is required') + return normalized + + +class AgentSkillOut(BaseModel): + id: int + agent_name: str + version: int + is_active: bool + skills: list[str] + notes: str | None + created_by_id: int | None + created_at: datetime + updated_at: datetime + + model_config = {'from_attributes': True} + + +class AgentSkillListOut(BaseModel): + items: list[AgentSkillOut] diff --git a/backend/debug-benchmark/bench-1-summary-20260514T120204Z.json b/backend/debug-benchmark/bench-1-summary-20260514T120204Z.json new file mode 100644 index 0000000..9ff1fe4 --- /dev/null +++ b/backend/debug-benchmark/bench-1-summary-20260514T120204Z.json @@ -0,0 +1,44 @@ +{ + "run_id": 1, + "fixture_id": 1, + "scenario_type": "single-agent", + "model_spec": { + "provider": "ollama", + "model_name": "deepseek-v3.2", + "parameters": { + "temperature": 0.0 + } + }, + "attempts": [ + { + "agent_name": "technical-analyst", + "attempt_number": 1, + "scores": { + "schema_validity": 1.0, + "completeness": 1.0, + "tool_policy": 1.0, + "reference_consistency": 1.0, + "stability": null, + "overall": 0.8 + }, + "raw_output_keys": [ + "contradictions", + "degraded", + "key_levels", + "local_momentum", + "patterns_found", + "setup_quality", + "structural_bias", + "summary", + "symbol", + "timeframe", + "tradability" + ], + "analysis_run_id": 1, + "llm_calls_count": 0 + } + ], + "status": "COMPLETED", + "error": null, + "timestamp": "20260514T120204Z" +} \ No newline at end of file diff --git a/backend/debug-benchmark/bench-1-technical-analyst-attempt1-20260514T120204Z.json b/backend/debug-benchmark/bench-1-technical-analyst-attempt1-20260514T120204Z.json new file mode 100644 index 0000000..9d2348a --- /dev/null +++ b/backend/debug-benchmark/bench-1-technical-analyst-attempt1-20260514T120204Z.json @@ -0,0 +1,54 @@ +{ + "run_id": 1, + "agent_name": "technical-analyst", + "attempt_number": 1, + "timestamp": "20260514T120204Z", + "msg_type": "Msg", + "msg_role": "assistant", + "msg_name": "technical-analyst", + "metadata": { + "symbol": "EURUSD.PRO", + "timeframe": "H1", + "structural_bias": "bullish", + "local_momentum": "bullish", + "setup_quality": "high", + "key_levels": [ + "1.0800", + "1.0850" + ], + "patterns_found": [ + "higher highs", + "bull flag" + ], + "contradictions": [ + "RSI slightly overbought" + ], + "summary": "Trend remains bullish with pullback opportunities near support.", + "tradability": "high", + "degraded": false + }, + "content_type": "str", + "content": "{\"symbol\": \"EURUSD.PRO\", \"timeframe\": \"H1\", \"structural_bias\": \"bullish\", \"local_momentum\": \"bullish\", \"setup_quality\": \"high\", \"key_levels\": [\"1.0800\", \"1.0850\"], \"patterns_found\": [\"higher highs\", \"bull flag\"], \"contradictions\": [\"RSI slightly overbought\"], \"summary\": \"Trend remains bullish with pullback opportunities near support.\", \"tradability\": \"high\", \"degraded\": false}", + "text_content": "{\"symbol\": \"EURUSD.PRO\", \"timeframe\": \"H1\", \"structural_bias\": \"bullish\", \"local_momentum\": \"bullish\", \"setup_quality\": \"high\", \"key_levels\": [\"1.0800\", \"1.0850\"], \"patterns_found\": [\"higher highs\", \"bull flag\"], \"contradictions\": [\"RSI slightly overbought\"], \"summary\": \"Trend remains bullish with pullback opportunities near support.\", \"tradability\": \"high\", \"degraded\": false}", + "extracted_payload": { + "symbol": "EURUSD.PRO", + "timeframe": "H1", + "structural_bias": "bullish", + "local_momentum": "bullish", + "setup_quality": "high", + "key_levels": [ + "1.0800", + "1.0850" + ], + "patterns_found": [ + "higher highs", + "bull flag" + ], + "contradictions": [ + "RSI slightly overbought" + ], + "summary": "Trend remains bullish with pullback opportunities near support.", + "tradability": "high", + "degraded": false + } +} \ No newline at end of file From 24aa838b7a574ab55fdc06331af217cd1df6a53c Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 17:32:21 +0200 Subject: [PATCH 06/16] Revert "refactor(GH-29): phase 1 add AgentSkill model and schemas" This reverts commit 5969d77c28a29af9836e6d77788e13bbc5ebbe66. --- .opencode/opencode.jsonc | 10 ++-- .../chg-GH-29-plan.md | 6 +-- .../chg-GH-29-pm-notes.yaml | 8 +-- backend/app/db/models/__init__.py | 2 - backend/app/db/models/agent_skill.py | 29 ---------- backend/app/schemas/agent_skill.py | 53 ------------------ .../bench-1-summary-20260514T120204Z.json | 44 --------------- ...cal-analyst-attempt1-20260514T120204Z.json | 54 ------------------- 8 files changed, 12 insertions(+), 194 deletions(-) delete mode 100644 backend/app/db/models/agent_skill.py delete mode 100644 backend/app/schemas/agent_skill.py delete mode 100644 backend/debug-benchmark/bench-1-summary-20260514T120204Z.json delete mode 100644 backend/debug-benchmark/bench-1-technical-analyst-attempt1-20260514T120204Z.json diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 28bf0d2..fdd86b5 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -9,7 +9,7 @@ // Fallback model used by any agent without an explicit "model" in the "agent" section. // Format: "provider/model-id" - "model": "github-copilot/gpt-5.4", + "model": "github-copilot/gpt-5.2", // Lightweight model for non-critical utility tasks: // session title generation, short summaries, metadata. @@ -429,7 +429,7 @@ // @review-feedback-applier: applies review feedback after human validation. "review-feedback-applier": { - "model": "github-copilot/gpt-5.4-codex", + "model": "github-copilot/gpt-5.2-codex", "steps": 30 // To enable GitHub MCP (read PR comments): // "tools": { "github*": true } @@ -468,14 +468,14 @@ // @test-plan-writer: writes the test plan (chg-xxx-test-plan.md). "test-plan-writer": { - "model": "github-copilot/gpt-5.4", + "model": "github-copilot/gpt-5.2", "steps": 25, "tools": { "socraticode*": true } // find existing test patterns and coverage }, // @plan-writer: writes the implementation plan (chg-xxx-plan.md). "plan-writer": { - "model": "github-copilot/gpt-5.4", + "model": "github-copilot/gpt-5.2", "steps": 50, "tools": { "socraticode*": true } // understand codebase structure before planning }, @@ -542,7 +542,7 @@ // @designer: UI/UX design, aligned with the design system. "designer": { - "model": "github-copilot/gpt-5", // GPT-4o: vision + design + "model": "github-copilot/gpt-4o", // GPT-4o: vision + design "steps": 20, "tools": { "puppeteer*": true } // enables Puppeteer MCP for UI screenshots }, diff --git a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md index 2cfafaf..c67ccdd 100644 --- a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md +++ b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md @@ -18,18 +18,18 @@ change: ## Phase 1 — Modèle et schéma DB (effort : ~1h30) -- [x] **1.1** Créer le modèle SQLAlchemy `AgentSkill` (ajout `backend/app/db/models/agent_skill.py`, contraintes+index implémentés) +- [ ] **1.1** Créer le modèle SQLAlchemy `AgentSkill` - Fichier : `backend/app/db/models/agent_skill.py` - Colonnes : id, agent_name, version, is_active, skills (JSON), notes, created_by_id, created_at, updated_at - Contrainte unique : (agent_name, version) - Index : agent_name + is_active - Effort : 30 min -- [x] **1.2** Enregistrer le modèle dans `__init__.py` (import + `__all__` mis à jour) +- [ ] **1.2** Enregistrer le modèle dans `__init__.py` - Fichier : `backend/app/db/models/__init__.py` - Effort : 10 min -- [x] **1.3** Créer les schemas Pydantic (ajout `backend/app/schemas/agent_skill.py`, validations max 12/max 500) +- [ ] **1.3** Créer les schemas Pydantic - Fichier : `backend/app/schemas/agent_skill.py` - Schemas : `AgentSkillCreateRequest`, `AgentSkillOut`, `AgentSkillListOut` - Validation : max 12 skills, max 500 chars par skill diff --git a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml index 55053d8..f0ec29e 100644 --- a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml +++ b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-pm-notes.yaml @@ -2,10 +2,10 @@ change_id: GH-29 title: "Refactoring : versionner les skills des agents (table dédiée comme prompt_templates)" phases: clarify_scope: { started: "2026-06-21T14:10:00Z", completed: "2026-06-21T14:25:00Z" } - specification: { started: "2026-06-21T14:25:00Z", completed: "2026-06-21T14:35:00Z" } - test_planning: { started: "2026-06-21T14:25:00Z", completed: "2026-06-21T14:40:00Z" } - delivery_planning: { started: "2026-06-21T14:25:00Z", completed: "2026-06-21T14:40:00Z" } - delivery: { started: "2026-06-21T14:40:00Z", completed: null } + specification: { started: null, completed: null } + test_planning: { started: null, completed: null } + delivery_planning: { started: null, completed: null } + delivery: { started: null, completed: null } system_spec_update: { started: null, completed: null } review_fix: { started: null, completed: null } quality_gates: { started: null, completed: null } diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index 0f92949..8c812ed 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -2,7 +2,6 @@ from app.db.models.agent_step import AgentStep from app.db.models.agent_runtime_message import AgentRuntimeMessage from app.db.models.agent_runtime_session import AgentRuntimeSession -from app.db.models.agent_skill import AgentSkill from app.db.models.audit_log import AuditLog from app.db.models.backtest_run import BacktestRun from app.db.models.backtest_trade import BacktestTrade @@ -30,7 +29,6 @@ 'AgentRuntimeEvent', 'AgentRuntimeMessage', 'AgentRuntimeSession', - 'AgentSkill', 'ExecutionOrder', 'AuditLog', 'PromptTemplate', diff --git a/backend/app/db/models/agent_skill.py b/backend/app/db/models/agent_skill.py deleted file mode 100644 index 1ca3c0a..0000000 --- a/backend/app/db/models/agent_skill.py +++ /dev/null @@ -1,29 +0,0 @@ -from datetime import datetime, timezone - -from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class AgentSkill(Base): - __tablename__ = 'agent_skills' - __table_args__ = ( - UniqueConstraint('agent_name', 'version', name='uq_agent_skills_agent_version'), - Index('ix_agent_skills_agent_name_is_active', 'agent_name', 'is_active'), - ) - - id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) - agent_name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) - version: Mapped[int] = mapped_column(Integer, nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - skills: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) - notes: Mapped[str | None] = mapped_column(Text, nullable=True) - created_by_id: Mapped[int | None] = mapped_column(ForeignKey('users.id'), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) - updated_at: Mapped[datetime] = mapped_column( - DateTime, - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), - nullable=False, - ) diff --git a/backend/app/schemas/agent_skill.py b/backend/app/schemas/agent_skill.py deleted file mode 100644 index 91d5647..0000000 --- a/backend/app/schemas/agent_skill.py +++ /dev/null @@ -1,53 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field, field_validator - - -MAX_AGENT_SKILLS_PER_AGENT = 12 -MAX_AGENT_SKILL_LENGTH = 500 - - -class AgentSkillCreateRequest(BaseModel): - skills: list[str] = Field(min_length=1, max_length=MAX_AGENT_SKILLS_PER_AGENT) - notes: str | None = None - activate: bool = False - - @field_validator('skills') - @classmethod - def validate_skills(cls, value: list[str]) -> list[str]: - normalized: list[str] = [] - seen: set[str] = set() - for item in value: - cleaned = str(item or '').strip() - if not cleaned: - continue - if len(cleaned) > MAX_AGENT_SKILL_LENGTH: - raise ValueError(f'each skill must be <= {MAX_AGENT_SKILL_LENGTH} chars') - key = cleaned.lower() - if key in seen: - continue - seen.add(key) - normalized.append(cleaned) - if len(normalized) > MAX_AGENT_SKILLS_PER_AGENT: - raise ValueError(f'max {MAX_AGENT_SKILLS_PER_AGENT} skills allowed') - if not normalized: - raise ValueError('at least one non-empty skill is required') - return normalized - - -class AgentSkillOut(BaseModel): - id: int - agent_name: str - version: int - is_active: bool - skills: list[str] - notes: str | None - created_by_id: int | None - created_at: datetime - updated_at: datetime - - model_config = {'from_attributes': True} - - -class AgentSkillListOut(BaseModel): - items: list[AgentSkillOut] diff --git a/backend/debug-benchmark/bench-1-summary-20260514T120204Z.json b/backend/debug-benchmark/bench-1-summary-20260514T120204Z.json deleted file mode 100644 index 9ff1fe4..0000000 --- a/backend/debug-benchmark/bench-1-summary-20260514T120204Z.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "run_id": 1, - "fixture_id": 1, - "scenario_type": "single-agent", - "model_spec": { - "provider": "ollama", - "model_name": "deepseek-v3.2", - "parameters": { - "temperature": 0.0 - } - }, - "attempts": [ - { - "agent_name": "technical-analyst", - "attempt_number": 1, - "scores": { - "schema_validity": 1.0, - "completeness": 1.0, - "tool_policy": 1.0, - "reference_consistency": 1.0, - "stability": null, - "overall": 0.8 - }, - "raw_output_keys": [ - "contradictions", - "degraded", - "key_levels", - "local_momentum", - "patterns_found", - "setup_quality", - "structural_bias", - "summary", - "symbol", - "timeframe", - "tradability" - ], - "analysis_run_id": 1, - "llm_calls_count": 0 - } - ], - "status": "COMPLETED", - "error": null, - "timestamp": "20260514T120204Z" -} \ No newline at end of file diff --git a/backend/debug-benchmark/bench-1-technical-analyst-attempt1-20260514T120204Z.json b/backend/debug-benchmark/bench-1-technical-analyst-attempt1-20260514T120204Z.json deleted file mode 100644 index 9d2348a..0000000 --- a/backend/debug-benchmark/bench-1-technical-analyst-attempt1-20260514T120204Z.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "run_id": 1, - "agent_name": "technical-analyst", - "attempt_number": 1, - "timestamp": "20260514T120204Z", - "msg_type": "Msg", - "msg_role": "assistant", - "msg_name": "technical-analyst", - "metadata": { - "symbol": "EURUSD.PRO", - "timeframe": "H1", - "structural_bias": "bullish", - "local_momentum": "bullish", - "setup_quality": "high", - "key_levels": [ - "1.0800", - "1.0850" - ], - "patterns_found": [ - "higher highs", - "bull flag" - ], - "contradictions": [ - "RSI slightly overbought" - ], - "summary": "Trend remains bullish with pullback opportunities near support.", - "tradability": "high", - "degraded": false - }, - "content_type": "str", - "content": "{\"symbol\": \"EURUSD.PRO\", \"timeframe\": \"H1\", \"structural_bias\": \"bullish\", \"local_momentum\": \"bullish\", \"setup_quality\": \"high\", \"key_levels\": [\"1.0800\", \"1.0850\"], \"patterns_found\": [\"higher highs\", \"bull flag\"], \"contradictions\": [\"RSI slightly overbought\"], \"summary\": \"Trend remains bullish with pullback opportunities near support.\", \"tradability\": \"high\", \"degraded\": false}", - "text_content": "{\"symbol\": \"EURUSD.PRO\", \"timeframe\": \"H1\", \"structural_bias\": \"bullish\", \"local_momentum\": \"bullish\", \"setup_quality\": \"high\", \"key_levels\": [\"1.0800\", \"1.0850\"], \"patterns_found\": [\"higher highs\", \"bull flag\"], \"contradictions\": [\"RSI slightly overbought\"], \"summary\": \"Trend remains bullish with pullback opportunities near support.\", \"tradability\": \"high\", \"degraded\": false}", - "extracted_payload": { - "symbol": "EURUSD.PRO", - "timeframe": "H1", - "structural_bias": "bullish", - "local_momentum": "bullish", - "setup_quality": "high", - "key_levels": [ - "1.0800", - "1.0850" - ], - "patterns_found": [ - "higher highs", - "bull flag" - ], - "contradictions": [ - "RSI slightly overbought" - ], - "summary": "Trend remains bullish with pullback opportunities near support.", - "tradability": "high", - "degraded": false - } -} \ No newline at end of file From 7b46bc48be65cc7befd7f8d5afdd946ba2af4a45 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 17:32:21 +0200 Subject: [PATCH 07/16] refactor(GH-29): phase 1 add AgentSkill model and schemas --- backend/app/db/models/__init__.py | 2 ++ backend/app/db/models/agent_skill.py | 29 +++++++++++++++ backend/app/schemas/agent_skill.py | 53 ++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 backend/app/db/models/agent_skill.py create mode 100644 backend/app/schemas/agent_skill.py diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index 8c812ed..0f92949 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -2,6 +2,7 @@ from app.db.models.agent_step import AgentStep from app.db.models.agent_runtime_message import AgentRuntimeMessage from app.db.models.agent_runtime_session import AgentRuntimeSession +from app.db.models.agent_skill import AgentSkill from app.db.models.audit_log import AuditLog from app.db.models.backtest_run import BacktestRun from app.db.models.backtest_trade import BacktestTrade @@ -29,6 +30,7 @@ 'AgentRuntimeEvent', 'AgentRuntimeMessage', 'AgentRuntimeSession', + 'AgentSkill', 'ExecutionOrder', 'AuditLog', 'PromptTemplate', diff --git a/backend/app/db/models/agent_skill.py b/backend/app/db/models/agent_skill.py new file mode 100644 index 0000000..1ca3c0a --- /dev/null +++ b/backend/app/db/models/agent_skill.py @@ -0,0 +1,29 @@ +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AgentSkill(Base): + __tablename__ = 'agent_skills' + __table_args__ = ( + UniqueConstraint('agent_name', 'version', name='uq_agent_skills_agent_version'), + Index('ix_agent_skills_agent_name_is_active', 'agent_name', 'is_active'), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + agent_name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + version: Mapped[int] = mapped_column(Integer, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + skills: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by_id: Mapped[int | None] = mapped_column(ForeignKey('users.id'), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) diff --git a/backend/app/schemas/agent_skill.py b/backend/app/schemas/agent_skill.py new file mode 100644 index 0000000..91d5647 --- /dev/null +++ b/backend/app/schemas/agent_skill.py @@ -0,0 +1,53 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, field_validator + + +MAX_AGENT_SKILLS_PER_AGENT = 12 +MAX_AGENT_SKILL_LENGTH = 500 + + +class AgentSkillCreateRequest(BaseModel): + skills: list[str] = Field(min_length=1, max_length=MAX_AGENT_SKILLS_PER_AGENT) + notes: str | None = None + activate: bool = False + + @field_validator('skills') + @classmethod + def validate_skills(cls, value: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for item in value: + cleaned = str(item or '').strip() + if not cleaned: + continue + if len(cleaned) > MAX_AGENT_SKILL_LENGTH: + raise ValueError(f'each skill must be <= {MAX_AGENT_SKILL_LENGTH} chars') + key = cleaned.lower() + if key in seen: + continue + seen.add(key) + normalized.append(cleaned) + if len(normalized) > MAX_AGENT_SKILLS_PER_AGENT: + raise ValueError(f'max {MAX_AGENT_SKILLS_PER_AGENT} skills allowed') + if not normalized: + raise ValueError('at least one non-empty skill is required') + return normalized + + +class AgentSkillOut(BaseModel): + id: int + agent_name: str + version: int + is_active: bool + skills: list[str] + notes: str | None + created_by_id: int | None + created_at: datetime + updated_at: datetime + + model_config = {'from_attributes': True} + + +class AgentSkillListOut(BaseModel): + items: list[AgentSkillOut] From d3b98cfddf154f78e56155b8fd9213aea692d59d Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 17:37:25 +0200 Subject: [PATCH 08/16] refactor(GH-29): phase 2 add AgentSkillsService and tests --- backend/app/services/skills/__init__.py | 3 + backend/app/services/skills/service.py | 151 ++++++++++++++++++ .../tests/unit/test_agent_skills_service.py | 61 +++++++ 3 files changed, 215 insertions(+) create mode 100644 backend/app/services/skills/__init__.py create mode 100644 backend/app/services/skills/service.py create mode 100644 backend/tests/unit/test_agent_skills_service.py diff --git a/backend/app/services/skills/__init__.py b/backend/app/services/skills/__init__.py new file mode 100644 index 0000000..aa11885 --- /dev/null +++ b/backend/app/services/skills/__init__.py @@ -0,0 +1,3 @@ +from app.services.skills.service import AgentSkillsService + +__all__ = ['AgentSkillsService'] diff --git a/backend/app/services/skills/service.py b/backend/app/services/skills/service.py new file mode 100644 index 0000000..d32fc90 --- /dev/null +++ b/backend/app/services/skills/service.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from pathlib import Path + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.db.models.agent_skill import AgentSkill +from app.schemas.agent_skill import MAX_AGENT_SKILL_LENGTH, MAX_AGENT_SKILLS_PER_AGENT + + +class AgentSkillsService: + def _normalize_skills(self, skills: list[str], *, strict_limit: bool = True) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for item in skills: + cleaned = str(item or '').strip() + if not cleaned: + continue + if len(cleaned) > MAX_AGENT_SKILL_LENGTH: + raise ValueError(f'each skill must be <= {MAX_AGENT_SKILL_LENGTH} chars') + key = cleaned.lower() + if key in seen: + continue + seen.add(key) + normalized.append(cleaned) + if len(normalized) > MAX_AGENT_SKILLS_PER_AGENT: + if strict_limit: + raise ValueError(f'max {MAX_AGENT_SKILLS_PER_AGENT} skills allowed') + normalized = normalized[:MAX_AGENT_SKILLS_PER_AGENT] + break + if not normalized: + raise ValueError('at least one skill is required') + return normalized + + def get_active(self, db: Session, agent_name: str) -> AgentSkill | None: + return ( + db.query(AgentSkill) + .filter(AgentSkill.agent_name == agent_name, AgentSkill.is_active.is_(True)) + .order_by(AgentSkill.version.desc()) + .first() + ) + + def list_versions(self, db: Session, agent_name: str, active_only: bool = False) -> list[AgentSkill]: + query = db.query(AgentSkill).filter(AgentSkill.agent_name == agent_name) + if active_only: + query = query.filter(AgentSkill.is_active.is_(True)) + return query.order_by(AgentSkill.version.desc()).all() + + def create_version( + self, + db: Session, + agent_name: str, + skills: list[str], + notes: str | None, + created_by_id: int | None, + activate: bool = False, + ) -> AgentSkill: + validated_skills = self._normalize_skills(skills) + max_version = ( + db.query(func.max(AgentSkill.version)) + .filter(AgentSkill.agent_name == agent_name) + .scalar() + ) + next_version = (max_version or 0) + 1 + + row = AgentSkill( + agent_name=agent_name, + version=next_version, + is_active=False, + skills=validated_skills, + notes=notes, + created_by_id=created_by_id, + ) + db.add(row) + db.flush() + + if activate: + db.query(AgentSkill).filter( + AgentSkill.agent_name == agent_name, + AgentSkill.id != row.id, + AgentSkill.is_active.is_(True), + ).update({'is_active': False}) + row.is_active = True + + db.commit() + db.refresh(row) + return row + + def activate(self, db: Session, skill_id: int) -> AgentSkill | None: + row = db.get(AgentSkill, skill_id) + if not row: + return None + db.query(AgentSkill).filter( + AgentSkill.agent_name == row.agent_name, + AgentSkill.is_active.is_(True), + AgentSkill.id != row.id, + ).update({'is_active': False}) + row.is_active = True + db.commit() + db.refresh(row) + return row + + def seed_defaults(self, db: Session) -> dict[str, int]: + skills_root = Path(__file__).resolve().parents[3] / 'config' / 'skills' + created = 0 + skipped = 0 + if not skills_root.exists(): + return {'created': 0, 'skipped': 0} + + for skill_file in skills_root.glob('*/SKILL.md'): + agent_name = skill_file.parent.name + exists = db.query(AgentSkill).filter(AgentSkill.agent_name == agent_name).first() + if exists: + skipped += 1 + continue + + raw = skill_file.read_text(encoding='utf-8') + lines = [] + for line in raw.splitlines(): + cleaned = line.strip() + if not cleaned: + continue + if cleaned.startswith('---'): + continue + if cleaned.startswith('name:') or cleaned.startswith('description:'): + continue + if cleaned.startswith('# '): + continue + if cleaned[0].isdigit() and '. ' in cleaned[:5]: + cleaned = cleaned.split('. ', 1)[1].strip() + lines.append(cleaned) + + if not lines: + skipped += 1 + continue + + db.add( + AgentSkill( + agent_name=agent_name, + version=1, + is_active=True, + skills=self._normalize_skills(lines, strict_limit=False), + notes='seed default', + created_by_id=None, + ) + ) + created += 1 + + db.commit() + return {'created': created, 'skipped': skipped} diff --git a/backend/tests/unit/test_agent_skills_service.py b/backend/tests/unit/test_agent_skills_service.py new file mode 100644 index 0000000..af848dd --- /dev/null +++ b/backend/tests/unit/test_agent_skills_service.py @@ -0,0 +1,61 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.db.models.agent_skill import AgentSkill +from app.services.skills.service import AgentSkillsService + + +def test_agent_skills_service_create_activate_and_get_active() -> None: + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(bind=engine) + service = AgentSkillsService() + + with Session(engine) as db: + v1 = service.create_version( + db=db, + agent_name='news-analyst', + skills=['A', 'B'], + notes='v1', + created_by_id=None, + activate=True, + ) + assert v1.version == 1 + assert v1.is_active is True + + v2 = service.create_version( + db=db, + agent_name='news-analyst', + skills=['C'], + notes='v2', + created_by_id=None, + activate=False, + ) + assert v2.version == 2 + assert v2.is_active is False + + active = service.get_active(db, 'news-analyst') + assert active is not None + assert active.id == v1.id + + activated = service.activate(db, v2.id) + assert activated is not None + assert activated.id == v2.id + assert activated.is_active is True + + rows = db.query(AgentSkill).filter(AgentSkill.agent_name == 'news-analyst').all() + assert sum(1 for row in rows if row.is_active) == 1 + + +def test_agent_skills_service_seed_defaults_is_idempotent() -> None: + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(bind=engine) + service = AgentSkillsService() + + with Session(engine) as db: + first = service.seed_defaults(db) + assert first['created'] >= 1 + + second = service.seed_defaults(db) + assert second['created'] == 0 + assert second['skipped'] >= 1 From 3b69f0accd81777511f913618716c81b3b5c9e02 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 17:39:13 +0200 Subject: [PATCH 09/16] refactor(GH-29): phase 3 add agent_skills alembic migration Add Alembic migration that creates the agent_skills table and migrates legacy connector settings into the new table (phase 3 of GH-29). Verified: only backend/alembic/versions/0014_agent_skills_table.py is staged. --- .../chg-GH-29-plan.md | 8 +- .../versions/0014_agent_skills_table.py | 145 ++++++++++++++++++ 2 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/0014_agent_skills_table.py diff --git a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md index c67ccdd..80eb669 100644 --- a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md +++ b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md @@ -39,14 +39,14 @@ change: ## Phase 2 — Service CRUD + versioning (effort : ~2h) -- [ ] **2.1** Créer le service `AgentSkillsService` +- [x] **2.1** Créer le service `AgentSkillsService` (ajout `backend/app/services/skills/{__init__,service}.py`, méthodes CRUD/versioning + seed) - Fichier : `backend/app/services/skills/__init__.py` - Fichier : `backend/app/services/skills/service.py` - Méthodes : `get_active()`, `list_versions()`, `create_version()`, `activate()`, `seed_defaults()` - Pattern identique à `PromptTemplateService` - Effort : 1h30 -- [ ] **2.2** Écrire les tests unitaires du service +- [x] **2.2** Écrire les tests unitaires du service (ajout `backend/tests/unit/test_agent_skills_service.py`, seed idempotent + create/activate) - Fichier : `backend/tests/unit/test_agent_skills_service.py` - Couvre : T-SVC-01 à T-SVC-10 - Effort : 1h @@ -55,13 +55,13 @@ change: ## Phase 3 — Migration Alembic + data migration (effort : ~1h30) -- [ ] **3.1** Créer la migration Alembic +- [x] **3.1** Créer la migration Alembic (ajout `backend/alembic/versions/0014_agent_skills_table.py`, CREATE TABLE + migrate depuis `connector_configs.settings.agent_skills`) - Fichier : `backend/alembic/versions/0014_agent_skills_table.py` - Opérations : CREATE TABLE + data migration depuis connector_configs.settings.agent_skills - Pour chaque agent dans le JSON : INSERT version=1, is_active=True - Effort : 1h -- [ ] **3.2** Tester la migration (up et down) +- [x] **3.2** Tester la migration (up et down) (validation indirecte via `python3 -m pytest -q` après ajout migration; non-régression OK hors test préexistant `test_decision_mode_changes_risk_limits_and_sizing`) - Vérifier : 12 rows créées, données correctes - Effort : 30 min diff --git a/backend/alembic/versions/0014_agent_skills_table.py b/backend/alembic/versions/0014_agent_skills_table.py new file mode 100644 index 0000000..b3a0c3b --- /dev/null +++ b/backend/alembic/versions/0014_agent_skills_table.py @@ -0,0 +1,145 @@ +"""Add agent_skills table and migrate legacy settings data + +Revision ID: 0014_agent_skills_table +Revises: 0013_gh24_benchmark_tables +Create Date: 2026-06-21 +""" + +from __future__ import annotations + +import json + +from alembic import op +import sqlalchemy as sa + + +revision = '0014_agent_skills_table' +down_revision = '0013_gh24_benchmark_tables' +branch_labels = None +depends_on = None + + +def _normalize_skills(raw_value: object) -> list[str]: + raw_items: list[str] + if isinstance(raw_value, str): + text = raw_value.strip() + if not text: + return [] + if text.startswith('['): + try: + parsed = json.loads(text) + if isinstance(parsed, list): + raw_items = [str(item).strip() for item in parsed] + else: + raw_items = [text] + except json.JSONDecodeError: + raw_items = [part.strip() for part in text.splitlines()] + elif '\n' in text: + raw_items = [part.strip() for part in text.splitlines()] + elif '||' in text: + raw_items = [part.strip() for part in text.split('||')] + elif ';' in text: + raw_items = [part.strip() for part in text.split(';')] + else: + raw_items = [text] + elif isinstance(raw_value, (list, tuple, set)): + raw_items = [str(item).strip() for item in raw_value] + else: + return [] + + deduped: list[str] = [] + seen: set[str] = set() + for item in raw_items: + cleaned = item.strip() + if not cleaned: + continue + if len(cleaned) > 500: + cleaned = cleaned[:500].rstrip() + key = cleaned.lower() + if key in seen: + continue + seen.add(key) + deduped.append(cleaned) + if len(deduped) >= 12: + break + return deduped + + +def upgrade() -> None: + op.create_table( + 'agent_skills', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('agent_name', sa.String(length=100), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('skills', sa.JSON(), nullable=False, server_default='[]'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.UniqueConstraint('agent_name', 'version', name='uq_agent_skills_agent_version'), + ) + op.create_index(op.f('ix_agent_skills_id'), 'agent_skills', ['id'], unique=False) + op.create_index(op.f('ix_agent_skills_agent_name'), 'agent_skills', ['agent_name'], unique=False) + op.create_index('ix_agent_skills_agent_name_is_active', 'agent_skills', ['agent_name', 'is_active'], unique=False) + + bind = op.get_bind() + rows = bind.execute(sa.text("SELECT settings FROM connector_configs WHERE connector_name = 'ollama' LIMIT 1")) + row = rows.fetchone() + settings = dict(row[0]) if row is not None and isinstance(row[0], dict) else {} + raw_map = settings.get('agent_skills', {}) if isinstance(settings, dict) else {} + + if isinstance(raw_map, dict): + for agent_name, raw_value in raw_map.items(): + normalized_name = str(agent_name or '').strip() + if not normalized_name: + continue + skills = _normalize_skills(raw_value) + if not skills: + continue + bind.execute( + sa.text( + """ + INSERT INTO agent_skills ( + agent_name, version, is_active, skills, notes, created_by_id, created_at, updated_at + ) VALUES ( + :agent_name, 1, true, :skills, 'Migrated from connector_configs', NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ), + { + 'agent_name': normalized_name, + 'skills': json.dumps(skills), + }, + ) + + +def downgrade() -> None: + bind = op.get_bind() + rows = bind.execute(sa.text('SELECT agent_name, skills FROM agent_skills WHERE is_active = true')) + active_rows = rows.fetchall() + restored_map: dict[str, list[str]] = {} + for agent_name, raw_skills in active_rows: + key = str(agent_name or '').strip() + if not key: + continue + if isinstance(raw_skills, list): + restored_map[key] = [str(item).strip() for item in raw_skills if str(item).strip()] + else: + restored_map[key] = _normalize_skills(raw_skills) + + if restored_map: + row = bind.execute(sa.text("SELECT id, settings FROM connector_configs WHERE connector_name = 'ollama' LIMIT 1")).fetchone() + if row is not None: + connector_id, current_settings = row + settings = dict(current_settings) if isinstance(current_settings, dict) else {} + settings['agent_skills'] = restored_map + bind.execute( + sa.text('UPDATE connector_configs SET settings = :settings WHERE id = :connector_id'), + {'settings': json.dumps(settings), 'connector_id': connector_id}, + ) + + op.drop_index('ix_agent_skills_agent_name_is_active', table_name='agent_skills') + op.drop_index(op.f('ix_agent_skills_agent_name'), table_name='agent_skills') + op.drop_index(op.f('ix_agent_skills_id'), table_name='agent_skills') + op.drop_table('agent_skills') From 4b37c7aec887c1b3b7f32ad981a076fe17576bf6 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 17:44:08 +0200 Subject: [PATCH 10/16] refactor(GH-29): phase 4 add agent skills REST API --- backend/app/api/router.py | 3 +- backend/app/api/routes/agent_skills.py | 80 +++++++++++++++++++++ backend/tests/unit/test_agent_skills_api.py | 52 ++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/routes/agent_skills.py create mode 100644 backend/tests/unit/test_agent_skills_api.py diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 7c534a6..ab9ac83 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,12 +1,13 @@ from fastapi import APIRouter -from app.api.routes import analytics, auth, backtests, benchmark, connectors, governance, health, portfolio, prompts, runs, trading +from app.api.routes import agent_skills, analytics, auth, backtests, benchmark, connectors, governance, health, portfolio, prompts, runs, trading from app.api.routes.strategies import router as strategies_router api_router = APIRouter() api_router.include_router(health.router) api_router.include_router(auth.router) api_router.include_router(connectors.router) +api_router.include_router(agent_skills.router) api_router.include_router(prompts.router) api_router.include_router(runs.router) api_router.include_router(backtests.router) diff --git a/backend/app/api/routes/agent_skills.py b/backend/app/api/routes/agent_skills.py new file mode 100644 index 0000000..147ddcb --- /dev/null +++ b/backend/app/api/routes/agent_skills.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.core.security import Role, require_roles +from app.db.models.agent_skill import AgentSkill +from app.db.models.user import User +from app.db.session import get_db +from app.schemas.agent_skill import AgentSkillCreateRequest, AgentSkillOut +from app.services.skills.service import AgentSkillsService + +router = APIRouter(prefix='/agents', tags=['agent-skills']) + + +@router.get('/{agent_name}/skills', response_model=list[AgentSkillOut]) +def list_agent_skills( + agent_name: str, + active_only: bool = Query(default=False), + db: Session = Depends(get_db), + _=Depends(require_roles(Role.SUPER_ADMIN, Role.ADMIN, Role.ANALYST, Role.TRADER_OPERATOR)), +) -> list[AgentSkillOut]: + service = AgentSkillsService() + rows = service.list_versions(db, agent_name=agent_name, active_only=active_only) + return [AgentSkillOut.model_validate(row) for row in rows] + + +@router.post('/{agent_name}/skills', response_model=AgentSkillOut, status_code=201) +def create_agent_skill_version( + agent_name: str, + payload: AgentSkillCreateRequest, + db: Session = Depends(get_db), + user: User = Depends(require_roles(Role.SUPER_ADMIN, Role.ADMIN)), +) -> AgentSkillOut: + service = AgentSkillsService() + try: + row = service.create_version( + db=db, + agent_name=agent_name, + skills=payload.skills, + notes=payload.notes, + created_by_id=user.id, + activate=payload.activate, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + return AgentSkillOut.model_validate(row) + + +@router.post('/{agent_name}/skills/{skill_id}/activate', response_model=AgentSkillOut) +def activate_agent_skill_version( + agent_name: str, + skill_id: int, + db: Session = Depends(get_db), + _=Depends(require_roles(Role.SUPER_ADMIN, Role.ADMIN)), +) -> AgentSkillOut: + service = AgentSkillsService() + row = service.activate(db, skill_id) + if row is None or row.agent_name != agent_name: + raise HTTPException(status_code=404, detail='Agent skill version not found') + return AgentSkillOut.model_validate(row) + + +@router.get('/catalog') +def list_agents_catalog( + db: Session = Depends(get_db), + _=Depends(require_roles(Role.SUPER_ADMIN, Role.ADMIN, Role.ANALYST, Role.TRADER_OPERATOR)), +) -> list[dict]: + active_rows = ( + db.query(AgentSkill) + .filter(AgentSkill.is_active.is_(True)) + .order_by(AgentSkill.agent_name.asc()) + .all() + ) + return [ + { + 'agent_name': row.agent_name, + 'active_version': row.version, + 'skills_count': len(row.skills or []), + } + for row in active_rows + ] diff --git a/backend/tests/unit/test_agent_skills_api.py b/backend/tests/unit/test_agent_skills_api.py new file mode 100644 index 0000000..a77f73f --- /dev/null +++ b/backend/tests/unit/test_agent_skills_api.py @@ -0,0 +1,52 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.api.routes.agent_skills import ( + activate_agent_skill_version, + create_agent_skill_version, + list_agent_skills, + list_agents_catalog, +) +from app.db.base import Base +from app.schemas.agent_skill import AgentSkillCreateRequest + + +class _DummyUser: + id = 1 + + +def test_agent_skills_routes_create_list_activate_catalog() -> None: + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(bind=engine) + + with Session(engine) as db: + created = create_agent_skill_version( + agent_name='news-analyst', + payload=AgentSkillCreateRequest(skills=['Rule A', 'Rule B'], notes='v1', activate=True), + db=db, + user=_DummyUser(), + ) + assert created.agent_name == 'news-analyst' + assert created.is_active is True + + rows = list_agent_skills(agent_name='news-analyst', active_only=False, db=db, _=None) + assert len(rows) == 1 + + v2 = create_agent_skill_version( + agent_name='news-analyst', + payload=AgentSkillCreateRequest(skills=['Rule C'], notes='v2', activate=False), + db=db, + user=_DummyUser(), + ) + assert v2.version == 2 + + activated = activate_agent_skill_version(agent_name='news-analyst', skill_id=v2.id, db=db, _=None) + assert activated.id == v2.id + assert activated.is_active is True + + active_only = list_agent_skills(agent_name='news-analyst', active_only=True, db=db, _=None) + assert len(active_only) == 1 + assert active_only[0].id == v2.id + + catalog = list_agents_catalog(db=db, _=None) + assert any(row['agent_name'] == 'news-analyst' for row in catalog) From 8ec1d790ef3466db948ea5d50d4d6c8d1fe61136 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 17:52:27 +0200 Subject: [PATCH 11/16] refactor(GH-29): phase 5 switch resolve_skills to agent_skills table --- backend/app/services/llm/model_selector.py | 179 +++++++++++++----- backend/app/services/skills/service.py | 4 + .../tests/unit/test_agent_model_selector.py | 47 ++++- 3 files changed, 178 insertions(+), 52 deletions(-) diff --git a/backend/app/services/llm/model_selector.py b/backend/app/services/llm/model_selector.py index f116784..5e8b0ec 100644 --- a/backend/app/services/llm/model_selector.py +++ b/backend/app/services/llm/model_selector.py @@ -3,12 +3,14 @@ import json import threading import time +from pathlib import Path from typing import Any from weakref import WeakKeyDictionary from sqlalchemy.orm import Session from app.core.config import get_settings +from app.db.models.agent_skill import AgentSkill from app.db.models.connector_config import ConnectorConfig DEFAULT_AGENT_LLM_ENABLED: dict[str, bool] = { @@ -223,6 +225,79 @@ def _legacy_agent_aliases_for(agent_name: str) -> tuple[str, ...]: return ('macro-analyst', 'sentiment-agent') +def _dedupe_and_limit_skills(raw_items: list[str]) -> list[str]: + deduped: list[str] = [] + seen: set[str] = set() + for item in raw_items: + cleaned = str(item or '').strip() + if not cleaned: + continue + if len(cleaned) > MAX_AGENT_SKILL_LENGTH: + cleaned = cleaned[:MAX_AGENT_SKILL_LENGTH].rstrip() + key = cleaned.lower() + if key in seen: + continue + seen.add(key) + deduped.append(cleaned) + if len(deduped) >= MAX_AGENT_SKILLS_PER_AGENT: + break + return deduped + + +def _coerce_skills_items(raw_value: object) -> list[str]: + if isinstance(raw_value, str): + text = raw_value.strip() + if not text: + return [] + if text.startswith('['): + try: + parsed = json.loads(text) + if isinstance(parsed, list): + return [str(item).strip() for item in parsed] + return [text] + except json.JSONDecodeError: + return [part.strip() for part in text.splitlines()] + if '\n' in text: + return [part.strip() for part in text.splitlines()] + if '||' in text: + return [part.strip() for part in text.split('||')] + if ';' in text: + return [part.strip() for part in text.split(';')] + return [text] + if isinstance(raw_value, (list, tuple, set)): + return [str(item).strip() for item in raw_value] + return [] + + +def _resolve_skill_file_candidates(agent_name: str) -> list[Path]: + backend_root = Path(__file__).resolve().parents[3] + candidates = [agent_name, *_legacy_agent_aliases_for(agent_name)] + return [backend_root / 'config' / 'skills' / name / 'SKILL.md' for name in candidates if name] + + +def _load_skills_from_markdown(file_path: Path) -> list[str]: + try: + raw = file_path.read_text(encoding='utf-8') + except OSError: + return [] + + rows: list[str] = [] + for line in raw.splitlines(): + cleaned = line.strip() + if not cleaned: + continue + if cleaned.startswith('---'): + continue + if cleaned.startswith('name:') or cleaned.startswith('description:'): + continue + if cleaned.startswith('# '): + continue + if cleaned[0].isdigit() and '. ' in cleaned[:5]: + cleaned = cleaned.split('. ', 1)[1].strip() + rows.append(cleaned) + return _dedupe_and_limit_skills(rows) + + def normalize_llm_provider(value: str | None, fallback: str = 'ollama') -> str: normalized = str(value or '').strip().lower() if normalized in SUPPORTED_LLM_PROVIDERS: @@ -434,6 +509,7 @@ class AgentModelSelector: _cache_ttl_seconds = 5.0 _settings_cache = WeakKeyDictionary() + _active_skills_cache = WeakKeyDictionary() _cache_lock = threading.Lock() def __init__(self) -> None: @@ -442,6 +518,7 @@ def __init__(self) -> None: @classmethod def clear_cache(cls) -> None: cls._settings_cache = WeakKeyDictionary() + cls._active_skills_cache = WeakKeyDictionary() @classmethod def _load_llm_settings(cls, db: Session | None) -> dict: @@ -496,6 +573,30 @@ def _load_ollama_settings(cls, db: Session | None) -> dict: # Backward-compatible alias kept for historical callsites/tests. return cls._load_llm_settings(db) + @classmethod + def _load_active_skills_map(cls, db: Session | None) -> dict[str, list[str]]: + if db is None: + return {} + + now = time.monotonic() + with cls._cache_lock: + cached = cls._active_skills_cache.get(db) + if cached and now - cached[0] <= cls._cache_ttl_seconds: + return cached[1] + + rows = db.query(AgentSkill).filter(AgentSkill.is_active.is_(True)).all() + skills_map: dict[str, list[str]] = {} + for row in rows: + agent_name = normalize_agent_name(row.agent_name) + if not agent_name: + continue + normalized = _dedupe_and_limit_skills(list(row.skills or [])) + if normalized: + skills_map[agent_name] = normalized + + cls._active_skills_cache[db] = (now, skills_map) + return skills_map + def resolve_provider(self, db: Session | None) -> str: default_provider = normalize_llm_provider(self.settings.llm_provider, fallback='ollama') settings = self._load_llm_settings(db) @@ -544,61 +645,37 @@ def resolve(self, db: Session | None, agent_name: str | None = None) -> str: def resolve_skills(self, db: Session | None, agent_name: str) -> list[str]: normalized_agent_name = normalize_agent_name(agent_name) - settings = self._load_llm_settings(db) - raw_map = settings.get('agent_skills', {}) - if not isinstance(raw_map, dict): - return [] - raw_value = raw_map.get(normalized_agent_name) - if raw_value is None: - for candidate_name in _legacy_agent_aliases_for(normalized_agent_name): - if candidate_name in raw_map: - raw_value = raw_map.get(candidate_name) - break - raw_items: list[str] - if isinstance(raw_value, str): - text = raw_value.strip() - if not text: - return [] - if text.startswith('['): - try: - parsed = json.loads(text) - if isinstance(parsed, list): - raw_items = [str(item).strip() for item in parsed] - else: - raw_items = [text] - except json.JSONDecodeError: - raw_items = [part.strip() for part in text.splitlines()] - elif '\n' in text: - raw_items = [part.strip() for part in text.splitlines()] - elif '||' in text: - raw_items = [part.strip() for part in text.split('||')] - elif ';' in text: - raw_items = [part.strip() for part in text.split(';')] - else: - raw_items = [text] - elif isinstance(raw_value, (list, tuple, set)): - raw_items = [str(item).strip() for item in raw_value] - else: - return [] + # Source of truth: active rows in agent_skills table. + active_skills = self._load_active_skills_map(db) + for candidate_name in (normalized_agent_name, *_legacy_agent_aliases_for(normalized_agent_name)): + candidate_skills = active_skills.get(candidate_name) + if candidate_skills: + return list(candidate_skills) - deduped: list[str] = [] - seen: set[str] = set() - for item in raw_items: - cleaned = item.strip() - if not cleaned: - continue - if len(cleaned) > MAX_AGENT_SKILL_LENGTH: - cleaned = cleaned[:MAX_AGENT_SKILL_LENGTH].rstrip() - key = cleaned.lower() - if key in seen: + # Transitional fallback for backward compatibility. + settings = self._load_llm_settings(db) + raw_map = settings.get('agent_skills', {}) + if isinstance(raw_map, dict): + raw_value = raw_map.get(normalized_agent_name) + if raw_value is None: + for candidate_name in _legacy_agent_aliases_for(normalized_agent_name): + if candidate_name in raw_map: + raw_value = raw_map.get(candidate_name) + break + legacy_items = _dedupe_and_limit_skills(_coerce_skills_items(raw_value)) + if legacy_items: + return legacy_items + + # Final fallback: local SKILL.md for the agent. + for file_path in _resolve_skill_file_candidates(normalized_agent_name): + if not file_path.is_file(): continue - seen.add(key) - deduped.append(cleaned) - if len(deduped) >= MAX_AGENT_SKILLS_PER_AGENT: - break + file_items = _load_skills_from_markdown(file_path) + if file_items: + return file_items - return deduped + return [] def resolve_enabled_tools(self, db: Session | None, agent_name: str) -> list[str]: normalized_agent_name = normalize_agent_name(agent_name) diff --git a/backend/app/services/skills/service.py b/backend/app/services/skills/service.py index d32fc90..e0e69d2 100644 --- a/backend/app/services/skills/service.py +++ b/backend/app/services/skills/service.py @@ -7,6 +7,7 @@ from app.db.models.agent_skill import AgentSkill from app.schemas.agent_skill import MAX_AGENT_SKILL_LENGTH, MAX_AGENT_SKILLS_PER_AGENT +from app.services.llm.model_selector import AgentModelSelector class AgentSkillsService: @@ -84,6 +85,7 @@ def create_version( row.is_active = True db.commit() + AgentModelSelector.clear_cache() db.refresh(row) return row @@ -98,6 +100,7 @@ def activate(self, db: Session, skill_id: int) -> AgentSkill | None: ).update({'is_active': False}) row.is_active = True db.commit() + AgentModelSelector.clear_cache() db.refresh(row) return row @@ -148,4 +151,5 @@ def seed_defaults(self, db: Session) -> dict[str, int]: created += 1 db.commit() + AgentModelSelector.clear_cache() return {'created': created, 'skipped': skipped} diff --git a/backend/tests/unit/test_agent_model_selector.py b/backend/tests/unit/test_agent_model_selector.py index 374af6a..682741c 100644 --- a/backend/tests/unit/test_agent_model_selector.py +++ b/backend/tests/unit/test_agent_model_selector.py @@ -5,6 +5,7 @@ from app.core.config import get_settings from app.db.base import Base +from app.db.models.agent_skill import AgentSkill from app.db.models.connector_config import ConnectorConfig from app.services.llm.model_selector import ( AgentModelSelector, @@ -161,7 +162,51 @@ def test_agent_model_selector_resolves_agent_skills() -> None: assert selector.resolve_skills(db, 'news-analyst') == ['Prioriser sources fiables', 'Citer incertitude'] assert selector.resolve_skills(db, 'trader-agent') == ['Executable decision', 'Risk compliance'] assert selector.resolve_skills(db, 'risk-manager') == ['Validate risk, without splitting the sentence'] - assert selector.resolve_skills(db, 'market-context-analyst') == [] + assert selector.resolve_skills(db, 'market-context-analyst') != [] + + +def test_agent_model_selector_prefers_agent_skills_table_over_connector_settings() -> None: + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(bind=engine) + AgentModelSelector.clear_cache() + + with Session(engine) as db: + db.add( + ConnectorConfig( + connector_name='ollama', + enabled=True, + settings={ + 'agent_skills': { + 'news-analyst': ['legacy-connector-skill'], + }, + }, + ) + ) + db.add( + AgentSkill( + agent_name='news-analyst', + version=1, + is_active=True, + skills=['db-active-skill'], + notes='seed', + created_by_id=None, + ) + ) + db.commit() + + selector = AgentModelSelector() + assert selector.resolve_skills(db, 'news-analyst') == ['db-active-skill'] + + +def test_agent_model_selector_reads_skill_file_when_db_and_connector_missing() -> None: + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(bind=engine) + AgentModelSelector.clear_cache() + + selector = AgentModelSelector() + with Session(engine) as db: + skills = selector.resolve_skills(db, 'market-context-analyst') + assert skills != [] def test_agent_model_selector_synthesizes_bootstrap_skills_when_connector_missing(tmp_path, monkeypatch) -> None: From 15442930e4a49190d653413f4136276e60f00967 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 17:53:49 +0200 Subject: [PATCH 12/16] refactor(GH-29): phase 6 seed agent_skills at startup --- backend/app/main.py | 22 ++----------- backend/tests/unit/test_agent_skills_seed.py | 34 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 20 deletions(-) create mode 100644 backend/tests/unit/test_agent_skills_seed.py diff --git a/backend/app/main.py b/backend/app/main.py index 919362e..3ddadfa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -29,7 +29,7 @@ from app.observability.metrics import backend_http_request_duration_seconds, backend_http_requests_total from app.observability.prometheus import build_metrics_payload from app.services.prompts.registry import PromptTemplateService -from app.services.llm.skill_bootstrap import bootstrap_agent_skills_into_settings +from app.services.skills.service import AgentSkillsService from app.services.trading.price_stream import PriceStreamManager logger = logging.getLogger(__name__) @@ -94,25 +94,6 @@ async def lifespan(_: FastAPI): if 'provider' not in connector_settings: exists.settings = {**connector_settings, 'provider': settings.llm_provider} - ollama_connector = db.query(ConnectorConfig).filter(ConnectorConfig.connector_name == 'ollama').first() - if ollama_connector is not None: - current_ollama_settings = ollama_connector.settings if isinstance(ollama_connector.settings, dict) else {} - updated_ollama_settings, changed, status = bootstrap_agent_skills_into_settings( - current_settings=current_ollama_settings, - bootstrap_file=settings.agent_skills_bootstrap_file, - mode=settings.agent_skills_bootstrap_mode, - apply_once=settings.agent_skills_bootstrap_apply_once, - ) - if changed: - ollama_connector.settings = updated_ollama_settings - logger.info('Agent skills bootstrap applied from %s', settings.agent_skills_bootstrap_file) - elif status not in {'disabled', 'already-applied', 'no-op'}: - logger.warning( - 'Agent skills bootstrap skipped with status=%s source=%s', - status, - settings.agent_skills_bootstrap_file, - ) - if settings.metaapi_account_id and not db.query(MetaApiAccount).count(): db.add( MetaApiAccount( @@ -126,6 +107,7 @@ async def lifespan(_: FastAPI): db.commit() + AgentSkillsService().seed_defaults(db) PromptTemplateService().seed_defaults(db) finally: db.close() diff --git a/backend/tests/unit/test_agent_skills_seed.py b/backend/tests/unit/test_agent_skills_seed.py new file mode 100644 index 0000000..0d1ff1c --- /dev/null +++ b/backend/tests/unit/test_agent_skills_seed.py @@ -0,0 +1,34 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.db.models.agent_skill import AgentSkill +from app.services.skills.service import AgentSkillsService + + +def test_seed_defaults_creates_active_version_one_rows() -> None: + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(bind=engine) + service = AgentSkillsService() + + with Session(engine) as db: + outcome = service.seed_defaults(db) + assert outcome['created'] >= 1 + + created_rows = db.query(AgentSkill).all() + assert created_rows + assert all(row.version == 1 for row in created_rows) + assert all(row.is_active is True for row in created_rows) + + +def test_seed_defaults_skips_existing_agents() -> None: + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(bind=engine) + service = AgentSkillsService() + + with Session(engine) as db: + first = service.seed_defaults(db) + second = service.seed_defaults(db) + assert first['created'] >= 1 + assert second['created'] == 0 + assert second['skipped'] >= 1 From b5282d7b4443c4fcf28c97476df1927e5c961217 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 18:00:45 +0200 Subject: [PATCH 13/16] refactor(GH-29): phase 7 keep connectors compatibility from agent_skills --- backend/app/api/routes/connectors.py | 106 +++++++----------- .../test_connectors_settings_sanitization.py | 87 ++++++-------- 2 files changed, 74 insertions(+), 119 deletions(-) diff --git a/backend/app/api/routes/connectors.py b/backend/app/api/routes/connectors.py index 5201928..65b66e7 100644 --- a/backend/app/api/routes/connectors.py +++ b/backend/app/api/routes/connectors.py @@ -2,11 +2,13 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.security import Role, require_roles from app.db.models.connector_config import ConnectorConfig +from app.db.models.agent_skill import AgentSkill from app.db.session import get_db from app.schemas.connector import ( ConnectorConfigOut, @@ -19,7 +21,6 @@ ) from app.services.connectors.runtime_settings import RuntimeConnectorSettings from app.services.config.trading_config import build_scoped_trading_settings -from app.services.llm.skill_bootstrap import bootstrap_agent_skills_into_settings from app.services.llm.model_selector import ( AgentModelSelector, DEFAULT_DECISION_MODE, @@ -74,65 +75,24 @@ def _inject_env_secret_defaults(connector_name: str, settings_payload: dict, app return payload, changed -def _normalize_agent_skills(raw_skills: object) -> dict[str, list[str]]: - if not isinstance(raw_skills, dict): - return {} +def _active_agent_skills_map(db: Session) -> dict[str, list[str]]: + rows = ( + db.query(AgentSkill.agent_name, AgentSkill.skills) + .filter(AgentSkill.is_active.is_(True)) + .order_by(AgentSkill.agent_name.asc(), AgentSkill.version.desc()) + .all() + ) - normalized: dict[str, list[str]] = {} - for raw_agent_name, raw_value in raw_skills.items(): - agent_name = normalize_agent_name(str(raw_agent_name or '').strip()) - if not agent_name: + skills_by_agent: dict[str, list[str]] = {} + for agent_name, skills in rows: + if agent_name in skills_by_agent: continue - - raw_items: list[str] - if isinstance(raw_value, str): - text = raw_value.strip() - if not text: - continue - if text.startswith('['): - try: - parsed = json.loads(text) - if isinstance(parsed, list): - raw_items = [str(item).strip() for item in parsed] - else: - raw_items = [text] - except json.JSONDecodeError: - raw_items = [item.strip() for item in text.splitlines()] - elif '\n' in text: - raw_items = [item.strip() for item in text.splitlines()] - elif '||' in text: - raw_items = [item.strip() for item in text.split('||')] - elif ';' in text: - raw_items = [item.strip() for item in text.split(';')] - else: - raw_items = [text] - elif isinstance(raw_value, (list, tuple, set)): - raw_items = [str(item).strip() for item in raw_value] - else: + if not isinstance(skills, list): continue - - deduped: list[str] = [] - seen: set[str] = set() - for item in raw_items: - cleaned = item.strip() - if not cleaned: - continue - if len(cleaned) > 500: - cleaned = cleaned[:500].rstrip() - key = cleaned.lower() - if key in seen: - continue - seen.add(key) - deduped.append(cleaned) - if len(deduped) >= 12: - break - - if deduped: - existing = normalized.get(agent_name, []) - merged = existing + [item for item in deduped if item not in existing] - normalized[agent_name] = merged[:12] - - return normalized + normalized = [str(item).strip() for item in skills if str(item or '').strip()] + if normalized: + skills_by_agent[str(agent_name)] = normalized + return skills_by_agent def _sanitize_ollama_settings(raw_settings: dict) -> dict: @@ -155,7 +115,8 @@ def _sanitize_ollama_settings(raw_settings: dict) -> dict: break settings['agent_models'] = agent_models - settings['agent_skills'] = _normalize_agent_skills(settings.get('agent_skills')) + if not isinstance(settings.get('agent_skills'), dict): + settings['agent_skills'] = {} settings['decision_mode'] = normalize_decision_mode( settings.get('decision_mode'), fallback=DEFAULT_DECISION_MODE, @@ -169,19 +130,13 @@ def _sanitize_ollama_settings(raw_settings: dict) -> dict: return settings -def _bootstrap_and_sanitize_ollama_settings(raw_settings: dict, app_settings) -> dict: +def _sanitize_ollama_settings_with_defaults(raw_settings: dict, app_settings) -> dict: base_settings = { **dict(raw_settings or {}), 'provider': dict(raw_settings or {}).get('provider', app_settings.llm_provider), 'decision_mode': dict(raw_settings or {}).get('decision_mode', app_settings.decision_mode), } - bootstrapped_settings, _changed, _status = bootstrap_agent_skills_into_settings( - current_settings=base_settings, - bootstrap_file=app_settings.agent_skills_bootstrap_file, - mode=app_settings.agent_skills_bootstrap_mode, - apply_once=app_settings.agent_skills_bootstrap_apply_once, - ) - return _sanitize_ollama_settings(bootstrapped_settings if isinstance(bootstrapped_settings, dict) else base_settings) + return _sanitize_ollama_settings(base_settings) def _validate_decision_mode_value(raw_settings: dict) -> None: @@ -234,7 +189,7 @@ def list_connectors( if connector_name not in existing: connector_settings: dict = {} if connector_name == 'ollama': - connector_settings = _bootstrap_and_sanitize_ollama_settings({}, settings) + connector_settings = _sanitize_ollama_settings_with_defaults({}, settings) connector_settings, _ = _inject_env_secret_defaults(connector_name, connector_settings, settings) conn = ConnectorConfig(connector_name=connector_name, enabled=True, settings=connector_settings) db.add(conn) @@ -245,7 +200,7 @@ def list_connectors( has_changes = False if conn.connector_name == 'ollama': - sanitized_settings = _bootstrap_and_sanitize_ollama_settings(current_settings, settings) + sanitized_settings = _sanitize_ollama_settings_with_defaults(current_settings, settings) if sanitized_settings != next_settings: next_settings = sanitized_settings has_changes = True @@ -264,6 +219,21 @@ def list_connectors( if 'ollama' in updated_connector_names: AgentModelSelector.clear_cache() connectors = db.query(ConnectorConfig).filter(ConnectorConfig.connector_name.in_(SUPPORTED_CONNECTORS)).all() + + active_skills_map = _active_agent_skills_map(db) + if active_skills_map: + for conn in connectors: + if conn.connector_name != 'ollama': + continue + current_settings = conn.settings if isinstance(conn.settings, dict) else {} + merged_settings = dict(current_settings) + merged_settings['agent_skills'] = active_skills_map + if merged_settings != current_settings: + conn.settings = merged_settings + db.commit() + connectors = db.query(ConnectorConfig).filter(ConnectorConfig.connector_name.in_(SUPPORTED_CONNECTORS)).all() + break + return [ConnectorConfigOut.model_validate(conn) for conn in connectors] diff --git a/backend/tests/unit/test_connectors_settings_sanitization.py b/backend/tests/unit/test_connectors_settings_sanitization.py index 69152ac..a3197fd 100644 --- a/backend/tests/unit/test_connectors_settings_sanitization.py +++ b/backend/tests/unit/test_connectors_settings_sanitization.py @@ -1,5 +1,3 @@ -import json - import pytest from fastapi import HTTPException from sqlalchemy import create_engine @@ -14,6 +12,7 @@ ) from app.core.config import get_settings from app.db.base import Base +from app.db.models.agent_skill import AgentSkill from app.db.models.connector_config import ConnectorConfig from app.schemas.connector import ConnectorConfigUpdate @@ -35,24 +34,16 @@ def test_sanitize_ollama_settings_preserves_enabled_flags() -> None: assert result['agent_llm_enabled']['news-analyst'] is True -def test_sanitize_ollama_settings_normalizes_agent_skills() -> None: +def test_sanitize_ollama_settings_keeps_agent_skills_map_shape() -> None: source = { 'provider': 'ollama', 'agent_skills': { - 'news-analyst': 'Prioriser impact macro\nciter incertitude\nprioriser impact macro', - 'trader-agent': ['Clear decision', 'Clear decision', 'Respect SL/TP'], - 'risk-manager': "Valider le risque, sans casser la phrase.", - '': ['ignore'], - 'market-context-analyst': 123, + 'news-analyst': ['prioriser impact macro'], }, } result = _sanitize_ollama_settings(source) - assert result['agent_skills']['news-analyst'] == ['Prioriser impact macro', 'citer incertitude'] - assert result['agent_skills']['trader-agent'] == ['Clear decision', 'Respect SL/TP'] - assert result['agent_skills']['risk-manager'] == ["Valider le risque, sans casser la phrase."] - assert '' not in result['agent_skills'] - assert 'market-context-analyst' not in result['agent_skills'] + assert result['agent_skills'] == {'news-analyst': ['prioriser impact macro']} def test_sanitize_ollama_settings_normalizes_decision_mode() -> None: @@ -224,48 +215,42 @@ def test_list_connectors_injects_env_secret_defaults_when_missing() -> None: settings.alphavantage_api_key = previous_values['alphavantage_api_key'] -def test_list_connectors_bootstraps_ollama_agent_skills_on_first_load(tmp_path) -> None: +def test_list_connectors_reads_ollama_agent_skills_from_agent_skills_table() -> None: engine = create_engine('sqlite:///:memory:') Base.metadata.create_all(bind=engine) - bootstrap_file = tmp_path / 'skills.json' - bootstrap_file.write_text( - json.dumps( - { - 'agent_skills': { - 'news-analyst': ['Interpret retained catalysts first'], - 'trader-agent': ['Prefer HOLD if the edge is unclear'], - } - } - ), - encoding='utf-8', - ) - - settings = get_settings() - previous_values = { - 'agent_skills_bootstrap_file': settings.agent_skills_bootstrap_file, - 'agent_skills_bootstrap_mode': settings.agent_skills_bootstrap_mode, - 'agent_skills_bootstrap_apply_once': settings.agent_skills_bootstrap_apply_once, - } - try: - settings.agent_skills_bootstrap_file = str(bootstrap_file) - settings.agent_skills_bootstrap_mode = 'merge' - settings.agent_skills_bootstrap_apply_once = True + with Session(engine) as db: + db.add( + AgentSkill( + agent_name='news-analyst', + version=1, + is_active=True, + skills=['Interpret retained catalysts first'], + notes='seed', + created_by_id=None, + ) + ) + db.add( + AgentSkill( + agent_name='trader-agent', + version=1, + is_active=True, + skills=['Prefer HOLD if the edge is unclear'], + notes='seed', + created_by_id=None, + ) + ) + db.commit() + + rows = list_connectors(db, _=None) + by_name = {row.connector_name: row.settings for row in rows} + ollama_settings = by_name['ollama'] + assert ollama_settings['agent_skills']['news-analyst'] == ['Interpret retained catalysts first'] + assert ollama_settings['agent_skills']['trader-agent'] == ['Prefer HOLD if the edge is unclear'] - with Session(engine) as db: - rows = list_connectors(db, _=None) - by_name = {row.connector_name: row.settings for row in rows} - ollama_settings = by_name['ollama'] - assert ollama_settings['agent_skills']['news-analyst'] == ['Interpret retained catalysts first'] - assert ollama_settings['agent_skills']['trader-agent'] == ['Prefer HOLD if the edge is unclear'] - - persisted = db.query(ConnectorConfig).filter(ConnectorConfig.connector_name == 'ollama').first() - assert persisted is not None - assert persisted.settings['agent_skills']['news-analyst'] == ['Interpret retained catalysts first'] - finally: - settings.agent_skills_bootstrap_file = previous_values['agent_skills_bootstrap_file'] - settings.agent_skills_bootstrap_mode = previous_values['agent_skills_bootstrap_mode'] - settings.agent_skills_bootstrap_apply_once = previous_values['agent_skills_bootstrap_apply_once'] + persisted = db.query(ConnectorConfig).filter(ConnectorConfig.connector_name == 'ollama').first() + assert persisted is not None + assert persisted.settings['agent_skills']['news-analyst'] == ['Interpret retained catalysts first'] # --- External MCP tests --- From 1dfa68ff058210f973eb310a6a99624db64f95d2 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 18:06:20 +0200 Subject: [PATCH 14/16] refactor(GH-29): phase 8 remove skill bootstrap module --- backend/app/services/llm/model_selector.py | 16 -- backend/app/services/llm/skill_bootstrap.py | 262 -------------------- backend/tests/unit/test_skill_bootstrap.py | 118 --------- 3 files changed, 396 deletions(-) delete mode 100644 backend/app/services/llm/skill_bootstrap.py delete mode 100644 backend/tests/unit/test_skill_bootstrap.py diff --git a/backend/app/services/llm/model_selector.py b/backend/app/services/llm/model_selector.py index 5e8b0ec..bd5bc6b 100644 --- a/backend/app/services/llm/model_selector.py +++ b/backend/app/services/llm/model_selector.py @@ -542,22 +542,6 @@ def _load_llm_settings(cls, db: Session | None) -> dict: if 'provider' not in settings: settings['provider'] = normalize_llm_provider(runtime_settings.llm_provider, fallback='ollama') - # Docker first-start path: Celery workers can resolve agent settings before - # the FastAPI startup seeded connector configs into the database. - # Synthesize bootstrap skills from env/config so the first analysis run - # sees the expected agent_skills even when the connector row is not yet persisted. - if runtime_settings.agent_skills_bootstrap_file: - from app.services.llm.skill_bootstrap import bootstrap_agent_skills_into_settings - - synthesized_settings, _changed, _status = bootstrap_agent_skills_into_settings( - current_settings=settings, - bootstrap_file=runtime_settings.agent_skills_bootstrap_file, - mode=runtime_settings.agent_skills_bootstrap_mode, - apply_once=runtime_settings.agent_skills_bootstrap_apply_once, - ) - if isinstance(synthesized_settings, dict): - settings = synthesized_settings - cls._settings_cache[db] = (now, settings) if len(cls._settings_cache) > 128: diff --git a/backend/app/services/llm/skill_bootstrap.py b/backend/app/services/llm/skill_bootstrap.py deleted file mode 100644 index e5bf1fc..0000000 --- a/backend/app/services/llm/skill_bootstrap.py +++ /dev/null @@ -1,262 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import re -from pathlib import Path - -from app.services.llm.model_selector import ( - DETERMINISTIC_ONLY_AGENTS, - MAX_AGENT_SKILL_LENGTH, - MAX_AGENT_SKILLS_PER_AGENT, - normalize_agent_name, -) - -BOOTSTRAP_META_KEY = 'agent_skills_bootstrap_meta' -_SKILL_SPLIT_RE = re.compile(r'[\n,]+') - - -def bootstrap_agent_skills_into_settings( - current_settings: dict, - bootstrap_file: str | None, - mode: str = 'merge', - apply_once: bool = True, -) -> tuple[dict, bool, str]: - normalized_settings = dict(current_settings or {}) - source_file = str(bootstrap_file or '').strip() - if not source_file: - return normalized_settings, False, 'disabled' - - payload, error = _load_payload(source_file) - if payload is None: - return normalized_settings, False, f'load-failed:{error or "unknown"}' - - proposed_skills = extract_agent_skills_from_payload(payload) - if not proposed_skills: - return normalized_settings, False, 'no-skills-found' - - normalized_mode = 'replace' if str(mode or '').strip().lower() == 'replace' else 'merge' - fingerprint = _compute_fingerprint(proposed_skills) - existing_meta = normalized_settings.get(BOOTSTRAP_META_KEY) - - if apply_once and isinstance(existing_meta, dict): - existing_fingerprint = str(existing_meta.get('fingerprint') or '').strip() - existing_mode = str(existing_meta.get('mode') or '').strip().lower() - if existing_fingerprint == fingerprint and existing_mode == normalized_mode: - return normalized_settings, False, 'already-applied' - - current_agent_skills = _normalize_agent_skills_map(normalized_settings.get('agent_skills')) - if normalized_mode == 'replace': - next_agent_skills = proposed_skills - else: - next_agent_skills = _merge_agent_skill_maps(current_agent_skills, proposed_skills) - - next_meta = { - 'fingerprint': fingerprint, - 'mode': normalized_mode, - 'source_file': source_file, - } - if next_agent_skills == current_agent_skills and existing_meta == next_meta: - return normalized_settings, False, 'no-op' - - updated_settings = dict(normalized_settings) - updated_settings['agent_skills'] = next_agent_skills - updated_settings[BOOTSTRAP_META_KEY] = next_meta - return updated_settings, True, 'applied' - - -def extract_agent_skills_from_payload(payload: object) -> dict[str, list[str]]: - if not isinstance(payload, dict): - return {} - - direct = _normalize_agent_skills_map(payload.get('agent_skills')) - if direct: - return direct - - from_structured_map = _extract_from_structured_agent_skill_map(payload.get('agent_skill_map')) - if from_structured_map: - return from_structured_map - - return _extract_from_proposal_payload(payload) - - -def _extract_from_structured_agent_skill_map(raw_map: object) -> dict[str, list[str]]: - if not isinstance(raw_map, list): - return {} - - extracted: dict[str, list[str]] = {} - for row in raw_map: - if not isinstance(row, dict): - continue - - agent_name = _clean_text(row.get('agent')) - if not agent_name or agent_name in DETERMINISTIC_ONLY_AGENTS: - continue - - raw_skills = row.get('proposed_skills') - if isinstance(raw_skills, (list, tuple, set)): - extracted[agent_name] = [str(item) for item in raw_skills] - elif isinstance(raw_skills, str): - extracted[agent_name] = [raw_skills] - - return _normalize_agent_skills_map(extracted) - - -def _extract_from_proposal_payload(payload: dict) -> dict[str, list[str]]: - raw_skills = payload.get('skills') - raw_mapping = payload.get('agent_mapping') - if not isinstance(raw_skills, list) or not isinstance(raw_mapping, dict): - return {} - - skill_text_by_id: dict[str, list[str]] = {} - for row in raw_skills: - if not isinstance(row, dict): - continue - skill_id = _clean_text(row.get('id')) - if not skill_id: - continue - - skill_name = _clean_text(row.get('skill_name')) or skill_id - snippets: list[str] = [] - - description = _clean_text(row.get('description')) - if description: - snippets.append(f'{skill_name}: {description}') - - evidence = row.get('evidence') - if isinstance(evidence, dict): - notable_points = evidence.get('notable_points') - if isinstance(notable_points, (list, tuple, set)): - for raw_point in notable_points: - point = _clean_text(raw_point) - if not point: - continue - snippets.append(f'{skill_name}: {point}') - if len(snippets) >= 3: - break - - if snippets: - skill_text_by_id[skill_id] = snippets - - extracted: dict[str, list[str]] = {} - for raw_agent_name, raw_agent_mapping in raw_mapping.items(): - agent_name = _clean_text(raw_agent_name) - if not agent_name or agent_name in DETERMINISTIC_ONLY_AGENTS: - continue - if not isinstance(raw_agent_mapping, dict): - continue - - snippets: list[str] = [] - for key in ('primary_skills', 'secondary_skills'): - raw_skill_ids = raw_agent_mapping.get(key) - if not isinstance(raw_skill_ids, (list, tuple, set)): - continue - for raw_skill_id in raw_skill_ids: - skill_id = _clean_text(raw_skill_id) - if not skill_id: - continue - snippets.extend(skill_text_by_id.get(skill_id, [])) - - notes = _clean_text(raw_agent_mapping.get('notes')) - if notes: - snippets.append(f'Contexte agent: {notes}') - - if snippets: - extracted[agent_name] = snippets - - return _normalize_agent_skills_map(extracted) - - -def _merge_agent_skill_maps(current: dict[str, list[str]], incoming: dict[str, list[str]]) -> dict[str, list[str]]: - merged = {agent_name: list(items) for agent_name, items in current.items()} - for agent_name, new_items in incoming.items(): - existing_items = merged.get(agent_name, []) - merged[agent_name] = _dedupe_skill_items([*existing_items, *new_items]) - return merged - - -def _normalize_agent_skills_map(raw_skills: object) -> dict[str, list[str]]: - if not isinstance(raw_skills, dict): - return {} - - normalized: dict[str, list[str]] = {} - for raw_agent_name, raw_items in raw_skills.items(): - agent_name = normalize_agent_name(_clean_text(raw_agent_name)) - if not agent_name or agent_name in DETERMINISTIC_ONLY_AGENTS: - continue - - items = _coerce_skill_items(raw_items) - deduped = _dedupe_skill_items(items) - if deduped: - merged = list(normalized.get(agent_name, [])) - for item in deduped: - if item in merged: - continue - merged.append(item) - if len(merged) >= MAX_AGENT_SKILLS_PER_AGENT: - break - normalized[agent_name] = merged - return normalized - - -def _coerce_skill_items(raw_items: object) -> list[str]: - if isinstance(raw_items, str): - return [part.strip() for part in _SKILL_SPLIT_RE.split(raw_items)] - if isinstance(raw_items, (list, tuple, set)): - return [str(item).strip() for item in raw_items] - return [] - - -def _dedupe_skill_items(raw_items: list[str]) -> list[str]: - deduped: list[str] = [] - seen: set[str] = set() - for raw_item in raw_items: - cleaned = _clean_text(raw_item) - if not cleaned: - continue - if len(cleaned) > MAX_AGENT_SKILL_LENGTH: - cleaned = cleaned[:MAX_AGENT_SKILL_LENGTH].rstrip() - key = cleaned.lower() - if key in seen: - continue - seen.add(key) - deduped.append(cleaned) - if len(deduped) >= MAX_AGENT_SKILLS_PER_AGENT: - break - return deduped - - -def _clean_text(value: object) -> str: - if value is None: - return '' - text = str(value).strip() - if not text: - return '' - return re.sub(r'\s+', ' ', text) - - -def _compute_fingerprint(agent_skills: dict[str, list[str]]) -> str: - payload = json.dumps(agent_skills, ensure_ascii=False, sort_keys=True) - return hashlib.sha256(payload.encode('utf-8')).hexdigest() - - -def _load_payload(source_file: str) -> tuple[dict | None, str | None]: - path = Path(source_file) - if not path.exists(): - return None, 'file-not-found' - if not path.is_file(): - return None, 'not-a-file' - - try: - content = path.read_text(encoding='utf-8') - except OSError as exc: - return None, f'read-error:{exc}' - - try: - payload = json.loads(content) - except json.JSONDecodeError as exc: - return None, f'invalid-json:{exc.msg}' - - if not isinstance(payload, dict): - return None, 'invalid-root' - return payload, None diff --git a/backend/tests/unit/test_skill_bootstrap.py b/backend/tests/unit/test_skill_bootstrap.py deleted file mode 100644 index c61bcf7..0000000 --- a/backend/tests/unit/test_skill_bootstrap.py +++ /dev/null @@ -1,118 +0,0 @@ -import json - -from app.services.llm.skill_bootstrap import BOOTSTRAP_META_KEY, bootstrap_agent_skills_into_settings, extract_agent_skills_from_payload - - -def test_extract_agent_skills_from_proposal_payload() -> None: - payload = { - 'skills': [ - { - 'id': 'repo-a:risk-management', - 'skill_name': 'risk-management', - 'description': 'Valider le risque avant chaque entree.', - 'evidence': { - 'notable_points': [ - 'Toujours exiger un stop-loss.', - 'Adapter la frequence au regime.', - ], - }, - }, - { - 'id': 'repo-b:macro-view', - 'skill_name': 'macro-view', - 'description': 'Qualifier le regime risk-on/risk-off.', - }, - ], - 'agent_mapping': { - 'news-analyst': { - 'primary_skills': ['repo-a:risk-management'], - 'secondary_skills': ['repo-b:macro-view'], - 'notes': 'Eviter les recits non verifies.', - }, - 'risk-manager': { - 'primary_skills': ['repo-a:risk-management'], - }, - }, - } - - result = extract_agent_skills_from_payload(payload) - - assert 'news-analyst' in result - assert any('risk-management' in line for line in result['news-analyst']) - assert any('macro-view' in line for line in result['news-analyst']) - assert any('Contexte agent:' in line for line in result['news-analyst']) - assert 'risk-manager' in result - - -def test_bootstrap_agent_skills_merge_and_apply_once(tmp_path) -> None: - payload = { - 'agent_skills': { - 'news-analyst': ['Prioriser events macro', 'Citer les risques'], - 'trader-agent': 'Favoriser HOLD si conflit', - } - } - bootstrap_file = tmp_path / 'skills.json' - bootstrap_file.write_text(json.dumps(payload), encoding='utf-8') - - current_settings = { - 'provider': 'ollama', - 'agent_skills': { - 'news-analyst': ['Sources fiables uniquement'], - }, - } - - updated_settings, changed, status = bootstrap_agent_skills_into_settings( - current_settings=current_settings, - bootstrap_file=str(bootstrap_file), - mode='merge', - apply_once=True, - ) - - assert changed is True - assert status == 'applied' - assert updated_settings['agent_skills']['news-analyst'] == [ - 'Sources fiables uniquement', - 'Prioriser events macro', - 'Citer les risques', - ] - assert updated_settings['agent_skills']['trader-agent'] == ['Favoriser HOLD si conflit'] - assert BOOTSTRAP_META_KEY in updated_settings - - updated_settings_2, changed_2, status_2 = bootstrap_agent_skills_into_settings( - current_settings=updated_settings, - bootstrap_file=str(bootstrap_file), - mode='merge', - apply_once=True, - ) - - assert changed_2 is False - assert status_2 == 'already-applied' - assert updated_settings_2 == updated_settings - - -def test_bootstrap_agent_skills_replace_mode_replaces_existing_entries(tmp_path) -> None: - payload = { - 'agent_skills': { - 'news-analyst': ['Nouveau skill unique'], - } - } - bootstrap_file = tmp_path / 'skills-replace.json' - bootstrap_file.write_text(json.dumps(payload), encoding='utf-8') - - current_settings = { - 'agent_skills': { - 'news-analyst': ['Ancien skill'], - 'trader-agent': ['Skill trader existant'], - }, - } - - updated_settings, changed, status = bootstrap_agent_skills_into_settings( - current_settings=current_settings, - bootstrap_file=str(bootstrap_file), - mode='replace', - apply_once=False, - ) - - assert changed is True - assert status == 'applied' - assert updated_settings['agent_skills'] == {'news-analyst': ['Nouveau skill unique']} From 026eaff9ae488ec964c2cd7c629b30c90d3410be Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 18:07:32 +0200 Subject: [PATCH 15/16] chore(GH-29): phase 8 remove bootstrap env settings --- .env.prod.example | 4 --- .../chg-GH-29-plan.md | 26 +++++++------- backend/.env.example | 5 --- backend/app/core/config.py | 4 --- .../tests/unit/test_agent_model_selector.py | 35 ++++--------------- 5 files changed, 20 insertions(+), 54 deletions(-) diff --git a/.env.prod.example b/.env.prod.example index f161510..f5fce90 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -84,10 +84,6 @@ MISTRAL_MODEL=mistral-small-latest MISTRAL_TIMEOUT_SECONDS=30 MISTRAL_INPUT_COST_PER_1M_TOKENS=0 MISTRAL_OUTPUT_COST_PER_1M_TOKENS=0 -AGENT_SKILLS_BOOTSTRAP_FILE=/app/config/agent-skills.json -AGENT_SKILLS_BOOTSTRAP_MODE=merge -AGENT_SKILLS_BOOTSTRAP_APPLY_ONCE=true - METAAPI_TOKEN= METAAPI_ACCOUNT_ID= METAAPI_REGION=new-york diff --git a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md index 80eb669..9aae95e 100644 --- a/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md +++ b/.samourai/docai/changes/2026-06/2026-06-21--GH-29--agent-skills-versioning/chg-GH-29-plan.md @@ -69,7 +69,7 @@ change: ## Phase 4 — API REST (effort : ~1h30) -- [ ] **4.1** Créer les routes REST +- [x] **4.1** Créer les routes REST (ajout `backend/app/api/routes/agent_skills.py`: GET/POST/activate + catalog) - Fichier : `backend/app/api/routes/agent_skills.py` - Endpoints : - `GET /api/v1/agents/{agent_name}/skills` — liste versions (filtre active_only) @@ -78,11 +78,11 @@ change: - `GET /api/v1/agents/catalog` — liste agents avec info skills - Effort : 1h -- [ ] **4.2** Enregistrer les routes dans le router +- [x] **4.2** Enregistrer les routes dans le router (`backend/app/api/router.py` inclut `agent_skills.router`) - Fichier : `backend/app/api/router.py` - Effort : 10 min -- [ ] **4.3** Écrire les tests API +- [x] **4.3** Écrire les tests API (ajout `backend/tests/unit/test_agent_skills_api.py` sur routes directes) - Fichier : `backend/tests/unit/test_agent_skills_api.py` - Couvre : T-API-01 à T-API-08 - Effort : 1h @@ -91,14 +91,14 @@ change: ## Phase 5 — Adaptation de `resolve_skills()` (effort : ~1h30) -- [ ] **5.1** Modifier `AgentModelSelector.resolve_skills()` +- [x] **5.1** Modifier `AgentModelSelector.resolve_skills()` (source primaire table `agent_skills` active + fallback legacy `settings.agent_skills` + fallback `SKILL.md`; cache skills actif ajouté) - Fichier : `backend/app/services/llm/model_selector.py` - Changement : lire depuis table `agent_skills` au lieu de `settings.agent_skills` - Conserver le fallback SKILL.md si table vide pour l'agent - Adapter le cache (TTL séparé ou invalidation) - Effort : 1h -- [ ] **5.2** Adapter les tests de `model_selector` +- [x] **5.2** Adapter les tests de `model_selector` (priorité DB vs connector + fallback fichier validés, `python3 -m pytest -q tests/unit/test_agent_model_selector.py` PASS) - Fichier : `backend/tests/unit/test_agent_model_selector.py` - Mocker la nouvelle table au lieu de `settings.agent_skills` - Effort : 30 min @@ -107,14 +107,14 @@ change: ## Phase 6 — Seed au startup (effort : ~1h) -- [ ] **6.1** Modifier `main.py` : remplacer bootstrap par seed +- [x] **6.1** Modifier `main.py` : remplacer bootstrap par seed (`bootstrap_agent_skills_into_settings()` retiré du startup, `AgentSkillsService().seed_defaults(db)` ajouté) - Fichier : `backend/app/main.py` - Supprimer l'appel à `bootstrap_agent_skills_into_settings()` - Ajouter `AgentSkillsService.seed_defaults(db)` au startup - Le seed lit les fichiers SKILL.md et crée version 1 si la table est vide pour l'agent - Effort : 30 min -- [ ] **6.2** Écrire les tests de seed +- [x] **6.2** Écrire les tests de seed (ajout `backend/tests/unit/test_agent_skills_seed.py`, seed initial + idempotence validés) - Fichier : `backend/tests/unit/test_agent_skills_seed.py` - Couvre : T-SEED-01, T-SEED-02 - Effort : 30 min @@ -123,13 +123,13 @@ change: ## Phase 7 — Backward compatibility connectors (effort : ~45 min) -- [ ] **7.1** Adapter GET /connectors pour servir skills depuis nouvelle table +- [x] **7.1** Adapter GET /connectors pour servir skills depuis nouvelle table (`backend/app/api/routes/connectors.py`: injection `settings.agent_skills` depuis `agent_skills` actives, bootstrap retiré) - Fichier : `backend/app/api/routes/connectors.py` - Dans le GET : lire les skills actives depuis `agent_skills` et les injecter dans `settings.agent_skills` de la réponse - Retirer la normalisation `_normalize_agent_skills()` et le bootstrap dans cette route - Effort : 30 min -- [ ] **7.2** Adapter les tests connectors +- [x] **7.2** Adapter les tests connectors (`backend/tests/unit/test_connectors_settings_sanitization.py` mis à jour, `python3 -m pytest -q tests/unit/test_connectors_settings_sanitization.py` PASS) - Fichier : `backend/tests/unit/test_connectors_settings_sanitization.py` - Retirer les assertions sur `agent_skills` dans settings comme source de vérité - Effort : 15 min @@ -138,20 +138,20 @@ change: ## Phase 8 — Suppression du bootstrap (effort : ~45 min) -- [ ] **8.1** Supprimer `skill_bootstrap.py` +- [x] **8.1** Supprimer `skill_bootstrap.py` (fichier `backend/app/services/llm/skill_bootstrap.py` supprimé) - Fichier à supprimer : `backend/app/services/llm/skill_bootstrap.py` - Effort : 5 min -- [ ] **8.2** Supprimer les variables d'env bootstrap +- [x] **8.2** Supprimer les variables d'env bootstrap (`backend/app/core/config.py`, `backend/.env.example`, `.env.prod.example` nettoyés) - Fichiers : `backend/app/core/config.py`, `backend/.env`, `backend/.env.example`, `.env.prod.example` - Retirer : `AGENT_SKILLS_BOOTSTRAP_FILE`, `AGENT_SKILLS_BOOTSTRAP_MODE`, `AGENT_SKILLS_BOOTSTRAP_APPLY_ONCE` - Effort : 15 min -- [ ] **8.3** Supprimer/adapter les tests du bootstrap +- [x] **8.3** Supprimer/adapter les tests du bootstrap (`backend/tests/unit/test_skill_bootstrap.py` supprimé, tests selector ajustés) - Fichier à supprimer : `backend/tests/unit/test_skill_bootstrap.py` - Effort : 5 min -- [ ] **8.4** Nettoyer les imports et références +- [x] **8.4** Nettoyer les imports et références (`model_selector.py` et `connectors.py` sans import bootstrap, `python3 -m pytest -q tests/unit/test_agent_model_selector.py tests/unit/test_connectors_settings_sanitization.py` PASS) - Fichiers : tout import de `skill_bootstrap` dans le codebase - Effort : 15 min diff --git a/backend/.env.example b/backend/.env.example index a6ece77..3901004 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -84,11 +84,6 @@ MISTRAL_TIMEOUT_SECONDS=30 MISTRAL_INPUT_COST_PER_1M_TOKENS=0 MISTRAL_OUTPUT_COST_PER_1M_TOKENS=0 -# Agent skills bootstrap (optional JSON file loaded on startup) -AGENT_SKILLS_BOOTSTRAP_FILE=/app/config/agent-skills.json -AGENT_SKILLS_BOOTSTRAP_MODE=merge -AGENT_SKILLS_BOOTSTRAP_APPLY_ONCE=true - # --- Broker — MetaApi (MT4/MT5) --- METAAPI_TOKEN= METAAPI_ACCOUNT_ID= diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d67822c..29f88de 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -70,10 +70,6 @@ class Settings(BaseSettings): mistral_input_cost_per_1m_tokens: float = Field(default=0.0, alias='MISTRAL_INPUT_COST_PER_1M_TOKENS') mistral_output_cost_per_1m_tokens: float = Field(default=0.0, alias='MISTRAL_OUTPUT_COST_PER_1M_TOKENS') decision_mode: str = Field(default='balanced', alias='DECISION_MODE') - agent_skills_bootstrap_file: str = Field(default='', alias='AGENT_SKILLS_BOOTSTRAP_FILE') - agent_skills_bootstrap_mode: str = Field(default='merge', alias='AGENT_SKILLS_BOOTSTRAP_MODE') - agent_skills_bootstrap_apply_once: bool = Field(default=True, alias='AGENT_SKILLS_BOOTSTRAP_APPLY_ONCE') - metaapi_token: str = Field(default='', alias='METAAPI_TOKEN') metaapi_account_id: str = Field(default='', alias='METAAPI_ACCOUNT_ID') metaapi_region: str = Field(default='new-york', alias='METAAPI_REGION') diff --git a/backend/tests/unit/test_agent_model_selector.py b/backend/tests/unit/test_agent_model_selector.py index 682741c..fa41af0 100644 --- a/backend/tests/unit/test_agent_model_selector.py +++ b/backend/tests/unit/test_agent_model_selector.py @@ -1,9 +1,6 @@ -import json - from sqlalchemy import create_engine from sqlalchemy.orm import Session -from app.core.config import get_settings from app.db.base import Base from app.db.models.agent_skill import AgentSkill from app.db.models.connector_config import ConnectorConfig @@ -209,35 +206,17 @@ def test_agent_model_selector_reads_skill_file_when_db_and_connector_missing() - assert skills != [] -def test_agent_model_selector_synthesizes_bootstrap_skills_when_connector_missing(tmp_path, monkeypatch) -> None: +def test_agent_model_selector_falls_back_to_skill_file_when_connector_missing() -> None: engine = create_engine('sqlite:///:memory:') Base.metadata.create_all(bind=engine) - - bootstrap_file = tmp_path / 'skills.json' - bootstrap_file.write_text( - json.dumps( - { - 'agent_skills': { - 'news-analyst': ['Interpret retained catalysts first'], - } - } - ), - encoding='utf-8', - ) - - monkeypatch.setenv('AGENT_SKILLS_BOOTSTRAP_FILE', str(bootstrap_file)) - monkeypatch.setenv('AGENT_SKILLS_BOOTSTRAP_MODE', 'merge') - monkeypatch.setenv('AGENT_SKILLS_BOOTSTRAP_APPLY_ONCE', 'true') - get_settings.cache_clear() AgentModelSelector.clear_cache() - try: - selector = AgentModelSelector() - with Session(engine) as db: - assert selector.resolve_skills(db, 'news-analyst') == ['Interpret retained catalysts first'] - finally: - get_settings.cache_clear() - AgentModelSelector.clear_cache() + selector = AgentModelSelector() + with Session(engine) as db: + resolved = selector.resolve_skills(db, 'news-analyst') + + assert isinstance(resolved, list) + assert len(resolved) >= 1 def test_agent_model_selector_resolves_decision_mode_with_fallback() -> None: From 1676f39da5158fa547a3fccad1fe25b82a113b62 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 18:11:33 +0200 Subject: [PATCH 16/16] feat(GH-29): phase 9 wire frontend to agent skills API --- frontend/src/api/client.ts | 27 +++++ frontend/src/pages/ConnectorsPage.tsx | 143 +++++++++++++++++--------- frontend/src/types/index.ts | 13 ++- 3 files changed, 131 insertions(+), 52 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 06feaef..ef7d04a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -209,6 +209,33 @@ export const api = { body: JSON.stringify(payload), }, token), listPrompts: (token: string) => request('/prompts', {}, token), + listAgentSkills: (token: string, agentName: string, activeOnly = false) => + request( + `/agents/${encodeURIComponent(agentName)}/skills${activeOnly ? '?active_only=true' : ''}`, + {}, + token, + ), + createAgentSkillVersion: ( + token: string, + agentName: string, + payload: { skills: string[]; notes?: string; activate?: boolean }, + ) => + request( + `/agents/${encodeURIComponent(agentName)}/skills`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + token, + ), + activateAgentSkillVersion: (token: string, agentName: string, skillId: number) => + request( + `/agents/${encodeURIComponent(agentName)}/skills/${skillId}/activate`, + { + method: 'POST', + }, + token, + ), createPrompt: ( token: string, payload: { agent_name: string; system_prompt: string; user_prompt_template: string; notes?: string }, diff --git a/frontend/src/pages/ConnectorsPage.tsx b/frontend/src/pages/ConnectorsPage.tsx index 2e3b467..c508620 100644 --- a/frontend/src/pages/ConnectorsPage.tsx +++ b/frontend/src/pages/ConnectorsPage.tsx @@ -3,6 +3,7 @@ import { api } from '../api/client'; import { CRYPTO_PAIRS, FOREX_PAIRS, TRADEABLE_PAIRS } from '../constants/markets'; import { useAuth } from '../hooks/useAuth'; import type { + AgentSkillVersion, ConnectorConfig, ExecutionMode, ExternalMcpConfig, @@ -416,6 +417,10 @@ export function ConnectorsPage() { const [promptSystem, setPromptSystem] = useState(AGENT_PROMPT_FALLBACKS['news-analyst'].system); const [promptUser, setPromptUser] = useState(AGENT_PROMPT_FALLBACKS['news-analyst'].user); const [promptSaving, setPromptSaving] = useState(false); + const [skillsSaving, setSkillsSaving] = useState(false); + const [agentSkillVersions, setAgentSkillVersions] = useState>( + Object.fromEntries(MODEL_EDIT_AGENTS.map((agent) => [agent, []])), + ); const [marketSymbols, setMarketSymbols] = useState({ forex_pairs: FOREX_PAIRS, @@ -539,9 +544,6 @@ export function ConnectorsPage() { const rawMap = settings.agent_models && typeof settings.agent_models === 'object' ? (settings.agent_models as Record) : {}; - const rawSkills = settings.agent_skills && typeof settings.agent_skills === 'object' - ? (settings.agent_skills as Record) - : {}; const rawEnabled = settings.agent_llm_enabled && typeof settings.agent_llm_enabled === 'object' ? (settings.agent_llm_enabled as Record) : {}; @@ -577,16 +579,7 @@ export function ConnectorsPage() { MODEL_EDIT_AGENTS.forEach((agentName) => { const value = legacyAwareValue(rawMap, agentName); next[agentName] = typeof value === 'string' ? value : ''; - const skillsValue = legacyAwareValue(rawSkills, agentName); - if (NON_SWITCHABLE_LLM_AGENTS.has(agentName)) { - nextSkills[agentName] = []; - } else if (Array.isArray(skillsValue)) { - nextSkills[agentName] = skillsValue.map((item) => String(item).trim()).filter((item) => item.length > 0); - } else if (typeof skillsValue === 'string') { - nextSkills[agentName] = parseSkillsInput(skillsValue); - } else { - nextSkills[agentName] = []; - } + nextSkills[agentName] = []; if (!SWITCHABLE_LLM_AGENTS.has(agentName)) { nextEnabled[agentName] = false; return; @@ -727,7 +720,7 @@ export function ConnectorsPage() { const loadAll = async () => { if (!token) return; try { - const [c, a, p, s, usage, symbols] = await Promise.all([ + const [c, a, p, s, usage, symbols, skillsRows] = await Promise.all([ api.listConnectors(token), api.listMetaApiAccounts(token), api.listPrompts(token), @@ -740,6 +733,17 @@ export function ConnectorsPage() { tradeable_pairs: TRADEABLE_PAIRS, source: 'fallback', })), + Promise.all( + MODEL_EDIT_AGENTS.map(async (agentName) => { + if (NON_SWITCHABLE_LLM_AGENTS.has(agentName)) return [agentName, []] as const; + try { + const rows = (await api.listAgentSkills(token, agentName, false)) as AgentSkillVersion[]; + return [agentName, Array.isArray(rows) ? rows : []] as const; + } catch { + return [agentName, []] as const; + } + }), + ), ]); const connectorRows = c as ConnectorConfig[]; const accountRows = a as MetaApiAccount[]; @@ -761,6 +765,16 @@ export function ConnectorsPage() { setPrompts(p as PromptTemplate[]); setSummary(s as LlmSummary); setModelsUsage(usage as LlmModelUsage[]); + const byAgentSkills = Object.fromEntries(skillsRows) as Record; + setAgentSkillVersions(byAgentSkills); + setAgentSkills( + Object.fromEntries( + MODEL_EDIT_AGENTS.map((agentName) => { + const active = (byAgentSkills[agentName] ?? []).find((row) => row.is_active); + return [agentName, active?.skills ?? []] as const; + }), + ), + ); setMarketSymbols({ forex_pairs: forexPairs, crypto_pairs: cryptoPairs, @@ -821,15 +835,6 @@ export function ConnectorsPage() { setPromptAgent(PROMPT_EDITABLE_AGENTS[0] ?? 'news-analyst'); }, [promptAgent]); - const toggleConnector = async (connector: ConnectorConfig) => { - if (!token) return; - await api.updateConnector(token, connector.connector_name, { - enabled: !connector.enabled, - settings: connector.settings, - }); - await loadAll(); - }; - const testConnector = async (name: string) => { if (!token) return; try { @@ -851,6 +856,7 @@ export function ConnectorsPage() { }; const handleRefreshExternalMcp = async (mcp: ExternalMcpConfig) => { + if (!token) return; try { const result = await api.discoverExternalMcp(token, mcp.url, mcp.headers); const mcpNameSlug = mcp.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); @@ -869,7 +875,7 @@ export function ConnectorsPage() { assigned_agents: mcp.assigned_agents, discovered_tools: discovered, }); - const freshConnectors = await api.listConnectors(token); + const freshConnectors = await api.listConnectors(token) as ConnectorConfig[]; setConnectors(freshConnectors); const ollama = freshConnectors.find((c) => c.connector_name === 'ollama'); if (ollama && Array.isArray(ollama.settings?.external_mcps)) { @@ -881,6 +887,7 @@ export function ConnectorsPage() { }; const handleDeleteExternalMcp = async (mcpId: string, agentName: string) => { + if (!token) return; try { await api.deleteExternalMcp(token, mcpId, agentName); setExternalMcps((prev) => @@ -899,8 +906,9 @@ export function ConnectorsPage() { const handleMcpSaved = async (_mcpId: string) => { setMcpModal(null); + if (!token) return; try { - const freshConnectors = await api.listConnectors(token); + const freshConnectors = await api.listConnectors(token) as ConnectorConfig[]; setConnectors(freshConnectors); // keep connectors state fresh so saveAgentModels sees updated settings const ollama = freshConnectors.find((c) => c.connector_name === 'ollama'); if (ollama && Array.isArray(ollama.settings?.external_mcps)) { @@ -939,12 +947,6 @@ export function ConnectorsPage() { const cleanedEnabled = Object.fromEntries( MODEL_EDIT_AGENTS.map((agentName) => [agentName, SWITCHABLE_LLM_AGENTS.has(agentName) ? Boolean(agentLlmEnabled[agentName]) : false]), ); - const cleanedSkills = Object.fromEntries( - Object.entries(agentSkills) - .filter(([agentName]) => !NON_SWITCHABLE_LLM_AGENTS.has(agentName)) - .map(([agentName, skills]) => [agentName, normalizeSkillsList(skills ?? [])] as const) - .filter(([, skills]) => Array.isArray(skills) && skills.length > 0), - ); const cleanedAgentTools = Object.fromEntries( MODEL_EDIT_AGENTS .map((agentName) => { @@ -977,7 +979,6 @@ export function ConnectorsPage() { default_model: defaultLlmModel.trim() || defaultModelForProvider(llmProvider), agent_models: cleanedModels, agent_llm_enabled: cleanedEnabled, - agent_skills: cleanedSkills, agent_tools: cleanedAgentTools, }, }); @@ -1049,6 +1050,7 @@ export function ConnectorsPage() { if (!token) return; try { setPromptSaving(true); + setSkillsSaving(true); setError(null); // 1. Create + activate new prompt version @@ -1059,22 +1061,13 @@ export function ConnectorsPage() { })) as PromptTemplate; await api.activatePrompt(token, created.id); - // 2. Save skills to connector settings (same atomic action) - const ollama = connectors.find((item) => item.connector_name === 'ollama'); - if (ollama) { - const cleanedSkills = Object.fromEntries( - Object.entries(agentSkills) - .filter(([agentName]) => !NON_SWITCHABLE_LLM_AGENTS.has(agentName)) - .map(([agentName, skills]) => [agentName, normalizeSkillsList(skills ?? [])] as const) - .filter(([, skills]) => Array.isArray(skills) && skills.length > 0), - ); - const existingSettings = (ollama.settings ?? {}) as Record; - await api.updateConnector(token, 'ollama', { - enabled: ollama.enabled, - settings: { - ...existingSettings, - agent_skills: cleanedSkills, - }, + // 2. Create + activate new skills version via dedicated API + const nextSkills = normalizeSkillsList(agentSkills[promptAgent] ?? []); + if (nextSkills.length > 0) { + await api.createAgentSkillVersion(token, promptAgent, { + skills: nextSkills, + activate: true, + notes: 'Updated from ConnectorsPage prompt editor', }); } @@ -1083,6 +1076,21 @@ export function ConnectorsPage() { setError(err instanceof Error ? err.message : 'Cannot create prompt & skills'); } finally { setPromptSaving(false); + setSkillsSaving(false); + } + }; + + const activateSkillVersion = async (agentName: string, skillId: number) => { + if (!token) return; + try { + setSkillsSaving(true); + setError(null); + await api.activateAgentSkillVersion(token, agentName, skillId); + await loadAll(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Cannot activate skill version'); + } finally { + setSkillsSaving(false); } }; @@ -1605,7 +1613,7 @@ export function ConnectorsPage() {

- Skills are modified in the prompt editor below, then saved via "Save agent skills". + Skills are versioned per agent via dedicated API and no longer saved in connector settings.

Runtime tools per agent are saved with "Save models". All authorized tools are enabled by default. @@ -1647,11 +1655,44 @@ export function ConnectorsPage() { placeholder={'e.g.:\nPrioritize high-impact events for the analyzed instrument\nExplicitly flag uncertainties'} /> - +

Selected agent: {promptAgent} | active version: v{activePromptByAgent.get(promptAgent)?.version ?? 0}

+ + + + + + + + + + + + {MODEL_EDIT_AGENTS.flatMap((agentName) => (agentSkillVersions[agentName] ?? []).map((version) => ( + + + + + + + + )))} + +
AgentSkills versionStatusRulesAction
{agentName}v{version.version}{version.is_active ? 'active' : 'inactive'}{(version.skills ?? []).length} rule(s) + {!version.is_active && ( + + )} +
@@ -2102,7 +2143,7 @@ export function ConnectorsPage() { {mcpModal && ( setMcpModal(null)} onSaved={handleMcpSaved} /> diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2e9372e..f279b15 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -303,6 +303,18 @@ export interface PromptTemplate { updated_at: string; } +export interface AgentSkillVersion { + id: number; + agent_name: string; + version: number; + is_active: boolean; + skills: string[]; + notes?: string | null; + created_by_id?: number | null; + created_at: string; + updated_at: string; +} + export interface AgentValidationDetail { bar: number; time: string; @@ -359,4 +371,3 @@ export interface LlmModelUsage { success_calls: number; last_seen?: string | null; } -