Merge a3f7189856 into 96096ea0ff
This commit is contained in:
commit
ca7b31b6c6
|
|
@ -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
|
||||
|
|
@ -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 1–2.
|
||||
- **À anticiper post-PR** : créer une route `/api/private-impact/chat/<sim_id>` dédiée pour finaliser le bug 4 (le runner privé n'a pas d'IPC de type `send_batch_interview`, donc un chat direct via `ReportAgent.chat` ou un LLM call sur le profil d'agent est sans doute plus pertinent).
|
||||
|
||||
---
|
||||
|
||||
### 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 2–5 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)
|
||||
29
Dockerfile
29
Dockerfile
|
|
@ -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"]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.0–1.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, 7–90, how many days until the network reaches equilibrium>,
|
||||
"rounds_per_day": <int, 2–4, typically 3: morning/noon/evening>,
|
||||
"reaction_delay_days_min": <int, 0–3>,
|
||||
"reaction_delay_days_max": <int, 1–14>,
|
||||
"reasoning": "<brief explanation>"
|
||||
}}
|
||||
|
||||
Guidelines:
|
||||
- Major organizational decisions: 30–60 days
|
||||
- Personal or family decisions: 7–21 days
|
||||
- Sudden crises: 7–14 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 — 2–4 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 1–3 agents (direct announcement recipients)
|
||||
- hot_topics: 3–6 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.0–1.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
|
||||
|
|
@ -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 (0–1).
|
||||
financial_sensitivity: Sensitivity to wealth signals (0–1).
|
||||
equity_tolerance: Tolerance for status disparities (0–1).
|
||||
institutional_loyalty: Loyalty to the org vs the person (0–1).
|
||||
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.0–1.0 (trust in decision maker)
|
||||
12. financial_sensitivity: Float 0.0–1.0 (sensitivity to wealth signals)
|
||||
13. equity_tolerance: Float 0.0–1.0 (tolerance for status disparities)
|
||||
14. institutional_loyalty: Float 0.0–1.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))
|
||||
|
|
@ -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.387–399):
|
||||
- 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.482–581):
|
||||
- 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.683–684):
|
||||
- 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)
|
||||
|
|
@ -126,6 +126,13 @@ class SimulationRunState:
|
|||
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,6 +509,7 @@ 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)
|
||||
|
|
@ -496,6 +519,7 @@ class SimulationRunner:
|
|||
|
||||
twitter_position = 0
|
||||
reddit_position = 0
|
||||
private_position = 0
|
||||
|
||||
try:
|
||||
while process.poll() is None: # 进程仍在运行
|
||||
|
|
@ -511,6 +535,12 @@ class SimulationRunner:
|
|||
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,6 +576,7 @@ class SimulationRunner:
|
|||
|
||||
state.twitter_running = False
|
||||
state.reddit_running = False
|
||||
state.private_running = False
|
||||
cls._save_run_state(state)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -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,6 +690,12 @@ 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:
|
||||
|
|
@ -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,6 +853,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()
|
||||
cls._save_run_state(state)
|
||||
|
||||
|
|
@ -935,6 +983,17 @@ class SimulationRunner:
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -259,9 +259,10 @@ class ZepEntityReader:
|
|||
# 只有默认标签,跳过
|
||||
continue
|
||||
|
||||
# 如果指定了预定义类型,检查是否匹配
|
||||
# 如果指定了预定义类型,检查是否匹配 (case-insensitive)
|
||||
if defined_entity_types:
|
||||
matching_labels = [l for l in custom_labels if l in defined_entity_types]
|
||||
defined_lower = {t.lower() for t in defined_entity_types}
|
||||
matching_labels = [l for l in custom_labels if l.lower() in defined_lower]
|
||||
if not matching_labels:
|
||||
continue
|
||||
entity_type = matching_labels[0]
|
||||
|
|
|
|||
|
|
@ -799,7 +799,7 @@ class ZepToolsService:
|
|||
filtered = []
|
||||
for node in all_nodes:
|
||||
# 检查labels是否包含指定类型
|
||||
if entity_type in node.labels:
|
||||
if any(lbl.lower() == entity_type.lower() for lbl in node.labels):
|
||||
filtered.append(node)
|
||||
|
||||
logger.info(t("console.foundEntitiesByType", count=len(filtered), type=entity_type))
|
||||
|
|
|
|||
|
|
@ -66,4 +66,11 @@ def t(key: str, **kwargs) -> str:
|
|||
def get_language_instruction() -> str:
|
||||
locale = get_locale()
|
||||
lang_config = _languages.get(locale, _languages.get('zh', {}))
|
||||
return lang_config.get('llmInstruction', '请使用中文回答。')
|
||||
base = lang_config.get('llmInstruction', '请使用中文回答。')
|
||||
override = (
|
||||
"LANGUAGE RULE (overrides everything else): "
|
||||
"Always respond in the same language as the simulation_requirement. "
|
||||
"If the simulation_requirement language is unclear, default to French. "
|
||||
"This rule takes precedence over any other language hint in this prompt."
|
||||
)
|
||||
return f"{base}\n\n{override}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# 启动服务
|
||||
|
|
|
|||
|
|
@ -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,6 +85,8 @@ 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
|
||||
|
||||
# 设置主日志
|
||||
|
|
@ -178,6 +181,12 @@ class SimulationLogManager:
|
|||
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
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
@ -217,6 +218,12 @@ const handleEnterEnvSetup = async () => {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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']
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 = () => {
|
|||
})
|
||||
}
|
||||
|
||||
// 开始模拟 - 立即跳转,API调用在Process页面进行
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
<template>
|
||||
<div class="main-view">
|
||||
<div class="main-view" :class="{ 'is-private-mode': isPrivateMode }">
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<div class="brand" @click="router.push('/')">MIROFISH</div>
|
||||
<div v-if="isPrivateMode" class="mode-badge">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" 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>
|
||||
{{ t('private.modeBadge') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
<div class="header-center" v-if="!isPrivateMode">
|
||||
<div class="view-switcher">
|
||||
<button
|
||||
v-for="mode in ['graph', 'split', 'workbench']"
|
||||
|
|
@ -24,8 +31,8 @@
|
|||
<LanguageSwitcher />
|
||||
<div class="step-divider"></div>
|
||||
<div class="workflow-step">
|
||||
<span class="step-num">Step {{ currentStep }}/5</span>
|
||||
<span class="step-name">{{ $tm('main.stepNames')[currentStep - 1] }}</span>
|
||||
<span class="step-num">Step {{ currentStep }}/{{ currentStepNames.length }}</span>
|
||||
<span class="step-name">{{ currentStepNames[currentStep - 1] }}</span>
|
||||
</div>
|
||||
<div class="step-divider"></div>
|
||||
<span class="status-indicator" :class="statusClass">
|
||||
|
|
@ -35,8 +42,10 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="content-area">
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
MODE PUBLIC — Graph + Step Panel
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<main v-if="!isPrivateMode" class="content-area">
|
||||
<!-- Left Panel: Graph -->
|
||||
<div class="panel-wrapper left" :style="leftPanelStyle">
|
||||
<GraphPanel
|
||||
|
|
@ -53,6 +62,7 @@
|
|||
<!-- Step 1: 图谱构建 -->
|
||||
<Step1GraphBuild
|
||||
v-if="currentStep === 1"
|
||||
mode="public"
|
||||
:currentPhase="currentPhase"
|
||||
:projectData="projectData"
|
||||
:ontologyProgress="ontologyProgress"
|
||||
|
|
@ -73,48 +83,259 @@
|
|||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
MODE PRIVATE — Bifurcation complète
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<template v-else>
|
||||
<!-- Step breadcrumb (private) -->
|
||||
<div class="steps-bar" v-if="currentStep >= 2">
|
||||
<div
|
||||
v-for="(name, idx) in privateBreadcrumb"
|
||||
:key="idx"
|
||||
class="step-node"
|
||||
:class="{
|
||||
'is-active': currentStep === idx + 2,
|
||||
'is-done': currentStep > idx + 2,
|
||||
}"
|
||||
@click="currentStep > idx + 2 ? goToPrivateStep(idx + 2) : null"
|
||||
>
|
||||
<div class="step-circle">
|
||||
<svg v-if="currentStep > idx + 2" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<span v-else>{{ idx + 1 }}</span>
|
||||
</div>
|
||||
<span class="step-node-name">{{ name }}</span>
|
||||
<div v-if="idx < privateBreadcrumb.length - 1" class="step-connector" :class="{ 'is-done': currentStep > idx + 2 }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error banner (private) -->
|
||||
<div v-if="privateError" class="error-banner">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
{{ privateError }}
|
||||
<button class="error-close" @click="privateError = null">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 — Graph build (commun, mode=private) -->
|
||||
<main v-if="currentStep === 1" class="content-area split-view">
|
||||
<div class="panel-wrapper left" style="width:50%;">
|
||||
<GraphPanel
|
||||
:graphData="graphData"
|
||||
:loading="graphLoading"
|
||||
:currentPhase="currentPhase"
|
||||
@refresh="refreshGraph"
|
||||
@toggle-maximize="toggleMaximize('graph')"
|
||||
/>
|
||||
</div>
|
||||
<div class="panel-wrapper right" style="width:50%;">
|
||||
<Step1GraphBuild
|
||||
mode="private"
|
||||
:currentPhase="currentPhase"
|
||||
:projectData="projectData"
|
||||
:ontologyProgress="ontologyProgress"
|
||||
:buildProgress="buildProgress"
|
||||
:graphData="graphData"
|
||||
:systemLogs="systemLogs"
|
||||
@next-step="handleNextStep"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Step 2 — Private Requirement (Decision form + Prepare results) -->
|
||||
<main v-else-if="currentStep === 2" class="content-area private-area">
|
||||
<div v-if="!privatePrepareReady">
|
||||
<Step2PrivateDecision
|
||||
:form="privateForm"
|
||||
:agentCounts="privateAgentCounts"
|
||||
:projectId="currentProjectId"
|
||||
:projectData="projectData"
|
||||
@prepare="runPrivatePrepare"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="centered-panel">
|
||||
<div v-if="privateIsLoading" class="loading-block">
|
||||
<div class="loading-ring"></div>
|
||||
<p class="loading-label">Generating relational profiles and behavioural parameters…</p>
|
||||
<p class="loading-hint">This may take a few seconds per agent. The LLM is building the simulation config.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="privatePrepareResult" class="prepare-results">
|
||||
<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>
|
||||
Simulation ready
|
||||
</div>
|
||||
|
||||
<div class="result-stats">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value mono">{{ privatePrepareResult.agent_count }}</span>
|
||||
<span class="stat-label">Agents generated</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value mono">{{ privateForm.horizonDays }}d</span>
|
||||
<span class="stat-label">Temporal horizon</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value mono">{{ privateForm.relationalTypes.length }}</span>
|
||||
<span class="stat-label">Relation types</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relation-tags">
|
||||
<span
|
||||
v-for="t in privateForm.relationalTypes"
|
||||
:key="t"
|
||||
class="relation-tag"
|
||||
>{{ t }}</span>
|
||||
</div>
|
||||
|
||||
<div class="sim-id-block">
|
||||
<span class="sim-id-label">SIM ID</span>
|
||||
<span class="sim-id-value mono">{{ privateSimId }}</span>
|
||||
</div>
|
||||
|
||||
<div class="result-actions">
|
||||
<button class="btn-secondary" @click="privatePrepareReady = false; privatePrepareResult = null">← Back</button>
|
||||
<button class="btn-primary" @click="runPrivateStart">
|
||||
Launch Simulation
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Step 3 — Private Sim Running -->
|
||||
<main v-else-if="currentStep === 3" class="content-area private-area">
|
||||
<Step3PrivateSim
|
||||
:simStatus="privateSimStatus"
|
||||
:recentActions="privateRecentActions"
|
||||
@stop="handlePrivateStop"
|
||||
@report="runPrivateReport"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<!-- Step 4 — Private Report -->
|
||||
<main v-else-if="currentStep === 4" class="content-area private-area">
|
||||
<Step4PrivateReport
|
||||
:reportResult="privateReportResult"
|
||||
:isLoading="privateIsLoading"
|
||||
:reportProgress="privateReportProgress"
|
||||
:simId="privateSimId"
|
||||
@retry="runPrivateReport"
|
||||
@next="goToPrivateStep(5)"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<!-- Step 5 — Private Interaction -->
|
||||
<main v-else-if="currentStep === 5" class="content-area private-area">
|
||||
<Step5PrivateInteraction
|
||||
:simId="privateSimId"
|
||||
:chatAgents="privateChatAgents"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import GraphPanel from '../components/GraphPanel.vue'
|
||||
import Step1GraphBuild from '../components/Step1GraphBuild.vue'
|
||||
import Step2EnvSetup from '../components/Step2EnvSetup.vue'
|
||||
import { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData } from '../api/graph'
|
||||
import { getPendingUpload, clearPendingUpload } from '../store/pendingUpload'
|
||||
import Step2PrivateDecision from '../components/private/Step2PrivateDecision.vue'
|
||||
import Step3PrivateSim from '../components/private/Step3PrivateSim.vue'
|
||||
import Step4PrivateReport from '../components/private/Step4PrivateReport.vue'
|
||||
import Step5PrivateInteraction from '../components/private/Step5PrivateInteraction.vue'
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
import { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData } from '../api/graph'
|
||||
import { getReport } from '../api/report.js'
|
||||
import {
|
||||
preparePrivateSimulation,
|
||||
startPrivateSimulation,
|
||||
getPrivateStatus,
|
||||
stopPrivateSimulation,
|
||||
getPrivateActions,
|
||||
generatePrivateReport,
|
||||
getPrivateReportStatus,
|
||||
} from '../api/private.js'
|
||||
import { getPendingUpload, clearPendingUpload } from '../store/pendingUpload'
|
||||
import { RELATIONAL_TYPE_LABELS } from '../constants/private.js'
|
||||
import { buildRequirement } from '../utils/private.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t, tm } = useI18n()
|
||||
|
||||
// Layout State
|
||||
const viewMode = ref('split') // graph | split | workbench
|
||||
// ── Mode detection ─────────────────────────────────────────────────────────
|
||||
const isPrivateMode = computed(() => route.query.mode === 'private')
|
||||
|
||||
// Step State
|
||||
const currentStep = ref(1) // 1: 图谱构建, 2: 环境搭建, 3: 开始模拟, 4: 报告生成, 5: 深度互动
|
||||
const stepNames = computed(() => tm('main.stepNames'))
|
||||
// ── Layout State ──────────────────────────────────────────────────────────
|
||||
const viewMode = ref('split')
|
||||
|
||||
// Data State
|
||||
// ── Step State ────────────────────────────────────────────────────────────
|
||||
const currentStep = ref(1)
|
||||
const publicStepNames = computed(() => tm('public.stepNames'))
|
||||
const privateStepNames = computed(() => tm('private.stepNames'))
|
||||
const privateBreadcrumb = computed(() => privateStepNames.value.slice(1))
|
||||
const currentStepNames = computed(() => isPrivateMode.value ? privateStepNames.value : publicStepNames.value)
|
||||
const stepNames = publicStepNames
|
||||
|
||||
// ── Data State (commun Step 1) ────────────────────────────────────────────
|
||||
const currentProjectId = ref(route.params.projectId)
|
||||
const loading = ref(false)
|
||||
const graphLoading = ref(false)
|
||||
const error = ref('')
|
||||
const projectData = ref(null)
|
||||
const graphData = ref(null)
|
||||
const currentPhase = ref(-1) // -1: Upload, 0: Ontology, 1: Build, 2: Complete
|
||||
const currentPhase = ref(-1)
|
||||
const ontologyProgress = ref(null)
|
||||
const buildProgress = ref(null)
|
||||
const systemLogs = ref([])
|
||||
|
||||
// Polling timers
|
||||
// Public polling timers
|
||||
let pollTimer = null
|
||||
let graphPollTimer = null
|
||||
|
||||
// --- Computed Layout Styles ---
|
||||
// ── Private State ─────────────────────────────────────────────────────────
|
||||
const privateSimId = ref(null)
|
||||
const privateSimStatus = ref(null)
|
||||
const privatePrepareResult = ref(null)
|
||||
const privatePrepareReady = ref(false)
|
||||
const privateReportResult = ref(null)
|
||||
const privateIsLoading = ref(false)
|
||||
const privateError = ref(null)
|
||||
const privateReportProgress = ref('')
|
||||
const privateRecentActions = ref([])
|
||||
const privateChatAgents = ref([])
|
||||
|
||||
const privateForm = reactive({
|
||||
decisionMakerName: '',
|
||||
decisionMakerRole: '',
|
||||
decisionMakerCompany: '',
|
||||
decisionText: '',
|
||||
decisionContext: '',
|
||||
relationalTypes: ['ouvrier_production', 'technicien', 'commercial', 'manager', 'codir'],
|
||||
horizonDays: 3,
|
||||
questionsToMeasure: '',
|
||||
})
|
||||
const privateAgentCounts = reactive({})
|
||||
|
||||
// Private polling timers
|
||||
let privatePollingTimer = null
|
||||
let privateReportPollingTimer = null
|
||||
|
||||
// ── Computed Layout Styles ────────────────────────────────────────────────
|
||||
const leftPanelStyle = computed(() => {
|
||||
if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
|
||||
if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }
|
||||
|
|
@ -127,14 +348,35 @@ const rightPanelStyle = computed(() => {
|
|||
return { width: '50%', opacity: 1, transform: 'translateX(0)' }
|
||||
})
|
||||
|
||||
// --- Status Computed ---
|
||||
// ── Status Computed ───────────────────────────────────────────────────────
|
||||
const statusClass = computed(() => {
|
||||
if (isPrivateMode.value) {
|
||||
const s = privateSimStatus.value?.runner_status
|
||||
if (s === 'running') return 'processing'
|
||||
if (s === 'completed') return 'completed'
|
||||
if (s === 'failed') return 'error'
|
||||
if (privateIsLoading.value) return 'processing'
|
||||
if (privateError.value || error.value) return 'error'
|
||||
if (currentPhase.value >= 2) return 'completed'
|
||||
return 'processing'
|
||||
}
|
||||
if (error.value) return 'error'
|
||||
if (currentPhase.value >= 2) return 'completed'
|
||||
return 'processing'
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isPrivateMode.value) {
|
||||
if (privateIsLoading.value) return 'Processing'
|
||||
const s = privateSimStatus.value?.runner_status
|
||||
if (s === 'running') return 'Running'
|
||||
if (s === 'completed') return 'Completed'
|
||||
if (s === 'failed') return 'Failed'
|
||||
if (currentPhase.value >= 2) return 'Ready'
|
||||
if (currentPhase.value === 1) return 'Building Graph'
|
||||
if (currentPhase.value === 0) return 'Generating Ontology'
|
||||
return 'Initializing'
|
||||
}
|
||||
if (error.value) return 'Error'
|
||||
if (currentPhase.value >= 2) return 'Ready'
|
||||
if (currentPhase.value === 1) return 'Building Graph'
|
||||
|
|
@ -142,32 +384,24 @@ const statusText = computed(() => {
|
|||
return 'Initializing'
|
||||
})
|
||||
|
||||
// --- Helpers ---
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
const addLog = (msg) => {
|
||||
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + '.' + new Date().getMilliseconds().toString().padStart(3, '0')
|
||||
systemLogs.value.push({ time, msg })
|
||||
// Keep last 100 logs
|
||||
if (systemLogs.value.length > 100) {
|
||||
systemLogs.value.shift()
|
||||
}
|
||||
if (systemLogs.value.length > 100) systemLogs.value.shift()
|
||||
}
|
||||
|
||||
// --- Layout Methods ---
|
||||
// ── Layout Methods ────────────────────────────────────────────────────────
|
||||
const toggleMaximize = (target) => {
|
||||
if (viewMode.value === target) {
|
||||
viewMode.value = 'split'
|
||||
} else {
|
||||
viewMode.value = target
|
||||
}
|
||||
if (viewMode.value === target) viewMode.value = 'split'
|
||||
else viewMode.value = target
|
||||
}
|
||||
|
||||
const handleNextStep = (params = {}) => {
|
||||
if (currentStep.value < 5) {
|
||||
currentStep.value++
|
||||
addLog(t('log.enterStep', { step: currentStep.value, name: stepNames.value[currentStep.value - 1] }))
|
||||
|
||||
// 如果是从 Step 2 进入 Step 3,记录模拟轮数配置
|
||||
if (currentStep.value === 3 && params.maxRounds) {
|
||||
addLog(t('log.enterStep', { step: currentStep.value, name: currentStepNames.value[currentStep.value - 1] }))
|
||||
if (!isPrivateMode.value && currentStep.value === 3 && params.maxRounds) {
|
||||
addLog(t('log.customSimRounds', { rounds: params.maxRounds }))
|
||||
}
|
||||
}
|
||||
|
|
@ -176,12 +410,15 @@ const handleNextStep = (params = {}) => {
|
|||
const handleGoBack = () => {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value--
|
||||
addLog(t('log.returnToStep', { step: currentStep.value, name: stepNames.value[currentStep.value - 1] }))
|
||||
addLog(t('log.returnToStep', { step: currentStep.value, name: currentStepNames.value[currentStep.value - 1] }))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Data Logic ---
|
||||
const goToPrivateStep = (n) => {
|
||||
currentStep.value = n
|
||||
}
|
||||
|
||||
// ── Data Logic (commune Step 1) ───────────────────────────────────────────
|
||||
const initProject = async () => {
|
||||
addLog('Project view initialized.')
|
||||
if (currentProjectId.value === 'new') {
|
||||
|
|
@ -215,7 +452,13 @@ const handleNewProject = async () => {
|
|||
currentProjectId.value = res.data.project_id
|
||||
projectData.value = res.data
|
||||
|
||||
router.replace({ name: 'Process', params: { projectId: res.data.project_id } })
|
||||
const queryMode = route.query.mode
|
||||
router.replace({
|
||||
name: 'Process',
|
||||
params: { projectId: res.data.project_id },
|
||||
query: queryMode ? { mode: queryMode } : {},
|
||||
})
|
||||
|
||||
ontologyProgress.value = null
|
||||
addLog(`Ontology generated successfully for project ${res.data.project_id}`)
|
||||
await startBuildGraph()
|
||||
|
|
@ -266,10 +509,10 @@ const loadProject = async () => {
|
|||
const updatePhaseByStatus = (status) => {
|
||||
switch (status) {
|
||||
case 'created':
|
||||
case 'ontology_generated': currentPhase.value = 0; break;
|
||||
case 'graph_building': currentPhase.value = 1; break;
|
||||
case 'graph_completed': currentPhase.value = 2; break;
|
||||
case 'failed': error.value = 'Project failed'; break;
|
||||
case 'ontology_generated': currentPhase.value = 0; break
|
||||
case 'graph_building': currentPhase.value = 1; break
|
||||
case 'graph_completed': currentPhase.value = 2; break
|
||||
case 'failed': error.value = 'Project failed'; break
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -302,7 +545,6 @@ const startGraphPolling = () => {
|
|||
|
||||
const fetchGraphData = async () => {
|
||||
try {
|
||||
// Refresh project info to check for graph_id
|
||||
const projRes = await getProject(currentProjectId.value)
|
||||
if (projRes.success && projRes.data.graph_id) {
|
||||
const gRes = await getGraphData(projRes.data.graph_id)
|
||||
|
|
@ -329,7 +571,6 @@ const pollTaskStatus = async (taskId) => {
|
|||
if (res.success) {
|
||||
const task = res.data
|
||||
|
||||
// Log progress message if it changed
|
||||
if (task.message && task.message !== buildProgress.value?.message) {
|
||||
addLog(task.message)
|
||||
}
|
||||
|
|
@ -339,14 +580,13 @@ const pollTaskStatus = async (taskId) => {
|
|||
if (task.status === 'completed') {
|
||||
addLog('Graph build task completed.')
|
||||
stopPolling()
|
||||
stopGraphPolling() // Stop polling, do final load
|
||||
stopGraphPolling()
|
||||
currentPhase.value = 2
|
||||
|
||||
// Final load
|
||||
const projRes = await getProject(currentProjectId.value)
|
||||
if (projRes.success && projRes.data.graph_id) {
|
||||
projectData.value = projRes.data
|
||||
await loadGraph(projRes.data.graph_id)
|
||||
projectData.value = projRes.data
|
||||
await loadGraph(projRes.data.graph_id)
|
||||
}
|
||||
} else if (task.status === 'failed') {
|
||||
stopPolling()
|
||||
|
|
@ -385,27 +625,218 @@ const refreshGraph = () => {
|
|||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
const stopGraphPolling = () => {
|
||||
if (graphPollTimer) {
|
||||
clearInterval(graphPollTimer)
|
||||
graphPollTimer = null
|
||||
addLog('Graph polling stopped.')
|
||||
if (graphPollTimer) { clearInterval(graphPollTimer); graphPollTimer = null; addLog('Graph polling stopped.') }
|
||||
}
|
||||
|
||||
// ── Private Flow ──────────────────────────────────────────────────────────
|
||||
const runPrivatePrepare = async () => {
|
||||
if (!projectData.value?.graph_id) {
|
||||
privateError.value = 'No graph_id found for this project. Build the graph first.'
|
||||
return
|
||||
}
|
||||
privateError.value = null
|
||||
privateIsLoading.value = true
|
||||
privatePrepareReady.value = true
|
||||
|
||||
try {
|
||||
const res = await preparePrivateSimulation({
|
||||
graph_id: projectData.value.graph_id,
|
||||
simulation_requirement: buildRequirement(privateForm, privateAgentCounts, RELATIONAL_TYPE_LABELS),
|
||||
decision_context: privateForm.decisionContext,
|
||||
entity_types: privateForm.relationalTypes,
|
||||
})
|
||||
privateSimId.value = res.data.sim_id
|
||||
privatePrepareResult.value = res.data
|
||||
} catch (e) {
|
||||
privateError.value = `Prepare failed: ${e.message}`
|
||||
privatePrepareReady.value = false
|
||||
} finally {
|
||||
privateIsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const runPrivateStart = async () => {
|
||||
privateError.value = null
|
||||
privateIsLoading.value = true
|
||||
currentStep.value = 3
|
||||
|
||||
try {
|
||||
await startPrivateSimulation({ sim_id: privateSimId.value })
|
||||
startPrivatePolling()
|
||||
} catch (e) {
|
||||
privateError.value = `Start failed: ${e.message}`
|
||||
currentStep.value = 2
|
||||
} finally {
|
||||
privateIsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startPrivatePolling = () => {
|
||||
privatePollingTimer = setInterval(pollPrivateStatus, 3000)
|
||||
pollPrivateStatus()
|
||||
}
|
||||
|
||||
const stopPrivatePolling = () => {
|
||||
if (privatePollingTimer) { clearInterval(privatePollingTimer); privatePollingTimer = null }
|
||||
}
|
||||
|
||||
const pollPrivateStatus = async () => {
|
||||
if (!privateSimId.value) return
|
||||
try {
|
||||
const res = await getPrivateStatus(privateSimId.value)
|
||||
privateSimStatus.value = res.data
|
||||
privateRecentActions.value = res.data.recent_actions || []
|
||||
|
||||
const status = res.data.runner_status
|
||||
if (status === 'completed' || status === 'stopped' || status === 'failed') {
|
||||
stopPrivatePolling()
|
||||
try {
|
||||
const actRes = await getPrivateActions(privateSimId.value)
|
||||
privateRecentActions.value = actRes.data || []
|
||||
} catch { /* keep last */ }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Private status poll error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrivateStop = async () => {
|
||||
try {
|
||||
stopPrivatePolling()
|
||||
await stopPrivateSimulation(privateSimId.value)
|
||||
const res = await getPrivateStatus(privateSimId.value)
|
||||
privateSimStatus.value = res.data
|
||||
} catch (e) {
|
||||
privateError.value = `Stop failed: ${e.message}`
|
||||
}
|
||||
}
|
||||
|
||||
const runPrivateReport = async () => {
|
||||
privateError.value = null
|
||||
privateIsLoading.value = true
|
||||
privateReportProgress.value = 'Initialising Report Agent…'
|
||||
currentStep.value = 4
|
||||
|
||||
try {
|
||||
const res = await generatePrivateReport(privateSimId.value)
|
||||
const reportId = res.data.report_id
|
||||
const taskId = res.data.task_id
|
||||
|
||||
if (res.data.already_generated) {
|
||||
const fullRes = await getReport(reportId)
|
||||
privateReportResult.value = fullRes.data
|
||||
privateIsLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
startPrivateReportPolling(taskId, reportId)
|
||||
} catch (e) {
|
||||
privateError.value = `Report trigger failed: ${e.message}`
|
||||
privateIsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startPrivateReportPolling = (taskId, reportId) => {
|
||||
privateReportPollingTimer = setInterval(() => pollPrivateReport(taskId, reportId), 4000)
|
||||
pollPrivateReport(taskId, reportId)
|
||||
}
|
||||
|
||||
const stopPrivateReportPolling = () => {
|
||||
if (privateReportPollingTimer) { clearInterval(privateReportPollingTimer); privateReportPollingTimer = null }
|
||||
}
|
||||
|
||||
const pollPrivateReport = async (taskId, reportId) => {
|
||||
try {
|
||||
const res = await getPrivateReportStatus(taskId)
|
||||
const status = res.data?.status
|
||||
privateReportProgress.value = res.data?.message || 'Generating…'
|
||||
|
||||
if (status === 'completed') {
|
||||
stopPrivateReportPolling()
|
||||
const finalReportId = res.data?.result?.report_id || reportId
|
||||
const fullRes = await getReport(finalReportId)
|
||||
privateReportResult.value = fullRes.data
|
||||
privateIsLoading.value = false
|
||||
} else if (status === 'failed') {
|
||||
stopPrivateReportPolling()
|
||||
privateError.value = `Report failed: ${res.data?.error || res.data?.message}`
|
||||
privateIsLoading.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Private report poll error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadPrivateChatAgents = async () => {
|
||||
try {
|
||||
const res = await getPrivateActions(privateSimId.value)
|
||||
const agentMap = {}
|
||||
for (const action of (res.data || [])) {
|
||||
if (!agentMap[action.agent_id]) {
|
||||
agentMap[action.agent_id] = {
|
||||
agent_id: action.agent_id,
|
||||
entity_name: action.agent_name || `Agent ${action.agent_id}`,
|
||||
relational_link_type: action.action_args?.relational_link_type || '',
|
||||
stance: action.action_args?.stance || 'neutral',
|
||||
}
|
||||
}
|
||||
}
|
||||
privateChatAgents.value = Object.values(agentMap)
|
||||
} catch (e) {
|
||||
console.error('Could not load agents:', e)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => currentStep.value, (step) => {
|
||||
if (isPrivateMode.value && step === 5 && privateChatAgents.value.length === 0) {
|
||||
loadPrivateChatAgents()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Timer Cleanup ─────────────────────────────────────────────────────────
|
||||
const cleanupPublicTimers = () => {
|
||||
stopPolling()
|
||||
stopGraphPolling()
|
||||
}
|
||||
|
||||
const cleanupPrivateTimers = () => {
|
||||
stopPrivatePolling()
|
||||
stopPrivateReportPolling()
|
||||
}
|
||||
|
||||
const cleanupAllTimers = () => {
|
||||
cleanupPublicTimers()
|
||||
cleanupPrivateTimers()
|
||||
}
|
||||
|
||||
// ── Mode watcher (reset transient state when mode changes) ────────────────
|
||||
watch(isPrivateMode, () => {
|
||||
currentStep.value = 1
|
||||
privatePrepareReady.value = false
|
||||
cleanupPrivateTimers()
|
||||
})
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
initProject()
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
cleanupAllTimers()
|
||||
})
|
||||
|
||||
onBeforeRouteUpdate((to, from) => {
|
||||
if (to.params.projectId !== from.params.projectId) {
|
||||
cleanupAllTimers()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
stopGraphPolling()
|
||||
cleanupAllTimers()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -432,6 +863,12 @@ onUnmounted(() => {
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
|
@ -446,6 +883,19 @@ onUnmounted(() => {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.view-switcher {
|
||||
display: flex;
|
||||
background: #F5F5F5;
|
||||
|
|
@ -532,6 +982,10 @@ onUnmounted(() => {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-area.split-view {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.panel-wrapper {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
|
@ -542,4 +996,201 @@ onUnmounted(() => {
|
|||
.panel-wrapper.left {
|
||||
border-right: 1px solid #EAEAEA;
|
||||
}
|
||||
|
||||
/* ── Private area ─────────────────────────────────────────────────────── */
|
||||
.private-area {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
/* Steps bar (private) */
|
||||
.steps-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 32px;
|
||||
border-bottom: 1px solid #EFEFEF;
|
||||
background: #FAFAFA;
|
||||
flex-shrink: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.step-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step-circle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid #D0D0D0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.step-node.is-active .step-circle { border-color: #000; background: #000; color: #fff; }
|
||||
.step-node.is-done .step-circle { border-color: #4CAF50; background: #4CAF50; color: #fff; }
|
||||
|
||||
.step-node-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #AAA;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-node.is-active .step-node-name { color: #000; }
|
||||
.step-node.is-done { cursor: pointer; }
|
||||
.step-node.is-done .step-node-name { color: #555; }
|
||||
|
||||
.step-connector {
|
||||
width: 32px;
|
||||
height: 1.5px;
|
||||
background: #E0E0E0;
|
||||
margin: 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-connector.is-done { background: #4CAF50; }
|
||||
|
||||
/* Error banner */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 32px;
|
||||
background: #FFF3F3;
|
||||
border-bottom: 1px solid #FFCDD2;
|
||||
font-size: 12px;
|
||||
color: #C62828;
|
||||
}
|
||||
|
||||
.error-close {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: #C62828;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* Private centered panel (step 2 after prepare) */
|
||||
.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; }
|
||||
|
||||
.prepare-results { display: flex; flex-direction: column; gap: 20px; padding: 20px 0; }
|
||||
|
||||
.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-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1.5px solid #E8E8E8;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #000; }
|
||||
.stat-label { font-size: 10px; font-weight: 600; letter-spacing: 0.1em; color: #888; text-transform: uppercase; }
|
||||
|
||||
.relation-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.relation-tag { font-size: 11px; padding: 3px 8px; background: #F0F0F0; border-radius: 2px; color: #444; font-weight: 500; text-transform: capitalize; }
|
||||
|
||||
.sim-id-block { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: #F7F7F7; border-radius: 3px; }
|
||||
.sim-id-label { font-size: 10px; font-weight: 700; letter-spacing: 0.1em; color: #999; }
|
||||
.sim-id-value { font-size: 12px; color: #333; }
|
||||
|
||||
.result-actions { display: flex; gap: 10px; }
|
||||
|
||||
.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-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; }
|
||||
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,14 @@
|
|||
"layoutWorkbench": "工作台",
|
||||
"stepNames": ["图谱构建", "环境搭建", "开始模拟", "报告生成", "深度互动"]
|
||||
},
|
||||
"public": {
|
||||
"stepNames": ["图谱构建", "环境搭建", "开始模拟", "报告生成", "深度互动"],
|
||||
"modeBadge": "公共舆论"
|
||||
},
|
||||
"private": {
|
||||
"stepNames": ["需求", "准备", "运行", "报告", "互动"],
|
||||
"modeBadge": "私域影响"
|
||||
},
|
||||
"step1": {
|
||||
"ontologyGeneration": "本体生成",
|
||||
"ontologyCompleted": "已完成",
|
||||
|
|
|
|||
Loading…
Reference in New Issue