feat: Private Impact simulation mode — v1
This commit is contained in:
parent
5939f01e7a
commit
074c03d685
332
CONTEXT.md
332
CONTEXT.md
|
|
@ -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 1–2.
|
||||
- **À 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).
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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' } : {},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue