diff --git a/CONTEXT.md b/CONTEXT.md index 1c7e3e9f..b418525e 100644 --- a/CONTEXT.md +++ b/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/` — 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 `
{{ reportResult.markdown_content }}
` | +| 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/` 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). diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 9e0b04e7..dd574b54 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -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') diff --git a/backend/app/api/private.py b/backend/app/api/private.py index 307c2f03..a92b19ea 100644 --- a/backend/app/api/private.py +++ b/backend/app/api/private.py @@ -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) diff --git a/backend/app/services/zep_entity_reader.py b/backend/app/services/zep_entity_reader.py index 71661be4..e950124b 100644 --- a/backend/app/services/zep_entity_reader.py +++ b/backend/app/services/zep_entity_reader.py @@ -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] diff --git a/backend/app/services/zep_tools.py b/backend/app/services/zep_tools.py index 3bc8a57a..a9711950 100644 --- a/backend/app/services/zep_tools.py +++ b/backend/app/services/zep_tools.py @@ -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)) diff --git a/backend/app/utils/locale.py b/backend/app/utils/locale.py index 23d04aa9..176f5a89 100644 --- a/backend/app/utils/locale.py +++ b/backend/app/utils/locale.py @@ -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}" diff --git a/backend/scripts/action_logger.py b/backend/scripts/action_logger.py index 340b47fd..aaf6b6f2 100644 --- a/backend/scripts/action_logger.py +++ b/backend/scripts/action_logger.py @@ -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') diff --git a/backend/scripts/run_private_simulation.py b/backend/scripts/run_private_simulation.py index d0c4ac07..f510ec61 100644 --- a/backend/scripts/run_private_simulation.py +++ b/backend/scripts/run_private_simulation.py @@ -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}") diff --git a/frontend/src/api/private.js b/frontend/src/api/private.js index eb6590dc..21974b1d 100644 --- a/frontend/src/api/private.js +++ b/frontend/src/api/private.js @@ -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 }) diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 97b7216e..5cb000dc 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -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' } : {}, }) }) } diff --git a/frontend/src/views/MainView.vue b/frontend/src/views/MainView.vue index d9c9ce5a..65749c20 100644 --- a/frontend/src/views/MainView.vue +++ b/frontend/src/views/MainView.vue @@ -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 } diff --git a/frontend/src/views/PrivateImpactView.vue b/frontend/src/views/PrivateImpactView.vue index 00e37df6..b3dbd73e 100644 --- a/frontend/src/views/PrivateImpactView.vue +++ b/frontend/src/views/PrivateImpactView.vue @@ -72,6 +72,29 @@

Fill in the decision context. These details will drive the simulation.

+
+ + + + + + + Glisser le fichier ici ou cliquer pour importer + private_impact_requirement.txt — dossier 02_simulation_params/ +
+ +
+
+ Graphe en construction — les champs peuvent déjà être remplis. Le bouton s'activera automatiquement. +
+
@@ -123,23 +146,37 @@ class="checkbox-native" /> - {{ t }} + {{ RELATIONAL_TYPE_LABELS[t] }}
+ +
+
+ {{ RELATIONAL_TYPE_LABELS[t] }} +
+ +
+
Total : {{ totalAgents }} agents
+
- -
- -
- 7d30d60d90d -
+ +
+
@@ -158,7 +195,7 @@
- -
-
LIVE ACTION FEED
-
-
- #{{ action.round_num }} - {{ action.agent_name || `Agent ${action.agent_id}` }} - {{ action.action_type }} - {{ shortTime(action.timestamp) }} + +
+
+ +
+
LIVE ACTION FEED
+
+
+ #{{ action.round_num }} + {{ action.agent_name || `Agent ${action.agent_id}` }} + {{ action.action_type }} + {{ shortTime(action.timestamp) }} +
+
Waiting for simulation events…
-
Waiting for simulation events…
@@ -349,14 +390,11 @@ Report ready
-

{{ reportResult.title }}

-

{{ reportResult.summary }}

- -
-
+
+
{{ String(idx + 1).padStart(2, '0') }} - {{ section.title }} + {{ section.title || ('Section ' + String(idx + 1).padStart(2, '0')) }} @@ -366,8 +404,12 @@
+
{{ reportResult.markdown_content }}
+ @@ -460,10 +502,11 @@