This commit is contained in:
Cyril 2026-05-25 12:05:37 +08:00 committed by GitHub
commit ca7b31b6c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 7585 additions and 260 deletions

View File

@ -1,23 +0,0 @@
.git
.github
.gitignore
.cursor
.DS_Store
.env
node_modules
frontend/node_modules
backend/.venv
.venv
.python-version
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
frontend/dist
frontend/.vite
backend/uploads

667
CONTEXT.md Normal file
View File

@ -0,0 +1,667 @@
# MiroFish — Fork Context (Private Impact Feature)
## Branche de travail
`feature/private-impact`
## Remotes
- `origin` → https://github.com/CyrilDEVIA/MiroResult.git (fork perso)
- `upstream` → https://github.com/666ghj/MiroFish.git (repo original)
---
## Historique des sessions
### 2026-04-16 — Session 1
#### Étapes terminées
- [x] **Prompt N°01** — Lecture complète du code source (audit, zéro modification)
- [x] **Prompt N°02** — Setup Git : fork + remote + branche `feature/private-impact`
- [x] **Prompt N°03** — Création `backend/scripts/run_private_simulation.py`
- [x] **Prompt N°04** — Création `backend/app/services/private_impact_profile_generator.py`
- [x] **Prompt N°05** — Création `backend/app/services/private_impact_config_generator.py`
- [x] **Prompt N°06** — Création `backend/app/services/private_impact_runner.py`
- [x] **Prompt N°07** — Blueprint `backend/app/api/private.py` + enregistrement (`api/__init__.py`, `app/__init__.py`)
- [x] **Prompt N°08** — Modification `backend/app/services/simulation_runner.py` (7 zones : champs private_*, start, monitor, read_log, check_completed, get_actions, cleanup)
- [x] **Prompt N°09** — Frontend : `api/private.js` + `ModeSelector.vue` + `PrivateImpactView.vue` + route `/private/:projectId`
- [x] **Prompt N°10**`action_logger.py` : ajout `get_private_logger()` + suppression fallback `run_private_simulation.py` + intégration `ModeSelector` dans `Home.vue`
#### Fichiers créés / modifiés
| Fichier | Action |
|---|---|
| `backend/scripts/run_private_simulation.py` | Créé — moteur de simulation privé |
| `backend/scripts/private/` | Créé — répertoire de sortie actions.jsonl |
| `backend/app/services/private_impact_profile_generator.py` | Créé — générateur de profils relationnels |
| `backend/app/services/private_impact_config_generator.py` | Créé — générateur de paramètres comportementaux |
| `backend/app/services/private_impact_runner.py` | Créé — orchestrateur subprocess + monitoring |
| `CONTEXT.md` | Créé — ce fichier |
| `backend/app/api/private.py` | Créé — blueprint Flask /api/private-impact (7 routes) |
| `backend/app/api/__init__.py` | Modifié — ajout private_bp + import private |
| `backend/app/__init__.py` | Modifié — enregistrement private_bp |
| `backend/app/services/simulation_runner.py` | Modifié — 7 zones private (champs, start, monitor, read_log, check, get_actions, cleanup) |
| `frontend/src/api/private.js` | Créé — client API private impact (7 fonctions) |
| `frontend/src/components/ModeSelector.vue` | Créé — sélecteur Public / Private Impact (2 cartes) |
| `frontend/src/views/PrivateImpactView.vue` | Créé — wizard 5 étapes (form → prepare → run → report → chat) |
| `frontend/src/router/index.js` | Modifié — route `/private/:projectId` ajoutée |
| `backend/scripts/action_logger.py` | Modifié — ajout `get_private_logger()` à `SimulationLogManager` |
| `backend/scripts/run_private_simulation.py` | Modifié — suppression fallback `hasattr`, appel direct `log_manager.get_private_logger()` |
| `frontend/src/views/Home.vue` | Modifié — intégration `ModeSelector` (right panel) + `handleModeSelected` + sessionStorage `pendingSimMode` |
#### Décisions d'architecture prises
- Pas d'env OASIS (pas de Twitter/Reddit/PlatformConfig)
- Appels LLM directs via `camel-ai ChatAgent` + `asyncio.to_thread()`
- Graphe relationnel construit depuis `cascade_influence` dans agent_configs
- `REACT_PRIVATELY` = invisible → ne propage pas l'exposition
- Tous les autres actions (sauf `DO_NOTHING`) cascade vers `cascade_influence` targets
- `zep_graph_memory_updater.py` réutilisé sans modification (platform="private")
- IPC `PrivateIPCHandler` : interviews via LLM direct (pas de SQLite)
- Output : `private/actions.jsonl` (même format JSONL que twitter/reddit)
- `RelationalAgentProfile` hérite de `OasisAgentProfile` — 8 champs relationnels ajoutés
- Encodage des dimensions relationnelles dans le champ `persona` (texte naturel)
- Fallback rule-based par type : Employee, Manager, Client, Competitor, Partner, FamilyMember
- `to_private_format()` retourne le dict lu par `run_private_simulation.py`
- `PrivateImpactConfigGenerator.generate_config()` : entrée = liste de dicts agents (issue de profile_generator), pas d'EntityNode direct
- `PrivateTimeConfig` : jours + rounds/jour (matin/midi/soir) — pas d'heures ni timezone
- `PrivateEventConfig` : injection par `decision_statement` — pas de posts sociaux
- `RelationalActivityConfig.exposure_round` : round 0 = exposition directe (distance 1)
- Fallback rule-based : table `RELATIONAL_FALLBACKS` dans le générateur (6 types)
- `PrivateImpactRunner` : même pattern classmethods que `SimulationRunner` (états en mémoire de classe)
- Config lue depuis `private_simulation_config.json` (≠ `simulation_config.json` OASIS)
- Log unique : `{sim_dir}/private/actions.jsonl` (une seule plateforme)
- `PrivateRunnerStatus` : enum séparé — pas de réutilisation de `RunnerStatus`
- `private_simulated_days` lu depuis le champ `simulated_day` du `round_end` event
- Frontend : CSS plain (pas Tailwind — non présent dans package.json) — même style que les vues existantes
- `ModeSelector.vue` : composant standalone, émet `@mode-selected` avec `"public"` ou `"private"`, à intégrer manuellement dans `Home.vue` ou `Process.vue`
- `PrivateImpactView.vue` : route `/private/:projectId` — charge le projet via `getProject()` pour récupérer `graph_id`
- Step 3 : polling `/api/private-impact/status/{simId}` toutes les 3s + affichage `recent_actions` depuis `to_detail_dict()`
- Step 4 : report via `generatePrivateReport()` → task_id → polling `getReportStatus(reportId)``getReport(reportId)` (réutilise le ReportManager existant)
- Step 5 : `chatAgents` reconstruit depuis la liste d'actions (agent_id + agent_name) ; chat via `interviewAgents()` (réutilise simulation.js)
- `SimulationRunState` : 5 champs `private_*` ajoutés (current_round, simulated_days, running, actions_count, completed)
- `add_action()` : elif platform=="private" → private_actions_count (évite comptage dans reddit)
- `to_dict()` : private_* inclus dans total_actions_count
- `start_simulation()` : elif platform=="private" → run_private_simulation.py + private_running=True
- `_monitor_simulation()` : lecture `private/actions.jsonl` dans la boucle ET en final ; private_running=False à la fin
- `_read_action_log()` : simulation_end → private_completed=True ; round_end → private_current_round + private_simulated_days (depuis simulated_day)
- `_check_all_platforms_completed()` : private_log + private_enabled + check private_completed
- `get_all_actions()` : bloc private après reddit (même pattern)
- `cleanup_simulation_logs()` : private_simulation.db + dirs_to_clean inclut "private"
- Blueprint `private_bp` enregistré sans url_prefix (les routes déclarent `/api/private-impact/...` en entier)
- `/prepare` stocke les métadonnées (graph_id, sim_requirement, agent_count…) dans `private_meta.json` dans le sim_dir
- `/prepare` appelle `ZepEntityReader.get_entities_by_type()` en boucle sur les types relationnels puis `PrivateImpactProfileGenerator.generate_profiles_from_entities()`
- `/start` lit `private_simulation_config.json` via `PrivateImpactRunner.start_simulation()`
- `/status` retourne `to_detail_dict()` (inclut `recent_actions`)
- `/report` réutilise `ReportAgent` avec `simulation_id=sim_id` et `graph_id` lu depuis `private_meta.json`
- `/cleanup` délègue entièrement à `PrivateImpactRunner.cleanup()`
- `get_private_logger()` ajouté à `SimulationLogManager` — même pattern que `get_twitter_logger()` / `get_reddit_logger()` ; fallback supprimé dans `run_private_simulation.py`
- `ModeSelector` intégré dans `Home.vue` (right panel, au-dessus de `.console-box`) ; mode stocké dans `sessionStorage` (`pendingSimMode`) — `MainView.vue` (N°11) doit lire ce flag et rediriger vers `/private/:projectId` après création du projet
---
### 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 |
|---|---|---|
| N°04 | `backend/app/services/private_impact_profile_generator.py` | ✅ Terminé |
| N°05 | `backend/app/services/private_impact_config_generator.py` | ✅ Terminé |
| N°06 | `backend/app/services/private_impact_runner.py` | ✅ Terminé |
| N°07 | `backend/app/api/private.py` | ✅ Terminé |
| N°07 | `backend/app/api/__init__.py` | ✅ Terminé |
| N°07 | `backend/app/__init__.py` | ✅ Terminé |
| N°08 | `backend/app/services/simulation_runner.py` | ✅ Terminé |
| N°09 | `frontend/src/views/PrivateImpactView.vue` | ✅ Terminé |
| N°09 | `frontend/src/components/ModeSelector.vue` | ✅ Terminé |
| 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 | `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 : ✅ RÉSOLU (Prompt N°10 Session 2)**
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.
---
### 2026-04-17 — Session 3
#### Prompt N°18 — Correction de 5 bugs + 1 fragilité
##### Bugs corrigés
| # | Bug | Correctif | Fichier(s) |
|---|---|---|---|
| 1 | Rapport section 01 en chinois | Règle de langue centralisée dans `get_language_instruction()` : override forçant l'alignement sur `simulation_requirement`, fallback français | `backend/app/utils/locale.py` |
| 2 | Graphe D3 sans arêtes | Endpoint `/status` augmenté avec `agents` + `relational_edges` issus de `cascade_influence`. Frontend : nœuds statiques + merge arêtes cascade (grises pointillées si pas d'action, pleines si activées) | `backend/app/api/private.py`, `frontend/src/views/PrivateImpactView.vue` |
| 3 | Bouton export rapport absent | Bouton `Export .md` ajouté dans Step 4 ; sérialisation `outline.sections` → markdown via Blob + download | `frontend/src/views/PrivateImpactView.vue` |
| 5 | Mode Public/Private via sessionStorage | Remplacé par query param `?mode=private` sur `/process/:projectId` ; MainView lit `route.query.mode` | `frontend/src/views/Home.vue`, `frontend/src/views/MainView.vue` |
##### Bug partiellement corrigé
| # | Bug | État | Raison |
|---|---|---|---|
| 4 | Chat agents 400 | **Corrigé côté frontend seulement** (body aligné sur `interviews: [{agent_id, prompt}]` + parsing réponse `result.results`) | La route chat pour Private Impact n'existe pas dans `private.py`. Le frontend tape `/api/simulation/interview/batch` qui exige `SimulationRunner.check_env_alive()` — incompatible avec `PrivateImpactRunner`. Le 400 "require interviews" est corrigé, mais `env not running` reste à traiter par une nouvelle route dédiée côté backend (hors périmètre : pas de nouveaux fichiers autorisés) |
##### Fichiers modifiés
- `backend/app/utils/locale.py` — directive de langue universelle
- `backend/app/api/private.py` — status endpoint renvoie `agents` + `relational_edges`
- `frontend/src/views/PrivateImpactView.vue` — graphe cascade, export .md, chat body corrigé
- `frontend/src/views/Home.vue` — suppression sessionStorage, passage query param
- `frontend/src/views/MainView.vue` — lecture `route.query.mode`
- `CONTEXT.md` — mise à jour
##### Prochaine étape
- **PR** vers `main` : `feature/private-impact` — regrouper cette session avec les sessions 12.
- **À anticiper post-PR** : créer une route `/api/private-impact/chat/<sim_id>` dédiée pour finaliser le bug 4 (le runner privé n'a pas d'IPC de type `send_batch_interview`, donc un chat direct via `ReportAgent.chat` ou un LLM call sur le profil d'agent est sans doute plus pertinent).
---
### 2026-04-17 — Session 4 (Prompt N°23 — Éclatement PrivateImpactView en 4 sous-composants)
#### Objectif
Refactoring iso-comportement de `PrivateImpactView.vue` (monolithe 2091 lignes) en orchestrateur + 4 sous-composants autonomes, pour préparer l'intégration future dans `MainView` (wizard partagé Public/Private, Prompt N°24).
#### Fichiers créés
| Fichier | Rôle |
|---|---|
| `frontend/src/constants/private.js` | Constantes extraites : `RELATIONAL_TYPES`, `RELATIONAL_TYPE_LABELS`, `HORIZON_OPTIONS`, `ACTION_COLORS`, `STEP_NAMES` |
| `frontend/src/utils/private.js` | Helpers purs : `shortTime`, `actionTypeClass`, `initials`, `nodeColor`, `buildRequirement`, `parseImportedConfig`, `exportReportMarkdown` |
| `frontend/src/components/private/Step2PrivateDecision.vue` | Formulaire décision + import .txt + bouton Prepare (émet `@prepare`) |
| `frontend/src/components/private/Step3PrivateSim.vue` | Graphe D3 force-directed + live feed + contrôles start/stop (émet `@stop`, `@report`) |
| `frontend/src/components/private/Step4PrivateReport.vue` | Affichage rapport + export markdown (émet `@retry`, `@next`) |
| `frontend/src/components/private/Step5PrivateInteraction.vue` | Liste agents + chat local (props `simId` + `chatAgents`) |
#### Fichier modifié
- `frontend/src/views/PrivateImpactView.vue` — réduit à l'orchestrateur : header, steps-bar, error banner, état global (`currentStep`, `simId`, `simStatus`, `prepareResult`, `reportResult`, `form`, `agentCounts`, `recentActions`, `chatAgents`, timers), méthodes async (`runPrepare`, `runStart`, `runReport`, `handleStop`, polling status/report, `waitForGraph`, `loadChatAgents`, `goToStep`), Step 2 (Prepare Results) conservé inline.
#### Décisions d'architecture — Session 4
- `form` et `agentCounts` restent des `reactive` dans le parent et sont passés en props aux enfants — les mutations (v-model) se propagent naturellement grâce à la réactivité Vue 3.
- Le watcher `form.relationalTypes``agentCounts` vit dans `Step2PrivateDecision` (avec `deep: true` pour capter les mutations d'array).
- Le cycle de vie D3 (`initGraph`, `updateGraph`, `simulation.stop()`) vit dans `Step3PrivateSim` via `onMounted` / `onUnmounted` — plus besoin du watcher `currentStep === 3` dans le parent.
- Les styles communs (`.btn-primary`, `.btn-secondary`, `.mono`, `.loading-ring`) sont dupliqués dans chaque composant `scoped` pour rester autonomes ; le parent conserve uniquement les styles réellement utilisés par son template (header, steps-bar, error banner, centered-panel, prepare-results).
- Le chargement initial de `chatAgents` reste dans le parent (`loadChatAgents` déclenché par le watcher `currentStep === 5`) — `Step5PrivateInteraction` reçoit la liste en prop et n'appelle jamais `getPrivateActions`.
- Step 2 (Prepare Results, ~60 lignes) reste inline dans l'orchestrateur — trop couplé à l'état parent pour justifier un 5ème composant.
#### Prochaine étape
- **Prompt N°24** — Refactor `MainView.vue` avec bifurcation `mode=public` / `mode=private` après le graph build (wizard partagé qui ré-utilise les 4 sous-composants privés + leurs équivalents publics).
---
### 2026-04-17 — Session 5 (Prompt N°24 — Bifurcation MainView par `route.query.mode`)
#### Objectif
Fusionner les deux wizards (Public / Private) dans un unique `MainView.vue` qui bifurque selon `route.query.mode` après l'étape 1 (graph build). `PrivateImpactView.vue` devient un simple passthrough de redirection vers `/process/:projectId?mode=private`.
#### Fichiers modifiés
| Fichier | Modification |
|---|---|
| `frontend/src/components/Step1GraphBuild.vue` | Ajout prop `mode: { type: String, default: 'public' }`. Si `mode === 'private'`, `handleEnterEnvSetup` émet `next-step` sans créer de simulation OASIS (pas de `createSimulation` ni de `router.push('/simulation/...')`). Le comportement public reste **strictement inchangé**. |
| `frontend/src/views/MainView.vue` | Refactor complet : `isPrivateMode` computed (`route.query.mode === 'private'`), ajout de tout l'état Private (`privateForm`, `privateAgentCounts`, `privateSimId`, `privateSimStatus`, `privatePrepareResult`, `privatePrepareReady`, `privateReportResult`, `privateIsLoading`, `privateError`, `privateReportProgress`, `privateRecentActions`, `privateChatAgents`, timers `privatePollingTimer` + `privateReportPollingTimer`). Méthodes Private migrées depuis PrivateImpactView : `runPrivatePrepare`, `runPrivateStart`, `pollPrivateStatus`, `handlePrivateStop`, `runPrivateReport`, `pollPrivateReport`, `loadPrivateChatAgents`. Template bifurqué : Step 1 commun (split layout, mode prop), Steps 25 branchés selon le mode. `onBeforeRouteLeave`, `onBeforeRouteUpdate`, `onUnmounted` cleanupent **tous** les timers (publics + privés). `watch(isPrivateMode)` reset `currentStep = 1` et `privatePrepareReady = false`. `handleNewProject` propage désormais `query: { mode }` dans `router.replace({ name: 'Process', ... })` au lieu de rediriger vers `/private/:projectId`. |
| `frontend/src/views/PrivateImpactView.vue` | Réduit à un composant de redirection : `onMounted``router.replace({ name: 'Process', params: { projectId }, query: { mode: 'private' } })`. |
| `frontend/src/components/ModeSelector.vue` | Suppression du `router.push({ name: 'PrivateImpact' })` (le routing est désormais déclenché par `Home.vue` via `selectedMode.value === 'private' ? { mode: 'private' } : {}`). |
#### Décisions d'architecture — Session 5
- **Step 1 inchangé pour le public** : ajout d'un prop `mode` avec default `'public'`. Le public branche exécute exactement l'ancien code (createSimulation + router.push vers `/simulation/:id`). Seule la branche `private` est nouvelle (émet `next-step`).
- **Étiquettes des étapes différentes par mode** :
- Public : `stepNames` venant de `tm('main.stepNames')` (i18n)
- Private : `['Graph Build', 'Requirement', 'Run', 'Report', 'Interact']`
- **Étape 2 privée (Requirement + Prepare)** : `privatePrepareReady` est un flag local dans MainView qui permet d'afficher le formulaire (`false`) ou le résultat `preparePrivateSimulation` (`true`). Le bouton « Back » remet le flag à `false` (retour au formulaire avec données conservées).
- **Steps bar Private** : affichée uniquement à partir de `currentStep >= 2` (étape 1 = graph build commun, avec son propre UI). Le breadcrumb couvre les étapes 2→5 (`['Requirement', 'Run', 'Report', 'Interact']`).
- **Cleanup timers** : un seul point de vérité `cleanupAllTimers()` appelé dans `onBeforeRouteLeave`, `onBeforeRouteUpdate` (si `projectId` change) et `onUnmounted`. `watch(isPrivateMode)` appelle uniquement `cleanupPrivateTimers()` (le changement de mode seul ne doit pas tuer les timers publics).
- **`currentStep` jamais persisté** : reset automatique à 1 dès que `isPrivateMode` change. Pas de localStorage / sessionStorage.
- **`PrivateImpactView.vue`** : maintenu comme simple redirecteur pour préserver la compatibilité des URLs `/private` et `/private/:projectId` (ModeSelector legacy, liens externes éventuels). À supprimer dans un prompt futur si plus utilisé.
#### Validation
- `npx vite build` → succès (704 modules, 1.96s, seuls les warnings préexistants persistent : chunk > 500 kB et dynamic import de `pendingUpload.js`).
- Flow public : `Home → /process/new → Step1GraphBuild (mode=public) → createSimulation → /simulation/:id`**inchangé**.
- Flow private : `Home (mode=private) → /process/new?mode=private → Step1GraphBuild (mode=private) → emit next-step → Step 2 privée (form Prepare) → Step 3 Sim → Step 4 Report → Step 5 Chat`.
- Compatibilité : `/private/:projectId` → redirige vers `/process/:projectId?mode=private`.
#### Prochaine étape
- PR `feature/private-impact``main` (regroupe Sessions 1 à 5).
- Cleanup optionnel : supprimer complètement les routes `/private` et `/private/:projectId` si PR marchée en production (et que les liens externes sont migrés).
---
### 2026-04-17 — Session 6 (Prompt N°25 — ModeSelector via query param + suppression routes /private)
#### Objectif
Finaliser l'intégration du mode selector : plus aucun état de mode hors URL, suppression définitive des routes legacy `/private` / `/private/:projectId` et du fichier passthrough `PrivateImpactView.vue`.
#### Fichiers modifiés
| Fichier | Modification |
|---|---|
| `frontend/src/components/ModeSelector.vue` | Refactor complet. Nouveaux props : `projectId: { type: String, default: 'new' }` et `disabled: { type: Boolean, default: false }`. `selectMode(mode)` appelle `emit('mode-selected', mode)` (synchrone — permet au parent de stocker la pending upload avant la navigation) puis `router.push({ path: '/process/${projectId}', query: { mode } })`. Cards ont désormais `:disabled` natif + classe `.is-disabled` (opacity 0.45, cursor not-allowed). |
| `frontend/src/views/Home.vue` | Suppression du bouton `start-engine-btn` (ModeSelector devient la CTA). Suppression de `selectedMode` ref, de `startSimulation()`, de `useRouter` import, de `error` ref non utilisé, du `mode-selector-wrapper` (déplacé dans console-box). ModeSelector est maintenant dans une `.console-section.mode-selector-section` après la textarea avec `:projectId="'new'"` + `:disabled="!canSubmit || loading"`. `handleModeSelected()` appelle `setPendingUpload(files, simulationRequirement)` de manière synchrone — l'emit Vue 3 s'exécute avant le `router.push` qui suit dans ModeSelector. Import de `setPendingUpload` passé de dynamic à statique. CSS obsolètes supprimées (`.start-engine-btn*`, `@keyframes pulse-border`, `.mode-selector-wrapper`, `.btn-section`). |
| `frontend/src/router/index.js` | Suppression de l'import `PrivateImpactView` et des deux entrées de route `/private` (`PrivateImpact`) et `/private/:projectId` (`PrivateImpactWithProject`). Une URL legacy `/private/...` donne désormais une 404 du router (comportement Vue Router par défaut). |
#### Fichier supprimé
- `frontend/src/views/PrivateImpactView.vue` — le passthrough de redirection Session 5 devient obsolète une fois le ModeSelector reconnecté et les routes legacy retirées.
#### Décisions d'architecture — Session 6
- **Mode dans l'URL uniquement** : aucun `sessionStorage`, aucun `localStorage`, aucune `ref` Home persistée. La query param `?mode=public|private` est la source de vérité.
- **Timing de `setPendingUpload`** : le parent (`Home.vue`) écoute `mode-selected`. L'emit Vue est synchrone et se déclenche AVANT le `router.push` dans le même `selectMode`. Le handler `handleModeSelected` s'exécute donc avant la navigation, garantissant que `getPendingUpload()` côté `MainView.handleNewProject` trouve bien les fichiers.
- **UX ModeSelector** : cards désactivées tant que `canSubmit === false` (pas de fichiers OU pas de prompt de simulation). Opacity réduite + cursor not-allowed pour indiquer l'état.
- **404 sur `/private*`** : choix délibéré pour nettoyer l'API publique du frontend. Aucun lien externe documenté dans le repo ne pointe vers ces URLs (confirmé par `grep -rn "/private"` restreint aux URLs frontend : 0 occurrence hors `api/private.js` backend et imports locaux `*/private/*` côté fichiers). Si un lien externe casse, la route pourra être rétablie en alias explicite dans une future PR.
- **Bouton « Start Engine » supprimé** : redondant avec ModeSelector une fois que celui-ci navigue directement. Deux CTAs pour la même action = ambiguïté UX. L'animation `pulse-border` disparaît avec le bouton.
#### Résultats des greps — AVANT (état pré-modification)
```
grep -rn "/private" frontend/src --include="*.vue" --include="*.js"
→ router/index.js:47 path: '/private'
→ router/index.js:53 path: '/private/:projectId'
→ api/private.js (7 lignes : endpoints backend /api/private-impact/... — À CONSERVER)
→ components/private/*.vue, utils/private.js, constants/private.js (imports fichiers locaux — À CONSERVER)
grep -rn "PrivateImpactView" frontend/src
→ router/index.js:8 import PrivateImpactView
→ router/index.js:49 component: PrivateImpactView
→ router/index.js:55 component: PrivateImpactView
grep -rn "privateImpact" frontend/src → 0 match
grep -rn "sessionStorage" frontend/src/components/ModeSelector.vue frontend/src/views/Home.vue → 0 match
```
#### Résultats des greps — APRÈS (état post-modification)
```
grep -rn "/private" frontend/src --include="*.vue" --include="*.js"
→ api/private.js (7 lignes : endpoints backend — LÉGITIMES)
→ MainView.vue + components/private/*.vue + utils/private.js + constants/private.js (imports locaux — LÉGITIMES)
→ AUCUNE URL frontend `/private/...` restante ✓
grep -rn "PrivateImpactView" frontend/src → 0 match ✓
grep -rn "privateImpact" frontend/src → 0 match ✓
grep -rn "sessionStorage" frontend/src → 0 match (recherche étendue au projet entier) ✓
```
#### Validation
- `npx vite build` → succès (701 modules, 1.13s). Warning dynamic import de `pendingUpload.js` disparu (import statique dans Home.vue).
- Scénario 1 : Home → upload + prompt → ModeSelector actif → clic « Public Opinion » → URL = `/process/new?mode=public``handleNewProject` lit pendingUpload → wizard public.
- Scénario 2 : Home → upload + prompt → clic « Private Impact » → URL = `/process/new?mode=private` → wizard privé.
- Scénario 3 : URL directe legacy `/private/:projectId` → 404 router (plus de route).
- Scénario 4 : F5 sur `/process/:projectId?mode=private` → query param préservé, même flux.
- Scénario 5 : back navigateur depuis wizard → retour à Home, ModeSelector en état `selected.value = null` (ref locale au composant, reset au remount).
- Scénario 6 : switch manuel d'URL `?mode=public``?mode=private` sur même projectId → `watch(isPrivateMode)` reset `currentStep = 1` + cleanup timers privés (Session 5).
#### Prochaine étape
- **Prompt N°26** — Tests bout-en-bout les deux flux (upload → graph build → steps privés/publics → rapport → chat) + corrections de régressions éventuelles (UX cards désactivées, feedback visuel de chargement pendant `generateOntology`, cleanup pendingUpload après succès).
---
### Session 7 — Prompt N°27 — i18n Private + header polishing (2026-04-17)
#### Objectif
Migrer les `stepNames` et `modeBadge` Private hors des hardcodes vers les fichiers i18n. Unifier la mécanique du compteur `Step X/Y` via `stepNames.length` (déjà en place depuis N°24). Ne pas toucher au flux Public fonctionnel.
#### Audit i18n — état AVANT modifications
Langues supportées (fichiers présents dans `locales/`) : **EN** (`en.json`), **ZH** (`zh.json`). Pas de `fr.json` malgré la présence de `fr` dans `locales/languages.json` (langue référencée mais sans pack). Langue par défaut : `zh`.
Clés existantes pertinentes pour le header / wizard Public :
- `main.stepNames` (array 5 entrées) — utilisé par `MainView.vue:288`, `SimulationView.vue:28`, `SimulationRunView.vue:28`, `ReportView.vue:28`, `InteractionView.vue:28`
- `main.layoutGraph|layoutSplit|layoutWorkbench` — utilisé par les 5 vues ci-dessus
- `common.ready|running|completed|failed|processing|error` — présents mais `statusText` dans `MainView.vue` reste hardcodé EN (hors périmètre de ce prompt — aucune divergence cross-mode)
- Aucune clé Private préexistante (tout était hardcodé dans le JS `MainView.vue`).
#### Fichiers modifiés
| Fichier | Modification |
|---|---|
| `locales/en.json` | Ajout section `public` (`stepNames` copie de `main.stepNames` + `modeBadge: "PUBLIC OPINION"`) et section `private` (`stepNames: ["Requirement", "Prepare", "Run", "Report", "Interact"]` + `modeBadge: "PRIVATE IMPACT"`). `main.stepNames` conservé — utilisé par 4 autres vues (SimulationView, SimulationRunView, ReportView, InteractionView) hors scope. |
| `locales/zh.json` | Idem, symétrique. Private ZH : `["需求", "准备", "运行", "报告", "互动"]`. Badge ZH : `"私域影响"` (Private) / `"公共舆论"` (Public). |
| `frontend/src/views/MainView.vue` | Template : `PRIVATE IMPACT``{{ t('private.modeBadge') }}`. Script : `publicStepNames` lit désormais `tm('public.stepNames')` (aligné sur la nouvelle clé). `privateStepNames` passe d'array statique à `computed(() => tm('private.stepNames'))`. `privateBreadcrumb` passe d'array statique à `computed(() => privateStepNames.value.slice(1))` — dérivé, toujours synchronisé. `currentStepNames` adapté pour `.value` sur la computed privée. Compteur `Step {{ currentStep }}/{{ currentStepNames.length }}` déjà robuste (N°24) — pas de modification. |
#### Clés i18n ajoutées
- `public.stepNames` (EN, ZH) — mirror de `main.stepNames`
- `public.modeBadge` (EN, ZH) — pour usage futur si badge Public affiché (non rendu actuellement dans le template puisque `<div v-if="isPrivateMode" class="mode-badge">`)
- `private.stepNames` (EN, ZH)
- `private.modeBadge` (EN, ZH)
#### Décisions — Session 7
- **Coexistence `main.stepNames` ↔ `public.stepNames`** : les deux clés contiennent actuellement la même liste. `main.stepNames` reste la source de vérité pour les 4 vues sub-étape (SimulationView, SimulationRunView, ReportView, InteractionView) qui lisent `$tm('main.stepNames')[N]`. `public.stepNames` est lu uniquement par MainView pour la symétrie avec `private.stepNames`. Migration complète vers `public.stepNames` hors périmètre de ce prompt (toucherait 4 vues non mentionnées).
- **Écart spec vs UI** : le prompt liste `['Requirement', 'Prepare', 'Run', 'Report', 'Interact']` mais Step 1 dans la UI est le composant `Step1GraphBuild` commun aux deux modes. La valeur « Requirement » pour Step 1 Private est donc sémantiquement le **cadrage** (l'utilisateur fournit les docs requirement qui alimentent la construction du graphe), pas la construction graph elle-même. Choix : suivre le prompt littéralement — le label affiché dans le header pour Step 1 en mode Private est « Requirement » (ZH : 需求). Le composant sous-jacent ne change pas.
- **Badge Public non affiché** : `public.modeBadge` est ajoutée pour cohérence API i18n (symétrie avec `private.modeBadge`) mais le template `<div v-if="isPrivateMode" class="mode-badge">` n'affiche pas de badge en mode Public. Comportement inchangé vs avant N°27. Activation future = retrait du `v-if`.
- **Pas de migration `statusText`** : les labels `Ready|Running|Completed|Failed|Error|Processing|Building Graph|Generating Ontology|Initializing` restent hardcodés EN dans `MainView.vue:368-385`. Ils sont identiques pour les deux modes (non-divergents). Per prompt section 5 : « Si aucune divergence supplémentaire trouvée au-delà de stepNames et modeBadge, c'est OK ». Migration i18n complète du `statusText` = hors périmètre (un prompt futur pourra le traiter).
#### Validation
- `npx vite build` → succès (701 modules, 1.13s, aucun warning nouveau).
- Lecture code : `currentStepNames.length === 5` dans les deux modes (header `Step X/5` cohérent).
- Lecture template : `privateBreadcrumb` (computed) = 4 entrées (`stepNames.slice(1)`), affiché uniquement pour steps 2→5 via `v-if="currentStep >= 2"`. Numérotation `{{ idx + 1 }}` = 1→4 (N°24, cohérent).
- Changement de langue via `LanguageSwitcher` : `tm('private.stepNames')` et `t('private.modeBadge')` sont réactifs via vue-i18n → bascule EN/ZH immédiate sans perte d'état (watch interne vue-i18n).
- Changement de mode (URL `?mode=public``?mode=private`) : la langue active ne bouge pas (stockée dans `localStorage` par `i18n/index.js`, indépendante de la query param).
#### Non-régressions constatées
- Aucune modification dans les 4 vues qui lisent `main.stepNames[N]` (SimulationView, SimulationRunView, ReportView, InteractionView) — leur header conserve son label EN/ZH existant.
- Public flow : `publicStepNames` renvoie `tm('public.stepNames')` dont le contenu est identique à `main.stepNames` → aucun changement visuel.
#### Prochaine étape
- **Prompt N°28** — Commit feature/private-impact + push + mise à jour PR #544.
---
## Session refactoring wizard — Terminée le 2026-04-17
Prompts exécutés : **N°23 → N°28** (N°26 absorbé : tests manuels skippés, aucune régression corrigée).
### Résultat
- **Private Impact** intégré dans le wizard MiroFish via bifurcation `route.query.mode`.
- **Public Opinion** préservé sans régression (zéro modification de `Step2EnvSetup.vue` et des 4 vues sub-étape).
- PR [#544](https://github.com/666ghj/MiroFish/pull/544) à jour (7 commits au total, état MERGEABLE, description refaite).
### Commits poussés (branche `fork/feature/private-impact`)
| SHA | Type | Prompt |
|---|---|---|
| `e4fe3f9` | refactor(private): extraire composants, constantes et helpers | N°23 |
| `75d5a9b` | refactor(wizard): bifurcation MainView selon route.query.mode + i18n Private | N°24 + N°27 |
| `9899afe` | refactor(routing): ModeSelector via query param + suppression routes /private | N°25 |
| `d73c0ac` | docs: journal sessions refactoring wizard (N°23 → N°27) | Session |
### Fichiers finaux du refactoring
**Créés** :
- `frontend/src/components/private/Step{2-5}Private*.vue`
- `frontend/src/constants/private.js`
- `frontend/src/utils/private.js`
**Modifiés** :
- `frontend/src/components/ModeSelector.vue` — nouveaux props, navigation via query param
- `frontend/src/components/Step1GraphBuild.vue` — prop `mode`
- `frontend/src/views/Home.vue` — ModeSelector déplacé, Start Engine supprimé
- `frontend/src/views/MainView.vue` — bifurcation + i18n Private
- `frontend/src/router/index.js` — routes `/private` supprimées
- `locales/en.json` + `locales/zh.json` — clés `public.*` / `private.*`
- `CONTEXT.md` — journal Sessions 4 → 7
**Supprimé** :
- `frontend/src/views/PrivateImpactView.vue` (passthrough obsolète après N°25)
### Remotes Git
- `fork``CyrilDEVIA/MiroFish` (push destination de PR #544)
- `upstream``666ghj/MiroFish` (repo hébergeant PR #544)
- Branche `feature/private-impact` trackée désormais sur `fork/feature/private-impact`.
### Prochaines étapes (hors roadmap actuelle)
- Review PR #544 par le mainteneur `666ghj`
- Merge vers `main`
- Documentation utilisateur (README, guide d'usage mode Private)
- Migration éventuelle du `statusText` de `MainView.vue` vers i18n (hors scope N°27)
- Consolidation `main.stepNames``public.stepNames` dans les 4 vues sub-étape (migration optionnelle)

View File

@ -1,29 +0,0 @@
FROM python:3.11
# 安装 Node.js (满足 >=18及必要工具
RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs npm \
&& rm -rf /var/lib/apt/lists/*
# 从 uv 官方镜像复制 uv
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/
WORKDIR /app
# 先复制依赖描述文件以利用缓存
COPY package.json package-lock.json ./
COPY frontend/package.json frontend/package-lock.json ./frontend/
COPY backend/pyproject.toml backend/uv.lock ./backend/
# 安装依赖Node + Python
RUN npm ci \
&& npm ci --prefix frontend \
&& cd backend && uv sync --frozen
# 复制项目源码
COPY . .
EXPOSE 3000 5001
# 同时启动前后端(开发模式)
CMD ["npm", "run", "dev"]

137
PULL_REQUEST.md Normal file
View File

@ -0,0 +1,137 @@
# PR — feat: Private Impact Simulation Mode
**Branche** : `feature/private-impact``main`
**Date** : 2026-04-16
**Auteur** : Cyril / iapluska
---
## Description
Ajout d'un second mode de simulation : **Private Impact**.
Contrairement au mode public (réseau social ouvert, diffusion virale), le mode Private Impact simule la propagation d'une décision dans un **réseau relationnel fermé et pondéré** : entourage professionnel, conseil d'administration, associés, investisseurs, équipe dirigeante.
Chaque agent est modélisé avec 8 champs relationnels (rôle, loyauté, influence, résistance au changement, historique de conflit, visibilité publique, accès décisionnel, sensibilité financière). La simulation prédit comment la décision se propage, qui bloque, qui amplifie, et quel est l'impact final agrégé.
---
## Cas d'usage concrets
### 1 — PDG envisage d'acheter une Rolls-Royce (dépense de prestige)
- Agents : DRH, DAF, actionnaires principaux, conseil, assistante de direction
- Question : cette dépense va-t-elle générer un retour d'image interne positif ou créer des tensions ?
- Résultat : score d'impact + profils de résistance identifiés
### 2 — Fondateur annonce une levée de fonds série A
- Agents : co-fondateurs, investisseurs seed, équipe technique, board advisor
- Question : quels agents freinent l'annonce, quels agents l'amplifient ?
- Résultat : carte de propagation + recommandations de communication interne
### 3 — Collectivité locale annonce une fermeture de service
- Agents : élus, directeurs de service, syndicats, presse locale, usagers clés
- Question : quel est le risque de crise avant annonce publique ?
- Résultat : score de risque + délai de propagation estimé
---
## Fichiers créés (nouveaux)
| Fichier | Rôle |
|---|---|
| `backend/app/api/private.py` | Blueprint Flask — 7 endpoints `/api/private-impact/*` |
| `backend/app/services/private_impact_profile_generator.py` | Génération des profils relationnels via LLM |
| `backend/app/services/private_impact_config_generator.py` | Configuration réseau + paramètres de simulation |
| `backend/app/services/private_impact_runner.py` | Moteur de simulation fermé (sans plateforme sociale) |
| `backend/scripts/run_private_simulation.py` | Script standalone — exécution sans API |
| `frontend/src/api/private.js` | Client API frontend pour les endpoints private |
| `frontend/src/components/ModeSelector.vue` | Sélecteur de mode (Public / Private) sur Home |
| `frontend/src/views/PrivateImpactView.vue` | Wizard 5 étapes — interface complète Private Impact |
| `CONTEXT.md` | Journal de développement de la feature (12 prompts) |
---
## Fichiers modifiés (extensions chirurgicales)
| Fichier | Modification |
|---|---|
| `backend/app/__init__.py` | Enregistrement du blueprint `private_impact` |
| `backend/app/api/__init__.py` | Export du blueprint |
| `backend/app/services/simulation_runner.py` | 6 extensions — support mode privé dans le runner public |
| `backend/run.py` | Import du blueprint private |
| `backend/scripts/action_logger.py` | Support logging mode private |
| `frontend/src/api/index.js` | Export centralisé des API |
| `frontend/src/router/index.js` | Nouvelle route `/private/:projectId` |
| `frontend/src/views/Home.vue` | Intégration ModeSelector |
| `frontend/src/views/MainView.vue` | Redirection post-création selon `pendingSimMode` |
| `frontend/vite.config.js` | Ajustement proxy pour nouveaux endpoints |
---
## Zéro breaking change — garanti
- Le mode public est **inchangé fonctionnellement** : toutes les routes, composants et services existants conservent leur comportement exact.
- Le `ModeSelector` est **opt-in** : sans sélection, le comportement par défaut reste le mode public.
- La lecture de `sessionStorage.pendingSimMode` est **non-bloquante** : si absent, fallback silencieux sur le flow public.
- Le blueprint `/api/private-impact` est un **module isolé**, sans dépendance croisée avec `/api/graph` ou `/api/simulation`.
- Les 6 modifications de `simulation_runner.py` sont toutes **conditionnelles** (guards `if mode == 'private'`).
---
## Instructions de test
### Prérequis
```bash
# Backend
pip install -r backend/requirements.txt
python backend/run.py
# Frontend
cd frontend && npm install && npm run dev
```
### Test mode Public (régression)
1. Aller sur `http://localhost:5173`
2. Ne pas interagir avec le ModeSelector (ou sélectionner "Public")
3. Uploader des fichiers et lancer une simulation
4. Vérifier : redirection vers `/main/:projectId` → comportement inchangé
### Test mode Private Impact
1. Aller sur `http://localhost:5173`
2. Cliquer sur **"Private Impact"** dans le ModeSelector
3. Uploader des documents (ex : organigramme, biographies courtes des agents)
4. Remplir le champ "simulation_requirement" avec une décision à tester
5. Vérifier : après création projet → redirection vers `/private/:projectId`
6. Suivre le wizard 5 étapes :
- Étape 1 : Génération des profils relationnels
- Étape 2 : Configuration du réseau
- Étape 3 : Lancement simulation
- Étape 4 : Rapport d'impact
- Étape 5 : Recommandations
### Test API standalone
```bash
cd backend
python scripts/run_private_simulation.py \
--project-id <project_id> \
--decision "Acquisition véhicule de prestige 350k€"
```
### Endpoints à vérifier
```
POST /api/private-impact/generate-profiles
POST /api/private-impact/generate-config
POST /api/private-impact/run
GET /api/private-impact/status/<task_id>
GET /api/private-impact/result/<project_id>
GET /api/private-impact/project/<project_id>
DELETE /api/private-impact/project/<project_id>
```
---
## Notes de merge
- Ne pas squash : l'historique des 12 prompts est documenté dans `CONTEXT.md`
- Aucune migration de base de données requise
- Aucune variable d'environnement nouvelle requise (réutilise les clés LLM existantes)

View File

@ -67,6 +67,8 @@ def create_app(config_class=Config):
app.register_blueprint(graph_bp, url_prefix='/api/graph')
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, url_prefix='/api')
# 健康检查
@app.route('/health')

View File

@ -7,8 +7,10 @@ from flask import Blueprint
graph_bp = Blueprint('graph', __name__)
simulation_bp = Blueprint('simulation', __name__)
report_bp = Blueprint('report', __name__)
private_bp = Blueprint('private', __name__)
from . import graph # noqa: E402, F401
from . import simulation # noqa: E402, F401
from . import report # noqa: E402, F401
from . import private # noqa: E402, F401

676
backend/app/api/private.py Normal file
View File

@ -0,0 +1,676 @@
"""
Private Impact API routes.
Exposes the /api/private-impact endpoints for the Private Impact simulation mode.
Follows the same error handling and JSON response format as graph/simulation/report blueprints.
"""
import json
import os
import traceback
import threading
import uuid
from datetime import datetime
from flask import request, jsonify
from . import private_bp
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, EntityNode
from ..services.report_agent import ReportAgent, ReportManager, ReportStatus
from ..models.task import TaskManager, TaskStatus
from ..models.project import ProjectManager
from ..utils.logger import get_logger
from ..utils.locale import t, get_locale, set_locale
logger = get_logger('mirofish.api.private')
# Simulation data directory (same root as PrivateImpactRunner.RUN_STATE_DIR)
_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 ────────────────────────────────────────────────────────────────────
def _sim_dir(sim_id: str) -> str:
"""Return absolute path to the simulation directory."""
return os.path.join(_SIM_DIR, sim_id)
def _meta_path(sim_id: str) -> str:
return os.path.join(_sim_dir(sim_id), "private_meta.json")
def _read_meta(sim_id: str) -> dict:
"""Load private_meta.json; return empty dict if missing."""
path = _meta_path(sim_id)
if not os.path.exists(path):
return {}
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def _write_meta(sim_id: str, meta: dict) -> None:
"""Persist private_meta.json to disk."""
os.makedirs(_sim_dir(sim_id), exist_ok=True)
with open(_meta_path(sim_id), 'w', encoding='utf-8') as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
# ── POST /api/private-impact/prepare ──────────────────────────────────────────
@private_bp.route('/private-impact/prepare', methods=['POST'])
def prepare_private_simulation():
"""
Prepare a Private Impact simulation.
Reads relational entities from the Zep graph, generates RelationalAgentProfile
instances via PrivateImpactProfileGenerator, then generates the full
PrivateSimulationParameters via PrivateImpactConfigGenerator.
Saves private_agents.json and private_simulation_config.json in the sim dir.
Request (JSON):
{
"graph_id": "mirofish_xxxx", // Required (or project_id)
"project_id": "proj_xxxx", // Optional used to resolve graph_id / sim_requirement
"simulation_requirement": "...", // Required if no project_id
"decision_context": "...", // Optional
"use_llm": true, // Optional, default true
"entity_types": ["employee", ...], // Optional filter (defaults to all relational types)
"sim_id": "private_xxxx" // Optional reuse an existing sim_id
}
Returns:
{ "success": true, "data": { "sim_id": "...", "agent_count": N, "status": "prepared" } }
"""
try:
data = request.get_json(silent=True) or {}
# Resolve graph_id and simulation_requirement
project_id = data.get('project_id')
graph_id = data.get('graph_id')
simulation_requirement = data.get('simulation_requirement', '')
decision_context = data.get('decision_context', '')
if project_id:
project = ProjectManager.get_project(project_id)
if not project:
return jsonify({
"success": False,
"error": t('api.projectNotFound', id=project_id)
}), 404
graph_id = graph_id or project.graph_id
simulation_requirement = simulation_requirement or project.simulation_requirement or ''
if not graph_id:
return jsonify({
"success": False,
"error": "graph_id is required"
}), 400
if not simulation_requirement:
return jsonify({
"success": False,
"error": "simulation_requirement is required"
}), 400
if not Config.ZEP_API_KEY:
return jsonify({
"success": False,
"error": t('api.zepApiKeyMissing')
}), 500
# Create or reuse sim_id
sim_id = data.get('sim_id') or f"private_{uuid.uuid4().hex[:12]}"
os.makedirs(_sim_dir(sim_id), exist_ok=True)
use_llm = data.get('use_llm', True)
# 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()
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:
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()
profiles_path = os.path.join(_sim_dir(sim_id), "private_agents.json")
profiles = profile_generator.generate_profiles_from_entities(
entities=all_entities,
use_llm=use_llm,
graph_id=graph_id,
realtime_output_path=profiles_path,
)
# Serialize profiles for config generator
agent_dicts = [p.to_private_format() for p in profiles]
# Save profiles file (final)
with open(profiles_path, 'w', encoding='utf-8') as f:
json.dump(agent_dicts, f, ensure_ascii=False, indent=2)
# Generate PrivateSimulationParameters
config_generator = PrivateImpactConfigGenerator()
params = config_generator.generate_config(
agent_profiles=agent_dicts,
simulation_requirement=simulation_requirement,
decision_context=decision_context,
)
# Save private_simulation_config.json (consumed by PrivateImpactRunner)
config_path = os.path.join(_sim_dir(sim_id), "private_simulation_config.json")
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(params.to_dict(), f, ensure_ascii=False, indent=2)
# Persist metadata for subsequent endpoints
_write_meta(sim_id, {
"sim_id": sim_id,
"project_id": project_id,
"graph_id": graph_id,
"simulation_requirement": simulation_requirement,
"decision_context": decision_context,
"agent_count": len(profiles),
"created_at": datetime.now().isoformat(),
"status": "prepared",
})
logger.info(
f"[PRIVATE] Simulation prepared: {sim_id}, "
f"agents={len(profiles)}, graph_id={graph_id}"
)
return jsonify({
"success": True,
"data": {
"sim_id": sim_id,
"agent_count": len(profiles),
"status": "prepared",
}
})
except Exception as e:
logger.error(f"[PRIVATE] Prepare failed: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
# ── POST /api/private-impact/start ────────────────────────────────────────────
@private_bp.route('/private-impact/start', methods=['POST'])
def start_private_simulation():
"""
Launch a prepared Private Impact simulation.
Request (JSON):
{
"sim_id": "private_xxxx", // Required
"max_rounds": null, // Optional
"enable_graph_memory_update": false, // Optional
"graph_id": null // Optional (required if enable_graph_memory_update)
}
Returns:
{ "success": true, "data": { "sim_id": "...", "status": "running" } }
"""
try:
data = request.get_json(silent=True) or {}
sim_id = data.get('sim_id')
if not sim_id:
return jsonify({
"success": False,
"error": "sim_id is required"
}), 400
max_rounds = data.get('max_rounds')
enable_graph_memory = data.get('enable_graph_memory_update', False)
graph_id = data.get('graph_id')
# Resolve graph_id from meta if needed
if enable_graph_memory and not graph_id:
meta = _read_meta(sim_id)
graph_id = meta.get('graph_id')
state = PrivateImpactRunner.start_simulation(
simulation_id=sim_id,
max_rounds=max_rounds,
enable_graph_memory_update=enable_graph_memory,
graph_id=graph_id,
)
return jsonify({
"success": True,
"data": {
"sim_id": sim_id,
"status": state.runner_status.value,
}
})
except ValueError as e:
return jsonify({
"success": False,
"error": str(e)
}), 400
except Exception as e:
logger.error(f"[PRIVATE] Start failed: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
# ── GET /api/private-impact/status/<sim_id> ───────────────────────────────────
@private_bp.route('/private-impact/status/<sim_id>', methods=['GET'])
def get_private_status(sim_id: str):
"""
Return the current run state of a Private Impact simulation.
Returns:
{ "success": true, "data": PrivateSimulationRunState.to_dict() }
"""
try:
state = PrivateImpactRunner.get_status(sim_id)
if not state:
return jsonify({
"success": False,
"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": data
})
except Exception as e:
logger.error(f"[PRIVATE] Get status failed: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
# ── POST /api/private-impact/stop/<sim_id> ────────────────────────────────────
@private_bp.route('/private-impact/stop/<sim_id>', methods=['POST'])
def stop_private_simulation(sim_id: str):
"""
Stop a running Private Impact simulation.
Returns:
{ "success": true, "data": { "sim_id": "...", "status": "stopped" } }
"""
try:
state = PrivateImpactRunner.stop_simulation(sim_id)
return jsonify({
"success": True,
"data": {
"sim_id": sim_id,
"status": state.runner_status.value,
}
})
except ValueError as e:
return jsonify({
"success": False,
"error": str(e)
}), 400
except Exception as e:
logger.error(f"[PRIVATE] Stop failed: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
# ── GET /api/private-impact/actions/<sim_id> ──────────────────────────────────
@private_bp.route('/private-impact/actions/<sim_id>', methods=['GET'])
def get_private_actions(sim_id: str):
"""
Return the full private action log for a simulation.
Query params:
agent_id: Filter by agent ID (optional, int)
round_num: Filter by round number (optional, int)
Returns:
{ "success": true, "data": [...], "count": N }
"""
try:
agent_id_raw = request.args.get('agent_id')
round_num_raw = request.args.get('round_num')
agent_id = int(agent_id_raw) if agent_id_raw is not None else None
round_num = int(round_num_raw) if round_num_raw is not None else None
actions = PrivateImpactRunner.get_all_actions(
simulation_id=sim_id,
agent_id=agent_id,
round_num=round_num,
)
return jsonify({
"success": True,
"data": [a.to_dict() for a in actions],
"count": len(actions)
})
except Exception as e:
logger.error(f"[PRIVATE] Get actions failed: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
# ── POST /api/private-impact/report/<sim_id> ──────────────────────────────────
@private_bp.route('/private-impact/report/<sim_id>', methods=['POST'])
def generate_private_report(sim_id: str):
"""
Generate an analysis report for a Private Impact simulation.
Reuses ReportAgent from report_agent.py with the private simulation actions.
Launches the generation in a background thread and returns a task_id immediately.
Request (JSON):
{ "force_regenerate": false } // Optional
Returns:
{ "success": true, "data": { "sim_id": "...", "report_id": "...", "task_id": "..." } }
"""
try:
data = request.get_json(silent=True) or {}
force_regenerate = data.get('force_regenerate', False)
meta = _read_meta(sim_id)
graph_id = meta.get('graph_id')
simulation_requirement = meta.get('simulation_requirement', '')
if not graph_id:
return jsonify({
"success": False,
"error": f"No metadata found for sim_id: {sim_id}. Run /prepare first."
}), 404
# Check for an existing completed report
if not force_regenerate:
existing = ReportManager.get_report_by_simulation(sim_id)
if existing and existing.status == ReportStatus.COMPLETED:
return jsonify({
"success": True,
"data": {
"sim_id": sim_id,
"report_id": existing.report_id,
"status": "completed",
"already_generated": True,
}
})
# Pre-generate report_id so the frontend can track immediately
report_id = f"report_{uuid.uuid4().hex[:12]}"
task_manager = TaskManager()
task_id = task_manager.create_task(
task_type="private_report_generate",
metadata={
"sim_id": sim_id,
"graph_id": graph_id,
"report_id": report_id,
}
)
current_locale = get_locale()
def run_generate():
set_locale(current_locale)
try:
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
progress=0,
message="Initialising Report Agent for private simulation..."
)
agent = ReportAgent(
graph_id=graph_id,
simulation_id=sim_id,
simulation_requirement=simulation_requirement,
)
def progress_callback(stage, progress, message):
task_manager.update_task(
task_id,
progress=progress,
message=f"[{stage}] {message}"
)
report = agent.generate_report(
progress_callback=progress_callback,
report_id=report_id,
)
ReportManager.save_report(report)
if report.status == ReportStatus.COMPLETED:
task_manager.complete_task(
task_id,
result={
"report_id": report.report_id,
"sim_id": sim_id,
"status": "completed",
}
)
else:
task_manager.fail_task(task_id, report.error or "Report generation failed")
except Exception as exc:
logger.error(f"[PRIVATE] Report generation failed: {exc}")
task_manager.fail_task(task_id, str(exc))
thread = threading.Thread(target=run_generate, daemon=True)
thread.start()
return jsonify({
"success": True,
"data": {
"sim_id": sim_id,
"report_id": report_id,
"task_id": task_id,
"status": "generating",
"already_generated": False,
}
})
except Exception as e:
logger.error(f"[PRIVATE] Report trigger failed: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
# ── DELETE /api/private-impact/cleanup/<sim_id> ───────────────────────────────
@private_bp.route('/private-impact/cleanup/<sim_id>', methods=['DELETE'])
def cleanup_private_simulation(sim_id: str):
"""
Remove Private Impact simulation artifacts to allow a fresh restart.
Deletes run_state.json, simulation.log, private_simulation.db, private/ directory.
Does NOT delete private_simulation_config.json or profile files.
Returns:
{ "success": true, "data": { "sim_id": "...", "cleaned_files": [...] } }
"""
try:
result = PrivateImpactRunner.cleanup(sim_id)
return jsonify({
"success": result["success"],
"data": {
"sim_id": sim_id,
"cleaned_files": result["cleaned_files"],
"errors": result["errors"],
}
})
except Exception as e:
logger.error(f"[PRIVATE] Cleanup failed: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500

View File

@ -0,0 +1,735 @@
"""
Private Impact Config Generator
Generates behavioral parameters for RelationalAgents in Private Impact mode.
Equivalent of simulation_config_generator.py for the private relational network.
Key differences from SimulationConfigGenerator:
- No PlatformConfig (no social network)
- Time is measured in days with rounds per day (morning / noon / evening)
- RelationalActivityConfig replaces AgentActivityConfig
- PrivateEventConfig uses a decision statement instead of initial posts
- LLM calls: time config event config agent configs (batches of 15)
"""
import json
import math
from dataclasses import dataclass, field, asdict
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from openai import OpenAI
from ..config import Config
from ..utils.logger import get_logger
from ..utils.locale import get_language_instruction
logger = get_logger('mirofish.private_impact_config')
# ── Dataclasses ───────────────────────────────────────────────────────────────
@dataclass
class RelationalActivityConfig:
"""
Behavioral activity configuration for a single RelationalAgent.
Equivalent of AgentActivityConfig for the private simulation mode.
No posting frequencies private reactions replace social media posts.
"""
agent_id: int
entity_uuid: str
entity_name: str
relational_link_type: str # employee | manager | client | competitor | partner | familymember
# Activity parameters
activity_level: float = 0.5 # Overall engagement level (0.01.0)
response_delay_min: int = 0 # Min reaction delay (days)
response_delay_max: int = 3 # Max reaction delay (days)
# Behavioral stance toward the decision
sentiment_bias: float = 0.0 # -1.0 (hostile) → 1.0 (supportive)
stance: str = "neutral" # supportive | opposing | neutral | observer
# Influence within the relational graph
influence_weight: float = 1.0 # Weight for cascade propagation
# Relational graph: agent_ids this agent can expose to the decision
cascade_influence: List[int] = field(default_factory=list)
# Simulation round at which this agent is first exposed to the decision
exposure_round: int = 0 # 0 = exposed at the very first round
@dataclass
class PrivateTimeConfig:
"""
Time configuration for the Private Impact simulation.
Replaces TimeSimulationConfig (Twitter/hour-based) with a day-based,
relational-rhythm model: days × rounds per day.
"""
total_simulation_days: int = 30 # Total simulated days
rounds_per_day: int = 3 # Morning / noon / evening
reaction_delay_days_min: int = 0 # Min delay before an agent reacts
reaction_delay_days_max: int = 7 # Max delay before an agent reacts
@dataclass
class PrivateEventConfig:
"""
Event configuration for the Private Impact simulation.
Replaces EventConfig (social posts) with a decision injection model.
"""
decision_statement: str = "" # The decision injected into the network
decision_maker_profile: str = "" # Short description of the decision maker
hot_topics: List[str] = field(default_factory=list) # Related sensitive topics
initial_exposed_agent_ids: List[int] = field( # Distance-1 agents (heard it first)
default_factory=list
)
@dataclass
class PrivateSimulationParameters:
"""Complete parameter set for a Private Impact simulation."""
time_config: PrivateTimeConfig = field(default_factory=PrivateTimeConfig)
agent_configs: List[RelationalActivityConfig] = field(default_factory=list)
event_config: PrivateEventConfig = field(default_factory=PrivateEventConfig)
# LLM metadata
llm_model: str = ""
llm_base_url: str = ""
generated_at: str = field(default_factory=lambda: datetime.now().isoformat())
generation_reasoning: str = ""
def to_dict(self) -> Dict[str, Any]:
"""Serialize to plain dict."""
return {
"time_config": asdict(self.time_config),
"agent_configs": [asdict(a) for a in self.agent_configs],
"event_config": asdict(self.event_config),
"llm_model": self.llm_model,
"llm_base_url": self.llm_base_url,
"generated_at": self.generated_at,
"generation_reasoning": self.generation_reasoning,
}
def to_json(self, indent: int = 2) -> str:
"""Serialize to JSON string."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
# ── PrivateImpactConfigGenerator ──────────────────────────────────────────────
class PrivateImpactConfigGenerator:
"""
Generates PrivateSimulationParameters for the Private Impact simulation.
Equivalent of SimulationConfigGenerator for the private relational mode.
Uses 3 sequential LLM calls:
1. PrivateTimeConfig relational rhythm (days, rounds)
2. PrivateEventConfig decision injection setup
3. RelationalActivityConfig list batches of AGENTS_PER_BATCH
Falls back to a rule-based table per relational type on LLM failure.
"""
AGENTS_PER_BATCH = 15
# Context length limits (characters)
TIME_CONFIG_CONTEXT_LENGTH = 8000
EVENT_CONFIG_CONTEXT_LENGTH = 6000
AGENT_SUMMARY_LENGTH = 300
# Rule-based fallback table by relational type
# Keys: activity_level, response_delay_min, response_delay_max, stance, influence_weight
RELATIONAL_FALLBACKS: Dict[str, Dict[str, Any]] = {
"employee": {
"activity_level": 0.6,
"response_delay_min": 0,
"response_delay_max": 3,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 0.8,
},
"manager": {
"activity_level": 0.5,
"response_delay_min": 0,
"response_delay_max": 1,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 1.5,
},
"client": {
"activity_level": 0.3,
"response_delay_min": 2,
"response_delay_max": 7,
"sentiment_bias": 0.0,
"stance": "observer",
"influence_weight": 1.2,
},
"competitor": {
"activity_level": 0.2,
"response_delay_min": 1,
"response_delay_max": 5,
"sentiment_bias": -0.3,
"stance": "opposing",
"influence_weight": 1.0,
},
"partner": {
"activity_level": 0.4,
"response_delay_min": 0,
"response_delay_max": 2,
"sentiment_bias": 0.1,
"stance": "neutral",
"influence_weight": 1.3,
},
"familymember": {
"activity_level": 0.7,
"response_delay_min": 0,
"response_delay_max": 0,
"sentiment_bias": 0.4,
"stance": "supportive",
"influence_weight": 0.9,
},
}
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
model_name: Optional[str] = None,
):
self.api_key = api_key or Config.LLM_API_KEY
self.base_url = base_url or Config.LLM_BASE_URL
self.model_name = model_name or Config.LLM_MODEL_NAME
if not self.api_key:
raise ValueError("LLM_API_KEY is not configured")
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url,
)
# ── Public API ─────────────────────────────────────────────────────────────
def generate_config(
self,
agent_profiles: List[Dict[str, Any]],
simulation_requirement: str,
decision_context: str = "",
progress_callback: Optional[Callable[[int, int, str], None]] = None,
) -> PrivateSimulationParameters:
"""
Generate the complete PrivateSimulationParameters.
Performs 3 sequential LLM call groups:
Step 1 PrivateTimeConfig (relational rhythm)
Step 2 PrivateEventConfig (decision injection)
Step 3-N RelationalActivityConfig batches (AGENTS_PER_BATCH each)
Falls back to rule-based generation per step on LLM failure.
Args:
agent_profiles: List of agent dicts from private_impact_profile_generator.
Each dict must include: agent_id, entity_name, relational_link_type,
cascade_influence, source_entity_uuid, persona (optional).
simulation_requirement: Natural language description of the simulation goal.
decision_context: Additional context about the decision or the organization.
progress_callback: Optional callback(current_step, total_steps, message).
Returns:
PrivateSimulationParameters ready for run_private_simulation.py.
"""
num_agents = len(agent_profiles)
num_batches = math.ceil(num_agents / self.AGENTS_PER_BATCH)
total_steps = 2 + num_batches # time + event + N agent batches
current_step = 0
def report(step: int, message: str) -> None:
nonlocal current_step
current_step = step
if progress_callback:
progress_callback(step, total_steps, message)
logger.info(f"[{step}/{total_steps}] {message}")
context = self._build_context(
simulation_requirement=simulation_requirement,
decision_context=decision_context,
agent_profiles=agent_profiles,
)
reasoning_parts: List[str] = []
# ── Step 1: PrivateTimeConfig ─────────────────────────────────────────
report(1, "Generating relational time configuration...")
time_result = self._generate_time_config(context, num_agents)
time_config = self._parse_time_config(time_result)
reasoning_parts.append(f"Time: {time_result.get('reasoning', 'ok')}")
# ── Step 2: PrivateEventConfig ────────────────────────────────────────
report(2, "Generating decision event configuration...")
event_result = self._generate_event_config(context, simulation_requirement, agent_profiles)
event_config = self._parse_event_config(event_result, agent_profiles)
reasoning_parts.append(f"Event: {event_result.get('reasoning', 'ok')}")
# ── Steps 3-N: RelationalActivityConfig batches ───────────────────────
all_agent_configs: List[RelationalActivityConfig] = []
for batch_idx in range(num_batches):
start = batch_idx * self.AGENTS_PER_BATCH
end = min(start + self.AGENTS_PER_BATCH, num_agents)
batch = agent_profiles[start:end]
report(
3 + batch_idx,
f"Generating agent configs {start + 1}{end} / {num_agents}...",
)
batch_configs = self._generate_agent_configs_batch(
context=context,
agent_profiles=batch,
start_idx=start,
simulation_requirement=simulation_requirement,
)
all_agent_configs.extend(batch_configs)
reasoning_parts.append(f"Agents: {len(all_agent_configs)} configured")
return PrivateSimulationParameters(
time_config=time_config,
agent_configs=all_agent_configs,
event_config=event_config,
llm_model=self.model_name,
llm_base_url=self.base_url,
generation_reasoning=" | ".join(reasoning_parts),
)
# ── Context builder ────────────────────────────────────────────────────────
def _build_context(
self,
simulation_requirement: str,
decision_context: str,
agent_profiles: List[Dict[str, Any]],
) -> str:
"""Build the shared LLM context string for all generation steps."""
by_type: Dict[str, int] = {}
for a in agent_profiles:
rtype = a.get("relational_link_type", "unknown")
by_type[rtype] = by_type.get(rtype, 0) + 1
type_summary = "\n".join(
f" - {rtype}: {count}" for rtype, count in sorted(by_type.items())
)
parts = [
f"## Simulation Requirement\n{simulation_requirement}",
f"\n## Relational Network ({len(agent_profiles)} agents)\n{type_summary}",
]
if decision_context:
parts.append(f"\n## Decision Context\n{decision_context[:3000]}")
return "\n".join(parts)
# ── Step 1: Time config ────────────────────────────────────────────────────
def _generate_time_config(
self, context: str, num_agents: int
) -> Dict[str, Any]:
"""Generate PrivateTimeConfig via LLM."""
context_truncated = context[:self.TIME_CONFIG_CONTEXT_LENGTH]
prompt = f"""Based on the following private impact simulation context, generate a relational time configuration.
{context_truncated}
## Task
Generate a time configuration for a private relational network simulation.
Unlike social media, this is a closed human network where decisions propagate
over days through interpersonal channels (conversations, emails, hallway talks).
Return a JSON object (no markdown):
{{
"total_simulation_days": <int, 790, how many days until the network reaches equilibrium>,
"rounds_per_day": <int, 24, typically 3: morning/noon/evening>,
"reaction_delay_days_min": <int, 03>,
"reaction_delay_days_max": <int, 114>,
"reasoning": "<brief explanation>"
}}
Guidelines:
- Major organizational decisions: 3060 days
- Personal or family decisions: 721 days
- Sudden crises: 714 days
- rounds_per_day=3 is the standard (morning / noon / evening)
- reaction_delay_days_max should not exceed total_simulation_days / 4"""
system_prompt = (
"You are an expert in organizational psychology and network dynamics. "
"Return pure JSON only — no markdown. "
f"{get_language_instruction()}"
)
try:
return self._call_llm_with_retry(prompt, system_prompt)
except Exception as e:
logger.warning(f"Time config LLM failed: {e}. Using defaults.")
return self._default_time_config()
def _default_time_config(self) -> Dict[str, Any]:
"""Rule-based default time config."""
return {
"total_simulation_days": 30,
"rounds_per_day": 3,
"reaction_delay_days_min": 0,
"reaction_delay_days_max": 7,
"reasoning": "Default relational time config",
}
def _parse_time_config(self, result: Dict[str, Any]) -> PrivateTimeConfig:
"""Parse and validate PrivateTimeConfig from LLM result."""
total_days = max(7, int(result.get("total_simulation_days", 30)))
rounds_per_day = max(2, min(4, int(result.get("rounds_per_day", 3))))
delay_min = max(0, int(result.get("reaction_delay_days_min", 0)))
delay_max = max(delay_min, int(result.get("reaction_delay_days_max", 7)))
if delay_max >= total_days:
delay_max = max(delay_min + 1, total_days // 4)
logger.warning(f"reaction_delay_days_max capped to {delay_max}")
return PrivateTimeConfig(
total_simulation_days=total_days,
rounds_per_day=rounds_per_day,
reaction_delay_days_min=delay_min,
reaction_delay_days_max=delay_max,
)
# ── Step 2: Event config ───────────────────────────────────────────────────
def _generate_event_config(
self,
context: str,
simulation_requirement: str,
agent_profiles: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Generate PrivateEventConfig via LLM."""
context_truncated = context[:self.EVENT_CONFIG_CONTEXT_LENGTH]
# Build distance-1 candidate list (agents closest to the decision maker)
distance1_candidates = [
{
"agent_id": a.get("agent_id", i),
"name": a.get("entity_name", ""),
"type": a.get("relational_link_type", ""),
}
for i, a in enumerate(agent_profiles)
if a.get("relational_link_type", "") in (
"manager", "employee", "partner", "familymember"
)
][:10]
candidates_json = json.dumps(distance1_candidates, ensure_ascii=False)
prompt = f"""Based on the following private impact simulation context, generate the event configuration.
{context_truncated}
## Distance-1 agent candidates (closest to decision maker)
{candidates_json}
## Task
Generate an event configuration for private impact injection.
Return a JSON object (no markdown):
{{
"decision_statement": "<precise wording of the decision being injected — 24 sentences>",
"decision_maker_profile": "<short description of the person making the decision — role, authority, relationship style>",
"hot_topics": ["<sensitive topic 1>", "<sensitive topic 2>", ...],
"initial_exposed_agent_ids": [<agent_id>, ...],
"reasoning": "<brief explanation>"
}}
Rules:
- decision_statement must be specific and concrete, not vague
- initial_exposed_agent_ids must only contain ids from the distance-1 candidates list above
- initial_exposed_agent_ids should be 13 agents (direct announcement recipients)
- hot_topics: 36 strings describing the sensitive dimensions of this decision
(e.g. "salary equity", "job security", "family impact", "competitive pressure")"""
system_prompt = (
"You are an expert in organizational decision impact simulation. "
"Return pure JSON only — no markdown. "
f"{get_language_instruction()}"
)
try:
return self._call_llm_with_retry(prompt, system_prompt)
except Exception as e:
logger.warning(f"Event config LLM failed: {e}. Using defaults.")
first_id = agent_profiles[0].get("agent_id", 0) if agent_profiles else 0
return {
"decision_statement": simulation_requirement,
"decision_maker_profile": "The decision maker",
"hot_topics": ["organizational change", "impact"],
"initial_exposed_agent_ids": [first_id],
"reasoning": "Default event config (LLM fallback)",
}
def _parse_event_config(
self,
result: Dict[str, Any],
agent_profiles: List[Dict[str, Any]],
) -> PrivateEventConfig:
"""Parse and validate PrivateEventConfig from LLM result."""
valid_ids = {a.get("agent_id", i) for i, a in enumerate(agent_profiles)}
raw_exposed = result.get("initial_exposed_agent_ids", [])
exposed = [aid for aid in raw_exposed if aid in valid_ids]
if not exposed and agent_profiles:
exposed = [agent_profiles[0].get("agent_id", 0)]
return PrivateEventConfig(
decision_statement=result.get("decision_statement", ""),
decision_maker_profile=result.get("decision_maker_profile", ""),
hot_topics=result.get("hot_topics", []),
initial_exposed_agent_ids=exposed,
)
# ── Steps 3-N: Agent config batches ───────────────────────────────────────
def _generate_agent_configs_batch(
self,
context: str,
agent_profiles: List[Dict[str, Any]],
start_idx: int,
simulation_requirement: str,
) -> List[RelationalActivityConfig]:
"""
Generate a batch of RelationalActivityConfig via LLM with rule-based fallback.
Args:
context: Shared simulation context string.
agent_profiles: Slice of agent dicts for this batch.
start_idx: Index of the first agent in the full list (for logging).
simulation_requirement: Natural language simulation goal.
Returns:
List of RelationalActivityConfig for this batch.
"""
agent_list = []
summary_len = self.AGENT_SUMMARY_LENGTH
for i, a in enumerate(agent_profiles):
agent_list.append({
"agent_id": a.get("agent_id", start_idx + i),
"entity_name": a.get("entity_name", ""),
"relational_link_type": a.get("relational_link_type", "peer"),
"cascade_influence": a.get("cascade_influence", []),
"persona_excerpt": (a.get("persona", "") or "")[:summary_len],
})
prompt = f"""Based on the following private impact simulation context, generate activity configurations for each relational agent.
Simulation requirement: {simulation_requirement}
## Agent list
```json
{json.dumps(agent_list, ensure_ascii=False, indent=2)}
```
## Task
Generate a RelationalActivityConfig for each agent.
Behavioral guidelines by relational type:
- employee: activity_level=0.6, response_delay_max=3 days, stance=neutral
- manager: activity_level=0.5, response_delay_max=1 day, stance=neutral
- client: activity_level=0.3, response_delay_max=7 days, stance=observer
- competitor: activity_level=0.2, response_delay_max=5 days, stance=opposing
- partner: activity_level=0.4, response_delay_max=2 days, stance=neutral
- familymember: activity_level=0.7, response_delay_max=0 days, stance=supportive
Return a JSON object (no markdown):
{{
"agent_configs": [
{{
"agent_id": <must match input>,
"activity_level": <0.01.0>,
"response_delay_min": <int, days>,
"response_delay_max": <int, days>,
"sentiment_bias": <-1.0 to 1.0>,
"stance": "<supportive|opposing|neutral|observer>",
"influence_weight": <float>,
"exposure_round": <int, 0 for initial exposed agents>
}},
...
]
}}
Rules:
- agent_id must match the input exactly
- stance must be one of: supportive, opposing, neutral, observer
- exposure_round = 0 for agents in initial_exposed_agent_ids, else infer from cascade distance"""
system_prompt = (
"You are an expert in private organizational network dynamics. "
"Return pure JSON only — no markdown. "
"IMPORTANT: The 'stance' field MUST be one of: "
"'supportive', 'opposing', 'neutral', 'observer'. "
f"{get_language_instruction()}"
)
try:
result = self._call_llm_with_retry(prompt, system_prompt)
llm_map: Dict[int, Dict[str, Any]] = {
cfg["agent_id"]: cfg for cfg in result.get("agent_configs", [])
}
except Exception as e:
logger.warning(f"Agent config batch LLM failed: {e}. Using rule-based fallback.")
llm_map = {}
configs: List[RelationalActivityConfig] = []
for i, agent in enumerate(agent_profiles):
agent_id = agent.get("agent_id", start_idx + i)
rtype = agent.get("relational_link_type", "employee").lower()
cfg = llm_map.get(agent_id)
if not cfg:
cfg = self._agent_config_by_rule(rtype)
configs.append(RelationalActivityConfig(
agent_id=agent_id,
entity_uuid=agent.get("source_entity_uuid", ""),
entity_name=agent.get("entity_name", ""),
relational_link_type=rtype,
activity_level=float(cfg.get("activity_level", 0.5)),
response_delay_min=int(cfg.get("response_delay_min", 0)),
response_delay_max=int(cfg.get("response_delay_max", 3)),
sentiment_bias=float(cfg.get("sentiment_bias", 0.0)),
stance=cfg.get("stance", "neutral"),
influence_weight=float(cfg.get("influence_weight", 1.0)),
cascade_influence=agent.get("cascade_influence", []),
exposure_round=int(cfg.get("exposure_round", 0)),
))
return configs
def _agent_config_by_rule(self, relational_type: str) -> Dict[str, Any]:
"""
Return rule-based activity config for a given relational type.
Falls back to 'employee' defaults for unknown types.
Args:
relational_type: Lowercase relational type string.
Returns:
Dict with activity_level, response_delay_min/max, sentiment_bias,
stance, influence_weight.
"""
return dict(self.RELATIONAL_FALLBACKS.get(
relational_type, self.RELATIONAL_FALLBACKS["employee"]
))
# ── LLM helpers ───────────────────────────────────────────────────────────
def _call_llm_with_retry(self, prompt: str, system_prompt: str) -> Dict[str, Any]:
"""
Call the LLM with up to 3 retry attempts.
Mirrors SimulationConfigGenerator._call_llm_with_retry with:
- JSON response format enforced
- Temperature annealing on each retry
- Truncation repair via _fix_truncated_json
Args:
prompt: User prompt.
system_prompt: System instructions.
Returns:
Parsed JSON dict.
Raises:
Exception: If all attempts fail.
"""
import time
max_attempts = 3
last_error: Optional[Exception] = None
for attempt in range(max_attempts):
try:
response = self.client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
temperature=0.7 - (attempt * 0.1),
)
content = response.choices[0].message.content
finish_reason = response.choices[0].finish_reason
if finish_reason == "length":
logger.warning(
f"LLM output truncated (attempt {attempt + 1}), attempting repair..."
)
content = self._fix_truncated_json(content)
try:
return json.loads(content)
except json.JSONDecodeError as e:
logger.warning(f"JSON parse failed (attempt {attempt + 1}): {str(e)[:80]}")
fixed = self._try_fix_config_json(content)
if fixed:
return fixed
last_error = e
except Exception as e:
logger.warning(f"LLM call failed (attempt {attempt + 1}): {str(e)[:80]}")
last_error = e
time.sleep(2 * (attempt + 1))
raise last_error or Exception("LLM call failed after all retries")
def _fix_truncated_json(self, content: str) -> str:
"""Repair truncated JSON by closing unclosed brackets and strings."""
content = content.strip()
open_braces = content.count("{") - content.count("}")
open_brackets = content.count("[") - content.count("]")
if content and content[-1] not in '",}]':
content += '"'
content += "]" * open_brackets
content += "}" * open_braces
return content
def _try_fix_config_json(self, content: str) -> Optional[Dict[str, Any]]:
"""Attempt to extract and repair a JSON object from malformed LLM output."""
import re
content = self._fix_truncated_json(content)
json_match = re.search(r"\{[\s\S]*\}", content)
if not json_match:
return None
json_str = json_match.group()
def fix_string(match: re.Match) -> str:
s = match.group(0)
s = s.replace("\n", " ").replace("\r", " ")
s = re.sub(r"\s+", " ", s)
return s
json_str = re.sub(r'"[^"\\]*(?:\\.[^"\\]*)*"', fix_string, json_str)
try:
return json.loads(json_str)
except Exception:
json_str = re.sub(r"[\x00-\x1f\x7f-\x9f]", " ", json_str)
json_str = re.sub(r"\s+", " ", json_str)
try:
return json.loads(json_str)
except Exception:
return None

View File

@ -0,0 +1,823 @@
"""
Private Impact Profile Generator
Converts Zep graph entities into RelationalAgentProfile instances for the
Private Impact simulation mode.
Extends OasisProfileGenerator with relational dimensions:
- Relationship type with the decision maker (hierarchical, client, peer, ...)
- Trust level, financial sensitivity, equity tolerance, institutional loyalty
- Natural reaction mode (internalize, confront, silent_leave, coalition_build)
- Cascade influence graph (which agents this agent can expose)
Key design principle (from IDEE-FORK-MIROFISH.md §4):
The `persona` field is the sole behavioral vector injected into the LLM
system prompt each round. All relational dimensions are encoded as natural
language inside `persona` no engine modification required.
"""
import json
import random
import time
from dataclasses import dataclass, field
from datetime import datetime
from threading import Lock
from typing import Any, Dict, List, Optional
import concurrent.futures
from openai import OpenAI
from zep_cloud.client import Zep
from ..config import Config
from ..utils.logger import get_logger
from ..utils.locale import get_language_instruction, get_locale, set_locale
from .oasis_profile_generator import OasisAgentProfile, OasisProfileGenerator
from .zep_entity_reader import EntityNode
logger = get_logger('mirofish.private_impact_profile')
# ── RelationalAgentProfile ────────────────────────────────────────────────────
@dataclass
class RelationalAgentProfile(OasisAgentProfile):
"""
Extended OASIS Agent Profile with relational network dimensions.
All relational fields are encoded into the `persona` text field via
_encode_relational_persona() before being stored. The inherited `persona`
is what gets injected into the LLM system prompt each simulation round.
"""
# Relationship with the decision maker
relational_link_type: str = "peer" # hierarchical | client | peer | family | competitor
relational_seniority_years: int = 0
relational_trust_level: float = 0.5 # 0.0 → 1.0
# Psycho-social dimensions
financial_sensitivity: float = 0.5 # Sensitivity to wealth signals
equity_tolerance: float = 0.5 # Tolerance for status disparities
institutional_loyalty: float = 0.5 # Loyalty to the org vs the person
# Natural reaction mode when facing a triggering decision
private_reaction_mode: str = "internalize" # internalize | confront | silent_leave | coalition_build
# Cascade influence graph: agent_ids this agent can expose
cascade_influence: List[int] = field(default_factory=list)
def to_private_format(self) -> Dict[str, Any]:
"""
Serialize to the format expected by run_private_simulation.py.
The simulation engine reads agent_configs as plain dicts, accessing:
agent_id, entity_name, persona, cascade_influence, active_hours,
activity_level.
"""
return {
"agent_id": self.user_id,
"entity_name": self.name,
"user_name": self.user_name,
"bio": self.bio,
"persona": self.persona, # Encoded with relational context
"cascade_influence": self.cascade_influence,
"relational_link_type": self.relational_link_type,
"relational_seniority_years": self.relational_seniority_years,
"relational_trust_level": self.relational_trust_level,
"financial_sensitivity": self.financial_sensitivity,
"equity_tolerance": self.equity_tolerance,
"institutional_loyalty": self.institutional_loyalty,
"private_reaction_mode": self.private_reaction_mode,
"age": self.age,
"gender": self.gender,
"mbti": self.mbti,
"country": self.country,
"profession": self.profession,
"source_entity_uuid": self.source_entity_uuid,
"source_entity_type": self.source_entity_type,
"created_at": self.created_at,
}
def to_dict(self) -> Dict[str, Any]:
"""Full dict representation including relational fields."""
base = super().to_dict()
base.update({
"relational_link_type": self.relational_link_type,
"relational_seniority_years": self.relational_seniority_years,
"relational_trust_level": self.relational_trust_level,
"financial_sensitivity": self.financial_sensitivity,
"equity_tolerance": self.equity_tolerance,
"institutional_loyalty": self.institutional_loyalty,
"private_reaction_mode": self.private_reaction_mode,
"cascade_influence": self.cascade_influence,
})
return base
# ── PrivateImpactProfileGenerator ────────────────────────────────────────────
class PrivateImpactProfileGenerator(OasisProfileGenerator):
"""
Generates RelationalAgentProfile instances for the Private Impact simulation.
Extends OasisProfileGenerator with:
- Relational entity types (Employee, Manager, Client, ...)
- LLM prompt enriched with relational dimensions
- Relational rule-based fallback by entity type
- persona encoding that injects relational context as natural language
Pipeline (same as OasisProfileGenerator):
EntityNode (Zep) _build_entity_context() LLM / rule-based
RelationalAgentProfile (relational fields encoded into persona)
"""
# Relational entity types — map to default behavioral parameters
RELATIONAL_ENTITY_TYPES = [
"employee", "manager", "client", "competitor",
"partner", "familymember", "colleague", "investor",
]
# Default behavioral parameters by relational type
# (trust_level, financial_sensitivity, equity_tolerance,
# institutional_loyalty, reaction_mode, activity_level, active_hours)
RELATIONAL_DEFAULTS: Dict[str, Dict[str, Any]] = {
"employee": {
"trust_level": 0.6,
"financial_sensitivity": 0.7,
"equity_tolerance": 0.4,
"institutional_loyalty": 0.6,
"reaction_mode": "internalize",
"activity_level": 0.6,
"active_hours": list(range(8, 19)),
"influence_weight": 0.8,
},
"manager": {
"trust_level": 0.7,
"financial_sensitivity": 0.5,
"equity_tolerance": 0.5,
"institutional_loyalty": 0.7,
"reaction_mode": "confront",
"activity_level": 0.5,
"active_hours": list(range(8, 20)),
"influence_weight": 1.5,
},
"client": {
"trust_level": 0.4,
"financial_sensitivity": 0.3,
"equity_tolerance": 0.6,
"institutional_loyalty": 0.3,
"reaction_mode": "silent_leave",
"activity_level": 0.3,
"active_hours": list(range(9, 13)) + list(range(17, 21)),
"influence_weight": 1.2,
},
"competitor": {
"trust_level": 0.2,
"financial_sensitivity": 0.4,
"equity_tolerance": 0.7,
"institutional_loyalty": 0.1,
"reaction_mode": "coalition_build",
"activity_level": 0.2,
"active_hours": list(range(9, 19)),
"influence_weight": 1.0,
},
"partner": {
"trust_level": 0.6,
"financial_sensitivity": 0.4,
"equity_tolerance": 0.6,
"institutional_loyalty": 0.5,
"reaction_mode": "internalize",
"activity_level": 0.4,
"active_hours": list(range(9, 18)),
"influence_weight": 1.3,
},
"familymember": {
"trust_level": 0.8,
"financial_sensitivity": 0.8,
"equity_tolerance": 0.5,
"institutional_loyalty": 0.2,
"reaction_mode": "confront",
"activity_level": 0.7,
"active_hours": list(range(7, 10)) + list(range(18, 24)),
"influence_weight": 0.9,
},
}
# Reaction mode descriptions for LLM prompt injection
REACTION_MODE_DESCRIPTIONS: Dict[str, str] = {
"internalize": "processes the news internally without immediate visible action; absorbs tension before potentially acting later",
"confront": "tends to address the issue head-on, speaking directly to the decision maker or raising concerns openly",
"silent_leave": "quietly disengages — reduces commitment, starts looking for alternatives, without announcing it",
"coalition_build": "looks for allies among peers before taking any visible action; builds shared narratives",
}
def generate_profile_from_entity(
self,
entity: EntityNode,
user_id: int,
use_llm: bool = True,
cascade_influence: Optional[List[int]] = None,
) -> RelationalAgentProfile:
"""
Generate a RelationalAgentProfile from a Zep entity node.
Divergence from OasisProfileGenerator.generate_profile_from_entity:
Returns RelationalAgentProfile instead of OasisAgentProfile.
Relational dimensions are encoded into the persona text field.
Args:
entity: Zep entity node.
user_id: Agent ID in the simulation.
use_llm: Whether to use LLM for profile generation.
cascade_influence: List of agent_ids this agent can expose (optional).
Returns:
RelationalAgentProfile with relational context encoded in persona.
"""
entity_type = entity.get_entity_type() or "peer"
name = entity.name
user_name = self._generate_username(name)
context = self._build_entity_context(entity)
if use_llm:
profile_data = self._generate_relational_profile_with_llm(
entity_name=name,
entity_type=entity_type,
entity_summary=entity.summary,
entity_attributes=entity.attributes,
context=context,
)
else:
profile_data = self._generate_relational_profile_rule_based(
entity_name=name,
entity_type=entity_type,
entity_summary=entity.summary,
)
# Extract relational dimensions from LLM output
relational_link_type = profile_data.get("relational_link_type", "peer")
seniority_years = int(profile_data.get("relational_seniority_years", 0))
trust_level = float(profile_data.get("relational_trust_level", 0.5))
financial_sensitivity = float(profile_data.get("financial_sensitivity", 0.5))
equity_tolerance = float(profile_data.get("equity_tolerance", 0.5))
institutional_loyalty = float(profile_data.get("institutional_loyalty", 0.5))
reaction_mode = profile_data.get("private_reaction_mode", "internalize")
# Clamp floats to [0.0, 1.0]
trust_level = max(0.0, min(1.0, trust_level))
financial_sensitivity = max(0.0, min(1.0, financial_sensitivity))
equity_tolerance = max(0.0, min(1.0, equity_tolerance))
institutional_loyalty = max(0.0, min(1.0, institutional_loyalty))
# Encode relational context into the persona text
base_persona = profile_data.get(
"persona", entity.summary or f"A {entity_type} named {name}."
)
enriched_persona = self._encode_relational_persona(
base_persona=base_persona,
name=name,
relational_link_type=relational_link_type,
seniority_years=seniority_years,
trust_level=trust_level,
financial_sensitivity=financial_sensitivity,
equity_tolerance=equity_tolerance,
institutional_loyalty=institutional_loyalty,
reaction_mode=reaction_mode,
)
return RelationalAgentProfile(
user_id=user_id,
user_name=user_name,
name=name,
bio=profile_data.get("bio", f"{entity_type}: {name}"),
persona=enriched_persona,
karma=profile_data.get("karma", random.randint(500, 3000)),
friend_count=profile_data.get("friend_count", random.randint(20, 300)),
follower_count=profile_data.get("follower_count", random.randint(30, 500)),
statuses_count=profile_data.get("statuses_count", random.randint(50, 1000)),
age=profile_data.get("age"),
gender=profile_data.get("gender"),
mbti=profile_data.get("mbti"),
country=profile_data.get("country"),
profession=profile_data.get("profession"),
interested_topics=profile_data.get("interested_topics", []),
source_entity_uuid=entity.uuid,
source_entity_type=entity_type,
relational_link_type=relational_link_type,
relational_seniority_years=seniority_years,
relational_trust_level=trust_level,
financial_sensitivity=financial_sensitivity,
equity_tolerance=equity_tolerance,
institutional_loyalty=institutional_loyalty,
private_reaction_mode=reaction_mode,
cascade_influence=cascade_influence or [],
)
# ── Persona encoding ──────────────────────────────────────────────────────
def _encode_relational_persona(
self,
base_persona: str,
name: str,
relational_link_type: str,
seniority_years: int,
trust_level: float,
financial_sensitivity: float,
equity_tolerance: float,
institutional_loyalty: float,
reaction_mode: str,
) -> str:
"""
Encode relational dimensions into natural language appended to persona.
This is the central mechanism: OASIS (and our private simulation) inject
the persona field as-is into the LLM system prompt. By appending a
structured relational context block, we guide agent behavior without
modifying the simulation engine.
Args:
base_persona: Base persona text from LLM or rule-based fallback.
name: Agent name.
relational_link_type: Type of relationship with the decision maker.
seniority_years: Years in this relational context.
trust_level: Trust level with decision maker (01).
financial_sensitivity: Sensitivity to wealth signals (01).
equity_tolerance: Tolerance for status disparities (01).
institutional_loyalty: Loyalty to the org vs the person (01).
reaction_mode: Natural reaction pattern.
Returns:
Enriched persona string with relational context block appended.
"""
# Trust descriptor
if trust_level >= 0.75:
trust_desc = "very high"
elif trust_level >= 0.5:
trust_desc = "moderate"
elif trust_level >= 0.25:
trust_desc = "low"
else:
trust_desc = "very low"
# Financial sensitivity descriptor
if financial_sensitivity >= 0.75:
fin_desc = "highly sensitive to wealth signals and perceived inequity"
elif financial_sensitivity >= 0.5:
fin_desc = "moderately sensitive to financial signals"
else:
fin_desc = "relatively indifferent to wealth signals"
# Equity tolerance descriptor
if equity_tolerance <= 0.25:
eq_desc = "very low tolerance for status disparities — notices and resents inequalities"
elif equity_tolerance <= 0.5:
eq_desc = "moderate discomfort with status disparities"
else:
eq_desc = "accepts status differences as normal"
reaction_desc = self.REACTION_MODE_DESCRIPTIONS.get(
reaction_mode,
"processes the situation and responds according to their character"
)
seniority_str = (
f"{seniority_years} year{'s' if seniority_years != 1 else ''}"
if seniority_years > 0 else "recent"
)
loyalty_desc = (
"strongly attached to the organization and its continuity"
if institutional_loyalty >= 0.7
else "balanced between personal interests and organizational ones"
if institutional_loyalty >= 0.4
else "primarily driven by personal interests over institutional ones"
)
relational_block = (
f"\n\n--- Relational Context (Private Impact Simulation) ---\n"
f"Your name is {name}.\n"
f"Your relationship with the decision maker: {relational_link_type} "
f"({seniority_str} of shared history).\n"
f"Trust level with the decision maker: {trust_desc} ({trust_level:.1f}/1.0).\n"
f"Financial sensitivity: {fin_desc} (score: {financial_sensitivity:.1f}).\n"
f"Equity tolerance: {eq_desc} (score: {equity_tolerance:.1f}).\n"
f"Institutional loyalty: {loyalty_desc} (score: {institutional_loyalty:.1f}).\n"
f"Your natural reaction mode: {reaction_mode} — you {reaction_desc}.\n"
f"--- End Relational Context ---"
)
return base_persona + relational_block
# ── LLM profile generation ────────────────────────────────────────────────
def _generate_relational_profile_with_llm(
self,
entity_name: str,
entity_type: str,
entity_summary: str,
entity_attributes: Dict[str, Any],
context: str,
) -> Dict[str, Any]:
"""
Generate relational profile via LLM.
Divergence from OasisProfileGenerator._generate_profile_with_llm:
Adds relational dimension fields to the JSON output schema.
Falls back to rule-based generation on failure (same pattern as parent).
Args:
entity_name: Entity name.
entity_type: Entity type from Zep.
entity_summary: Entity summary from Zep.
entity_attributes: Entity attributes dict.
context: Enriched context from _build_entity_context().
Returns:
Profile data dict including relational dimensions.
"""
prompt = self._build_relational_persona_prompt(
entity_name=entity_name,
entity_type=entity_type,
entity_summary=entity_summary,
entity_attributes=entity_attributes,
context=context,
)
system_prompt = (
"You are an expert in organizational psychology and behavioral simulation. "
"Generate realistic relational agent profiles for private impact simulations. "
"Return valid JSON only — no markdown, no prose outside the JSON object. "
f"{get_language_instruction()}"
)
max_attempts = 3
last_error = None
for attempt in range(max_attempts):
try:
response = self.client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
temperature=0.7 - (attempt * 0.1),
)
content = response.choices[0].message.content
finish_reason = response.choices[0].finish_reason
if finish_reason == 'length':
logger.warning(f"LLM output truncated (attempt {attempt + 1}), attempting fix...")
content = self._fix_truncated_json(content)
try:
result = json.loads(content)
# Ensure required fields
if not result.get("bio"):
result["bio"] = entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}"
if not result.get("persona"):
result["persona"] = entity_summary or f"{entity_name} is a {entity_type}."
return result
except json.JSONDecodeError as je:
logger.warning(f"JSON parse failed (attempt {attempt + 1}): {str(je)[:80]}")
result = self._try_fix_json(content, entity_name, entity_type, entity_summary)
if result.get("_fixed"):
del result["_fixed"]
return result
last_error = je
except Exception as e:
logger.warning(f"LLM call failed (attempt {attempt + 1}): {str(e)[:80]}")
last_error = e
time.sleep(1 * (attempt + 1))
logger.warning(
f"LLM profile generation failed after {max_attempts} attempts: {last_error}. "
f"Falling back to rule-based."
)
return self._generate_relational_profile_rule_based(
entity_name=entity_name,
entity_type=entity_type,
entity_summary=entity_summary,
)
def _build_relational_persona_prompt(
self,
entity_name: str,
entity_type: str,
entity_summary: str,
entity_attributes: Dict[str, Any],
context: str,
) -> str:
"""
Build the LLM prompt for relational profile generation.
Divergence from parent _build_individual_persona_prompt:
Adds relational dimension fields to the JSON schema.
"""
attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else "none"
context_str = context[:3000] if context else "No additional context."
return f"""Generate a relational agent profile for a private impact simulation.
Entity name: {entity_name}
Entity type: {entity_type}
Entity summary: {entity_summary}
Entity attributes: {attrs_str}
Context:
{context_str}
Return a JSON object with these fields:
1. bio: Short profile description (max 200 characters)
2. persona: Detailed behavioral description (plain text, no line breaks inside the string, ~500 words):
- Background, personality, professional history
- Emotional patterns and communication style
- Relationship with authority and institutions
- Known reactions to organizational decisions
3. age: Integer (or null)
4. gender: "male", "female", or "other"
5. mbti: MBTI type (e.g. "INTJ") or null
6. country: Country name
7. profession: Current role or function
8. interested_topics: Array of relevant topics
Relational dimensions (required):
9. relational_link_type: One of "hierarchical", "client", "peer", "family", "competitor"
10. relational_seniority_years: Integer (years in this relational context)
11. relational_trust_level: Float 0.01.0 (trust in decision maker)
12. financial_sensitivity: Float 0.01.0 (sensitivity to wealth signals)
13. equity_tolerance: Float 0.01.0 (tolerance for status disparities)
14. institutional_loyalty: Float 0.01.0 (loyalty to org vs personal interests)
15. private_reaction_mode: One of "internalize", "confront", "silent_leave", "coalition_build"
Rules:
- All string values must not contain literal line breaks
- persona must be a single continuous text paragraph
- Float values must be between 0.0 and 1.0
- Infer relational dimensions from entity type and context when possible
"""
# ── Rule-based fallback ───────────────────────────────────────────────────
def _generate_relational_profile_rule_based(
self,
entity_name: str,
entity_type: str,
entity_summary: str,
) -> Dict[str, Any]:
"""
Generate relational profile using predefined defaults by entity type.
Divergence from OasisProfileGenerator._generate_profile_rule_based:
Uses RELATIONAL_DEFAULTS table instead of social media entity types.
Covers: Employee, Manager, Client, Competitor, Partner, FamilyMember.
Args:
entity_name: Entity name.
entity_type: Relational entity type.
entity_summary: Entity summary for persona fallback.
Returns:
Profile data dict with relational dimensions set from defaults.
"""
type_key = entity_type.lower()
defaults = self.RELATIONAL_DEFAULTS.get(type_key, self.RELATIONAL_DEFAULTS["employee"])
base_persona = (
entity_summary
or f"{entity_name} is a {entity_type} connected to the decision maker's network."
)
return {
"bio": (
entity_summary[:150]
if entity_summary
else f"{entity_type}: {entity_name}"
),
"persona": base_persona,
"age": random.randint(25, 55),
"gender": random.choice(["male", "female"]),
"mbti": random.choice(self.MBTI_TYPES),
"country": random.choice(self.COUNTRIES),
"profession": entity_type.capitalize(),
"interested_topics": ["Professional Development", "Organizational Dynamics"],
# Relational dimensions from defaults table
"relational_link_type": type_key if type_key in (
"hierarchical", "client", "peer", "family", "competitor"
) else "peer",
"relational_seniority_years": random.randint(1, 8),
"relational_trust_level": defaults["trust_level"],
"financial_sensitivity": defaults["financial_sensitivity"],
"equity_tolerance": defaults.get("equity_tolerance", 0.5),
"institutional_loyalty": defaults.get("institutional_loyalty", 0.5),
"private_reaction_mode": defaults["reaction_mode"],
}
# ── Batch generation ──────────────────────────────────────────────────────
def generate_profiles_from_entities(
self,
entities: List[EntityNode],
use_llm: bool = True,
progress_callback: Optional[callable] = None,
graph_id: Optional[str] = None,
parallel_count: int = 5,
realtime_output_path: Optional[str] = None,
cascade_influence_map: Optional[Dict[int, List[int]]] = None,
**kwargs, # absorb unused parent kwargs (output_platform, etc.)
) -> List[RelationalAgentProfile]:
"""
Generate RelationalAgentProfile instances for all entities in parallel.
Divergence from OasisProfileGenerator.generate_profiles_from_entities:
Returns RelationalAgentProfile instances.
Accepts cascade_influence_map to assign relational graph edges per agent.
Args:
entities: List of Zep entity nodes.
use_llm: Whether to use LLM generation (falls back to rule-based).
progress_callback: Optional callback(current, total, message).
graph_id: Zep graph ID for context enrichment.
parallel_count: Number of concurrent generation threads.
realtime_output_path: Path to write profiles as they are generated.
cascade_influence_map: {agent_index: [influenced_agent_ids]}.
Returns:
List of RelationalAgentProfile instances.
"""
if graph_id:
self.graph_id = graph_id
cascade_influence_map = cascade_influence_map or {}
total = len(entities)
profiles: List[Optional[RelationalAgentProfile]] = [None] * total
completed_count = [0]
lock = Lock()
def save_realtime() -> None:
"""Write generated profiles to file as they complete."""
if not realtime_output_path:
return
with lock:
existing = [p for p in profiles if p is not None]
if not existing:
return
try:
data = [p.to_private_format() for p in existing]
with open(realtime_output_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"Realtime save failed: {e}")
current_locale = get_locale()
def generate_single(idx: int, entity: EntityNode) -> tuple:
set_locale(current_locale)
entity_type = entity.get_entity_type() or "peer"
cascade = cascade_influence_map.get(idx, [])
try:
profile = self.generate_profile_from_entity(
entity=entity,
user_id=idx,
use_llm=use_llm,
cascade_influence=cascade,
)
self._print_generated_relational_profile(entity.name, entity_type, profile)
return idx, profile, None
except Exception as e:
logger.error(f"Profile generation failed for {entity.name}: {e}")
fallback = RelationalAgentProfile(
user_id=idx,
user_name=self._generate_username(entity.name),
name=entity.name,
bio=f"{entity_type}: {entity.name}",
persona=(
entity.summary
or f"{entity.name} is a {entity_type} in the relational network."
),
source_entity_uuid=entity.uuid,
source_entity_type=entity_type,
cascade_influence=cascade,
)
return idx, fallback, str(e)
logger.info(
f"Starting parallel profile generation — {total} entities, "
f"parallel_count={parallel_count}"
)
print(f"\n{'='*60}")
print(f"Private Impact — Generating {total} relational profiles (parallel: {parallel_count})")
print(f"{'='*60}\n")
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel_count) as executor:
future_map = {
executor.submit(generate_single, idx, entity): (idx, entity)
for idx, entity in enumerate(entities)
}
for future in concurrent.futures.as_completed(future_map):
idx, entity = future_map[future]
entity_type = entity.get_entity_type() or "peer"
try:
result_idx, profile, error = future.result()
profiles[result_idx] = profile
with lock:
completed_count[0] += 1
current = completed_count[0]
save_realtime()
if progress_callback:
progress_callback(
current,
total,
f"Done {current}/{total}: {entity.name} ({entity_type})"
)
if error:
logger.warning(f"[{current}/{total}] {entity.name} using fallback: {error}")
else:
logger.info(f"[{current}/{total}] Generated: {entity.name} ({entity_type})")
except Exception as e:
logger.error(f"Error processing {entity.name}: {e}")
with lock:
completed_count[0] += 1
profiles[idx] = RelationalAgentProfile(
user_id=idx,
user_name=self._generate_username(entity.name),
name=entity.name,
bio=f"{entity_type}: {entity.name}",
persona=entity.summary or "A participant in the relational network.",
source_entity_uuid=entity.uuid,
source_entity_type=entity_type,
)
save_realtime()
valid_count = len([p for p in profiles if p is not None])
print(f"\n{'='*60}")
print(f"Profile generation complete — {valid_count} relational agents ready")
print(f"{'='*60}\n")
return [p for p in profiles if p is not None]
def save_profiles(
self,
profiles: List[RelationalAgentProfile],
file_path: str,
platform: str = "private",
) -> None:
"""
Save relational profiles to JSON.
Divergence from OasisProfileGenerator.save_profiles:
Always uses to_private_format() no CSV output, no Reddit/Twitter format.
The output is a JSON array of agent config dicts consumed by
run_private_simulation.py.
Args:
profiles: List of RelationalAgentProfile instances.
file_path: Output path (.json).
platform: Ignored always uses private format.
"""
data = [p.to_private_format() for p in profiles]
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"Saved {len(profiles)} relational profiles to {file_path}")
# ── Console output ────────────────────────────────────────────────────────
def _print_generated_relational_profile(
self,
entity_name: str,
entity_type: str,
profile: RelationalAgentProfile,
) -> None:
"""Print a summary of the generated relational profile to stdout."""
separator = "-" * 70
lines = [
f"\n{separator}",
f"[Private Impact] Profile generated: {entity_name} ({entity_type})",
separator,
f"Name: {profile.name} | Link: {profile.relational_link_type} "
f"| Reaction: {profile.private_reaction_mode}",
f"Trust: {profile.relational_trust_level:.2f} "
f"| Fin.Sensitivity: {profile.financial_sensitivity:.2f} "
f"| Loyalty: {profile.institutional_loyalty:.2f}",
f"Cascade influence: {profile.cascade_influence}",
f"",
f"[Bio] {profile.bio}",
separator,
]
print("\n".join(lines))

View File

@ -0,0 +1,903 @@
"""
Private Impact Runner
Orchestrates the Private Impact simulation via subprocess, monitors
private/actions.jsonl for real-time state updates, and exposes the
interface used by the Flask /api/private-impact blueprint.
Equivalent of simulation_runner.py for the Private Impact mode.
Key differences from SimulationRunner:
- Single platform: "private" (no Twitter/Reddit split)
- Action log: {sim_dir}/private/actions.jsonl
- Config file: private_simulation_config.json
- Script: backend/scripts/run_private_simulation.py
- Time unit: simulated days (not hours)
- Cleanup removes private_simulation.db + private/ directory
Note on SimulationLogManager.get_private_logger():
SimulationLogManager (backend/scripts/action_logger.py) does NOT currently
expose get_private_logger(). run_private_simulation.py falls back directly
to PlatformActionLogger("private", simulation_dir). This method must be added
to action_logger.py in a future prompt see CONTEXT.md.
"""
import json
import os
import shutil
import signal
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional
from ..utils.logger import get_logger
from ..utils.locale import get_locale, set_locale
from .zep_graph_memory_updater import ZepGraphMemoryManager
logger = get_logger('mirofish.private_impact_runner')
IS_WINDOWS = sys.platform == 'win32'
# ── Enums ─────────────────────────────────────────────────────────────────────
class PrivateRunnerStatus(str, Enum):
"""Run state of the Private Impact simulation subprocess."""
IDLE = "idle"
STARTING = "starting"
RUNNING = "running"
STOPPING = "stopping"
STOPPED = "stopped"
COMPLETED = "completed"
FAILED = "failed"
# ── Dataclasses ───────────────────────────────────────────────────────────────
@dataclass
class PrivateAgentAction:
"""
Single relational action record parsed from private/actions.jsonl.
Equivalent of AgentAction for the private simulation mode.
No platform split all actions are platform="private".
"""
round_num: int
timestamp: str
agent_id: int
agent_name: str
action_type: str # REACT_PRIVATELY | CONFRONT | COALITION_BUILD |
# SILENT_LEAVE | VOCAL_SUPPORT | DO_NOTHING
action_args: Dict[str, Any] = field(default_factory=dict)
result: Optional[str] = None
success: bool = True
def to_dict(self) -> Dict[str, Any]:
return {
"round_num": self.round_num,
"timestamp": self.timestamp,
"platform": "private",
"agent_id": self.agent_id,
"agent_name": self.agent_name,
"action_type": self.action_type,
"action_args": self.action_args,
"result": self.result,
"success": self.success,
}
@dataclass
class PrivateSimulationRunState:
"""
Real-time run state for a Private Impact simulation.
Equivalent of SimulationRunState for the private mode.
Uses private_* field names no twitter_* / reddit_* split.
"""
simulation_id: str
runner_status: PrivateRunnerStatus = PrivateRunnerStatus.IDLE
# Progress
private_current_round: int = 0
private_total_rounds: int = 0
private_simulated_days: int = 0
private_total_days: int = 0
# Platform state (single: private)
private_running: bool = False
private_actions_count: int = 0
private_completed: bool = False
# Error
private_error: Optional[str] = None
# Recent actions for frontend live display
recent_actions: List[PrivateAgentAction] = field(default_factory=list)
max_recent_actions: int = 50
# Timestamps
started_at: Optional[str] = None
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
completed_at: Optional[str] = None
# Subprocess PID
process_pid: Optional[int] = None
def add_action(self, action: PrivateAgentAction) -> None:
"""Prepend action to recent_actions and increment actions counter."""
self.recent_actions.insert(0, action)
if len(self.recent_actions) > self.max_recent_actions:
self.recent_actions = self.recent_actions[:self.max_recent_actions]
self.private_actions_count += 1
self.updated_at = datetime.now().isoformat()
def to_dict(self) -> Dict[str, Any]:
total = max(self.private_total_rounds, 1)
return {
"simulation_id": self.simulation_id,
"runner_status": self.runner_status.value,
"private_current_round": self.private_current_round,
"private_total_rounds": self.private_total_rounds,
"private_simulated_days": self.private_simulated_days,
"private_total_days": self.private_total_days,
"progress_percent": round(self.private_current_round / total * 100, 1),
"private_running": self.private_running,
"private_actions_count": self.private_actions_count,
"private_completed": self.private_completed,
"private_error": self.private_error,
"started_at": self.started_at,
"updated_at": self.updated_at,
"completed_at": self.completed_at,
"process_pid": self.process_pid,
}
def to_detail_dict(self) -> Dict[str, Any]:
"""Extended dict including recent actions."""
result = self.to_dict()
result["recent_actions"] = [a.to_dict() for a in self.recent_actions]
return result
# ── PrivateImpactRunner ────────────────────────────────────────────────────────
class PrivateImpactRunner:
"""
Orchestrates Private Impact simulations.
Equivalent of SimulationRunner for the private relational mode.
Launches run_private_simulation.py as a subprocess, monitors
private/actions.jsonl for state updates, and exposes the interface
consumed by the Flask /api/private-impact blueprint.
Directory layout (under RUN_STATE_DIR/{simulation_id}/):
private_simulation_config.json PrivateSimulationParameters.to_dict()
private/actions.jsonl relational action log
simulation.log subprocess stdout + stderr
run_state.json persisted PrivateSimulationRunState
"""
RUN_STATE_DIR = os.path.join(
os.path.dirname(__file__),
'../../uploads/simulations'
)
SCRIPTS_DIR = os.path.join(
os.path.dirname(__file__),
'../../scripts'
)
CONFIG_FILENAME = "private_simulation_config.json"
SCRIPT_NAME = "run_private_simulation.py"
# Class-level in-memory state (same pattern as SimulationRunner)
_run_states: Dict[str, PrivateSimulationRunState] = {}
_processes: Dict[str, subprocess.Popen] = {}
_monitor_threads: Dict[str, threading.Thread] = {}
_stdout_files: Dict[str, Any] = {}
_graph_memory_enabled: Dict[str, bool] = {}
# ── Public API ─────────────────────────────────────────────────────────────
@classmethod
def get_status(cls, simulation_id: str) -> Optional[PrivateSimulationRunState]:
"""
Return the current run state for a simulation.
Checks in-memory cache first, then falls back to disk
(same pattern as SimulationRunner.get_run_state).
Args:
simulation_id: Simulation identifier.
Returns:
PrivateSimulationRunState or None if not found.
"""
if simulation_id in cls._run_states:
return cls._run_states[simulation_id]
return cls._load_run_state(simulation_id)
@classmethod
def start_simulation(
cls,
simulation_id: str,
max_rounds: Optional[int] = None,
enable_graph_memory_update: bool = False,
graph_id: Optional[str] = None,
) -> PrivateSimulationRunState:
"""
Launch the private impact simulation subprocess.
Same mechanics as SimulationRunner.start_simulation (L.387399):
- Reads private_simulation_config.json from the simulation directory
- Spawns run_private_simulation.py with start_new_session=True
- Redirects stdout/stderr to simulation.log
- Launches a background monitor thread
Args:
simulation_id: Unique simulation identifier.
max_rounds: Optional upper bound on simulation rounds.
enable_graph_memory_update: Push activity updates to Zep graph.
graph_id: Required when enable_graph_memory_update=True.
Returns:
PrivateSimulationRunState with status=STARTING.
Raises:
ValueError: If already running, config missing, or graph_id absent.
"""
existing = cls.get_status(simulation_id)
if existing and existing.runner_status in (
PrivateRunnerStatus.RUNNING, PrivateRunnerStatus.STARTING
):
raise ValueError(f"Private simulation already running: {simulation_id}")
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
config_path = os.path.join(sim_dir, cls.CONFIG_FILENAME)
if not os.path.exists(config_path):
raise ValueError(
f"Private simulation config not found: {config_path}. "
"Call /prepare first to generate the config."
)
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
time_cfg = config.get("time_config", {})
total_days = time_cfg.get("total_simulation_days", 30)
rounds_per_day = time_cfg.get("rounds_per_day", 3)
total_rounds = total_days * rounds_per_day
if max_rounds is not None and max_rounds > 0:
total_rounds = min(total_rounds, max_rounds)
logger.info(
f"[PRIVATE] Rounds capped to {total_rounds} "
f"(max_rounds={max_rounds})"
)
state = PrivateSimulationRunState(
simulation_id=simulation_id,
runner_status=PrivateRunnerStatus.STARTING,
private_total_rounds=total_rounds,
private_total_days=total_days,
private_running=True,
started_at=datetime.now().isoformat(),
)
cls._save_run_state(state)
# Optional Zep graph memory update
if enable_graph_memory_update:
if not graph_id:
raise ValueError(
"graph_id is required when enable_graph_memory_update=True"
)
try:
ZepGraphMemoryManager.create_updater(simulation_id, graph_id)
cls._graph_memory_enabled[simulation_id] = True
logger.info(
f"[PRIVATE] Graph memory update enabled: "
f"simulation_id={simulation_id}, graph_id={graph_id}"
)
except Exception as e:
logger.error(f"[PRIVATE] Failed to create graph memory updater: {e}")
cls._graph_memory_enabled[simulation_id] = False
else:
cls._graph_memory_enabled[simulation_id] = False
script_path = os.path.join(cls.SCRIPTS_DIR, cls.SCRIPT_NAME)
if not os.path.exists(script_path):
raise ValueError(f"Script not found: {script_path}")
try:
cmd = [sys.executable, script_path, "--config", config_path]
if max_rounds is not None and max_rounds > 0:
cmd.extend(["--max-rounds", str(max_rounds)])
main_log_path = os.path.join(sim_dir, "simulation.log")
main_log_file = open(main_log_path, 'w', encoding='utf-8')
env = os.environ.copy()
env['PYTHONUTF8'] = '1'
env['PYTHONIOENCODING'] = 'utf-8'
process = subprocess.Popen(
cmd,
cwd=sim_dir,
stdout=main_log_file,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8',
bufsize=1,
env=env,
start_new_session=True,
)
cls._stdout_files[simulation_id] = main_log_file
state.process_pid = process.pid
state.runner_status = PrivateRunnerStatus.RUNNING
cls._processes[simulation_id] = process
cls._save_run_state(state)
current_locale = get_locale()
monitor_thread = threading.Thread(
target=cls._monitor_simulation,
args=(simulation_id, current_locale),
daemon=True,
)
monitor_thread.start()
cls._monitor_threads[simulation_id] = monitor_thread
logger.info(
f"[PRIVATE] Simulation started: {simulation_id}, "
f"pid={process.pid}, total_rounds={total_rounds}, "
f"total_days={total_days}"
)
except Exception as e:
state.runner_status = PrivateRunnerStatus.FAILED
state.private_error = str(e)
state.private_running = False
cls._save_run_state(state)
raise
return state
@classmethod
def stop_simulation(cls, simulation_id: str) -> PrivateSimulationRunState:
"""
Stop a running private simulation with a clean SIGTERM.
Same mechanics as SimulationRunner.stop_simulation.
Args:
simulation_id: Simulation identifier.
Returns:
Updated PrivateSimulationRunState with status=STOPPED.
Raises:
ValueError: If simulation does not exist or is not running.
"""
state = cls.get_status(simulation_id)
if not state:
raise ValueError(f"Private simulation not found: {simulation_id}")
if state.runner_status not in (
PrivateRunnerStatus.RUNNING, PrivateRunnerStatus.STARTING
):
raise ValueError(
f"Private simulation is not running: "
f"{simulation_id}, status={state.runner_status}"
)
state.runner_status = PrivateRunnerStatus.STOPPING
cls._save_run_state(state)
process = cls._processes.get(simulation_id)
if process and process.poll() is None:
try:
cls._terminate_process(process, simulation_id)
except ProcessLookupError:
pass
except Exception as e:
logger.error(f"[PRIVATE] Terminate failed: {simulation_id}, {e}")
try:
process.terminate()
process.wait(timeout=5)
except Exception:
process.kill()
state.runner_status = PrivateRunnerStatus.STOPPED
state.private_running = False
state.completed_at = datetime.now().isoformat()
cls._save_run_state(state)
if cls._graph_memory_enabled.get(simulation_id, False):
try:
ZepGraphMemoryManager.stop_updater(simulation_id)
except Exception as e:
logger.error(f"[PRIVATE] Failed to stop graph updater: {e}")
cls._graph_memory_enabled.pop(simulation_id, None)
logger.info(f"[PRIVATE] Simulation stopped: {simulation_id}")
return state
@classmethod
def get_all_actions(
cls,
simulation_id: str,
agent_id: Optional[int] = None,
round_num: Optional[int] = None,
) -> List[PrivateAgentAction]:
"""
Read the complete private/actions.jsonl action history.
Args:
simulation_id: Simulation identifier.
agent_id: Optional filter by agent ID.
round_num: Optional filter by round number.
Returns:
List of PrivateAgentAction sorted by timestamp descending.
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
log_path = os.path.join(sim_dir, "private", "actions.jsonl")
actions = cls._read_actions_from_file(
log_path, agent_id=agent_id, round_num=round_num
)
actions.sort(key=lambda a: a.timestamp, reverse=True)
return actions
@classmethod
def cleanup(cls, simulation_id: str) -> Dict[str, Any]:
"""
Remove private simulation artifacts to allow a fresh restart.
Deletes:
- run_state.json
- simulation.log
- private_simulation.db
- private/ directory (contains actions.jsonl)
Does NOT delete: private_simulation_config.json, profile files.
Args:
simulation_id: Simulation identifier.
Returns:
Dict with keys: success (bool), cleaned_files (list), errors (list|None).
"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
if not os.path.exists(sim_dir):
return {"success": True, "cleaned_files": [], "errors": None}
cleaned: List[str] = []
errors: List[str] = []
for filename in ("run_state.json", "simulation.log", "private_simulation.db"):
path = os.path.join(sim_dir, filename)
if os.path.exists(path):
try:
os.remove(path)
cleaned.append(filename)
except Exception as e:
errors.append(f"Failed to delete {filename}: {e}")
private_dir = os.path.join(sim_dir, "private")
if os.path.exists(private_dir):
try:
shutil.rmtree(private_dir)
cleaned.append("private/")
except Exception as e:
errors.append(f"Failed to delete private/: {e}")
cls._run_states.pop(simulation_id, None)
logger.info(
f"[PRIVATE] Cleanup done: {simulation_id}, removed={cleaned}"
)
return {
"success": len(errors) == 0,
"cleaned_files": cleaned,
"errors": errors or None,
}
# ── Internal: monitor thread ───────────────────────────────────────────────
@classmethod
def _monitor_simulation(cls, simulation_id: str, locale: str = 'en') -> None:
"""
Background thread: poll private/actions.jsonl until subprocess exits.
Same pattern as SimulationRunner._monitor_simulation (L.482581):
- Loops while process is alive, reading new log lines every 2 s
- Performs a final read after process exit
- Sets COMPLETED or FAILED based on exit code
- Stops graph memory updater in finally block
Args:
simulation_id: Simulation identifier.
locale: Locale inherited from the calling thread.
"""
set_locale(locale)
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)
private_log = os.path.join(sim_dir, "private", "actions.jsonl")
process = cls._processes.get(simulation_id)
state = cls.get_status(simulation_id)
if not process or not state:
return
log_position = 0
try:
while process.poll() is None:
if os.path.exists(private_log):
log_position = cls._read_action_log(
private_log, log_position, state
)
cls._save_run_state(state)
time.sleep(2)
# Final read after process exits
if os.path.exists(private_log):
cls._read_action_log(private_log, log_position, state)
exit_code = process.returncode
if exit_code == 0:
state.runner_status = PrivateRunnerStatus.COMPLETED
state.completed_at = datetime.now().isoformat()
logger.info(f"[PRIVATE] Simulation completed: {simulation_id}")
else:
state.runner_status = PrivateRunnerStatus.FAILED
main_log = os.path.join(sim_dir, "simulation.log")
error_tail = ""
try:
if os.path.exists(main_log):
with open(main_log, 'r', encoding='utf-8') as f:
error_tail = f.read()[-2000:]
except Exception:
pass
state.private_error = (
f"Process exited with code {exit_code}. "
f"Last log output: {error_tail}"
)
logger.error(
f"[PRIVATE] Simulation failed: {simulation_id}, "
f"exit_code={exit_code}"
)
state.private_running = False
cls._save_run_state(state)
except Exception as e:
logger.error(f"[PRIVATE] Monitor thread error: {simulation_id}, {e}")
state.runner_status = PrivateRunnerStatus.FAILED
state.private_error = str(e)
cls._save_run_state(state)
finally:
if cls._graph_memory_enabled.get(simulation_id, False):
try:
ZepGraphMemoryManager.stop_updater(simulation_id)
logger.info(
f"[PRIVATE] Graph memory updater stopped: {simulation_id}"
)
except Exception as e:
logger.error(
f"[PRIVATE] Failed to stop graph updater: {e}"
)
cls._graph_memory_enabled.pop(simulation_id, None)
cls._processes.pop(simulation_id, None)
if simulation_id in cls._stdout_files:
try:
cls._stdout_files[simulation_id].close()
except Exception:
pass
cls._stdout_files.pop(simulation_id, None)
# ── Internal: log reader ───────────────────────────────────────────────────
@classmethod
def _read_action_log(
cls,
log_path: str,
position: int,
state: PrivateSimulationRunState,
) -> int:
"""
Incremental read of private/actions.jsonl from a byte offset.
Same pattern as SimulationRunner._read_action_log (L.683684):
- Seeks to last read position, reads new lines only
- Calls ZepGraphMemoryUpdater.add_activity_from_dict(data, "private")
- Handles round_end and simulation_end event entries
Args:
log_path: Absolute path to private/actions.jsonl.
position: Byte offset of the previous read.
state: Mutable run state to update in place.
Returns:
New byte offset after reading.
"""
graph_memory_enabled = cls._graph_memory_enabled.get(
state.simulation_id, False
)
graph_updater = None
if graph_memory_enabled:
graph_updater = ZepGraphMemoryManager.get_updater(state.simulation_id)
try:
with open(log_path, 'r', encoding='utf-8') as f:
f.seek(position)
for line in f:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
# Structured event entries (no agent_id)
if "event_type" in data:
event_type = data["event_type"]
if event_type == "simulation_end":
state.private_completed = True
state.private_running = False
state.runner_status = PrivateRunnerStatus.COMPLETED
state.completed_at = datetime.now().isoformat()
logger.info(
f"[PRIVATE] simulation_end received: "
f"{state.simulation_id}, "
f"total_rounds={data.get('total_rounds')}, "
f"total_actions={data.get('total_actions')}"
)
elif event_type == "round_end":
round_num = data.get("round", 0)
if round_num > state.private_current_round:
state.private_current_round = round_num
# simulated_day may be written by run_private_simulation.py
simulated_day = data.get("simulated_day", 0)
if simulated_day > state.private_simulated_days:
state.private_simulated_days = simulated_day
continue
# Skip non-agent entries
if "agent_id" not in data:
continue
action = PrivateAgentAction(
round_num=data.get("round", 0),
timestamp=data.get(
"timestamp", datetime.now().isoformat()
),
agent_id=data.get("agent_id", 0),
agent_name=data.get("agent_name", ""),
action_type=data.get("action_type", ""),
action_args=data.get("action_args", {}),
result=data.get("result"),
success=data.get("success", True),
)
state.add_action(action)
if action.round_num > state.private_current_round:
state.private_current_round = action.round_num
# Push to Zep graph memory with platform="private"
if graph_updater:
graph_updater.add_activity_from_dict(data, "private")
except json.JSONDecodeError:
pass
return f.tell()
except Exception as e:
logger.warning(
f"[PRIVATE] Failed to read action log: {log_path}, {e}"
)
return position
# ── Internal: persistence ─────────────────────────────────────────────────
@classmethod
def _save_run_state(cls, state: PrivateSimulationRunState) -> None:
"""Persist run state to run_state.json and update in-memory cache."""
sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id)
os.makedirs(sim_dir, exist_ok=True)
state_file = os.path.join(sim_dir, "run_state.json")
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(state.to_detail_dict(), f, ensure_ascii=False, indent=2)
cls._run_states[state.simulation_id] = state
@classmethod
def _load_run_state(
cls, simulation_id: str
) -> Optional[PrivateSimulationRunState]:
"""
Load run state from disk.
Same pattern as SimulationRunner._load_run_state.
Args:
simulation_id: Simulation identifier.
Returns:
PrivateSimulationRunState or None on failure / missing file.
"""
state_file = os.path.join(
cls.RUN_STATE_DIR, simulation_id, "run_state.json"
)
if not os.path.exists(state_file):
return None
try:
with open(state_file, 'r', encoding='utf-8') as f:
data = json.load(f)
state = PrivateSimulationRunState(
simulation_id=simulation_id,
runner_status=PrivateRunnerStatus(
data.get("runner_status", "idle")
),
private_current_round=data.get("private_current_round", 0),
private_total_rounds=data.get("private_total_rounds", 0),
private_simulated_days=data.get("private_simulated_days", 0),
private_total_days=data.get("private_total_days", 0),
private_running=data.get("private_running", False),
private_actions_count=data.get("private_actions_count", 0),
private_completed=data.get("private_completed", False),
private_error=data.get("private_error"),
started_at=data.get("started_at"),
updated_at=data.get("updated_at", datetime.now().isoformat()),
completed_at=data.get("completed_at"),
process_pid=data.get("process_pid"),
)
for a in data.get("recent_actions", []):
state.recent_actions.append(PrivateAgentAction(
round_num=a.get("round_num", 0),
timestamp=a.get("timestamp", ""),
agent_id=a.get("agent_id", 0),
agent_name=a.get("agent_name", ""),
action_type=a.get("action_type", ""),
action_args=a.get("action_args", {}),
result=a.get("result"),
success=a.get("success", True),
))
cls._run_states[simulation_id] = state
return state
except Exception as e:
logger.error(
f"[PRIVATE] Failed to load run state: {simulation_id}, {e}"
)
return None
@classmethod
def _read_actions_from_file(
cls,
file_path: str,
agent_id: Optional[int] = None,
round_num: Optional[int] = None,
) -> List[PrivateAgentAction]:
"""
Read all agent actions from a JSONL file with optional filters.
Args:
file_path: Path to actions.jsonl.
agent_id: Optional filter by agent ID.
round_num: Optional filter by round number.
Returns:
List of PrivateAgentAction instances.
"""
if not os.path.exists(file_path):
return []
actions: List[PrivateAgentAction] = []
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
if "event_type" in data:
continue
if "agent_id" not in data:
continue
if agent_id is not None and data.get("agent_id") != agent_id:
continue
if round_num is not None and data.get("round") != round_num:
continue
actions.append(PrivateAgentAction(
round_num=data.get("round", 0),
timestamp=data.get("timestamp", ""),
agent_id=data.get("agent_id", 0),
agent_name=data.get("agent_name", ""),
action_type=data.get("action_type", ""),
action_args=data.get("action_args", {}),
result=data.get("result"),
success=data.get("success", True),
))
except json.JSONDecodeError:
continue
return actions
# ── Internal: process management ──────────────────────────────────────────
@classmethod
def _terminate_process(
cls,
process: subprocess.Popen,
simulation_id: str,
timeout: int = 10,
) -> None:
"""
Terminate subprocess and its children cross-platform.
Same implementation as SimulationRunner._terminate_process:
- Windows: taskkill /PID /T, then /F if unresponsive
- Unix: SIGTERM to process group, SIGKILL on timeout
Args:
process: Subprocess to terminate.
simulation_id: Simulation ID for logging.
timeout: Seconds to wait before force-killing.
"""
if IS_WINDOWS:
logger.info(
f"[PRIVATE] Terminating process tree (Windows): "
f"simulation={simulation_id}, pid={process.pid}"
)
try:
subprocess.run(
['taskkill', '/PID', str(process.pid), '/T'],
capture_output=True, timeout=5
)
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
logger.warning(
f"[PRIVATE] Process unresponsive, force killing: "
f"{simulation_id}"
)
subprocess.run(
['taskkill', '/F', '/PID', str(process.pid), '/T'],
capture_output=True, timeout=5
)
process.wait(timeout=5)
except Exception as e:
logger.warning(f"[PRIVATE] taskkill failed, falling back: {e}")
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
else:
pgid = os.getpgid(process.pid)
logger.info(
f"[PRIVATE] Terminating process group (Unix): "
f"simulation={simulation_id}, pgid={pgid}"
)
os.killpg(pgid, signal.SIGTERM)
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
logger.warning(
f"[PRIVATE] Process group unresponsive, force killing: "
f"{simulation_id}"
)
os.killpg(pgid, signal.SIGKILL)
process.wait(timeout=5)

View File

@ -125,7 +125,14 @@ class SimulationRunState:
# 平台完成状态(通过检测 actions.jsonl 中的 simulation_end 事件)
twitter_completed: bool = False
reddit_completed: bool = False
# Private Impact platform state
private_current_round: int = 0
private_simulated_days: int = 0
private_running: bool = False
private_actions_count: int = 0
private_completed: bool = False
# 每轮摘要
rounds: List[RoundSummary] = field(default_factory=list)
@ -152,6 +159,8 @@ class SimulationRunState:
if action.platform == "twitter":
self.twitter_actions_count += 1
elif action.platform == "private":
self.private_actions_count += 1
else:
self.reddit_actions_count += 1
@ -177,7 +186,12 @@ class SimulationRunState:
"reddit_completed": self.reddit_completed,
"twitter_actions_count": self.twitter_actions_count,
"reddit_actions_count": self.reddit_actions_count,
"total_actions_count": self.twitter_actions_count + self.reddit_actions_count,
"private_current_round": self.private_current_round,
"private_simulated_days": self.private_simulated_days,
"private_running": self.private_running,
"private_completed": self.private_completed,
"private_actions_count": self.private_actions_count,
"total_actions_count": self.twitter_actions_count + self.reddit_actions_count + self.private_actions_count,
"started_at": self.started_at,
"updated_at": self.updated_at,
"completed_at": self.completed_at,
@ -268,6 +282,11 @@ class SimulationRunner:
reddit_completed=data.get("reddit_completed", False),
twitter_actions_count=data.get("twitter_actions_count", 0),
reddit_actions_count=data.get("reddit_actions_count", 0),
private_current_round=data.get("private_current_round", 0),
private_simulated_days=data.get("private_simulated_days", 0),
private_running=data.get("private_running", False),
private_completed=data.get("private_completed", False),
private_actions_count=data.get("private_actions_count", 0),
started_at=data.get("started_at"),
updated_at=data.get("updated_at", datetime.now().isoformat()),
completed_at=data.get("completed_at"),
@ -391,6 +410,9 @@ class SimulationRunner:
elif platform == "reddit":
script_name = "run_reddit_simulation.py"
state.reddit_running = True
elif platform == "private":
script_name = "run_private_simulation.py"
state.private_running = True
else:
script_name = "run_parallel_simulation.py"
state.twitter_running = True
@ -487,15 +509,17 @@ class SimulationRunner:
# 新的日志结构:分平台的动作日志
twitter_actions_log = os.path.join(sim_dir, "twitter", "actions.jsonl")
reddit_actions_log = os.path.join(sim_dir, "reddit", "actions.jsonl")
private_actions_log = os.path.join(sim_dir, "private", "actions.jsonl")
process = cls._processes.get(simulation_id)
state = cls.get_run_state(simulation_id)
if not process or not state:
return
twitter_position = 0
reddit_position = 0
private_position = 0
try:
while process.poll() is None: # 进程仍在运行
@ -510,7 +534,13 @@ class SimulationRunner:
reddit_position = cls._read_action_log(
reddit_actions_log, reddit_position, state, "reddit"
)
# 读取 Private 动作日志
if os.path.exists(private_actions_log):
private_position = cls._read_action_log(
private_actions_log, private_position, state, "private"
)
# 更新状态
cls._save_run_state(state)
time.sleep(2)
@ -520,6 +550,8 @@ class SimulationRunner:
cls._read_action_log(twitter_actions_log, twitter_position, state, "twitter")
if os.path.exists(reddit_actions_log):
cls._read_action_log(reddit_actions_log, reddit_position, state, "reddit")
if os.path.exists(private_actions_log):
cls._read_action_log(private_actions_log, private_position, state, "private")
# 进程结束
exit_code = process.returncode
@ -544,8 +576,9 @@ class SimulationRunner:
state.twitter_running = False
state.reddit_running = False
state.private_running = False
cls._save_run_state(state)
except Exception as e:
logger.error(f"监控线程异常: {simulation_id}, error={str(e)}")
state.runner_status = RunnerStatus.FAILED
@ -629,6 +662,10 @@ class SimulationRunner:
state.reddit_completed = True
state.reddit_running = False
logger.info(f"Reddit 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}")
elif platform == "private":
state.private_completed = True
state.private_running = False
logger.info(f"Private 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}")
# 检查是否所有启用的平台都已完成
# 如果只运行了一个平台,只检查那个平台
@ -653,7 +690,13 @@ class SimulationRunner:
if round_num > state.reddit_current_round:
state.reddit_current_round = round_num
state.reddit_simulated_hours = simulated_hours
elif platform == "private":
if round_num > state.private_current_round:
state.private_current_round = round_num
simulated_day = action_data.get("simulated_day", 0)
if simulated_day > state.private_simulated_days:
state.private_simulated_days = simulated_day
# 总体轮次取两个平台的最大值
if round_num > state.current_round:
state.current_round = round_num
@ -703,19 +746,23 @@ class SimulationRunner:
sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id)
twitter_log = os.path.join(sim_dir, "twitter", "actions.jsonl")
reddit_log = os.path.join(sim_dir, "reddit", "actions.jsonl")
private_log = os.path.join(sim_dir, "private", "actions.jsonl")
# 检查哪些平台被启用(通过文件是否存在判断)
twitter_enabled = os.path.exists(twitter_log)
reddit_enabled = os.path.exists(reddit_log)
private_enabled = os.path.exists(private_log)
# 如果平台被启用但未完成,则返回 False
if twitter_enabled and not state.twitter_completed:
return False
if reddit_enabled and not state.reddit_completed:
return False
if private_enabled and not state.private_completed:
return False
# 至少有一个平台被启用且已完成
return twitter_enabled or reddit_enabled
return twitter_enabled or reddit_enabled or private_enabled
@classmethod
def _terminate_process(cls, process: subprocess.Popen, simulation_id: str, timeout: int = 10):
@ -806,9 +853,10 @@ class SimulationRunner:
state.runner_status = RunnerStatus.STOPPED
state.twitter_running = False
state.reddit_running = False
state.private_running = False
state.completed_at = datetime.now().isoformat()
cls._save_run_state(state)
# 停止图谱记忆更新器
if cls._graph_memory_enabled.get(simulation_id, False):
try:
@ -934,7 +982,18 @@ class SimulationRunner:
agent_id=agent_id,
round_num=round_num
))
# 读取 Private 动作文件
private_actions_log = os.path.join(sim_dir, "private", "actions.jsonl")
if not platform or platform == "private":
actions.extend(cls._read_actions_from_file(
private_actions_log,
default_platform="private",
platform_filter=platform,
agent_id=agent_id,
round_num=round_num
))
# 如果分平台文件不存在,尝试读取旧的单一文件格式
if not actions:
actions_log = os.path.join(sim_dir, "actions.jsonl")
@ -1140,11 +1199,12 @@ class SimulationRunner:
"stderr.log",
"twitter_simulation.db", # Twitter 平台数据库
"reddit_simulation.db", # Reddit 平台数据库
"private_simulation.db", # Private Impact 平台数据库
"env_status.json", # 环境状态文件
]
# 要删除的目录列表(包含动作日志)
dirs_to_clean = ["twitter", "reddit"]
dirs_to_clean = ["twitter", "reddit", "private"]
# 删除文件
for filename in files_to_delete:
@ -1236,6 +1296,7 @@ class SimulationRunner:
state.runner_status = RunnerStatus.STOPPED
state.twitter_running = False
state.reddit_running = False
state.private_running = False
state.completed_at = datetime.now().isoformat()
state.error = "服务器关闭,模拟被终止"
cls._save_run_state(state)

View File

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

View File

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

View File

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

View File

@ -38,7 +38,7 @@ def main():
# 获取运行配置
host = os.environ.get('FLASK_HOST', '0.0.0.0')
port = int(os.environ.get('FLASK_PORT', 5001))
port = int(os.environ.get('FLASK_PORT', 9902))
debug = Config.DEBUG
# 启动服务

View File

@ -77,7 +77,7 @@ class PlatformActionLogger:
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_round_end(self, round_num: int, actions_count: int):
def log_round_end(self, round_num: int, actions_count: int, simulated_day: Optional[int] = None):
"""记录轮次结束"""
entry = {
"round": round_num,
@ -85,7 +85,9 @@ class PlatformActionLogger:
"event_type": "round_end",
"actions_count": actions_count,
}
if simulated_day is not None:
entry["simulated_day"] = simulated_day
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
@ -132,6 +134,7 @@ class SimulationLogManager:
self.simulation_dir = simulation_dir
self.twitter_logger: Optional[PlatformActionLogger] = None
self.reddit_logger: Optional[PlatformActionLogger] = None
self.private_logger: Optional[PlatformActionLogger] = None
self._main_logger: Optional[logging.Logger] = None
# 设置主日志
@ -177,7 +180,13 @@ class SimulationLogManager:
if self.reddit_logger is None:
self.reddit_logger = PlatformActionLogger("reddit", self.simulation_dir)
return self.reddit_logger
def get_private_logger(self) -> PlatformActionLogger:
"""获取 Private Impact 平台日志记录器"""
if self.private_logger is None:
self.private_logger = PlatformActionLogger("private", self.simulation_dir)
return self.private_logger
def log(self, message: str, level: str = "info"):
"""记录主日志"""
if self._main_logger:

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +0,0 @@
services:
mirofish:
image: ghcr.io/666ghj/mirofish:latest
# 加速镜像(如拉取缓慢可替换上方地址)
# image: ghcr.nju.edu.cn/666ghj/mirofish:latest
container_name: mirofish
env_file:
- .env
ports:
- "3000:3000"
- "5001:5001"
restart: unless-stopped
volumes:
- ./backend/uploads:/app/backend/uploads

View File

@ -1435,7 +1435,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -1913,7 +1912,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2053,7 +2051,6 @@
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -2128,7 +2125,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",

View File

@ -3,7 +3,7 @@ import i18n from '../i18n'
// 创建axios实例
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001',
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:9902',
timeout: 300000, // 5分钟超时本体生成可能需要较长时间
headers: {
'Content-Type': 'application/json'

View File

@ -0,0 +1,25 @@
import service, { requestWithRetry } from './index'
export const preparePrivateSimulation = (data) =>
requestWithRetry(() => service.post('/api/private-impact/prepare', data), 3, 2000)
export const startPrivateSimulation = (data) =>
requestWithRetry(() => service.post('/api/private-impact/start', data), 3, 1000)
export const getPrivateStatus = (simId) =>
service.get(`/api/private-impact/status/${simId}`)
export const stopPrivateSimulation = (simId) =>
service.post(`/api/private-impact/stop/${simId}`)
export const getPrivateActions = (simId, params = {}) =>
service.get(`/api/private-impact/actions/${simId}`, { params })
export const generatePrivateReport = (simId, data = {}) =>
requestWithRetry(() => service.post(`/api/private-impact/report/${simId}`, data), 3, 1000)
export const cleanupPrivateSimulation = (simId) =>
service.delete(`/api/private-impact/cleanup/${simId}`)
export const getPrivateReportStatus = (taskId) =>
service.post('/api/report/generate/status', { task_id: taskId })

View File

@ -0,0 +1,244 @@
<template>
<div class="mode-selector">
<div class="selector-header">
<span class="selector-label">SELECT SIMULATION MODE</span>
<p class="selector-hint">Choose how you want to run your impact analysis</p>
</div>
<div class="mode-cards">
<!-- Public Mode -->
<button
type="button"
class="mode-card"
:class="{ 'is-selected': selected === 'public', 'is-disabled': disabled }"
:disabled="disabled"
@click="selectMode('public')"
>
<div class="card-icon">
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<div class="card-body">
<div class="card-title">Public Opinion</div>
<div class="card-subtitle">Twitter / Reddit</div>
<p class="card-desc">Simulate how a decision, event, or message propagates through open social networks.</p>
<div class="card-tags">
<span class="tag">Social Media</span>
<span class="tag">Public Sentiment</span>
<span class="tag">Virality</span>
</div>
</div>
<div class="card-check">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
</button>
<!-- Private Impact Mode -->
<button
type="button"
class="mode-card mode-card--private"
:class="{ 'is-selected': selected === 'private', 'is-disabled': disabled }"
:disabled="disabled"
@click="selectMode('private')"
>
<div class="card-icon">
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div class="card-body">
<div class="card-title">Private Impact</div>
<div class="card-subtitle">Closed Relational Network</div>
<p class="card-desc">Simulate how a private decision propagates through a relational network employees, clients, partners.</p>
<div class="card-tags">
<span class="tag">Org Network</span>
<span class="tag">Decision Impact</span>
<span class="tag">Confidential</span>
</div>
</div>
<div class="card-check">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
projectId: { type: String, default: 'new' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['mode-selected'])
const selected = ref(null)
const router = useRouter()
const selectMode = (mode) => {
if (props.disabled) return
selected.value = mode
emit('mode-selected', mode)
router.push({
path: `/process/${props.projectId}`,
query: { mode },
})
}
</script>
<style scoped>
.mode-selector {
padding: 0;
}
.selector-header {
margin-bottom: 20px;
}
.selector-label {
display: block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
color: #999;
margin-bottom: 6px;
}
.selector-hint {
font-size: 13px;
color: #555;
}
.mode-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.mode-card {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
padding: 20px;
border: 1.5px solid #E0E0E0;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s, opacity 0.18s;
width: 100%;
gap: 14px;
font-family: inherit;
}
.mode-card:hover:not(.is-disabled) {
border-color: #000;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.mode-card.is-selected {
border-color: #000;
background: #FAFAFA;
}
.mode-card--private:hover:not(.is-disabled),
.mode-card--private.is-selected {
border-color: #1A1A1A;
background: #F8F8F8;
}
.mode-card.is-disabled {
cursor: not-allowed;
opacity: 0.45;
}
.card-icon {
color: #333;
flex-shrink: 0;
}
.card-body {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 14px;
font-weight: 700;
color: #000;
margin-bottom: 2px;
letter-spacing: 0.02em;
}
.card-subtitle {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
color: #888;
text-transform: uppercase;
margin-bottom: 8px;
}
.card-desc {
font-size: 12px;
color: #555;
line-height: 1.5;
margin-bottom: 10px;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag {
font-size: 10px;
font-weight: 500;
color: #666;
background: #F0F0F0;
border-radius: 2px;
padding: 2px 6px;
letter-spacing: 0.04em;
}
.card-check {
position: absolute;
top: 12px;
right: 12px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #000;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0.7);
transition: opacity 0.15s, transform 0.15s;
}
.mode-card.is-selected .card-check {
opacity: 1;
transform: scale(1);
}
@media (max-width: 640px) {
.mode-cards {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -201,10 +201,11 @@ const props = defineProps({
ontologyProgress: Object,
buildProgress: Object,
graphData: Object,
systemLogs: { type: Array, default: () => [] }
systemLogs: { type: Array, default: () => [] },
mode: { type: String, default: 'public' }
})
defineEmits(['next-step'])
const emit = defineEmits(['next-step'])
const selectedOntologyItem = ref(null)
const logContent = ref(null)
@ -216,9 +217,15 @@ const handleEnterEnvSetup = async () => {
console.error('缺少项目或图谱信息')
return
}
// Mode privé : pas de simulation publique, on passe directement à l'étape 2 (Requirement)
if (props.mode === 'private') {
emit('next-step')
return
}
creatingSimulation.value = true
try {
const res = await createSimulation({
project_id: props.projectData.project_id,
@ -226,7 +233,7 @@ const handleEnterEnvSetup = async () => {
enable_twitter: true,
enable_reddit: true
})
if (res.success && res.data?.simulation_id) {
// simulation
router.push({

View File

@ -0,0 +1,418 @@
<template>
<div class="form-container">
<div class="section-title-row">
<h2 class="section-h2">Define the Decision</h2>
<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="projectId && !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">
<div class="field-group">
<label class="field-label">DECISION MAKER</label>
<div class="field-row-3">
<input class="field-input" v-model="form.decisionMakerName" placeholder="Full name" />
<input class="field-input" v-model="form.decisionMakerRole" placeholder="Role / title" />
<input class="field-input" v-model="form.decisionMakerCompany" placeholder="Organisation" />
</div>
</div>
<div class="field-group">
<label class="field-label">DECISION <span class="required">*</span></label>
<textarea
class="field-textarea"
v-model="form.decisionText"
rows="5"
placeholder="Describe the decision precisely. E.g. 'We are closing the Lyon office and transferring 40 employees to Paris by Q3.'"
></textarea>
</div>
<div class="field-group">
<label class="field-label">ADDITIONAL CONTEXT</label>
<textarea
class="field-textarea"
v-model="form.decisionContext"
rows="3"
placeholder="Background information, strategic rationale, known sensitivities..."
></textarea>
</div>
</div>
<!-- Right column -->
<div class="form-col">
<div class="field-group">
<label class="field-label">RELATIONAL NETWORK types to include</label>
<div class="checkbox-grid">
<label
v-for="t in RELATIONAL_TYPES"
:key="t"
class="checkbox-item"
:class="{ 'is-checked': form.relationalTypes.includes(t) }"
>
<input
type="checkbox"
:value="t"
v-model="form.relationalTypes"
class="checkbox-native"
/>
<span class="checkbox-box"></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</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>
<div class="field-group">
<label class="field-label">QUESTIONS TO MEASURE</label>
<textarea
class="field-textarea"
v-model="form.questionsToMeasure"
rows="3"
placeholder="What do you want to measure? E.g. 'What is the risk of collective resistance? Who are the key opinion leaders?'"
></textarea>
</div>
</div>
</div>
<div class="form-footer">
<button
class="btn-primary"
:disabled="!form.decisionText.trim() || form.relationalTypes.length === 0 || (projectId && !projectData?.graph_id)"
@click="$emit('prepare')"
>
Prepare Simulation
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" />
</svg>
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { RELATIONAL_TYPES, RELATIONAL_TYPE_LABELS, HORIZON_OPTIONS } from '../../constants/private.js'
import { parseImportedConfig } from '../../utils/private.js'
const props = defineProps({
form: { type: Object, required: true },
agentCounts: { type: Object, required: true },
projectId: { type: String, default: null },
projectData: { type: Object, default: null },
})
defineEmits(['prepare'])
const importInput = ref(null)
const isDragOver = ref(false)
const totalAgents = computed(() =>
Object.values(props.agentCounts).reduce((sum, n) => sum + (n || 0), 0)
)
watch(() => props.form.relationalTypes, (types) => {
for (const t of types) {
if (!(t in props.agentCounts)) props.agentCounts[t] = 10
}
for (const key of Object.keys(props.agentCounts)) {
if (!types.includes(key)) delete props.agentCounts[key]
}
}, { immediate: true, deep: true })
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, props.form, props.agentCounts, RELATIONAL_TYPES, RELATIONAL_TYPE_LABELS)
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, props.form, props.agentCounts, RELATIONAL_TYPES, RELATIONAL_TYPE_LABELS)
event.target.value = ''
}
reader.readAsText(file)
}
</script>
<style scoped>
.form-container { max-width: 1100px; margin: 0 auto; }
.section-title-row { margin-bottom: 24px; }
.section-h2 { font-size: 18px; font-weight: 700; color: #000; margin-bottom: 6px; }
.section-hint { font-size: 13px; color: #777; }
.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 {
width: 40px;
height: 40px;
border: 3px solid #E5E7EB;
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-ring--sm {
width: 16px;
height: 16px;
border-width: 2px;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.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; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }
.form-col { display: flex; flex-direction: column; gap: 20px; }
.field-group { display: flex; flex-direction: column; gap: 8px; }
.field-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
color: #888;
}
.required { color: #FF5722; }
.field-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
.field-input, .field-textarea {
border: 1.5px solid #E0E0E0;
border-radius: 3px;
padding: 9px 12px;
font-size: 13px;
font-family: inherit;
color: #000;
background: #fff;
transition: border-color 0.15s;
resize: vertical;
}
.field-input:focus, .field-textarea:focus {
outline: none;
border-color: #000;
}
.field-input::placeholder, .field-textarea::placeholder { color: #BBB; }
.checkbox-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 6px 8px;
border: 1.5px solid #E8E8E8;
border-radius: 3px;
transition: border-color 0.15s, background 0.15s;
}
.checkbox-item.is-checked { border-color: #000; background: #FAFAFA; }
.checkbox-native { display: none; }
.checkbox-box {
width: 14px;
height: 14px;
border: 1.5px solid #CCC;
border-radius: 2px;
flex-shrink: 0;
background: #fff;
transition: all 0.12s;
}
.checkbox-item.is-checked .checkbox-box {
background: #000;
border-color: #000;
}
.checkbox-label { font-size: 11px; font-weight: 500; color: #444; text-transform: capitalize; }
.form-footer { margin-top: 28px; display: flex; justify-content: flex-end; }
.btn-primary {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 22px;
background: #000;
color: #fff;
border: none;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover { background: #222; }
.btn-primary:disabled { background: #CCC; cursor: not-allowed; }
.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 {
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;
}
</style>

View File

@ -0,0 +1,460 @@
<template>
<div class="run-layout">
<!-- Left: Progress panel -->
<div class="run-progress-panel">
<div class="run-platform-status" :class="{ 'is-running': simStatus?.private_running, 'is-done': simStatus?.private_completed }">
<div class="rps-header">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>Private Network</span>
<span v-if="simStatus?.private_completed" class="rps-badge-done">DONE</span>
<span v-else-if="simStatus?.private_running" class="rps-badge-run">RUNNING</span>
<span v-else class="rps-badge-idle">IDLE</span>
</div>
<div class="rps-stats">
<div class="rps-stat">
<span class="rps-stat-label">ROUND</span>
<span class="rps-stat-value mono">{{ simStatus?.private_current_round || 0 }}</span>
</div>
<div class="rps-stat">
<span class="rps-stat-label">DAY</span>
<span class="rps-stat-value mono">{{ simStatus?.private_simulated_days || 0 }}</span>
</div>
<div class="rps-stat">
<span class="rps-stat-label">ACTIONS</span>
<span class="rps-stat-value mono">{{ simStatus?.private_actions_count || 0 }}</span>
</div>
</div>
<div class="rps-progress-track">
<div
class="rps-progress-fill"
:style="{ width: roundProgress + '%' }"
></div>
</div>
<div class="rps-progress-label mono">{{ roundProgress }}%</div>
</div>
<div class="run-action-types">
<div class="run-action-types-title">ACTION TYPES</div>
<div v-for="(count, type) in actionTypeCounts" :key="type" class="action-type-row">
<span class="action-type-name">{{ type }}</span>
<span class="action-type-count mono">{{ count }}</span>
</div>
<div v-if="Object.keys(actionTypeCounts).length === 0" class="no-actions-yet">
Waiting for first actions
</div>
</div>
<div class="run-controls">
<button
v-if="simStatus?.runner_status === 'running'"
class="btn-stop"
@click="$emit('stop')"
>
Stop Simulation
</button>
<button
v-if="simStatus?.runner_status === 'completed' || simStatus?.runner_status === 'stopped'"
class="btn-primary"
@click="$emit('report')"
>
Generate Report
</button>
</div>
</div>
<!-- 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>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import * as d3 from 'd3'
import { ACTION_COLORS } from '../../constants/private.js'
import { shortTime, actionTypeClass, nodeColor } from '../../utils/private.js'
const props = defineProps({
simStatus: { type: Object, default: null },
recentActions: { type: Array, default: () => [] },
})
defineEmits(['stop', 'report'])
const graphContainer = ref(null)
const feedPanel = ref(null)
let simulation = null
let svgEl = null
let linkGroup = null
let nodeGroup = null
const roundProgress = computed(() => {
if (props.simStatus?.progress_percent != null) return props.simStatus.progress_percent
const total = props.simStatus?.private_total_rounds || 0
const current = props.simStatus?.private_current_round || 0
if (!total) return 0
return Math.round((current / total) * 100)
})
const actionTypeCounts = computed(() => {
const counts = {}
for (const action of props.recentActions) {
counts[action.action_type] = (counts[action.action_type] || 0) + 1
}
return counts
})
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 = {}
const staticAgents = props.simStatus?.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))
const staticEdges = props.simStatus?.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, ACTION_COLORS))
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
nodeMerge.select('text')
.text(d => d.name.slice(0, 12))
}
onMounted(async () => {
await nextTick()
initGraph()
updateGraph(props.recentActions || [])
})
onUnmounted(() => {
if (simulation) simulation.stop()
})
watch(() => props.recentActions.length, () => {
nextTick(() => {
if (feedPanel.value) feedPanel.value.scrollTop = feedPanel.value.scrollHeight
})
updateGraph(props.recentActions)
})
watch(
() => (props.simStatus?.agents?.length || 0) + (props.simStatus?.relational_edges?.length || 0),
() => updateGraph(props.recentActions || [])
)
</script>
<style scoped>
.run-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 20px;
height: calc(100vh - 172px);
}
.run-progress-panel {
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.run-platform-status {
border: 1.5px solid #E0E0E0;
border-radius: 4px;
padding: 16px;
transition: border-color 0.2s;
}
.run-platform-status.is-running { border-color: #FF5722; }
.run-platform-status.is-done { border-color: #4CAF50; }
.rps-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
color: #000;
margin-bottom: 12px;
}
.rps-badge-run { margin-left: auto; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: #FF5722; background: #FFF3E0; padding: 2px 6px; border-radius: 2px; }
.rps-badge-done { margin-left: auto; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: #2E7D32; background: #E8F5E9; padding: 2px 6px; border-radius: 2px; }
.rps-badge-idle { margin-left: auto; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: #999; background: #F5F5F5; padding: 2px 6px; border-radius: 2px; }
.rps-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
.rps-stat { display: flex; flex-direction: column; gap: 2px; }
.rps-stat-label { font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: #AAA; }
.rps-stat-value { font-size: 18px; font-weight: 700; color: #000; }
.rps-progress-track { height: 4px; background: #E8E8E8; border-radius: 2px; overflow: hidden; margin-bottom: 4px; }
.rps-progress-fill { height: 100%; background: #000; border-radius: 2px; transition: width 0.5s ease; }
.rps-progress-label { font-size: 10px; color: #888; text-align: right; }
.run-action-types {
border: 1.5px solid #EFEFEF;
border-radius: 4px;
padding: 12px;
}
.run-action-types-title { font-size: 9px; font-weight: 700; letter-spacing: 0.12em; color: #AAA; margin-bottom: 8px; }
.action-type-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid #F5F5F5;
font-size: 11px;
}
.action-type-name { color: #555; text-transform: uppercase; font-size: 10px; font-weight: 600; }
.action-type-count { color: #000; font-weight: 700; }
.no-actions-yet { font-size: 11px; color: #CCC; }
.run-controls { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
.btn-primary {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 22px;
background: #000;
color: #fff;
border: none;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover { background: #222; }
.btn-primary:disabled { background: #CCC; cursor: not-allowed; }
.btn-stop {
padding: 9px 18px;
background: #fff;
color: #C62828;
border: 1.5px solid #C62828;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.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;
}
.run-feed-panel {
border: 1.5px solid #EFEFEF;
border-radius: 4px;
overflow-y: auto;
display: flex;
flex-direction: column;
height: 200px;
flex-shrink: 0;
}
.feed-header {
padding: 10px 14px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.14em;
color: #AAA;
border-bottom: 1px solid #F0F0F0;
background: #FAFAFA;
position: sticky;
top: 0;
flex-shrink: 0;
}
.feed-list { flex: 1; padding: 8px 0; }
.feed-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
border-bottom: 1px solid #F7F7F7;
font-size: 11px;
}
.feed-round { color: #BBB; min-width: 36px; flex-shrink: 0; }
.feed-agent { color: #333; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-time { color: #CCC; flex-shrink: 0; font-size: 10px; }
.feed-action-type {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 2px 6px;
border-radius: 2px;
flex-shrink: 0;
}
.type-hostile { background: #FFEBEE; color: #C62828; }
.type-support { background: #E8F5E9; color: #2E7D32; }
.type-passive { background: #F5F5F5; color: #999; }
.type-neutral { background: #E3F2FD; color: #1565C0; }
.feed-empty { padding: 24px 14px; font-size: 12px; color: #CCC; }
.mono { font-family: 'JetBrains Mono', monospace; }
</style>

View File

@ -0,0 +1,169 @@
<template>
<div class="centered-panel">
<!-- Generating -->
<div v-if="isLoading" class="loading-block">
<div class="loading-ring"></div>
<p class="loading-label">Report Agent is analysing the simulation</p>
<p class="loading-hint">{{ reportProgress }}</p>
</div>
<!-- Report ready -->
<div v-else-if="reportResult" class="report-ready">
<div class="result-badge result-badge--ok">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12" />
</svg>
Report ready
</div>
<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 || ('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>
</div>
<div v-show="!collapsedSections.has(idx)" class="rs-body">
<p>{{ section.content }}</p>
</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="onExport">
Export .md
</button>
<button class="btn-secondary" @click="$emit('next')">
Talk to Agents
</button>
</div>
</div>
<!-- Error generating report -->
<div v-else class="error-placeholder">
<p>Report generation did not complete. Check logs and retry.</p>
<button class="btn-secondary" @click="$emit('retry')">Retry</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { exportReportMarkdown } from '../../utils/private.js'
const props = defineProps({
reportResult: { type: Object, default: null },
isLoading: { type: Boolean, default: false },
reportProgress: { type: String, default: '' },
simId: { type: String, default: null },
})
defineEmits(['retry', 'next'])
const collapsedSections = ref(new Set())
const toggleSection = (idx) => {
const s = new Set(collapsedSections.value)
s.has(idx) ? s.delete(idx) : s.add(idx)
collapsedSections.value = s
}
const onExport = () => {
exportReportMarkdown(props.reportResult, props.simId)
}
</script>
<style scoped>
.centered-panel {
max-width: 680px;
margin: 0 auto;
}
.loading-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 60px 0;
text-align: center;
}
.loading-ring {
width: 40px;
height: 40px;
border: 3px solid #E5E7EB;
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-label { font-size: 14px; font-weight: 600; color: #000; }
.loading-hint { font-size: 12px; color: #888; max-width: 400px; line-height: 1.5; }
.result-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
padding: 6px 12px;
border-radius: 2px;
}
.result-badge--ok { background: #E8F5E9; color: #2E7D32; }
.result-actions { display: flex; gap: 10px; }
.btn-secondary {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
background: #fff;
color: #000;
border: 1.5px solid #000;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
}
.btn-secondary:hover { background: #F5F5F5; }
.report-ready { display: flex; flex-direction: column; gap: 20px; padding: 20px 0; }
.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; }
.report-section { border-bottom: 1px solid #F0F0F0; }
.report-section:last-child { border-bottom: none; }
.rs-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
cursor: pointer;
background: #FAFAFA;
transition: background 0.12s;
}
.rs-header:hover { background: #F3F3F3; }
.rs-num { font-size: 11px; font-weight: 700; color: #CCC; min-width: 24px; }
.rs-title { flex: 1; font-size: 13px; font-weight: 600; color: #000; }
.rs-chevron { flex-shrink: 0; transition: transform 0.2s; transform: rotate(-90deg); }
.rs-chevron.is-open { transform: rotate(0deg); }
.rs-body { padding: 14px 16px 14px 52px; font-size: 13px; color: #444; line-height: 1.6; background: #fff; }
.error-placeholder { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 40px 0; font-size: 13px; color: #888; }
</style>

View File

@ -0,0 +1,290 @@
<template>
<div class="chat-layout">
<!-- Left: agent list -->
<div class="chat-agents-panel">
<div class="chat-agents-title">RELATIONAL AGENTS</div>
<div
v-for="agent in chatAgents"
:key="agent.agent_id"
class="chat-agent-item"
:class="{ 'is-selected': selectedAgentId === agent.agent_id }"
@click="selectedAgentId = agent.agent_id"
>
<div class="agent-avatar">{{ initials(agent.entity_name) }}</div>
<div class="agent-info">
<div class="agent-name">{{ agent.entity_name }}</div>
<div class="agent-type">{{ agent.relational_link_type }}</div>
</div>
<div class="agent-stance-dot" :class="'stance-' + agent.stance"></div>
</div>
<div v-if="chatAgents.length === 0" class="chat-agents-empty">Loading agents</div>
</div>
<!-- Right: chat -->
<div class="chat-main">
<div class="chat-messages" ref="chatMessagesEl">
<div v-if="!selectedAgentId" class="chat-placeholder">
Select an agent on the left to start a conversation.
</div>
<template v-else>
<div
v-for="(msg, idx) in currentMessages"
:key="idx"
class="chat-msg"
:class="msg.role === 'user' ? 'chat-msg--user' : 'chat-msg--agent'"
>
<div class="chat-msg-label">{{ msg.role === 'user' ? 'You' : selectedAgentName }}</div>
<div class="chat-msg-text">{{ msg.content }}</div>
</div>
<div v-if="isChatLoading" class="chat-msg chat-msg--agent">
<div class="chat-msg-label">{{ selectedAgentName }}</div>
<div class="chat-msg-text chat-thinking">
<span></span><span></span><span></span>
</div>
</div>
</template>
</div>
<div class="chat-input-row" v-if="selectedAgentId">
<textarea
class="chat-input"
v-model="chatInput"
placeholder="Ask this agent a question…"
rows="2"
@keydown.enter.exact.prevent="sendChat"
></textarea>
<button class="chat-send-btn" :disabled="!chatInput.trim() || isChatLoading" @click="sendChat">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, nextTick } from 'vue'
import { interviewAgents } from '../../api/simulation.js'
import { initials } from '../../utils/private.js'
const props = defineProps({
simId: { type: String, required: true },
chatAgents: { type: Array, required: true },
})
const selectedAgentId = ref(null)
const chatMessages = reactive({})
const chatInput = ref('')
const isChatLoading = ref(false)
const chatMessagesEl = ref(null)
const selectedAgentName = computed(() => {
const agent = props.chatAgents.find(a => a.agent_id === selectedAgentId.value)
return agent?.entity_name || `Agent ${selectedAgentId.value}`
})
const currentMessages = computed(() => {
return chatMessages[selectedAgentId.value] || []
})
const scrollChat = () => {
if (chatMessagesEl.value) {
chatMessagesEl.value.scrollTop = chatMessagesEl.value.scrollHeight
}
}
const sendChat = async () => {
if (!chatInput.value.trim() || !selectedAgentId.value || isChatLoading.value) return
const userMsg = chatInput.value.trim()
chatInput.value = ''
if (!chatMessages[selectedAgentId.value]) chatMessages[selectedAgentId.value] = []
chatMessages[selectedAgentId.value].push({ role: 'user', content: userMsg })
await nextTick()
scrollChat()
isChatLoading.value = true
try {
const history = chatMessages[selectedAgentId.value]
.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: props.simId,
interviews: [{
agent_id: selectedAgentId.value,
prompt,
}],
})
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}` })
} finally {
isChatLoading.value = false
await nextTick()
scrollChat()
}
}
</script>
<style scoped>
.chat-layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 16px;
height: calc(100vh - 172px);
}
.chat-agents-panel { border: 1.5px solid #EFEFEF; border-radius: 4px; overflow-y: auto; }
.chat-agents-title { padding: 10px 14px; font-size: 9px; font-weight: 700; letter-spacing: 0.14em; color: #AAA; border-bottom: 1px solid #F0F0F0; background: #FAFAFA; }
.chat-agent-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid #F5F5F5;
cursor: pointer;
transition: background 0.12s;
}
.chat-agent-item:hover { background: #F9F9F9; }
.chat-agent-item.is-selected { background: #F2F2F2; }
.agent-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #000;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.agent-info { flex: 1; min-width: 0; }
.agent-name { font-size: 12px; font-weight: 600; color: #000; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-type { font-size: 10px; color: #999; text-transform: capitalize; }
.agent-stance-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.stance-supportive { background: #4CAF50; }
.stance-opposing { background: #F44336; }
.stance-neutral { background: #9E9E9E; }
.stance-observer { background: #2196F3; }
.chat-agents-empty { padding: 20px 14px; font-size: 11px; color: #CCC; }
.chat-main { border: 1.5px solid #EFEFEF; border-radius: 4px; display: flex; flex-direction: column; }
.chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.chat-placeholder { font-size: 13px; color: #CCC; text-align: center; margin: auto; }
.chat-msg { display: flex; flex-direction: column; gap: 4px; max-width: 70%; }
.chat-msg--user { align-self: flex-end; }
.chat-msg--agent { align-self: flex-start; }
.chat-msg-label { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; color: #AAA; }
.chat-msg-text {
padding: 10px 14px;
border-radius: 4px;
font-size: 13px;
line-height: 1.5;
}
.chat-msg--user .chat-msg-text { background: #000; color: #fff; border-radius: 4px 4px 2px 4px; }
.chat-msg--agent .chat-msg-text { background: #F5F5F5; color: #000; border-radius: 4px 4px 4px 2px; }
.chat-thinking {
display: flex;
gap: 4px;
align-items: center;
padding: 12px 14px;
}
.chat-thinking span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #999;
animation: bounce 1s infinite;
}
.chat-thinking span:nth-child(2) { animation-delay: 0.2s; }
.chat-thinking span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 100% { transform: translateY(0); opacity: 0.5; }
50% { transform: translateY(-4px); opacity: 1; }
}
.chat-input-row {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid #EFEFEF;
align-items: flex-end;
}
.chat-input {
flex: 1;
border: 1.5px solid #E0E0E0;
border-radius: 3px;
padding: 8px 12px;
font-size: 13px;
font-family: inherit;
resize: none;
line-height: 1.4;
transition: border-color 0.15s;
}
.chat-input:focus { outline: none; border-color: #000; }
.chat-send-btn {
padding: 10px 14px;
background: #000;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s;
}
.chat-send-btn:hover { background: #222; }
.chat-send-btn:disabled { background: #CCC; cursor: not-allowed; }
</style>

View File

@ -0,0 +1,33 @@
export const RELATIONAL_TYPES = [
'ouvrier_production', 'technicien', 'commercial',
'manager', 'codir', 'client_externe', 'partenaire', 'concurrent',
]
export const RELATIONAL_TYPE_LABELS = {
ouvrier_production: 'Ouvrier / Production',
technicien: 'Technicien',
commercial: 'Commercial',
manager: 'Manager',
codir: 'CODIR',
client_externe: 'Client externe',
partenaire: 'Partenaire',
concurrent: 'Concurrent',
}
export 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)' },
]
export const ACTION_COLORS = {
CONFRONT: '#F44336',
COALITION_BUILD: '#FF9800',
VOCAL_SUPPORT: '#4CAF50',
SILENT_LEAVE: '#616161',
REACT_PRIVATELY: '#E0E0E0',
DO_NOTHING: '#E0E0E0',
}
export const STEP_NAMES = ['Requirement', 'Prepare', 'Run', 'Report', 'Interact']

View File

@ -0,0 +1,121 @@
export const shortTime = (ts) => {
if (!ts) return ''
try {
return new Date(ts).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch {
return ts.slice(11, 19) || ''
}
}
export const actionTypeClass = (type) => {
if (!type) return ''
const t = type.toLowerCase()
if (t.includes('confront') || t.includes('oppos')) return 'type-hostile'
if (t.includes('support') || t.includes('coalition')) return 'type-support'
if (t.includes('nothing') || t.includes('idle') || t.includes('react_privately')) return 'type-passive'
return 'type-neutral'
}
export const initials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
export const nodeColor = (actionType, ACTION_COLORS) => {
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'
}
export const buildRequirement = (form, agentCounts, RELATIONAL_TYPE_LABELS) => {
const parts = []
if (form.decisionMakerName) {
parts.push(`Decision maker: ${form.decisionMakerName}` +
(form.decisionMakerRole ? `${form.decisionMakerRole}` : '') +
(form.decisionMakerCompany ? ` at ${form.decisionMakerCompany}` : ''))
}
parts.push(`Decision: ${form.decisionText}`)
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')
}
export const parseImportedConfig = (text, form, agentCounts, RELATIONAL_TYPES, RELATIONAL_TYPE_LABELS) => {
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 */ }
}
}
export const exportReportMarkdown = (reportResult, simId) => {
if (!reportResult) return
let md = reportResult.markdown_content
if (!md) {
const title = reportResult.outline?.title || 'Private Impact Report'
const summary = reportResult.outline?.summary || ''
const sections = reportResult.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 || 'report'}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}

View File

@ -132,8 +132,8 @@
<span class="console-label">{{ $t('home.realitySeed') }}</span>
<span class="console-meta">{{ $t('home.supportedFormats') }}</span>
</div>
<div
<div
class="upload-zone"
:class="{ 'drag-over': isDragOver, 'has-files': files.length > 0 }"
@dragover.prevent="handleDragOver"
@ -150,13 +150,13 @@
style="display: none"
:disabled="loading"
/>
<div v-if="files.length === 0" class="upload-placeholder">
<div class="upload-icon"></div>
<div class="upload-title">{{ $t('home.dragToUpload') }}</div>
<div class="upload-hint">{{ $t('home.orBrowse') }}</div>
</div>
<div v-else class="file-list">
<div v-for="(file, index) in files" :key="index" class="file-item">
<span class="file-icon">📄</span>
@ -189,17 +189,13 @@
</div>
</div>
<!-- 启动按钮 -->
<div class="console-section btn-section">
<button
class="start-engine-btn"
@click="startSimulation"
<!-- Mode selector (CTA) -->
<div class="console-section mode-selector-section">
<ModeSelector
projectId="new"
:disabled="!canSubmit || loading"
>
<span v-if="!loading">{{ $t('home.startEngine') }}</span>
<span v-else>{{ $t('home.initializing') }}</span>
<span class="btn-arrow"></span>
</button>
@mode-selected="handleModeSelected"
/>
</div>
</div>
</div>
@ -213,11 +209,10 @@
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import HistoryDatabase from '../components/HistoryDatabase.vue'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
const router = useRouter()
import ModeSelector from '../components/ModeSelector.vue'
import { setPendingUpload } from '../store/pendingUpload.js'
//
const formData = ref({
@ -229,7 +224,6 @@ const files = ref([])
//
const loading = ref(false)
const error = ref('')
const isDragOver = ref(false)
//
@ -240,6 +234,14 @@ const canSubmit = computed(() => {
return formData.value.simulationRequirement.trim() !== '' && files.value.length > 0
})
// ModeSelector prend la main sur la navigation. Son emit `mode-selected` est
// synchrone et arrive AVANT son `router.push`, ce qui permet de stocker la
// pending upload avant que MainView ne la lise via getPendingUpload().
const handleModeSelected = () => {
if (!canSubmit.value || loading.value) return
setPendingUpload(files.value, formData.value.simulationRequirement)
}
//
const triggerFileInput = () => {
if (!loading.value) {
@ -294,21 +296,6 @@ const scrollToBottom = () => {
})
}
// - APIProcess
const startSimulation = () => {
if (!canSubmit.value || loading.value) return
//
import('../store/pendingUpload.js').then(({ setPendingUpload }) => {
setPendingUpload(files.value, formData.value.simulationRequirement)
// Process使
router.push({
name: 'Process',
params: { projectId: 'new' }
})
})
}
</script>
<style scoped>
@ -681,7 +668,7 @@ const startSimulation = () => {
padding: 20px;
}
.console-section.btn-section {
.console-section.mode-selector-section {
padding-top: 0;
}
@ -823,57 +810,6 @@ const startSimulation = () => {
color: #AAA;
}
.start-engine-btn {
width: 100%;
background: var(--black);
color: var(--white);
border: none;
padding: 20px;
font-family: var(--font-mono);
font-weight: 700;
font-size: 1.1rem;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.3s ease;
letter-spacing: 1px;
position: relative;
overflow: hidden;
}
/* 可点击状态(非禁用) */
.start-engine-btn:not(:disabled) {
background: var(--black);
border: 1px solid var(--black);
animation: pulse-border 2s infinite;
}
.start-engine-btn:hover:not(:disabled) {
background: var(--orange);
border-color: var(--orange);
transform: translateY(-2px);
}
.start-engine-btn:active:not(:disabled) {
transform: translateY(0);
}
.start-engine-btn:disabled {
background: #E5E5E5;
color: #999;
cursor: not-allowed;
transform: none;
border: 1px solid #E5E5E5;
}
/* 引导动画:微妙的边框脉冲 */
@keyframes pulse-border {
0% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.2); }
70% { box-shadow: 0 0 0 6px rgba(0, 0, 0, 0); }
100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
}
/* 响应式适配 */
@media (max-width: 1024px) {
.dashboard-section {

File diff suppressed because it is too large Load Diff

View File

@ -12,11 +12,11 @@ export default defineConfig({
}
},
server: {
port: 3000,
port: 9901,
open: true,
proxy: {
'/api': {
target: 'http://localhost:5001',
target: 'http://localhost:9902',
changeOrigin: true,
secure: false
}

View File

@ -77,6 +77,14 @@
"layoutWorkbench": "Workbench",
"stepNames": ["Graph Build", "Env Setup", "Run Simulation", "Report Generation", "Deep Interaction"]
},
"public": {
"stepNames": ["Graph Build", "Env Setup", "Run Simulation", "Report Generation", "Deep Interaction"],
"modeBadge": "PUBLIC OPINION"
},
"private": {
"stepNames": ["Requirement", "Prepare", "Run", "Report", "Interact"],
"modeBadge": "PRIVATE IMPACT"
},
"step1": {
"ontologyGeneration": "Ontology Generation",
"ontologyCompleted": "Completed",

View File

@ -77,6 +77,14 @@
"layoutWorkbench": "工作台",
"stepNames": ["图谱构建", "环境搭建", "开始模拟", "报告生成", "深度互动"]
},
"public": {
"stepNames": ["图谱构建", "环境搭建", "开始模拟", "报告生成", "深度互动"],
"modeBadge": "公共舆论"
},
"private": {
"stepNames": ["需求", "准备", "运行", "报告", "互动"],
"modeBadge": "私域影响"
},
"step1": {
"ontologyGeneration": "本体生成",
"ontologyCompleted": "已完成",