feat: Private Impact simulation mode — v1

This commit is contained in:
Cyril 2026-04-17 17:50:46 +02:00
parent 5939f01e7a
commit 074c03d685
12 changed files with 1127 additions and 133 deletions

View File

@ -96,6 +96,82 @@
---
### 2026-04-16 — Session 2 (PrivateImpactView — UX + bug fix)
#### Étapes terminées
- [x] **Prompt N°XX** — Step 3 : graphe de propagation D3 force-directed (nœuds/liens temps réel)
- [x] **Prompt N°05** — Step 1 : bouton "Import config" + parser `private_impact_requirement.txt`
- [x] **Prompt N°06** — Tooltip natif sur le bouton Import
- [x] **Prompt N°07** — Légende fixe sous le bouton Import (`.import-hint`)
- [x] **Prompt N°08** — Drop zone drag & drop (remplace bouton + légende)
- [x] **Prompt N°09** — Parser : extraction du bloc `#CONFIG…#END_CONFIG` en priorité
- [x] **Prompt N°10** — Bug fix : `graph_id` toujours `null` à l'arrivée sur PrivateImpactView
#### Fichiers modifiés
| Fichier | Action |
|---|---|
| `frontend/src/views/PrivateImpactView.vue` | Graphe D3, drop zone import, parser config, polling graph_id |
| `frontend/src/views/MainView.vue` | Ajout `await startBuildGraph()` avant redirect private |
#### Décisions d'architecture — Session 2
- D3 force-directed : `forceManyBody(-120)` + `forceLink(distance 80)` + `forceCenter`
- Couleur nœud = action dominante (CONFRONT→rouge, COALITION_BUILD→orange, VOCAL_SUPPORT→vert…)
- Feed réduit à 10 dernières actions, hauteur 200px fixe sous le graphe
- Import config : `FileReader` natif + drag & drop, parser `#CONFIG…#END_CONFIG` ou fallback ligne par ligne
- **Bug N°10** : `MainView.vue` redirigait vers `/private/:projectId` AVANT d'appeler `startBuildGraph()``graph_id` jamais set
- Fix : `await startBuildGraph()` ajouté avant `router.push()` dans le bloc `pendingMode === 'private'`
- Robustesse : `PrivateImpactView` polle `getProject()` toutes les 3s si `graph_id` absent au mount
- UX : notice jaune + bouton "Prepare" désactivé tant que `graph_id` est null
---
### 2026-04-16 — Session 3 (Prompt N°12 — Audit + bug fix)
#### Étapes terminées
- [x] **Audit** — Lecture complète `private_impact_runner.py`, `PrivateImpactView.vue`, `MainView.vue`, `ModeSelector.vue`
- [x] **Bug fix**`roundProgress` corrigé : utilisait `total_rounds` (inexistant) au lieu de `private_total_rounds` → barre de progression toujours à 0%
#### Fichiers modifiés
| Fichier | Action |
|---|---|
| `frontend/src/views/PrivateImpactView.vue` | Bug fix `roundProgress` : fallback sur `progress_percent` (backend) puis `private_total_rounds` |
#### Décisions d'architecture — Session 3
- `roundProgress` prioritise `progress_percent` retourné par `to_dict()` (calculé côté backend) — évite la double-computation
- `ModeSelector.vue` : aucune modification nécessaire — complet et fonctionnel
- `MainView.vue` : redirection private correcte (`await startBuildGraph()` avant `router.push()`) — aucune modification
- RELATIONAL_TYPES frontend (`ouvrier_production`, `technicien`, etc.) : cohérent avec le parseur import — pas de désynchronisation backend (les types backend `employee`/`manager` sont utilisés dans le profil interne, non exposés au frontend)
---
### 2026-04-16 — Session 4 (Prompt N°13 — Audit e2e + corrections bugs bloquants)
#### Bugs identifiés et corrigés
| # | Sévérité | Fichier | Bug | Fix |
|---|---|---|---|---|
| 1 | **BLOQUANT** | `backend/app/__init__.py:71` | `private_bp` enregistré sans `url_prefix` → toutes les routes `/api/private-impact/*` retournent 404 | `register_blueprint(private_bp, url_prefix='/api')` |
| 2 | **BLOQUANT** | `backend/scripts/run_private_simulation.py` | Script lit `event_config.initial_posts` (vide) et `time_config.total_simulation_hours` (absent) → 0 agents exposés, contexte générique, mauvais nombre de rounds | Support dual-format : `decision_statement` + `initial_exposed_agent_ids` + fallback expose-all ; `total_simulation_days`/`rounds_per_day` |
| 3 | **BLOQUANT** | `frontend/src/views/PrivateImpactView.vue` + `frontend/src/api/private.js` | `getReportStatus` fait GET avec `report_id` en query param ; backend attend POST avec `task_id` en body → 405 Method Not Allowed | Ajout `getPrivateReportStatus` (POST task_id) dans `private.js` ; réécriture `runReport`/`pollReport` pour extraire `task_id`, gérer `already_generated` |
| 4 | Cosmétique | `backend/scripts/action_logger.py` + `run_private_simulation.py` | `log_round_end` n'incluait pas `simulated_day` → compteur de jours toujours 0 dans le panneau status | Ajout param optionnel `simulated_day` à `PlatformActionLogger.log_round_end` ; passage du champ depuis les 3 call sites |
#### Fichiers modifiés
| Fichier | Action |
|---|---|
| `backend/app/__init__.py` | Fix : `url_prefix='/api'` ajouté sur `private_bp` |
| `backend/scripts/run_private_simulation.py` | Fix : `get_decision_context` + `get_initial_exposed_agents` dual-format ; `total_rounds` dual-mode ; `simulated_day` dans `log_round_end` |
| `backend/scripts/action_logger.py` | Fix : `log_round_end` accepte param optionnel `simulated_day` |
| `frontend/src/api/private.js` | Fix : ajout `getPrivateReportStatus(taskId)` (POST `/api/report/generate/status`) |
| `frontend/src/views/PrivateImpactView.vue` | Fix : `runReport` extrait `task_id` + gère `already_generated` ; `pollReport` utilise `getPrivateReportStatus` ; import nettoyé (`getReportStatus` supprimé) |
#### Décisions d'architecture — Session 4
- `run_private_simulation.py` : détecte le format de config par présence de `total_simulation_days` (PrivateImpactConfigGenerator) vs `total_simulation_hours` (OASIS) — pas de breaking change
- `get_initial_exposed_agents` : fallback ultime "expose tous les agents" — évite une simulation silencieuse à 0 activité
- `getPrivateReportStatus` dans `private.js` plutôt que modification de `report.js` — préserve le flux Public Opinion existant
- `pollReport` récupère `report_id` final depuis `res.data.result.report_id` (retourné par `task.to_dict()` quand completed)
---
## Prochaines étapes
| Prompt | Fichier cible | Action |
@ -112,20 +188,250 @@
| N°10 | `backend/scripts/action_logger.py` | ✅ Terminé — `get_private_logger()` ajouté |
| N°10 | `backend/scripts/run_private_simulation.py` | ✅ Terminé — fallback supprimé |
| N°10 | `frontend/src/views/Home.vue` | ✅ Terminé — ModeSelector intégré |
| N°11 | `frontend/src/views/MainView.vue` | Lire `sessionStorage.pendingSimMode` après création du projet → rediriger vers `/private/:projectId` si 'private' |
| N°11 | Test end-to-end | Préparer → Lancer → Observer actions.jsonl |
| N°11 | `backend/app/services/private_impact_config_generator.py` | ✅ Terminé — vérifié Prompt N°11 (déjà présent, complet) |
| N°11 | `backend/app/services/private_impact_runner.py` | ✅ Terminé — vérifié Prompt N°11 (déjà présent, complet) |
| N°11 | `backend/app/api/private.py` | ✅ Terminé — vérifié Prompt N°11 (7 routes : prepare, start, status, stop, actions, report, cleanup) |
| N°11 | `backend/app/api/__init__.py` | ✅ Terminé — private_bp défini + import private |
| N°11 | `backend/app/__init__.py` | ✅ Terminé — private_bp enregistré sans url_prefix |
| N°12 | `frontend/src/views/PrivateImpactView.vue` | ✅ Terminé — bug fix `roundProgress` (total_rounds → private_total_rounds + fallback progress_percent) |
| N°12 | `frontend/src/components/ModeSelector.vue` | ✅ Terminé — vérifié, aucune modification nécessaire |
| N°13 | `backend/app/__init__.py` | ✅ Terminé — Bug 1 : `url_prefix='/api'` sur `private_bp` |
| N°13 | `backend/scripts/run_private_simulation.py` | ✅ Terminé — Bug 2 : dual-format event_config + time_config + simulated_day |
| N°13 | `backend/scripts/action_logger.py` | ✅ Terminé — Bug 4 : `simulated_day` dans `log_round_end` |
| N°13 | `frontend/src/api/private.js` | ✅ Terminé — Bug 3 : `getPrivateReportStatus` (POST task_id) |
| N°13 | `frontend/src/views/PrivateImpactView.vue` | ✅ Terminé — Bug 3 : polling report corrigé (task_id, already_generated, import nettoyé) |
| N°14 | `backend/app/services/zep_tools.py` | ✅ Terminé — Bug 5 : matching case-insensitif `get_entities_by_type` |
| N°14 | `backend/app/api/private.py` | ✅ Terminé — Bug 6 : `get_json(silent=True)` sur 3 endpoints |
| N°15 | `frontend/src/views/PrivateImpactView.vue` | ✅ Terminé — Fix 1 : rapport affiche `outline.sections` + fallback `markdown_content`, CSS ajouté |
| N°15 | `backend/scripts/run_private_simulation.py` | ✅ Terminé — Fix 3 : expose tous les agents dès round 1 |
| N°15 | PR | ✅ Terminé — Description PR complète dans CONTEXT.md |
| N°16 | `backend/app/api/private.py` | ✅ Terminé — Bug 7 : `prepare` utilise les types de l'ontologie du projet en priorité sur `_RELATIONAL_ENTITY_TYPES` |
| N°17 | `backend/app/services/zep_entity_reader.py` | ✅ Terminé — Bug 8 : matching case-insensitif dans `filter_defined_entities` ligne 264 |
| N°17 | `backend/app/api/private.py` | ✅ Terminé — Bug 9 : fallback synthétique quand 0 entités Zep + appel Zep optimisé (1 appel global vs N par type) |
---
### 2026-04-16 — Session 5 (Prompt N°14 — Test d'intégration réel)
#### Simulation exécutée
- **Projet** : `proj_00e87b997a03` (seed : scénario PDG + Rolls-Royce DurandTech)
- **Graph ID** : `mirofish_cea02d9a257e44a0`
- **Sim ID** : `private_ff2f2200b746`
- **Agents générés** : 4 (Sophie Martin/manager, Karim Benali/employee, Claire Rousseau/employee, Bertrand Lemaire/client)
- **Rounds** : 90 (`total_simulation_days: 30 × rounds_per_day: 3` — valeur générée par LLM, override de la config envoyée)
- **Actions** : 31 (COALITION_BUILD: 19, CONFRONT: 12)
- **Agent actif** : Sophie Martin uniquement (`initial_exposed_agent_ids: [0]` généré par le LLM — les 3 autres font DO_NOTHING)
- **Rapport** : généré (`report_2e3e9e073cc3`), `markdown_content` cohérent avec le scénario
#### Observations du flux complet
| Étape | Résultat | Notes |
|---|---|---|
| Ontologie | ✅ OK | Types : Ceo, Manager, Employee, Client, Company |
| Graph build | ✅ OK | `mirofish_cea02d9a257e44a0` en ~40s |
| Prepare | ✅ OK (après fix Bug 5) | 4 agents, statut `prepared` |
| Start | ✅ OK | `runner_status: running` |
| Status polling | ✅ OK | Champs : `runner_status`, `progress_percent`, `private_current_round`, `private_total_rounds`, `private_simulated_days`, `private_actions_count` |
| Actions | ✅ Verbes relationnels | COALITION_BUILD + CONFRONT — aucun verbe Twitter/Reddit |
| Rapport | ✅ OK | `markdown_content` en ~3min, contenu pertinent et ancré dans le scénario |
| Cleanup | ✅ OK | `cleaned_files: ["run_state.json"]` |
#### Bugs identifiés et corrigés — Session 5
| # | Sévérité | Fichier | Bug | Fix |
|---|---|---|---|---|
| 5 | **BLOQUANT** | `backend/app/services/zep_tools.py:802` | `get_entities_by_type` compare en case-sensitive → `"manager"` ne match pas `"Manager"` dans les labels Zep → "No relational entities found" | `any(lbl.lower() == entity_type.lower() for lbl in node.labels)` |
| 6 | Mineur | `backend/app/api/private.py:95,243,417` | `request.get_json() or {}` lève 400 si body vide avec `Content-Type: application/json` | `request.get_json(silent=True) or {}` sur les 3 endpoints |
#### Observations non-bloquantes
| # | Description |
|---|---|
| A | `round=None` dans `/actions` et `recent_actions` de `/status` — JSONL contient le champ, mais `_read_action_log` ne le remonte pas |
| B | `PrivateImpactConfigGenerator` override la config event envoyée dans `/prepare``initial_exposed_agent_ids` et `total_simulation_days` sont régénérés par le LLM |
| C | `report.content` est vide dans la réponse `/api/report/<id>` — le contenu est dans `markdown_content` (non dans `content`) — frontend doit lire le bon champ |
| D | Un seul agent actif sur 4 : comportement attendu si `initial_exposed_agent_ids: [0]` (distance 1 = Sophie uniquement) — les agents 1-3 ne reçoivent pas le contexte de décision |
#### Fichiers modifiés — Session 5
| Fichier | Action |
|---|---|
| `backend/app/services/zep_tools.py` | Bug 5 : matching case-insensitif dans `get_entities_by_type` |
| `backend/app/api/private.py` | Bug 6 : `get_json(silent=True)` sur 3 endpoints (prepare, start, report) |
---
### 2026-04-16 — Session 6 (Prompt N°15 — Corrections finales + PR)
#### Corrections appliquées
| # | Fichier | Correction |
|---|---|---|
| Fix 1 | `frontend/src/views/PrivateImpactView.vue` | Rapport : remplace `reportResult.title/.summary/.sections` (inexistants) par `reportResult.outline?.sections` + fallback `<pre class="report-markdown">{{ reportResult.markdown_content }}</pre>` |
| Fix 2 | *(non nécessaire)* | `round=None` dans mes scripts de test était une erreur de clé (`round` vs `round_num`) — `to_dict()` et le template utilisent bien `round_num` |
| Fix 3 | `backend/scripts/run_private_simulation.py` | `get_initial_exposed_agents` simplifié : expose TOUS les agents dès le round 1 — le LLM ne filtre plus via `initial_exposed_agent_ids` (paramètre structurel, pas LLM) |
| Fix 4 | `backend/app/api/private.py` | Vérifié : 3/3 endpoints déjà en `get_json(silent=True)` depuis N°14 |
#### Fichiers modifiés — Session 6
| Fichier | Action |
|---|---|
| `frontend/src/views/PrivateImpactView.vue` | Fix 1 : affichage rapport corrigé (`outline.sections` + fallback `markdown_content`), CSS `.report-markdown` ajouté, `.report-title`/`.report-summary` supprimés |
| `backend/scripts/run_private_simulation.py` | Fix 3 : `get_initial_exposed_agents` expose tous les agents — suppression du filtre LLM |
---
### 2026-04-16 — Session 7 (Prompt N°16 — Bug 7 : entity types domaine-spécifiques)
#### Diagnostic
- `/api/private-impact/prepare` → 404 (en fait 400) sur le projet `fromagerie-auriac`
- Diagnostic : les routes sont correctement enregistrées (`flask routes` confirme `/api/private-impact/prepare POST`)
- Cause réelle : l'ontologie LLM génère des types domaine-spécifiques (`ProductionWorker`, `CheeseTechnician`, `FoodRetailer`, `FamilyMember`, etc.) — aucun ne matche `_RELATIONAL_ENTITY_TYPES` (même avec fix case-insensitif du Bug 5)
#### Bug corrigé
| # | Sévérité | Fichier | Bug | Fix |
|---|---|---|---|---|
| 7 | **BLOQUANT** | `backend/app/api/private.py:136` | `entity_types` hardcodé (`employee`, `manager`, etc.) — l'ontologie LLM génère des types domaine-spécifiques jamais en liste → 0 entités, "No relational entities found" | Résolution en 3 niveaux : 1) `entity_types` explicite dans la requête 2) types de l'ontologie du projet (avec filtre `_is_structural_type`) 3) `_RELATIONAL_ENTITY_TYPES` en dernier recours |
#### Résultat
- `proj_b420d07dfb38` (Fromagerie Auriac, 3 fichiers seed) : **27 agents générés**, statut `prepared`
- Types utilisés : `ProductionWorker`, `CheeseTechnician`, `SalesRepresentative`, `ExecutiveTeam`, `FoodRetailer`, `FamilyMember`
#### Fichiers modifiés — Session 7
| Fichier | Action |
|---|---|
| `backend/app/api/private.py` | Bug 7 : `_is_structural_type()` + résolution entity types en 3 niveaux (request → ontologie projet → défaut) |
---
### 2026-04-16 — Session 8 (Prompt N°17 — Bugs 8 & 9 : 0 entités Zep → fallback synthétique)
#### Diagnostic
- `筛选完成: 总节点 14, 符合条件 0, entité_types: set()` — graphe `mirofish_80371e75ec2844b3` a 14 nœuds mais 0 match
- Cause 1 : `filter_defined_entities` ligne 264 fait `l in defined_entity_types` (case-sensitive) — les labels Zep peuvent différer en casse
- Cause 2 : même avec case-insensitif, les 14 nœuds ont uniquement les labels `["Entity", "Node"]` (pas de labels typés) → 0 entités retournées
- Le fallback Bug 7 ne s'activait pas car le 404 était retourné par le code précédent avant le fallback synthétique
- Cause 3 : la boucle `for etype in entity_types: reader.get_entities_by_type(...)` faisait N appels Zep (N = nombre de types) — chacun lit tous les nœuds + toutes les arêtes
#### Bugs corrigés
| # | Sévérité | Fichier | Bug | Fix |
|---|---|---|---|---|
| 8 | **MINEUR** | `backend/app/services/zep_entity_reader.py:264` | `l in defined_entity_types` case-sensitive → labels Zep `ProductionWorker` vs type `productionworker` ne matchent pas | `defined_lower = {t.lower() for t in defined_entity_types}; matching_labels = [l for l in custom_labels if l.lower() in defined_lower]` |
| 9 | **BLOQUANT** | `backend/app/api/private.py:180` | Quand 0 entités matchent, retourne 404 immédiatement sans alternative | Remplacé par : `_build_synthetic_entities()` — crée des `EntityNode` synthétiques par type d'ontologie (LLM enrichit les profils sans ancrage Zep) ; ajout helper `_build_synthetic_entities()` ; appel Zep optimisé (`filter_defined_entities` 1 fois pour tous les types au lieu de N appels) |
#### Résultat
- Test unitaire Python confirmé : 7 agents synthétiques créés pour `proj_d86ee68acfa3` (types : `ProductionWorker`, `CheeseMaster`, `SalesRepresentative`, `ExecutiveTeam`, `RetailClient`, `FamilyMember`, `UnionRepresentative`)
- `EntityNode` import ajouté dans `private.py`
- La génération HTTP complète prend plusieurs minutes (3 LLM call groups dans `PrivateImpactConfigGenerator.generate_config()` — comportement attendu)
#### Fichiers modifiés — Session 8
| Fichier | Action |
|---|---|
| `backend/app/services/zep_entity_reader.py` | Bug 8 : case-insensitive dans `filter_defined_entities` |
| `backend/app/api/private.py` | Bug 9 : `_build_synthetic_entities()` helper + fallback + import `EntityNode` + appel Zep optimisé (1 appel) |
---
## Pull Request — feat: Private Impact simulation mode
### Titre
`feat: Private Impact simulation mode`
### Description
Ce PR ajoute un second mode de simulation à MiroFish : **Private Impact**.
Contrairement au mode Opinion Publique (Twitter/Reddit), Private Impact simule l'impact d'une **décision privée** (ex. achat d'un bien de luxe, licenciement, choix stratégique) dans un **réseau relationnel fermé** : employés, managers, clients, partenaires, famille.
#### Ce que ça fait
- **Pipeline complet** : Prepare → Start → Status polling → Actions → Rapport → Cleanup
- **Agents relationnels** : profils enrichis avec `relational_link_type`, `trust_level`, `seniority_years`, encodés dans le persona LLM
- **Verbes relationnels** : `REACT_PRIVATELY`, `CONFRONT`, `COALITION_BUILD`, `SILENT_LEAVE`, `VOCAL_SUPPORT`, `DO_NOTHING` (≠ verbes Twitter/Reddit)
- **Rapport** : réutilise `ReportAgent` avec `markdown_content` structuré
- **Frontend** : wizard 5 étapes, graphe D3 force-directed temps réel, import config `#CONFIG…#END_CONFIG`
#### Comment tester
1. `git checkout feature/private-impact`
2. `npm run setup:all && npm run dev`
3. Sur l'interface Home → sélectionner mode "Private Impact"
4. Créer un projet avec un fichier seed décrivant un décideur + son réseau (voir `/tmp/mirofish_private_test_seed.txt` comme exemple)
5. Attendre la génération de l'ontologie + du graphe
6. Accéder à `/private/:projectId` → remplir le formulaire → Prepare → Start
7. Observer le graphe D3 et le feed d'actions en temps réel
8. Générer le rapport → vérifier `markdown_content`
#### Fichiers créés
| Fichier | Description |
|---|---|
| `backend/scripts/run_private_simulation.py` | Moteur subprocess — 6 verbes relationnels, LLM direct via camel-ai |
| `backend/app/services/private_impact_profile_generator.py` | Générateur de profils relationnels (8 dimensions) |
| `backend/app/services/private_impact_config_generator.py` | Générateur de paramètres comportementaux via LLM |
| `backend/app/services/private_impact_runner.py` | Orchestrateur : subprocess + monitoring + état en mémoire |
| `backend/app/api/private.py` | Blueprint Flask — 7 routes `/api/private-impact/*` |
| `frontend/src/api/private.js` | Client API Private Impact (7 fonctions) |
| `frontend/src/components/ModeSelector.vue` | Sélecteur Public / Private Impact |
| `frontend/src/views/PrivateImpactView.vue` | Wizard 5 étapes + graphe D3 |
#### Fichiers modifiés
| Fichier | Modification |
|---|---|
| `backend/app/__init__.py` | Enregistrement `private_bp` avec `url_prefix='/api'` |
| `backend/app/api/__init__.py` | Import + export `private_bp` |
| `backend/app/services/simulation_runner.py` | 7 zones private (champs, start, monitor, log, check, actions, cleanup) |
| `backend/app/services/zep_tools.py` | `get_entities_by_type` — matching case-insensitif |
| `backend/scripts/action_logger.py` | `get_private_logger()` + `simulated_day` dans `log_round_end` |
| `frontend/src/router/index.js` | Route `/private/:projectId` |
| `frontend/src/views/Home.vue` | Intégration `ModeSelector` |
| `frontend/src/views/MainView.vue` | `await startBuildGraph()` avant redirect private |
#### Bugs connus résiduels (non bloquants)
- `PrivateImpactConfigGenerator` peut générer un `total_simulation_days` différent de celui envoyé dans la requête (comportement LLM — override délibéré du générateur)
- Le rapport affiché dans l'accordéon utilise `outline.sections` sans titres de sections — les sections s'affichent "Section 01, 02…" si pas de titre ; le contenu complet est toujours accessible via le fallback `markdown_content`
#### Checklist
- [x] Test d'intégration end-to-end réalisé (scénario PDG + Rolls-Royce, 4 agents, 90 rounds, 31 actions)
- [x] Verbes relationnels vérifiés (COALITION_BUILD, CONFRONT — aucune fuite Twitter/Reddit)
- [x] Rapport généré et lisible (`markdown_content` non vide, contenu pertinent)
- [x] Cleanup propre (`run_state.json` supprimé)
- [x] 6 bugs bloquants corrigés (url_prefix, config mismatch, report polling, simulated_day, case-sensitive, silent json)
- [x] CONTEXT.md à jour
---
## Point d'attention — `MainView.vue` (N°11)
**Status : ⏳ PENDING**
**Status : ✅ RÉSOLU (Prompt N°10 Session 2)**
`Home.vue` stocke `sessionStorage.pendingSimMode = 'private'` quand l'utilisateur sélectionne Private Impact.
`MainView.vue` doit être modifié pour lire ce flag après la création du projet + le build du graphe Zep, et rediriger vers `/private/:projectId` au lieu de rester sur la vue OASIS standard.
Bug : redirection vers `/private/:projectId` se faisait après l'ontologie, avant `startBuildGraph()`.
Fix : `await startBuildGraph()` ajouté avant `router.push()` dans le bloc `pendingMode === 'private'`.
Robustesse : `PrivateImpactView` polle `getProject()` jusqu'à ce que `graph_id` soit disponible.
**Action requise** dans `MainView.vue` — après la séquence upload → create_project → build_graph :
```javascript
const pendingMode = sessionStorage.getItem('pendingSimMode')
if (pendingMode === 'private') {
sessionStorage.removeItem('pendingSimMode')
router.push(`/private/${projectId}`)
}
```
---
### 2026-04-17 — Session 3
#### Prompt N°18 — Correction de 5 bugs + 1 fragilité
##### Bugs corrigés
| # | Bug | Correctif | Fichier(s) |
|---|---|---|---|
| 1 | Rapport section 01 en chinois | Règle de langue centralisée dans `get_language_instruction()` : override forçant l'alignement sur `simulation_requirement`, fallback français | `backend/app/utils/locale.py` |
| 2 | Graphe D3 sans arêtes | Endpoint `/status` augmenté avec `agents` + `relational_edges` issus de `cascade_influence`. Frontend : nœuds statiques + merge arêtes cascade (grises pointillées si pas d'action, pleines si activées) | `backend/app/api/private.py`, `frontend/src/views/PrivateImpactView.vue` |
| 3 | Bouton export rapport absent | Bouton `Export .md` ajouté dans Step 4 ; sérialisation `outline.sections` → markdown via Blob + download | `frontend/src/views/PrivateImpactView.vue` |
| 5 | Mode Public/Private via sessionStorage | Remplacé par query param `?mode=private` sur `/process/:projectId` ; MainView lit `route.query.mode` | `frontend/src/views/Home.vue`, `frontend/src/views/MainView.vue` |
##### Bug partiellement corrigé
| # | Bug | État | Raison |
|---|---|---|---|
| 4 | Chat agents 400 | **Corrigé côté frontend seulement** (body aligné sur `interviews: [{agent_id, prompt}]` + parsing réponse `result.results`) | La route chat pour Private Impact n'existe pas dans `private.py`. Le frontend tape `/api/simulation/interview/batch` qui exige `SimulationRunner.check_env_alive()` — incompatible avec `PrivateImpactRunner`. Le 400 "require interviews" est corrigé, mais `env not running` reste à traiter par une nouvelle route dédiée côté backend (hors périmètre : pas de nouveaux fichiers autorisés) |
##### Fichiers modifiés
- `backend/app/utils/locale.py` — directive de langue universelle
- `backend/app/api/private.py` — status endpoint renvoie `agents` + `relational_edges`
- `frontend/src/views/PrivateImpactView.vue` — graphe cascade, export .md, chat body corrigé
- `frontend/src/views/Home.vue` — suppression sessionStorage, passage query param
- `frontend/src/views/MainView.vue` — lecture `route.query.mode`
- `CONTEXT.md` — mise à jour
##### Prochaine étape
- **PR** vers `main` : `feature/private-impact` — regrouper cette session avec les sessions 12.
- **À anticiper post-PR** : créer une route `/api/private-impact/chat/<sim_id>` dédiée pour finaliser le bug 4 (le runner privé n'a pas d'IPC de type `send_batch_interview`, donc un chat direct via `ReportAgent.chat` ou un LLM call sur le profil d'agent est sans doute plus pertinent).

View File

@ -68,7 +68,7 @@ def create_app(config_class=Config):
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
app.register_blueprint(report_bp, url_prefix='/api/report')
from .api import private_bp
app.register_blueprint(private_bp)
app.register_blueprint(private_bp, url_prefix='/api')
# 健康检查
@app.route('/health')

View File

@ -19,7 +19,7 @@ from ..config import Config
from ..services.private_impact_profile_generator import PrivateImpactProfileGenerator
from ..services.private_impact_config_generator import PrivateImpactConfigGenerator
from ..services.private_impact_runner import PrivateImpactRunner
from ..services.zep_entity_reader import ZepEntityReader
from ..services.zep_entity_reader import ZepEntityReader, EntityNode
from ..services.report_agent import ReportAgent, ReportManager, ReportStatus
from ..models.task import TaskManager, TaskStatus
from ..models.project import ProjectManager
@ -32,11 +32,73 @@ logger = get_logger('mirofish.api.private')
_SIM_DIR = os.path.join(os.path.dirname(__file__), '../../uploads/simulations')
# Relational entity types recognised by PrivateImpactProfileGenerator
# Used as last-resort fallback when no project ontology is available.
_RELATIONAL_ENTITY_TYPES = [
"employee", "manager", "client", "competitor",
"partner", "familymember", "colleague", "investor",
]
# Structural/non-person entity type suffixes to exclude when reading ontology types
_STRUCTURAL_SUFFIXES = ('company', 'media', 'platform', 'organization', 'union')
_STRUCTURAL_EXACT = frozenset({'Person', 'Organization'})
def _is_structural_type(entity_type: str) -> bool:
"""Return True if the entity type represents an org/platform rather than a person."""
if entity_type in _STRUCTURAL_EXACT:
return True
return any(entity_type.lower().endswith(s) for s in _STRUCTURAL_SUFFIXES)
def _build_synthetic_entities(
entity_types: list,
simulation_requirement: str = '',
) -> list:
"""
Fallback: create synthetic EntityNode objects when Zep has no matching entities.
Parses Agent distribution from the #CONFIG block of simulation_requirement to
determine how many agents to create per type (capped at 3 for performance).
Falls back to 1 agent per type if no distribution info is found.
LLM will enrich these synthetic profiles during profile generation no Zep
anchoring, which is acceptable for simulation when the graph has no typed nodes.
"""
import re as _re
dist: dict = {}
config_match = _re.search(r'#CONFIG(.*?)#END_CONFIG', simulation_requirement, _re.DOTALL)
if config_match:
dist_match = _re.search(r'Agent distribution:\s*(.+)', config_match.group(1))
if dist_match:
for part in dist_match.group(1).split(','):
m = _re.match(r'(.+?)\s*[×x]\s*(\d+)', part.strip())
if m:
dist[m.group(1).strip().lower()] = min(int(m.group(2)), 3)
entities = []
for etype in entity_types:
count = 1
for dist_label, dist_count in dist.items():
if dist_label in etype.lower() or etype.lower() in dist_label:
count = dist_count
break
for i in range(count):
suffix = f" {i + 1}" if count > 1 else ""
entities.append(EntityNode(
uuid=f"synthetic_{uuid.uuid4().hex[:8]}",
name=f"{etype.capitalize()}{suffix}",
labels=[etype, "Entity"],
summary=f"Synthetic {etype} agent in the decision maker's network.",
attributes={},
))
logger.info(
f"[PRIVATE] Synthetic fallback: {len(entities)} agents "
f"from {len(entity_types)} types"
)
return entities
# ── Helpers ────────────────────────────────────────────────────────────────────
@ -92,7 +154,7 @@ def prepare_private_simulation():
{ "success": true, "data": { "sim_id": "...", "agent_count": N, "status": "prepared" } }
"""
try:
data = request.get_json() or {}
data = request.get_json(silent=True) or {}
# Resolve graph_id and simulation_requirement
project_id = data.get('project_id')
@ -133,28 +195,54 @@ def prepare_private_simulation():
os.makedirs(_sim_dir(sim_id), exist_ok=True)
use_llm = data.get('use_llm', True)
entity_types = data.get('entity_types') or _RELATIONAL_ENTITY_TYPES
# Read relational entities from Zep
# Resolve entity types to query:
# 1. Explicit list from request (user override)
# 2. Ontology types from project (auto — excludes structural types)
# 3. Default hardcoded list (fallback for projects without ontology)
entity_types = data.get('entity_types')
if not entity_types:
if project and project.ontology:
ontology_types = [
e.get('name') for e in project.ontology.get('entity_types', [])
if e.get('name') and not _is_structural_type(e.get('name'))
]
entity_types = ontology_types or _RELATIONAL_ENTITY_TYPES
logger.info(f"[PRIVATE] Using ontology entity types: {entity_types}")
else:
entity_types = _RELATIONAL_ENTITY_TYPES
# Read relational entities from Zep — single call for all types at once
reader = ZepEntityReader()
all_entities = []
for etype in entity_types:
try:
found = reader.get_entities_by_type(
graph_id=graph_id,
entity_type=etype,
enrich_with_edges=True,
)
all_entities.extend(found)
logger.info(f"[PRIVATE] {len(found)} '{etype}' entities read")
except Exception as e:
logger.warning(f"[PRIVATE] Could not read '{etype}' entities: {e}")
try:
zep_result = reader.filter_defined_entities(
graph_id=graph_id,
defined_entity_types=entity_types,
enrich_with_edges=True,
)
all_entities = zep_result.entities
logger.info(
f"[PRIVATE] Zep read: {zep_result.total_count} nodes total, "
f"{len(all_entities)} matched ({list(zep_result.entity_types)})"
)
except Exception as e:
logger.warning(f"[PRIVATE] Zep read failed: {e}")
all_entities = []
if not all_entities:
return jsonify({
"success": False,
"error": "No relational entities found in the graph for the given entity types."
}), 404
logger.warning(
f"[PRIVATE] No Zep entities matched {entity_types} in graph {graph_id}. "
f"Switching to synthetic fallback (no Zep anchoring)."
)
all_entities = _build_synthetic_entities(
entity_types=entity_types or _RELATIONAL_ENTITY_TYPES,
simulation_requirement=simulation_requirement,
)
if not all_entities:
return jsonify({
"success": False,
"error": "No relational entities found and synthetic fallback produced 0 agents."
}), 404
# Generate RelationalAgentProfile instances
profile_generator = PrivateImpactProfileGenerator()
@ -240,7 +328,7 @@ def start_private_simulation():
{ "success": true, "data": { "sim_id": "...", "status": "running" } }
"""
try:
data = request.get_json() or {}
data = request.get_json(silent=True) or {}
sim_id = data.get('sim_id')
if not sim_id:
@ -306,9 +394,39 @@ def get_private_status(sim_id: str):
"error": f"No private simulation found for sim_id: {sim_id}"
}), 404
data = state.to_detail_dict()
# Attach static relational graph (cascade_influence) so the frontend
# can render edges even before any action has been logged.
config_path = os.path.join(_SIM_DIR, sim_id, "private_simulation_config.json")
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
sim_cfg = json.load(f)
agent_configs = sim_cfg.get("agent_configs", []) or []
data["agents"] = [
{
"agent_id": a.get("agent_id"),
"entity_name": a.get("entity_name"),
"cascade_influence": a.get("cascade_influence", []) or [],
}
for a in agent_configs
if a.get("agent_id") is not None
]
edges = []
for a in agent_configs:
src = a.get("agent_id")
if src is None:
continue
for tgt in (a.get("cascade_influence") or []):
edges.append({"source": src, "target": tgt})
data["relational_edges"] = edges
except Exception as cfg_err:
logger.warning(f"[PRIVATE] Could not load cascade graph: {cfg_err}")
return jsonify({
"success": True,
"data": state.to_detail_dict()
"data": data
})
except Exception as e:
@ -414,7 +532,7 @@ def generate_private_report(sim_id: str):
{ "success": true, "data": { "sim_id": "...", "report_id": "...", "task_id": "..." } }
"""
try:
data = request.get_json() or {}
data = request.get_json(silent=True) or {}
force_regenerate = data.get('force_regenerate', False)
meta = _read_meta(sim_id)

View File

@ -259,9 +259,10 @@ class ZepEntityReader:
# 只有默认标签,跳过
continue
# 如果指定了预定义类型,检查是否匹配
# 如果指定了预定义类型,检查是否匹配 (case-insensitive)
if defined_entity_types:
matching_labels = [l for l in custom_labels if l in defined_entity_types]
defined_lower = {t.lower() for t in defined_entity_types}
matching_labels = [l for l in custom_labels if l.lower() in defined_lower]
if not matching_labels:
continue
entity_type = matching_labels[0]

View File

@ -799,7 +799,7 @@ class ZepToolsService:
filtered = []
for node in all_nodes:
# 检查labels是否包含指定类型
if entity_type in node.labels:
if any(lbl.lower() == entity_type.lower() for lbl in node.labels):
filtered.append(node)
logger.info(t("console.foundEntitiesByType", count=len(filtered), type=entity_type))

View File

@ -66,4 +66,11 @@ def t(key: str, **kwargs) -> str:
def get_language_instruction() -> str:
locale = get_locale()
lang_config = _languages.get(locale, _languages.get('zh', {}))
return lang_config.get('llmInstruction', '请使用中文回答。')
base = lang_config.get('llmInstruction', '请使用中文回答。')
override = (
"LANGUAGE RULE (overrides everything else): "
"Always respond in the same language as the simulation_requirement. "
"If the simulation_requirement language is unclear, default to French. "
"This rule takes precedence over any other language hint in this prompt."
)
return f"{base}\n\n{override}"

View File

@ -77,7 +77,7 @@ class PlatformActionLogger:
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_round_end(self, round_num: int, actions_count: int):
def log_round_end(self, round_num: int, actions_count: int, simulated_day: Optional[int] = None):
"""记录轮次结束"""
entry = {
"round": round_num,
@ -85,7 +85,9 @@ class PlatformActionLogger:
"event_type": "round_end",
"actions_count": actions_count,
}
if simulated_day is not None:
entry["simulated_day"] = simulated_day
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')

View File

@ -212,36 +212,43 @@ def build_relational_graph(agent_configs: List[Dict[str, Any]]) -> Dict[int, Lis
def get_initial_exposed_agents(config: Dict[str, Any]) -> Set[int]:
"""
Determine distance-1 agents: those directly targeted by initial_posts.
Return the full set of agent IDs all agents are exposed to the decision
at simulation start.
Divergence from run_parallel_simulation.py:
Instead of posting on Twitter/Reddit, the decision maker (poster_agent_id=0)
announces the decision to agents listed in initial_posts targets.
All agent_ids mentioned in initial_posts (excluding the poster) are exposed.
Relational network propagation: in Private Impact mode, the decision
circulates through the network (e.g. LinkedIn post) and all agents
receive context from round 1. The LLM-generated initial_exposed_agent_ids
is intentionally ignored exposure is a structural parameter, not an
LLM decision.
"""
exposed: Set[int] = set()
event_config = config.get("event_config", {})
for post in event_config.get("initial_posts", []):
poster_id = post.get("poster_agent_id", 0)
# All agents except the poster are exposed at distance 1
for cfg in config.get("agent_configs", []):
agent_id = cfg.get("agent_id")
if agent_id is not None and agent_id != poster_id:
exposed.add(agent_id)
for cfg in config.get("agent_configs", []):
agent_id = cfg.get("agent_id")
if agent_id is not None:
exposed.add(agent_id)
return exposed
def get_decision_context(config: Dict[str, Any]) -> str:
"""
Extract the triggering decision text from event_config.initial_posts.
Extract the triggering decision text from event_config.
Reuses the initial_posts mechanism but changes its semantic:
instead of a Twitter post, it is the private decision announcement.
Supports two event_config formats:
- PrivateImpactConfigGenerator: decision_statement (plain text)
- OASIS initial_posts: content of the first post
"""
event_config = config.get("event_config", {})
# PrivateImpactConfigGenerator format
decision_statement = event_config.get("decision_statement", "")
if decision_statement:
return decision_statement
# OASIS initial_posts format
posts = event_config.get("initial_posts", [])
if posts:
return posts[0].get("content", "A private decision has been made.")
return "A private decision has been made."
@ -689,12 +696,20 @@ async def run_private_simulation(
log(f"Decision injected: {initial_count} initial post(s)")
if action_logger:
action_logger.log_round_end(0, initial_count)
action_logger.log_round_end(0, initial_count, simulated_day=1)
# Compute total rounds
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
total_rounds = (total_hours * 60) // minutes_per_round
# Compute total rounds — support both time config formats:
# PrivateImpactConfigGenerator: total_simulation_days + rounds_per_day
# OASIS format: total_simulation_hours + minutes_per_round
if "total_simulation_days" in time_config:
_days = int(time_config["total_simulation_days"])
_rpd = int(time_config.get("rounds_per_day", 3))
total_rounds = _days * _rpd
minutes_per_round = (24 * 60) // _rpd if _rpd > 0 else 480
else:
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
total_rounds = (total_hours * 60) // minutes_per_round
if max_rounds is not None and max_rounds > 0:
original_rounds = total_rounds
@ -725,7 +740,7 @@ async def run_private_simulation(
if not active_cfgs:
if action_logger:
action_logger.log_round_end(round_num + 1, 0)
action_logger.log_round_end(round_num + 1, 0, simulated_day=simulated_day)
continue
# Build context summary for LLM prompts this round
@ -792,7 +807,7 @@ async def run_private_simulation(
exposed_agents.update(newly_exposed)
if action_logger:
action_logger.log_round_end(round_num + 1, round_action_count)
action_logger.log_round_end(round_num + 1, round_action_count, simulated_day=simulated_day)
if (round_num + 1) % 20 == 0:
progress = (round_num + 1) / total_rounds * 100
@ -865,13 +880,23 @@ async def main() -> None:
log_manager.info("=" * 60)
time_config = config.get("time_config", {})
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
config_total_rounds = (total_hours * 60) // minutes_per_round
if "total_simulation_days" in time_config:
config_total_rounds = (
int(time_config["total_simulation_days"])
* int(time_config.get("rounds_per_day", 3))
)
else:
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
config_total_rounds = (total_hours * 60) // minutes_per_round
log_manager.info("Simulation parameters:")
log_manager.info(f" - Total simulated duration: {total_hours}h")
log_manager.info(f" - Minutes per round: {minutes_per_round}")
if "total_simulation_days" in time_config:
log_manager.info(f" - Total simulated duration: {time_config['total_simulation_days']} days")
log_manager.info(f" - Rounds per day: {time_config.get('rounds_per_day', 3)}")
else:
log_manager.info(f" - Total simulated duration: {time_config.get('total_simulation_hours', 72)}h")
log_manager.info(f" - Minutes per round: {time_config.get('minutes_per_round', 30)}")
log_manager.info(f" - Config total rounds: {config_total_rounds}")
if args.max_rounds:
log_manager.info(f" - Round cap: {args.max_rounds}")

View File

@ -20,3 +20,6 @@ export const generatePrivateReport = (simId, data = {}) =>
export const cleanupPrivateSimulation = (simId) =>
service.delete(`/api/private-impact/cleanup/${simId}`)
export const getPrivateReportStatus = (taskId) =>
service.post('/api/report/generate/status', { task_id: taskId })

View File

@ -228,11 +228,6 @@ const selectedMode = ref(null)
const handleModeSelected = (mode) => {
selectedMode.value = mode
if (mode === 'private') {
sessionStorage.setItem('pendingSimMode', 'private')
} else {
sessionStorage.removeItem('pendingSimMode')
}
}
//
@ -321,7 +316,8 @@ const startSimulation = () => {
// Process使
router.push({
name: 'Process',
params: { projectId: 'new' }
params: { projectId: 'new' },
query: selectedMode.value === 'private' ? { mode: 'private' } : {},
})
})
}

View File

@ -215,9 +215,9 @@ const handleNewProject = async () => {
currentProjectId.value = res.data.project_id
projectData.value = res.data
const pendingMode = sessionStorage.getItem('pendingSimMode')
sessionStorage.removeItem('pendingSimMode')
const pendingMode = route.query.mode
if (pendingMode === 'private') {
await startBuildGraph()
router.push(`/private/${res.data.project_id}`)
return
}

View File

@ -72,6 +72,29 @@
<p class="section-hint">Fill in the decision context. These details will drive the simulation.</p>
</div>
<div
class="drop-zone"
:class="{ 'drop-zone--active': isDragOver }"
@dragover.prevent="isDragOver = true"
@dragleave="isDragOver = false"
@drop.prevent="handleDrop"
@click="triggerImport"
>
<input type="file" ref="importInput" accept=".txt" style="display:none" @change="handleImport" />
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span class="drop-zone-label">Glisser le fichier ici ou cliquer pour importer</span>
<span class="drop-zone-hint">private_impact_requirement.txt dossier 02_simulation_params/</span>
</div>
<div v-if="!projectData?.graph_id" class="graph-building-notice">
<div class="loading-ring loading-ring--sm"></div>
<span>Graphe en construction les champs peuvent déjà être remplis. Le bouton s'activera automatiquement.</span>
</div>
<div class="form-grid">
<!-- Left column -->
<div class="form-col">
@ -123,23 +146,37 @@
class="checkbox-native"
/>
<span class="checkbox-box"></span>
<span class="checkbox-label">{{ t }}</span>
<span class="checkbox-label">{{ RELATIONAL_TYPE_LABELS[t] }}</span>
</label>
</div>
<div v-if="form.relationalTypes.length > 0" class="agent-counts-block">
<div v-for="t in form.relationalTypes" :key="t" class="agent-count-row">
<span class="agent-count-label">{{ RELATIONAL_TYPE_LABELS[t] }}</span>
<div class="agent-count-sep"></div>
<input
type="number"
class="agent-count-input"
v-model.number="agentCounts[t]"
min="1"
max="200"
/>
</div>
<div class="agent-count-total">Total : {{ totalAgents }} agents</div>
</div>
</div>
<div class="field-group">
<label class="field-label">TEMPORAL HORIZON {{ form.horizonDays }} days</label>
<div class="slider-group">
<input
type="range"
class="field-slider"
v-model.number="form.horizonDays"
min="7" max="90" step="1"
/>
<div class="slider-ticks">
<span>7d</span><span>30d</span><span>60d</span><span>90d</span>
</div>
<label class="field-label">TEMPORAL HORIZON</label>
<div class="horizon-btns">
<button
v-for="opt in HORIZON_OPTIONS"
:key="opt.days"
type="button"
class="horizon-btn"
:class="{ 'is-active': form.horizonDays === opt.days }"
@click="form.horizonDays = opt.days"
>{{ opt.label }}</button>
</div>
</div>
@ -158,7 +195,7 @@
<div class="form-footer">
<button
class="btn-primary"
:disabled="!form.decisionText.trim() || form.relationalTypes.length === 0"
:disabled="!form.decisionText.trim() || form.relationalTypes.length === 0 || !projectData?.graph_id"
@click="runPrepare"
>
Prepare Simulation
@ -306,21 +343,25 @@
</div>
</div>
<!-- Right: Live action feed -->
<div class="run-feed-panel" ref="feedPanel">
<div class="feed-header">LIVE ACTION FEED</div>
<div class="feed-list">
<div
v-for="(action, idx) in recentActions"
:key="idx"
class="feed-item"
>
<span class="feed-round mono">#{{ action.round_num }}</span>
<span class="feed-agent">{{ action.agent_name || `Agent ${action.agent_id}` }}</span>
<span class="feed-action-type" :class="actionTypeClass(action.action_type)">{{ action.action_type }}</span>
<span class="feed-time mono">{{ shortTime(action.timestamp) }}</span>
<!-- Right col: propagation graph + reduced feed -->
<div class="run-right-col">
<div class="graph-panel" ref="graphContainer"></div>
<div class="run-feed-panel" ref="feedPanel">
<div class="feed-header">LIVE ACTION FEED</div>
<div class="feed-list">
<div
v-for="(action, idx) in recentActions.slice(-10)"
:key="idx"
class="feed-item"
>
<span class="feed-round mono">#{{ action.round_num }}</span>
<span class="feed-agent">{{ action.agent_name || `Agent ${action.agent_id}` }}</span>
<span class="feed-action-type" :class="actionTypeClass(action.action_type)">{{ action.action_type }}</span>
<span class="feed-time mono">{{ shortTime(action.timestamp) }}</span>
</div>
<div v-if="recentActions.length === 0" class="feed-empty">Waiting for simulation events</div>
</div>
<div v-if="recentActions.length === 0" class="feed-empty">Waiting for simulation events</div>
</div>
</div>
@ -349,14 +390,11 @@
Report ready
</div>
<h2 class="report-title">{{ reportResult.title }}</h2>
<p class="report-summary">{{ reportResult.summary }}</p>
<div class="report-sections" v-if="reportResult.sections">
<div v-for="(section, idx) in reportResult.sections" :key="idx" class="report-section">
<div class="report-sections" v-if="reportResult.outline?.sections?.length">
<div v-for="(section, idx) in reportResult.outline.sections" :key="idx" class="report-section">
<div class="rs-header" @click="toggleSection(idx)">
<span class="rs-num">{{ String(idx + 1).padStart(2, '0') }}</span>
<span class="rs-title">{{ section.title }}</span>
<span class="rs-title">{{ section.title || ('Section ' + String(idx + 1).padStart(2, '0')) }}</span>
<svg class="rs-chevron" :class="{ 'is-open': !collapsedSections.has(idx) }" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
@ -366,8 +404,12 @@
</div>
</div>
</div>
<pre v-else-if="reportResult.markdown_content" class="report-markdown">{{ reportResult.markdown_content }}</pre>
<div class="result-actions">
<button class="btn-secondary" @click="exportReportMarkdown">
Export .md
</button>
<button class="btn-secondary" @click="goToStep(5)">
Talk to Agents
</button>
@ -460,10 +502,11 @@
<script setup>
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import * as d3 from 'd3'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
import { getProject } from '../api/graph.js'
import { interviewAgents } from '../api/simulation.js'
import { getReportStatus, getReport } from '../api/report.js'
import { getReport } from '../api/report.js'
import {
preparePrivateSimulation,
startPrivateSimulation,
@ -471,6 +514,7 @@ import {
stopPrivateSimulation,
getPrivateActions,
generatePrivateReport,
getPrivateReportStatus,
} from '../api/private.js'
const props = defineProps({
@ -482,8 +526,26 @@ const router = useRouter()
// Constants
const RELATIONAL_TYPES = [
'employee', 'manager', 'client', 'competitor',
'partner', 'familymember', 'colleague', 'investor',
'ouvrier_production', 'technicien', 'commercial',
'manager', 'codir', 'client_externe', 'partenaire', 'concurrent',
]
const RELATIONAL_TYPE_LABELS = {
ouvrier_production: 'Ouvrier / Production',
technicien: 'Technicien',
commercial: 'Commercial',
manager: 'Manager',
codir: 'CODIR',
client_externe: 'Client externe',
partenaire: 'Partenaire',
concurrent: 'Concurrent',
}
const HORIZON_OPTIONS = [
{ days: 3, label: '3 jours (72h)' },
{ days: 7, label: '7 jours' },
{ days: 30, label: '30 jours' },
{ days: 180, label: '6 mois (180 jours)' },
]
const stepNames = ['Requirement', 'Prepare', 'Run', 'Report', 'Interact']
@ -500,11 +562,25 @@ const isLoading = ref(false)
const error = ref(null)
const reportProgress = ref('')
// Step 1 - import config
const importInput = ref(null)
const isDragOver = ref(false)
// Step 3 - live feed
const recentActions = ref([])
const feedPanel = ref(null)
let pollingTimer = null
// Step 1 - graph ready polling
let graphReadyTimer = null
// Step 3 - D3 propagation graph
const graphContainer = ref(null)
let simulation = null
let svgEl = null
let linkGroup = null
let nodeGroup = null
// Step 4 - report polling
let reportPollingTimer = null
const collapsedSections = ref(new Set())
@ -524,13 +600,29 @@ const form = reactive({
decisionMakerCompany: '',
decisionText: '',
decisionContext: '',
relationalTypes: ['employee', 'manager', 'client', 'partner', 'familymember'],
horizonDays: 30,
relationalTypes: ['ouvrier_production', 'technicien', 'commercial', 'manager', 'codir'],
horizonDays: 3,
questionsToMeasure: '',
})
// Agent counts per relational type
const agentCounts = reactive({})
watch(() => form.relationalTypes, (types) => {
for (const t of types) {
if (!(t in agentCounts)) agentCounts[t] = 10
}
for (const key of Object.keys(agentCounts)) {
if (!types.includes(key)) delete agentCounts[key]
}
}, { immediate: true })
// Computed
const totalAgents = computed(() =>
Object.values(agentCounts).reduce((sum, n) => sum + (n || 0), 0)
)
const statusClass = computed(() => {
const s = simStatus.value?.runner_status
if (s === 'running') return 'processing'
@ -550,7 +642,8 @@ const statusText = computed(() => {
})
const roundProgress = computed(() => {
const total = simStatus.value?.total_rounds || 0
if (simStatus.value?.progress_percent != null) return simStatus.value.progress_percent
const total = simStatus.value?.private_total_rounds || 0
const current = simStatus.value?.private_current_round || 0
if (!total) return 0
return Math.round((current / total) * 100)
@ -573,6 +666,231 @@ const currentMessages = computed(() => {
return chatMessages[selectedAgentId.value] || []
})
// D3 Graph
const ACTION_COLORS = {
CONFRONT: '#F44336',
COALITION_BUILD: '#FF9800',
VOCAL_SUPPORT: '#4CAF50',
SILENT_LEAVE: '#616161',
REACT_PRIVATELY: '#E0E0E0',
DO_NOTHING: '#E0E0E0',
}
const nodeColor = (actionType) => {
if (!actionType) return '#E0E0E0'
const upper = actionType.toUpperCase()
for (const [key, color] of Object.entries(ACTION_COLORS)) {
if (upper.includes(key)) return color
}
return '#E0E0E0'
}
const ticked = () => {
if (!linkGroup || !nodeGroup) return
linkGroup.selectAll('line')
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y)
nodeGroup.selectAll('g.node')
.attr('transform', d => `translate(${d.x},${d.y})`)
}
const initGraph = () => {
if (!graphContainer.value) return
const container = graphContainer.value
const width = container.clientWidth || 600
const height = container.clientHeight || 400
d3.select(container).selectAll('*').remove()
svgEl = d3.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${width} ${height}`)
linkGroup = svgEl.append('g').attr('class', 'links')
nodeGroup = svgEl.append('g').attr('class', 'nodes')
simulation = d3.forceSimulation([])
.force('charge', d3.forceManyBody().strength(-120))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('link', d3.forceLink([]).id(d => d.id).distance(80))
.on('tick', ticked)
}
const updateGraph = (actions) => {
if (!simulation || !svgEl) return
const agentActions = {}
const agentNames = {}
const linkPairs = {}
// Seed nodes from static config so the graph shows the full relational
// network even before any action has been recorded.
const staticAgents = simStatus.value?.agents || []
for (const a of staticAgents) {
if (a.agent_id === undefined || a.agent_id === null) continue
const sid = String(a.agent_id)
agentNames[sid] = a.entity_name || `Agent ${sid}`
}
for (const action of actions) {
const id = action.agent_id
if (id === undefined || id === null) continue
const sid = String(id)
agentNames[sid] = action.agent_name || agentNames[sid] || `Agent ${sid}`
if (!agentActions[sid]) agentActions[sid] = {}
const t = action.action_type || 'DO_NOTHING'
agentActions[sid][t] = (agentActions[sid][t] || 0) + 1
if (action.target_agent_id !== undefined && action.target_agent_id !== null) {
const key = `${sid}__${String(action.target_agent_id)}`
linkPairs[key] = (linkPairs[key] || 0) + 1
}
}
const nodes = Object.keys(agentNames).map(id => {
const counts = agentActions[id] || {}
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'DO_NOTHING'
return { id, name: agentNames[id], dominant }
})
const nodeIds = new Set(nodes.map(n => n.id))
// Merge static cascade_influence edges with dynamic action-based edges.
const staticEdges = simStatus.value?.relational_edges || []
const staticKeys = new Set()
for (const e of staticEdges) {
if (e.source === undefined || e.target === undefined) continue
staticKeys.add(`${String(e.source)}__${String(e.target)}`)
}
const links = []
staticKeys.forEach(key => {
const [source, target] = key.split('__')
if (nodeIds.has(source) && nodeIds.has(target)) {
links.push({ source, target, count: linkPairs[key] || 0, kind: 'cascade' })
}
})
Object.entries(linkPairs).forEach(([key, count]) => {
if (staticKeys.has(key)) return
const [source, target] = key.split('__')
if (nodeIds.has(source) && nodeIds.has(target)) {
links.push({ source, target, count, kind: 'action' })
}
})
const existing = {}
simulation.nodes().forEach(n => { existing[n.id] = { x: n.x, y: n.y, vx: n.vx, vy: n.vy } })
nodes.forEach(n => {
if (existing[n.id]) {
n.x = existing[n.id].x; n.y = existing[n.id].y
n.vx = existing[n.id].vx; n.vy = existing[n.id].vy
}
})
simulation.nodes(nodes)
simulation.force('link').links(links)
simulation.alpha(0.3).restart()
const linkSel = linkGroup.selectAll('line')
.data(links, d => `${d.source.id || d.source}__${d.target.id || d.target}`)
linkSel.exit().remove()
linkSel.enter().append('line')
.merge(linkSel)
.attr('stroke', d => d.kind === 'cascade' && d.count === 0 ? '#E5E5E5' : '#999')
.attr('stroke-dasharray', d => d.kind === 'cascade' && d.count === 0 ? '3,3' : null)
.attr('stroke-width', d => Math.min(1 + d.count * 0.5, 4))
const nodeSel = nodeGroup.selectAll('g.node').data(nodes, d => d.id)
nodeSel.exit().remove()
const nodeEnter = nodeSel.enter().append('g').attr('class', 'node')
nodeEnter.append('circle').attr('r', 8)
nodeEnter.append('text')
.attr('y', 20)
.attr('text-anchor', 'middle')
.attr('font-size', '9px')
.attr('fill', '#555')
const nodeMerge = nodeEnter.merge(nodeSel)
nodeMerge.select('circle')
.attr('fill', d => nodeColor(d.dominant))
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
nodeMerge.select('text')
.text(d => d.name.slice(0, 12))
}
// Import config (Step 1)
const triggerImport = () => { importInput.value?.click() }
const handleDrop = (event) => {
isDragOver.value = false
const file = event.dataTransfer.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => parseImportedConfig(e.target.result)
reader.readAsText(file)
}
const handleImport = (event) => {
const file = event.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
parseImportedConfig(e.target.result)
event.target.value = ''
}
reader.readAsText(file)
}
const parseImportedConfig = (text) => {
let configText = text
const configMatch = text.match(/#CONFIG\n([\s\S]*?)\n#END_CONFIG/)
if (configMatch) configText = configMatch[1]
const labelToKey = {}
for (const [key, label] of Object.entries(RELATIONAL_TYPE_LABELS)) {
labelToKey[label.toLowerCase()] = key
}
for (const line of configText.split('\n')) {
try {
if (line.startsWith('Décideur :')) {
const val = line.replace('Décideur :', '').trim()
const [nameAndRole, company] = val.split(' at ')
const [name, role] = (nameAndRole || '').split(' — ')
if (name) form.decisionMakerName = name.trim()
if (role) form.decisionMakerRole = role.trim()
if (company) form.decisionMakerCompany = company.trim()
} else if (line.startsWith('Décision :')) {
form.decisionText = line.replace('Décision :', '').trim()
} else if (line.startsWith('Réseau simulé :')) {
const types = line.replace('Réseau simulé :', '').trim()
.split(', ').map(s => s.trim()).filter(t => RELATIONAL_TYPES.includes(t))
if (types.length) form.relationalTypes = types
} else if (line.startsWith('Horizon temporel :')) {
const days = parseInt(line.replace('Horizon temporel :', '').trim(), 10)
if (!isNaN(days)) form.horizonDays = days
} else if (line.startsWith('Questions to measure :')) {
form.questionsToMeasure = line.replace('Questions to measure :', '').trim()
} else if (line.startsWith('Agent distribution:')) {
const entries = line.replace('Agent distribution:', '').trim().split(',')
for (const entry of entries) {
const parts = entry.trim().split(' × ')
if (parts.length !== 2) continue
const key = labelToKey[parts[0].trim().toLowerCase()]
const count = parseInt(parts[1].trim(), 10)
if (key && !isNaN(count)) agentCounts[key] = count
}
}
} catch { /* ligne ignorée */ }
}
}
// Helpers
const buildRequirement = () => {
@ -586,6 +904,10 @@ const buildRequirement = () => {
parts.push(`Relational network: ${form.relationalTypes.join(', ')}`)
parts.push(`Temporal horizon: ${form.horizonDays} days`)
if (form.questionsToMeasure) parts.push(`Questions to measure: ${form.questionsToMeasure}`)
const agentDistrib = form.relationalTypes
.map(t => `${RELATIONAL_TYPE_LABELS[t]} × ${agentCounts[t] || 10}`)
.join(', ')
parts.push(`Agent distribution: ${agentDistrib}`)
return parts.join('\n')
}
@ -620,20 +942,67 @@ const toggleSection = (idx) => {
collapsedSections.value = s
}
const exportReportMarkdown = () => {
const report = reportResult.value
if (!report) return
let md = report.markdown_content
if (!md) {
const title = report.outline?.title || 'Private Impact Report'
const summary = report.outline?.summary || ''
const sections = report.outline?.sections || []
md = `# ${title}\n\n`
if (summary) md += `> ${summary}\n\n`
sections.forEach((s, idx) => {
const num = String(idx + 1).padStart(2, '0')
md += `## ${num}${s.title || 'Section ' + num}\n\n`
md += `${s.content || ''}\n\n`
})
}
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `private-impact-report-${simId.value || 'report'}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// Data loading
onMounted(async () => {
try {
const res = await getProject(props.projectId)
projectData.value = res.data
if (!res.data?.graph_id) {
waitForGraph()
}
} catch (e) {
error.value = `Could not load project: ${e.message}`
}
})
const waitForGraph = () => {
graphReadyTimer = setInterval(async () => {
try {
const res = await getProject(props.projectId)
if (res.data?.graph_id) {
projectData.value = res.data
clearInterval(graphReadyTimer)
graphReadyTimer = null
}
} catch { /* continue polling */ }
}, 3000)
}
onUnmounted(() => {
stopPolling()
stopReportPolling()
if (simulation) simulation.stop()
if (graphReadyTimer) { clearInterval(graphReadyTimer); graphReadyTimer = null }
})
// Step 2: Prepare
@ -734,36 +1103,46 @@ const runReport = async () => {
try {
const res = await generatePrivateReport(simId.value)
const reportId = res.data.report_id
startReportPolling(reportId)
const taskId = res.data.task_id
if (res.data.already_generated) {
const fullRes = await getReport(reportId)
reportResult.value = fullRes.data
isLoading.value = false
return
}
startReportPolling(taskId, reportId)
} catch (e) {
error.value = `Report trigger failed: ${e.message}`
isLoading.value = false
}
}
const startReportPolling = (reportId) => {
reportPollingTimer = setInterval(() => pollReport(reportId), 4000)
pollReport(reportId)
const startReportPolling = (taskId, reportId) => {
reportPollingTimer = setInterval(() => pollReport(taskId, reportId), 4000)
pollReport(taskId, reportId)
}
const stopReportPolling = () => {
if (reportPollingTimer) { clearInterval(reportPollingTimer); reportPollingTimer = null }
}
const pollReport = async (reportId) => {
const pollReport = async (taskId, reportId) => {
try {
const res = await getReportStatus(reportId)
const res = await getPrivateReportStatus(taskId)
const status = res.data?.status
reportProgress.value = res.data?.message || 'Generating…'
if (status === 'completed') {
stopReportPolling()
const fullRes = await getReport(reportId)
const finalReportId = res.data?.result?.report_id || reportId
const fullRes = await getReport(finalReportId)
reportResult.value = fullRes.data
isLoading.value = false
} else if (status === 'failed') {
stopReportPolling()
error.value = `Report failed: ${res.data?.error}`
error.value = `Report failed: ${res.data?.error || res.data?.message}`
isLoading.value = false
}
} catch (e) {
@ -774,6 +1153,10 @@ const pollReport = async (reportId) => {
// Step 5: Interaction
watch(() => currentStep.value, async (step) => {
if (step === 3) {
await nextTick()
initGraph()
}
if (step === 5 && chatAgents.value.length === 0) {
loadChatAgents()
}
@ -817,13 +1200,28 @@ const sendChat = async () => {
.slice(0, -1)
.map(m => ({ role: m.role, content: m.content }))
const historyContext = history
.map(m => `${m.role === 'user' ? 'User' : 'You'}: ${m.content}`)
.join('\n')
const prompt = historyContext
? `Previous conversation:\n${historyContext}\n\nNew question: ${userMsg}`
: userMsg
const res = await interviewAgents({
simulation_id: simId.value,
agent_ids: [selectedAgentId.value],
prompt: userMsg,
chat_history: history,
interviews: [{
agent_id: selectedAgentId.value,
prompt,
}],
})
const reply = res.data?.[0]?.response || res.data?.response || '(no response)'
let reply = '(no response)'
if (res.success && res.data) {
const resultData = res.data.result || res.data
const resultsDict = resultData.results || resultData
const first = Object.values(resultsDict || {}).find(v => v && v.response)
if (first?.response) reply = first.response
}
chatMessages[selectedAgentId.value].push({ role: 'agent', content: reply })
} catch (e) {
chatMessages[selectedAgentId.value].push({ role: 'agent', content: `Error: ${e.message}` })
@ -840,12 +1238,20 @@ const scrollChat = () => {
}
}
// Auto-scroll feed
// Auto-scroll feed + update propagation graph
watch(() => recentActions.value.length, () => {
nextTick(() => {
if (feedPanel.value) feedPanel.value.scrollTop = feedPanel.value.scrollHeight
})
updateGraph(recentActions.value)
})
// Re-render graph skeleton as soon as the static cascade graph arrives,
// even if no action has been produced yet.
watch(
() => (simStatus.value?.agents?.length || 0) + (simStatus.value?.relational_edges?.length || 0),
() => updateGraph(recentActions.value || [])
)
</script>
<style scoped>
@ -1030,6 +1436,44 @@ watch(() => recentActions.value.length, () => {
.form-container { max-width: 1100px; margin: 0 auto; }
.section-title-row { margin-bottom: 24px; }
.graph-building-notice {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #FFF8E1;
border: 1px solid #FFE082;
border-radius: 4px;
font-size: 12px;
color: #795548;
margin-bottom: 20px;
}
.loading-ring--sm {
width: 16px;
height: 16px;
border-width: 2px;
flex-shrink: 0;
}
.drop-zone {
border: 2px dashed #D0D0D0;
border-radius: 6px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 24px;
color: #AAA;
}
.drop-zone:hover { border-color: #000; color: #000; }
.drop-zone--active { border-color: #000; background: #F5F5F5; color: #000; }
.drop-zone-label { font-size: 13px; font-weight: 600; }
.drop-zone-hint { font-size: 10px; letter-spacing: 0.04em; }
.section-h2 { font-size: 18px; font-weight: 700; color: #000; margin-bottom: 6px; }
.section-hint { font-size: 13px; color: #777; }
@ -1327,6 +1771,22 @@ watch(() => recentActions.value.length, () => {
.run-controls { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
/* ── Right column + graph panel (step 3) ─────────────────────────────────── */
.run-right-col {
display: flex;
flex-direction: column;
gap: 12px;
height: calc(100vh - 172px);
}
.graph-panel {
flex: 1;
border: 1.5px solid #EFEFEF;
border-radius: 4px;
overflow: hidden;
background: #FAFAFA;
}
/* ── Feed panel (step 3 right) ───────────────────────────────────────────── */
.run-feed-panel {
border: 1.5px solid #EFEFEF;
@ -1334,6 +1794,8 @@ watch(() => recentActions.value.length, () => {
overflow-y: auto;
display: flex;
flex-direction: column;
height: 200px;
flex-shrink: 0;
}
.feed-header {
@ -1382,8 +1844,7 @@ watch(() => recentActions.value.length, () => {
/* ── Report (step 4) ─────────────────────────────────────────────────────── */
.report-ready { display: flex; flex-direction: column; gap: 20px; padding: 20px 0; }
.report-title { font-size: 22px; font-weight: 700; color: #000; line-height: 1.3; }
.report-summary { font-size: 14px; color: #555; line-height: 1.6; }
.report-markdown { white-space: pre-wrap; font-size: 13px; line-height: 1.7; color: #222; font-family: inherit; background: #FAFAFA; border: 1.5px solid #E8E8E8; border-radius: 4px; padding: 20px; margin: 0; }
.report-sections { display: flex; flex-direction: column; gap: 0; border: 1.5px solid #E8E8E8; border-radius: 4px; overflow: hidden; }
@ -1549,6 +2010,81 @@ watch(() => recentActions.value.length, () => {
.chat-send-btn:hover { background: #222; }
.chat-send-btn:disabled { background: #CCC; cursor: not-allowed; }
/* ── Horizon buttons ─────────────────────────────────────────────────────── */
.horizon-btns { display: flex; flex-wrap: wrap; gap: 8px; }
.horizon-btn {
padding: 7px 14px;
border: 1.5px solid #E8E8E8;
border-radius: 3px;
background: #fff;
font-size: 12px;
font-weight: 500;
font-family: inherit;
color: #444;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.horizon-btn.is-active {
border-color: #000;
background: #FAFAFA;
color: #000;
font-weight: 600;
}
/* ── Agent counts block ──────────────────────────────────────────────────── */
.agent-counts-block {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.agent-count-row {
display: flex;
align-items: center;
gap: 8px;
}
.agent-count-label {
min-width: 130px;
font-size: 11px;
font-weight: 500;
color: #444;
flex-shrink: 0;
}
.agent-count-sep {
flex: 1;
height: 1px;
background: #E8E8E8;
}
.agent-count-input {
width: 64px;
border: 1.5px solid #E0E0E0;
border-radius: 3px;
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
color: #000;
text-align: right;
background: #fff;
flex-shrink: 0;
}
.agent-count-input:focus { outline: none; border-color: #000; }
.agent-count-total {
font-size: 11px;
font-weight: 700;
color: #555;
letter-spacing: 0.04em;
text-align: right;
margin-top: 4px;
}
/* ── Mono utility ────────────────────────────────────────────────────────── */
.mono { font-family: 'JetBrains Mono', monospace; }
</style>