feat(f2a-b): step02/03 UX overhaul, Azure OpenAI fix, offline report interviews

Step 02/03 UX:
- Remove duplicate simulation params (total_hours, minutes_per_round) from Fase B form
- Add following_probability + recsys_type to step 03 config panel instead
- Step 03 now read-only by default with Edit / Save toolbar toggle
- Add common.edit and log.configSaved i18n keys to all 4 locales

Backend - Azure OpenAI fix (run_parallel_simulation.py):
- create_model() now detects Azure URLs (cognitiveservices.azure.com / openai.azure.com)
  and uses ModelPlatformType.AZURE with AzureOpenAIModel, passing api_version and
  azure_deployment_name explicitly — fixes 404 "Resource not found" from camel-ai

Backend - offline report interviews (simulation_runner.py):
- interview_agents_batch() falls back to _offline_batch_interview() when OASIS env is
  closed (simulation finished), reading posts from SQLite DB instead of failing with
  "No successful interviews / check OASIS environment status"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-05-04 23:20:41 +00:00
parent 34962edc20
commit 2aa44760d1
19 changed files with 3910 additions and 672 deletions

View File

@ -49,12 +49,12 @@ EXPOSE 5001
# gunicorn: 1 worker (Container Apps escala horitzontalment via rèpliques)
# threads=4 per gestionar concurrència sense multiprocessing
# timeout=120s per a les operacions LLM de llarga durada
# timeout=300s per cobrir interview/all (timeout IPC 180s) + marge
CMD ["backend/.venv/bin/gunicorn", \
"--bind", "0.0.0.0:5001", \
"--workers", "1", \
"--threads", "4", \
"--timeout", "120", \
"--timeout", "300", \
"--worker-class", "gthread", \
"--chdir", "/app/backend", \
"wsgi:application"]

View File

@ -46,6 +46,31 @@ def optimize_interview_prompt(prompt: str) -> str:
# ============== Entity retrieval endpoints ==============
@simulation_bp.route('/entities/<graph_id>/count', methods=['GET'])
def get_graph_entity_count(graph_id: str):
"""
Fast entity count for a graph returns only the filtered_count,
no entity data or edge enrichment.
"""
try:
reader = ZepEntityReader()
result = reader.filter_defined_entities(
graph_id=graph_id,
defined_entity_types=None,
enrich_with_edges=False
)
return jsonify({
"success": True,
"data": {
"filtered_count": result.filtered_count,
"entity_types": list(result.entity_types),
}
})
except Exception as e:
logger.error(f"Failed to get entity count for graph {graph_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@simulation_bp.route('/entities/<graph_id>', methods=['GET'])
def get_graph_entities(graph_id: str):
"""
@ -245,14 +270,32 @@ def _check_simulation_prepared(simulation_id: str) -> tuple:
if not os.path.exists(simulation_dir):
return False, {"reason": "Simulation directory does not exist"}
# Required file list (scripts excluded; scripts are in backend/scripts/)
required_files = [
"state.json",
"simulation_config.json",
"reddit_profiles.json",
"twitter_profiles.csv"
]
# Read state.json first to know which files are required.
# profiles_ready = agents done but config not yet generated (Fase A waiting for user).
# All other active statuses require simulation_config.json too.
state_file = os.path.join(simulation_dir, "state.json")
if not os.path.exists(state_file):
return False, {"reason": "Missing state.json"}
import json
try:
with open(state_file, 'r', encoding='utf-8') as _f:
_state_preview = json.load(_f)
_preview_status = _state_preview.get("status", "")
except Exception:
_preview_status = ""
profiles_only_statuses = {"profiles_ready"}
if _preview_status in profiles_only_statuses:
required_files = ["state.json", "reddit_profiles.json"]
else:
required_files = [
"state.json",
"simulation_config.json",
"reddit_profiles.json",
"twitter_profiles.csv"
]
# Check if files exist
existing_files = []
missing_files = []
@ -262,7 +305,7 @@ def _check_simulation_prepared(simulation_id: str) -> tuple:
existing_files.append(f)
else:
missing_files.append(f)
if missing_files:
return False, {
"reason": "Missing required files",
@ -270,14 +313,10 @@ def _check_simulation_prepared(simulation_id: str) -> tuple:
"existing_files": existing_files
}
# Check status in state.json
state_file = os.path.join(simulation_dir, "state.json")
# Check status in state.json (already read above; reuse to avoid double read)
try:
import json
with open(state_file, 'r', encoding='utf-8') as f:
state_data = json.load(f)
status = state_data.get("status", "")
state_data = _state_preview
status = _preview_status
config_generated = state_data.get("config_generated", False)
# Detailed log
@ -291,8 +330,10 @@ def _check_simulation_prepared(simulation_id: str) -> tuple:
# - completed: run finished, meaning preparation completed long ago
# - stopped: stopped, meaning preparation completed long ago
# - failed: run failed (but preparation was complete)
# profiles_ready = agents generated, awaiting user confirmation (config not yet generated)
# all other statuses require config_generated=True to be considered fully prepared
prepared_statuses = ["ready", "preparing", "running", "completed", "stopped", "failed"]
if status in prepared_statuses and config_generated:
if status == "profiles_ready" or (status in prepared_statuses and config_generated):
# Get file statistics
profiles_file = os.path.join(simulation_dir, "reddit_profiles.json")
config_file = os.path.join(simulation_dir, "simulation_config.json")
@ -415,11 +456,14 @@ def prepare_simulation():
logger.debug(f"Check result: is_prepared={is_prepared}, prepare_info={prepare_info}")
if is_prepared:
logger.info(f"Simulation {simulation_id} is already prepared; skipping regeneration")
# Return the actual simulation status so the frontend can decide
# whether to show Fase A (profiles_ready) or skip it (ready).
actual_status = state.status.value if hasattr(state.status, 'value') else str(state.status)
return jsonify({
"success": True,
"data": {
"simulation_id": simulation_id,
"status": "ready",
"status": actual_status,
"message": t('api.alreadyPrepared'),
"already_prepared": True,
"prepare_info": prepare_info
@ -468,10 +512,11 @@ def prepare_simulation():
defined_entity_types=entity_types_list,
enrich_with_edges=False # Skip edge info to speed things up
)
# Save entity count to state (so frontend can fetch it immediately)
state.entities_count = filtered_preview.filtered_count
# Save entity count to state — capped by max_agents if provided
raw_count = filtered_preview.filtered_count
state.entities_count = min(raw_count, max_agents) if max_agents else raw_count
state.entity_types = list(filtered_preview.entity_types)
logger.info(f"Expected entity count: {filtered_preview.filtered_count}, types: {filtered_preview.entity_types}")
logger.info(f"Expected entity count: {state.entities_count} (raw={raw_count}, max_agents={max_agents})")
except Exception as e:
logger.warning(f"Failed to synchronously fetch entity count (will retry in background task): {e}")
# Failure does not block the rest of the flow; the background task will retry.
@ -669,18 +714,21 @@ def get_prepare_status():
if simulation_id:
is_prepared, prepare_info = _check_simulation_prepared(simulation_id)
if is_prepared:
sim_state = SimulationManager().get_simulation(simulation_id)
actual_status = sim_state.status.value if sim_state and hasattr(sim_state.status, 'value') else "ready"
return jsonify({
"success": True,
"data": {
"simulation_id": simulation_id,
"status": "ready",
"status": actual_status,
"simulation_status": actual_status, # mirror so frontend pollPrepareStatus can detect profiles_ready
"progress": 100,
"message": t('api.alreadyPrepared'),
"already_prepared": True,
"prepare_info": prepare_info
}
})
# If no task_id, return error
if not task_id:
if simulation_id:
@ -708,12 +756,14 @@ def get_prepare_status():
if simulation_id:
is_prepared, prepare_info = _check_simulation_prepared(simulation_id)
if is_prepared:
sim_state = SimulationManager().get_simulation(simulation_id)
actual_status = sim_state.status.value if sim_state and hasattr(sim_state.status, 'value') else "ready"
return jsonify({
"success": True,
"data": {
"simulation_id": simulation_id,
"task_id": task_id,
"status": "ready",
"status": actual_status,
"progress": 100,
"message": t('api.taskCompletedPrepared'),
"already_prepared": True,
@ -728,7 +778,15 @@ def get_prepare_status():
task_dict = task
task_dict["already_prepared"] = False
# Add the real simulation status alongside the task status so the frontend
# can show the correct phase without altering task.status (which is used by
# pollTaskUntilDone to detect completion).
if simulation_id:
sim_state = SimulationManager().get_simulation(simulation_id)
if sim_state:
task_dict["simulation_status"] = sim_state.status.value if hasattr(sim_state.status, 'value') else str(sim_state.status)
return jsonify({
"success": True,
"data": task_dict
@ -2831,6 +2889,7 @@ def generate_config_endpoint(simulation_id: str):
current_locale = get_locale()
def run_generate_config():
import json as _json
set_locale(current_locale)
try:
task_manager.update_task(task_id, status=TaskStatus.PROCESSING, progress=0,
@ -2839,9 +2898,9 @@ def generate_config_endpoint(simulation_id: str):
sim_dir = manager._get_simulation_dir(simulation_id)
profiles_file = os.path.join(sim_dir, "reddit_profiles.json")
with open(profiles_file, 'r', encoding='utf-8') as f:
profiles = json.load(f)
profiles = _json.load(f)
from ..services.zep_entity_reader import ZepEntityReader
from ..services.zep_entity_reader import ZepEntityReader, EntityNode
entity_nodes = []
reader = ZepEntityReader()
for p in profiles:
@ -2854,17 +2913,41 @@ def generate_config_endpoint(simulation_id: str):
except Exception:
pass
gen = SimulationConfigGenerator(graph_id=state.graph_id)
params = gen.generate_simulation_parameters(
# If Zep lookup yielded nothing, synthesize minimal EntityNodes from profiles
# so the config generator can still produce per-agent configs.
if not entity_nodes:
for p in profiles:
user_id = p.get("user_id", 0)
entity_nodes.append(EntityNode(
uuid=p.get("source_entity_uuid") or f"profile_{user_id}",
name=p.get("name") or p.get("username") or f"agent_{user_id}",
labels=[p.get("profession") or "Person"],
summary=p.get("bio") or "",
attributes={
"age": p.get("age"),
"gender": p.get("gender"),
"country": p.get("country"),
"profession": p.get("profession"),
"mbti": p.get("mbti"),
"stance": p.get("stance"),
},
))
gen = SimulationConfigGenerator()
params = gen.generate_config(
simulation_id=simulation_id,
project_id=state.project_id,
graph_id=state.graph_id,
simulation_requirement=simulation_requirement,
document_text=document_text,
entities=entity_nodes,
enable_twitter=state.enable_twitter,
enable_reddit=state.enable_reddit,
)
config_data = params.to_dict() if hasattr(params, 'to_dict') else {}
config_file = os.path.join(sim_dir, "simulation_config.json")
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(config_data, f, ensure_ascii=False, indent=2)
f.write(params.to_json())
state2 = manager.get_simulation(simulation_id)
if state2:
@ -3100,10 +3183,8 @@ def regenerate_agent(simulation_id: str, user_id: int):
if agent_entry is None:
return jsonify({"success": False, "error": t('api.agentNotFound', user_id=user_id)}), 404
source_entity_uuid = agent_entry.get("source_entity_uuid")
if not source_entity_uuid:
return jsonify({"success": False, "error": t('api.agentNoSourceEntity')}), 400
# source_entity_uuid may be None for profiles generated before this field was persisted;
# the background task will fall back to searching by agent name.
task_manager = TaskManager()
task_id = task_manager.create_task(
task_type="regenerate_agent",
@ -3134,14 +3215,27 @@ def regenerate_agent(simulation_id: str, user_id: int):
raise LookupError(f"Agent with user_id {user_id} not found")
source_entity_uuid = agent.get("source_entity_uuid")
if not source_entity_uuid:
raise ValueError(f"Agent {user_id} has no source_entity_uuid — cannot regenerate")
# Fetch entity
reader = ZepEntityReader()
entity = reader.get_entity_with_context(state.graph_id, source_entity_uuid)
entity = None
if source_entity_uuid:
entity = reader.get_entity_with_context(state.graph_id, source_entity_uuid)
# Fallback: search by agent name when UUID is missing (profiles generated before
# source_entity_uuid was persisted in to_reddit_format).
if not entity:
raise ValueError(f"Entity not found: {source_entity_uuid}")
agent_name = agent.get("name") or agent.get("username") or ""
if agent_name:
all_entities_result = reader.filter_defined_entities(
graph_id=state.graph_id,
defined_entity_types=None,
enrich_with_edges=True
)
for e in (all_entities_result.entities or []):
if e.name and agent_name.lower() in e.name.lower():
entity = e
break
if not entity:
raise ValueError(f"Entity not found for agent {user_id} (uuid={source_entity_uuid!r}, name={agent.get('name')!r})")
# Generate new profile
gen = OasisProfileGenerator(graph_id=state.graph_id)
@ -3184,3 +3278,16 @@ def regenerate_agent(simulation_id: str, user_id: int):
except Exception as e:
logger.error(f"regenerate_agent endpoint error: {e}")
return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500
@simulation_bp.route('/task/<task_id>', methods=['GET'])
def get_task_status(task_id: str):
"""Generic task status endpoint for polling any simulation-related async task."""
from ..models.task import TaskManager
try:
task = TaskManager().get_task(task_id)
if not task:
return jsonify({"success": False, "error": t('api.taskNotFound', id=task_id)}), 404
return jsonify({"success": True, "data": task})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500

View File

@ -99,9 +99,13 @@ class OasisAgentProfile:
profile["profession"] = self.profession
if self.interested_topics:
profile["interested_topics"] = self.interested_topics
if self.source_entity_uuid:
profile["source_entity_uuid"] = self.source_entity_uuid
if self.source_entity_type:
profile["source_entity_type"] = self.source_entity_type
return profile
def to_twitter_format(self) -> Dict[str, Any]:
"""Convert to Twitter platform format"""
profile = {

View File

@ -412,65 +412,11 @@ class SimulationManager:
total=len(profiles)
)
# ========== Stage 3: LLM intelligent simulation configuration generation ==========
if progress_callback:
progress_callback(
"generating_config", 0,
t('progress.analyzingRequirements'),
current=0,
total=3
)
# Note: config generation (Stage 3) is intentionally NOT done here.
# It happens in generate_config_endpoint (Fase B) after the user reviews agents.
config_generator = SimulationConfigGenerator()
if progress_callback:
progress_callback(
"generating_config", 30,
t('progress.callingLLMConfig'),
current=1,
total=3
)
sim_params = config_generator.generate_config(
simulation_id=simulation_id,
project_id=state.project_id,
graph_id=state.graph_id,
simulation_requirement=simulation_requirement,
document_text=document_text,
entities=filtered.entities,
enable_twitter=state.enable_twitter,
enable_reddit=state.enable_reddit
)
if progress_callback:
progress_callback(
"generating_config", 70,
t('progress.savingConfigFiles'),
current=2,
total=3
)
# Save configuration file
config_path = os.path.join(sim_dir, "simulation_config.json")
with open(config_path, 'w', encoding='utf-8') as f:
f.write(sim_params.to_json())
state.config_generated = True
state.config_reasoning = sim_params.generation_reasoning
if progress_callback:
progress_callback(
"generating_config", 100,
t('progress.configComplete'),
current=3,
total=3
)
# Note: run scripts remain in backend/scripts/; they are not copied to the simulation directory.
# When starting a simulation, simulation_runner runs scripts from the scripts/ directory.
# Update status
state.status = SimulationStatus.READY
# Update status — wait for user confirmation (Fase A) before config generation
state.status = SimulationStatus.PROFILES_READY
self._save_simulation_state(state)
logger.info(f"Simulation preparation complete: {simulation_id}, "

View File

@ -1525,7 +1525,9 @@ class SimulationRunner:
ipc_client = SimulationIPCClient(sim_dir)
if not ipc_client.check_env_alive():
raise ValueError(f"Simulation environment is not running or has been closed; cannot interview: {simulation_id}")
# Env closed (simulation finished): build synthetic interview responses from DB posts
logger.info(f"Env not alive for {simulation_id}; building offline interview responses from DB posts")
return cls._offline_batch_interview(sim_dir, interviews, platform)
logger.info(f"Sending batch Interview command: simulation_id={simulation_id}, count={len(interviews)}, platform={platform}")
@ -1659,6 +1661,80 @@ class SimulationRunner:
"message": "Close-environment command sent (timed out waiting for response; environment may be closing)"
}
@classmethod
@classmethod
def _offline_batch_interview(
cls,
sim_dir: str,
interviews: List[Dict[str, Any]],
platform: Optional[str] = None,
) -> Dict[str, Any]:
"""Build synthetic interview responses from DB post history when the OASIS env is closed.
Reads posts from twitter_simulation.db and reddit_simulation.db (table `post`,
column `content`) and assembles a text response per agent per platform.
Returns the same structure that the live IPC path returns so callers are unaffected.
"""
import sqlite3
from datetime import datetime, timezone
results: Dict[str, Any] = {}
platforms_to_check = []
if platform in (None, "twitter"):
platforms_to_check.append(("twitter", os.path.join(sim_dir, "twitter_simulation.db")))
if platform in (None, "reddit"):
platforms_to_check.append(("reddit", os.path.join(sim_dir, "reddit_simulation.db")))
for interview in interviews:
agent_id = interview.get("agent_id")
if agent_id is None:
continue
for plat_name, db_path in platforms_to_check:
key = f"{plat_name}_{agent_id}"
response_text = None
if os.path.exists(db_path):
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute(
"SELECT content FROM post WHERE user_id = ? AND content != '' ORDER BY created_at DESC LIMIT 10",
(agent_id,)
)
rows = cursor.fetchall()
conn.close()
if rows:
post_texts = [r[0] for r in rows if r[0]]
response_text = (
"(Based on simulation activity — live interview unavailable)\n\n"
+ "\n\n".join(post_texts)
)
except Exception as e:
logger.warning(f"Offline interview DB read failed ({plat_name}, agent {agent_id}): {e}")
results[key] = {
"agent_id": agent_id,
"response": response_text or "(No activity recorded for this agent on this platform)",
"timestamp": datetime.now(timezone.utc).isoformat(),
"platform": plat_name,
"offline": True,
}
if results:
return {
"success": True,
"interviews_count": len(results),
"result": {"results": results},
"offline": True,
}
return {
"success": False,
"interviews_count": 0,
"error": "No interview results available (simulation finished and no DB posts found)",
}
@classmethod
def _get_interview_history_from_db(
cls,

View File

@ -94,13 +94,13 @@ from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
load_dotenv(_env_file)
print(f"已加载环境配置: {_env_file}")
print(f"Loaded env config: {_env_file}")
else:
# 尝试加载 backend/.env
_backend_env = os.path.join(_backend_dir, '.env')
if os.path.exists(_backend_env):
load_dotenv(_backend_env)
print(f"已加载环境配置: {_backend_env}")
print(f"Loaded env config: {_backend_env}")
class MaxTokensWarningFilter(logging.Filter):
@ -169,8 +169,8 @@ try:
generate_reddit_agent_graph
)
except ImportError as e:
print(f"错误: 缺少依赖 {e}")
print("请先安装: pip install oasis-ai camel-ai")
print(f"Error: missing dependency {e}")
print("Please install: pip install oasis-ai camel-ai")
sys.exit(1)
@ -324,7 +324,7 @@ class ParallelIPCHandler:
env, agent_graph, actual_platform = self._get_env_and_graph(platform)
if not env or not agent_graph:
return {"platform": platform, "error": f"{platform}平台不可用"}
return {"platform": platform, "error": f"{platform} platform unavailable"}
try:
agent = agent_graph.get_agent(agent_id)
@ -364,11 +364,11 @@ class ParallelIPCHandler:
if "error" in result:
self.send_response(command_id, "failed", error=result["error"])
print(f" Interview失败: agent_id={agent_id}, platform={platform}, error={result['error']}")
print(f" Interview failed: agent_id={agent_id}, platform={platform}, error={result['error']}")
return False
else:
self.send_response(command_id, "completed", result=result)
print(f" Interview完成: agent_id={agent_id}, platform={platform}")
print(f" Interview done: agent_id={agent_id}, platform={platform}")
return True
# 未指定平台:同时采访两个平台
@ -405,12 +405,12 @@ class ParallelIPCHandler:
if success_count > 0:
self.send_response(command_id, "completed", result=results)
print(f" Interview完成: agent_id={agent_id}, 成功平台数={success_count}/{len(platforms_to_interview)}")
print(f" Interview done: agent_id={agent_id}, successful platforms={success_count}/{len(platforms_to_interview)}")
return True
else:
errors = [f"{p}: {r.get('error', '未知错误')}" for p, r in results["platforms"].items()]
self.send_response(command_id, "failed", error="; ".join(errors))
print(f" Interview失败: agent_id={agent_id}, 所有平台都失败")
print(f" Interview failed: agent_id={agent_id}, all platforms failed")
return False
async def handle_batch_interview(self, command_id: str, interviews: List[Dict], platform: str = None) -> bool:
@ -463,7 +463,7 @@ class ParallelIPCHandler:
action_args={"prompt": prompt}
)
except Exception as e:
print(f" 警告: 无法获取Twitter Agent {agent_id}: {e}")
print(f" Warning: cannot get Twitter Agent {agent_id}: {e}")
if twitter_actions:
await self.twitter_env.step(twitter_actions)
@ -474,7 +474,7 @@ class ParallelIPCHandler:
result["platform"] = "twitter"
results[f"twitter_{agent_id}"] = result
except Exception as e:
print(f" Twitter批量Interview失败: {e}")
print(f" Twitter batch interview failed: {e}")
# 处理Reddit平台的采访
if reddit_interviews and self.reddit_env:
@ -490,7 +490,7 @@ class ParallelIPCHandler:
action_args={"prompt": prompt}
)
except Exception as e:
print(f" 警告: 无法获取Reddit Agent {agent_id}: {e}")
print(f" Warning: cannot get Reddit Agent {agent_id}: {e}")
if reddit_actions:
await self.reddit_env.step(reddit_actions)
@ -501,17 +501,17 @@ class ParallelIPCHandler:
result["platform"] = "reddit"
results[f"reddit_{agent_id}"] = result
except Exception as e:
print(f" Reddit批量Interview失败: {e}")
print(f" Reddit batch interview failed: {e}")
if results:
self.send_response(command_id, "completed", result={
"interviews_count": len(results),
"results": results
})
print(f" 批量Interview完成: {len(results)} 个Agent")
print(f" Batch interview done: {len(results)} agents")
return True
else:
self.send_response(command_id, "failed", error="没有成功的采访")
self.send_response(command_id, "failed", error="No successful interviews")
return False
def _get_interview_result(self, agent_id: int, platform: str) -> Dict[str, Any]:
@ -553,7 +553,7 @@ class ParallelIPCHandler:
conn.close()
except Exception as e:
print(f" 读取Interview结果失败: {e}")
print(f" Failed to read interview result: {e}")
return result
@ -572,7 +572,7 @@ class ParallelIPCHandler:
command_type = command.get("command_type")
args = command.get("args", {})
print(f"\n收到IPC命令: {command_type}, id={command_id}")
print(f"\nReceived IPC command: {command_type}, id={command_id}")
if command_type == CommandType.INTERVIEW:
await self.handle_interview(
@ -592,12 +592,12 @@ class ParallelIPCHandler:
return True
elif command_type == CommandType.CLOSE_ENV:
print("收到关闭环境命令")
self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
print("Received close-env command")
self.send_response(command_id, "completed", result={"message": "Environment shutting down"})
return False
else:
self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
self.send_response(command_id, "failed", error=f"Unknown command type: {command_type}")
return True
@ -741,7 +741,7 @@ def fetch_new_actions_from_db(
conn.close()
except Exception as e:
print(f"读取数据库动作失败: {e}")
print(f"Failed to read DB actions: {e}")
return actions, new_last_rowid
@ -851,7 +851,7 @@ def _enrich_action_context(
except Exception as e:
# 补充上下文失败不影响主流程
print(f"补充动作上下文失败: {e}")
print(f"Failed to enrich action context: {e}")
def _get_post_info(
@ -981,61 +981,121 @@ def _get_comment_info(
return None
def _extract_azure_deployment(raw_url: str):
"""Extract deployment name and clean base_url from an Azure OpenAI full URL.
Azure Portal gives URLs like:
https://<resource>.cognitiveservices.azure.com/openai/deployments/<model>/chat/completions?api-version=...
camel-ai needs the base_url WITHOUT /chat/completions and uses the model name
passed as model_type to build the path but Azure ignores the model field in
the request body and routes by deployment name in the URL path.
Strategy:
1. If the URL contains /deployments/<name>/, extract <name> as the model.
2. Strip /chat/completions (and /embeddings) suffix so camel-ai can append it.
3. Preserve ?api-version as a separate string to inject via OPENAI_API_VERSION.
"""
from urllib.parse import urlparse, parse_qs, urlunparse
import re
model = None
api_version = None
if not raw_url:
return raw_url, model, api_version
parsed = urlparse(raw_url)
qs = parse_qs(parsed.query)
if 'api-version' in qs:
api_version = qs['api-version'][0]
# Extract deployment name from path
m = re.search(r'/deployments/([^/]+)', parsed.path)
if m:
model = m.group(1)
# Strip /chat/completions and /embeddings from the path so camel-ai can append them
clean_path = re.sub(r'/chat/completions.*$', '', parsed.path)
clean_path = re.sub(r'/embeddings.*$', '', clean_path).rstrip('/')
clean_url = urlunparse(parsed._replace(path=clean_path, query=''))
return clean_url, model, api_version
def create_model(config: Dict[str, Any], use_boost: bool = False):
"""Create an LLM model for camel-ai.
Supports dual-LLM setup (standard + boost) for parallel simulation.
Detects Azure OpenAI URLs (cognitiveservices.azure.com or openai.azure.com)
and uses ModelPlatformType.AZURE with the correct env vars; otherwise falls
back to ModelPlatformType.OPENAI for standard OpenAI-compatible endpoints.
"""
创建LLM模型
支持双 LLM 配置用于并行模拟时提速
- 通用配置LLM_API_KEY, LLM_BASE_URL, LLM_MODEL_NAME
- 加速配置可选LLM_BOOST_API_KEY, LLM_BOOST_BASE_URL, LLM_BOOST_MODEL_NAME
如果配置了加速 LLM并行模拟时可以让不同平台使用不同的 API 服务商提高并发能力
Args:
config: 模拟配置字典
use_boost: 是否使用加速 LLM 配置如果可用
"""
# 检查是否有加速配置
boost_api_key = os.environ.get("LLM_BOOST_API_KEY", "")
boost_base_url = os.environ.get("LLM_BOOST_BASE_URL", "")
boost_model = os.environ.get("LLM_BOOST_MODEL_NAME", "")
has_boost_config = bool(boost_api_key)
# 根据参数和配置情况选择使用哪个 LLM
if use_boost and has_boost_config:
# 使用加速配置
llm_api_key = boost_api_key
llm_base_url = boost_base_url
llm_model = boost_model or os.environ.get("LLM_MODEL_NAME", "")
config_label = "[加速LLM]"
raw_url = boost_base_url
llm_model_env = boost_model or os.environ.get("LLM_MODEL_NAME", "")
config_label = "[Boost LLM]"
else:
# 使用通用配置
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
config_label = "[通用LLM]"
# 如果 .env 中没有模型名,则使用 config 作为备用
if not llm_model:
llm_model = config.get("llm_model", "gpt-4o-mini")
# 设置 camel-ai 所需的环境变量
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"{config_label} model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=llm_model,
raw_url = os.environ.get("LLM_BASE_URL", "")
llm_model_env = os.environ.get("LLM_MODEL_NAME", "")
config_label = "[Standard LLM]"
# Parse Azure URL: extract deployment name, clean base_url, and api-version
clean_url, deployment_from_url, api_version = _extract_azure_deployment(raw_url)
# Deployment name from URL takes priority over env var
llm_model = deployment_from_url or llm_model_env or config.get("llm_model", "gpt-4o-mini")
if not llm_api_key:
raise ValueError("Missing API key — set LLM_API_KEY in the project root .env file")
is_azure = bool(deployment_from_url) or (
raw_url and (
"cognitiveservices.azure.com" in raw_url
or "openai.azure.com" in raw_url
)
)
if is_azure:
# AzureOpenAIModel reads these specific env vars
os.environ["AZURE_OPENAI_API_KEY"] = llm_api_key
if clean_url:
os.environ["AZURE_OPENAI_BASE_URL"] = clean_url
if api_version:
os.environ["AZURE_API_VERSION"] = api_version
os.environ["AZURE_DEPLOYMENT_NAME"] = llm_model
print(f"{config_label} [Azure] deployment={llm_model}, endpoint={clean_url[:60] if clean_url else 'default'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.AZURE,
model_type=llm_model,
api_key=llm_api_key,
url=clean_url or None,
api_version=api_version,
azure_deployment_name=llm_model,
)
else:
os.environ["OPENAI_API_KEY"] = llm_api_key
if clean_url:
os.environ["OPENAI_API_BASE_URL"] = clean_url
print(f"{config_label} [OpenAI] model={llm_model}, base_url={clean_url[:60] if clean_url else 'default'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=llm_model,
api_key=llm_api_key,
url=clean_url or None,
)
def get_active_agents_for_round(
env,
@ -1124,7 +1184,7 @@ async def run_twitter_simulation(
main_logger.info(f"[Twitter] {msg}")
print(f"[Twitter] {msg}")
log_info("初始化...")
log_info("Initializing...")
# Twitter 使用通用 LLM 配置
model = create_model(config, use_boost=False)
@ -1132,7 +1192,7 @@ async def run_twitter_simulation(
# OASIS Twitter使用CSV格式
profile_path = os.path.join(simulation_dir, "twitter_profiles.csv")
if not os.path.exists(profile_path):
log_info(f"错误: Profile文件不存在: {profile_path}")
log_info(f"Error: profile file not found: {profile_path}")
return result
result.agent_graph = await generate_twitter_agent_graph(
@ -1160,7 +1220,7 @@ async def run_twitter_simulation(
)
await result.env.reset()
log_info("环境已启动")
log_info("Environment started")
if action_logger:
action_logger.log_simulation_start(config)
@ -1204,7 +1264,7 @@ async def run_twitter_simulation(
if initial_actions:
await result.env.step(initial_actions)
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
log_info(f"Published {len(initial_actions)} initial posts")
# 记录 round 0 结束
if action_logger:
@ -1221,7 +1281,7 @@ async def run_twitter_simulation(
original_rounds = total_rounds
total_rounds = min(total_rounds, max_rounds)
if total_rounds < original_rounds:
log_info(f"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
log_info(f"Rounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
start_time = datetime.now()
@ -1229,7 +1289,7 @@ async def run_twitter_simulation(
# 检查是否收到退出信号
if _shutdown_event and _shutdown_event.is_set():
if main_logger:
main_logger.info(f"收到退出信号,在第 {round_num + 1} 轮停止模拟")
main_logger.info(f"Shutdown signal received, stopping at round {round_num + 1}")
break
simulated_minutes = round_num * minutes_per_round
@ -1285,7 +1345,7 @@ async def run_twitter_simulation(
result.total_actions = total_actions
elapsed = (datetime.now() - start_time).total_seconds()
log_info(f"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}")
log_info(f"Simulation loop done! Elapsed: {elapsed:.1f}s, total actions: {total_actions}")
return result
@ -1316,14 +1376,14 @@ async def run_reddit_simulation(
main_logger.info(f"[Reddit] {msg}")
print(f"[Reddit] {msg}")
log_info("初始化...")
log_info("Initializing...")
# Reddit 使用加速 LLM 配置(如果有的话,否则回退到通用配置)
model = create_model(config, use_boost=True)
profile_path = os.path.join(simulation_dir, "reddit_profiles.json")
if not os.path.exists(profile_path):
log_info(f"错误: Profile文件不存在: {profile_path}")
log_info(f"Error: profile file not found: {profile_path}")
return result
result.agent_graph = await generate_reddit_agent_graph(
@ -1351,7 +1411,7 @@ async def run_reddit_simulation(
)
await result.env.reset()
log_info("环境已启动")
log_info("Environment started")
if action_logger:
action_logger.log_simulation_start(config)
@ -1403,7 +1463,7 @@ async def run_reddit_simulation(
if initial_actions:
await result.env.step(initial_actions)
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
log_info(f"Published {len(initial_actions)} initial posts")
# 记录 round 0 结束
if action_logger:
@ -1420,7 +1480,7 @@ async def run_reddit_simulation(
original_rounds = total_rounds
total_rounds = min(total_rounds, max_rounds)
if total_rounds < original_rounds:
log_info(f"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
log_info(f"Rounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
start_time = datetime.now()
@ -1428,7 +1488,7 @@ async def run_reddit_simulation(
# 检查是否收到退出信号
if _shutdown_event and _shutdown_event.is_set():
if main_logger:
main_logger.info(f"收到退出信号,在第 {round_num + 1} 轮停止模拟")
main_logger.info(f"Shutdown signal received, stopping at round {round_num + 1}")
break
simulated_minutes = round_num * minutes_per_round
@ -1484,7 +1544,7 @@ async def run_reddit_simulation(
result.total_actions = total_actions
elapsed = (datetime.now() - start_time).total_seconds()
log_info(f"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}")
log_info(f"Simulation loop done! Elapsed: {elapsed:.1f}s, total actions: {total_actions}")
return result
@ -1527,7 +1587,7 @@ async def main():
_shutdown_event = asyncio.Event()
if not os.path.exists(args.config):
print(f"错误: 配置文件不存在: {args.config}")
print(f"Error: config file not found: {args.config}")
sys.exit(1)
config = load_config(args.config)
@ -1543,31 +1603,31 @@ async def main():
reddit_logger = log_manager.get_reddit_logger()
log_manager.info("=" * 60)
log_manager.info("OASIS 双平台并行模拟")
log_manager.info(f"配置文件: {args.config}")
log_manager.info(f"模拟ID: {config.get('simulation_id', 'unknown')}")
log_manager.info(f"等待命令模式: {'启用' if wait_for_commands else '禁用'}")
log_manager.info("OASIS Dual-Platform Parallel Simulation")
log_manager.info(f"Config file: {args.config}")
log_manager.info(f"Simulation ID: {config.get('simulation_id', 'unknown')}")
log_manager.info(f"Wait-for-commands mode: {'enabled' if wait_for_commands else 'disabled'}")
log_manager.info("=" * 60)
time_config = config.get("time_config", {})
total_hours = time_config.get('total_simulation_hours', 72)
minutes_per_round = time_config.get('minutes_per_round', 30)
config_total_rounds = (total_hours * 60) // minutes_per_round
log_manager.info(f"模拟参数:")
log_manager.info(f" - 总模拟时长: {total_hours}小时")
log_manager.info(f" - 每轮时间: {minutes_per_round}分钟")
log_manager.info(f" - 配置总轮数: {config_total_rounds}")
log_manager.info("Simulation parameters:")
log_manager.info(f" - Total simulation time: {total_hours} hours")
log_manager.info(f" - Time per round: {minutes_per_round} minutes")
log_manager.info(f" - Config total rounds: {config_total_rounds}")
if args.max_rounds:
log_manager.info(f" - 最大轮数限制: {args.max_rounds}")
log_manager.info(f" - Max rounds limit: {args.max_rounds}")
if args.max_rounds < config_total_rounds:
log_manager.info(f" - 实际执行轮数: {args.max_rounds} (已截断)")
log_manager.info(f" - Agent数量: {len(config.get('agent_configs', []))}")
log_manager.info("日志结构:")
log_manager.info(f" - 主日志: simulation.log")
log_manager.info(f" - Twitter动作: twitter/actions.jsonl")
log_manager.info(f" - Reddit动作: reddit/actions.jsonl")
log_manager.info(f" - Actual rounds (truncated): {args.max_rounds}")
log_manager.info(f" - Agent count: {len(config.get('agent_configs', []))}")
log_manager.info("Log structure:")
log_manager.info(f" - Main log: simulation.log")
log_manager.info(f" - Twitter actions: twitter/actions.jsonl")
log_manager.info(f" - Reddit actions: reddit/actions.jsonl")
log_manager.info("=" * 60)
start_time = datetime.now()
@ -1590,14 +1650,14 @@ async def main():
total_elapsed = (datetime.now() - start_time).total_seconds()
log_manager.info("=" * 60)
log_manager.info(f"模拟循环完成! 总耗时: {total_elapsed:.1f}")
# 是否进入等待命令模式
log_manager.info(f"Simulation loop complete! Total elapsed: {total_elapsed:.1f}s")
# Enter wait-for-commands mode if requested
if wait_for_commands:
log_manager.info("")
log_manager.info("=" * 60)
log_manager.info("进入等待命令模式 - 环境保持运行")
log_manager.info("支持的命令: interview, batch_interview, close_env")
log_manager.info("Entering wait-for-commands mode — environment remains active")
log_manager.info("Supported commands: interview, batch_interview, close_env")
log_manager.info("=" * 60)
# 创建IPC处理器
@ -1623,27 +1683,27 @@ async def main():
except asyncio.TimeoutError:
pass # 超时继续循环
except KeyboardInterrupt:
print("\n收到中断信号")
print("\nInterrupt signal received")
except asyncio.CancelledError:
print("\n任务被取消")
print("\nTask cancelled")
except Exception as e:
print(f"\n命令处理出错: {e}")
print(f"\nCommand processing error: {e}")
log_manager.info("\n关闭环境...")
log_manager.info("\nShutting down environments...")
ipc_handler.update_status("stopped")
# 关闭环境
# Close environments
if twitter_result and twitter_result.env:
await twitter_result.env.close()
log_manager.info("[Twitter] 环境已关闭")
log_manager.info("[Twitter] Environment closed")
if reddit_result and reddit_result.env:
await reddit_result.env.close()
log_manager.info("[Reddit] 环境已关闭")
log_manager.info("[Reddit] Environment closed")
log_manager.info("=" * 60)
log_manager.info(f"全部完成!")
log_manager.info(f"日志文件:")
log_manager.info("All done!")
log_manager.info("Log files:")
log_manager.info(f" - {os.path.join(simulation_dir, 'simulation.log')}")
log_manager.info(f" - {os.path.join(simulation_dir, 'twitter', 'actions.jsonl')}")
log_manager.info(f" - {os.path.join(simulation_dir, 'reddit', 'actions.jsonl')}")
@ -1663,7 +1723,7 @@ def setup_signal_handlers(loop=None):
def signal_handler(signum, frame):
global _cleanup_done
sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT"
print(f"\n收到 {sig_name} 信号,正在退出...")
print(f"\nReceived {sig_name} signal, shutting down...")
if not _cleanup_done:
_cleanup_done = True
@ -1674,7 +1734,7 @@ def setup_signal_handlers(loop=None):
# 不要直接 sys.exit(),让 asyncio 循环正常退出并清理资源
# 如果是重复收到信号,才强制退出
else:
print("强制退出...")
print("Force exit...")
sys.exit(1)
signal.signal(signal.SIGTERM, signal_handler)
@ -1686,7 +1746,7 @@ if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n程序被中断")
print("\nProgram interrupted")
except SystemExit:
pass
finally:
@ -1696,4 +1756,4 @@ if __name__ == "__main__":
resource_tracker._resource_tracker._stop()
except Exception:
pass
print("模拟进程已退出")
print("Simulation process exited")

View File

@ -18,6 +18,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
]
[[package]]
name = "alembic"
version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@ -76,6 +90,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "azure-core"
version = "1.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/d9/6f5972b44761277394527a3a76af5ae2ef82fc5f20ce351abf0c826eca67/azure_core-1.40.0.tar.gz", hash = "sha256:ecf5b6ddf2564471fae9d576147b7e77a4da285958b2d9f4fd6c3af104f3e9d7", size = 380057, upload-time = "2026-05-01T00:59:45.488Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/c9/25edc67692fb17523c7d29c73898be649b4d3c7ae13cc0f74f5c91938022/azure_core-1.40.0-py3-none-any.whl", hash = "sha256:7f3ea02579b1bb1d34e45043423b650621d11d7c2ea3b05e5554010080b78dfd", size = 220450, upload-time = "2026-05-01T00:59:47.17Z" },
]
[[package]]
name = "azure-storage-blob"
version = "12.28.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-core" },
{ name = "cryptography" },
{ name = "isodate" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/24/072ba8e27b0e2d8fec401e9969b429d4f5fc4c8d4f0f05f4661e11f7234a/azure_storage_blob-12.28.0.tar.gz", hash = "sha256:e7d98ea108258d29aa0efbfd591b2e2075fa1722a2fae8699f0b3c9de11eff41", size = 604225, upload-time = "2026-01-06T23:48:57.282Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/3a/6ef2047a072e54e1142718d433d50e9514c999a58f51abfff7902f3a72f8/azure_storage_blob-12.28.0-py3-none-any.whl", hash = "sha256:00fb1db28bf6a7b7ecaa48e3b1d5c83bfadacc5a678b77826081304bd87d6461", size = 431499, upload-time = "2026-01-06T23:48:58.995Z" },
]
[[package]]
name = "backcall"
version = "0.2.0"
@ -94,6 +136,76 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
{ url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" },
{ url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" },
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
@ -586,6 +698,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" },
]
[[package]]
name = "flask-jwt-extended"
version = "4.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "pyjwt" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/51/16/96b101f18cba17ecce3225ab07bc4c8f23e6befd8552dbbed87482e7c7fb/flask_jwt_extended-4.7.1.tar.gz", hash = "sha256:8085d6757505b6f3291a2638c84d207e8f0ad0de662d1f46aa2f77e658a0c976", size = 34411, upload-time = "2024-11-20T23:44:41.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/34/9a91da47b1565811ab4aa5fb134632c8d1757960bfa7d457f486947c4d75/Flask_JWT_Extended-4.7.1-py2.py3-none-any.whl", hash = "sha256:52f35bf0985354d7fb7b876e2eb0e0b141aaff865a22ff6cc33d9a18aa987978", size = 22588, upload-time = "2024-11-20T23:44:39.435Z" },
]
[[package]]
name = "flask-sqlalchemy"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" },
]
[[package]]
name = "fsspec"
version = "2025.12.0"
@ -613,6 +752,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/cd/e6203f1fee0a8e2a797f2d5f9a867513e0c63af4e19fdd1c5a7e14a47670/graphiti_core-0.28.2-py3-none-any.whl", hash = "sha256:4e1c19b7bc70a73a612a473144ed4b3fe615ac6d4c5d6b10f48e206a858bcb53", size = 314919, upload-time = "2026-03-11T16:20:01.037Z" },
]
[[package]]
name = "greenlet"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" },
{ url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" },
{ url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" },
{ url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" },
{ url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" },
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
{ url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
{ url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" },
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
{ url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" },
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
]
[[package]]
name = "gunicorn"
version = "25.3.0"
@ -790,6 +976,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/97/8fe103906cd81bc42d3b0175b5534a9f67dccae47d6451131cf8d0d70bb2/ipython-8.12.3-py3-none-any.whl", hash = "sha256:b0340d46a933d27c657b211a329d0be23793c36595acf9e6ef4164bc01a1804c", size = 798307, upload-time = "2023-09-29T09:14:34.431Z" },
]
[[package]]
name = "isodate"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
@ -1147,6 +1342,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
]
[[package]]
name = "mako"
version = "1.3.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
]
[[package]]
name = "markdown"
version = "3.10.2"
@ -1284,12 +1491,17 @@ name = "mirofish-backend"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "azure-storage-blob" },
{ name = "bcrypt" },
{ name = "camel-ai" },
{ name = "camel-oasis" },
{ name = "chardet" },
{ name = "charset-normalizer" },
{ name = "flask" },
{ name = "flask-cors" },
{ name = "flask-jwt-extended" },
{ name = "flask-sqlalchemy" },
{ name = "gunicorn" },
{ name = "markdown" },
{ name = "openai" },
@ -1297,6 +1509,7 @@ dependencies = [
{ name = "pyjwt" },
{ name = "pymupdf" },
{ name = "python-dotenv" },
{ name = "sqlalchemy" },
{ name = "zep-cloud" },
]
@ -1319,12 +1532,17 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.13.0" },
{ name = "azure-storage-blob", specifier = ">=12.19.0" },
{ name = "bcrypt", specifier = ">=4.1.0" },
{ name = "camel-ai", specifier = "==0.2.78" },
{ name = "camel-oasis", specifier = "==0.2.5" },
{ name = "chardet", specifier = ">=5.0.0" },
{ name = "charset-normalizer", specifier = ">=3.0.0" },
{ name = "flask", specifier = ">=3.0.0" },
{ name = "flask-cors", specifier = ">=6.0.0" },
{ name = "flask-jwt-extended", specifier = ">=4.6.0" },
{ name = "flask-sqlalchemy", specifier = ">=3.1.0" },
{ name = "graphiti-core", marker = "extra == 'graphiti'", specifier = "==0.28.2" },
{ name = "gunicorn", specifier = ">=22.0.0" },
{ name = "markdown", specifier = ">=3.6" },
@ -1337,6 +1555,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "zep-cloud", specifier = "==3.13.0" },
]
provides-extras = ["graphiti", "dev"]
@ -2996,6 +3215,59 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.49"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" },
{ url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" },
{ url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" },
{ url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" },
{ url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" },
{ url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" },
{ url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" },
{ url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" },
{ url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" },
{ url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" },
{ url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" },
{ url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" },
{ url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" },
{ url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" },
{ url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" },
{ url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" },
{ url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" },
{ url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" },
{ url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" },
{ url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" },
{ url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" },
{ url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" },
{ url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" },
{ url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
]
[[package]]
name = "sse-starlette"
version = "3.0.4"

146
docs/TechnicalDesign.md Normal file
View File

@ -0,0 +1,146 @@
# Technical Design — MiroFish
## Graph Backend
MiroFish suporta dos backends de knowledge graph, seleccionable via `GRAPH_BACKEND` al `.env`:
| Valor | Backend | Requisits |
|-------|---------|-----------|
| `zep` (per defecte) | Zep Cloud (gestionat) | `ZEP_API_KEY` |
| `graphiti` | Graphiti + Neo4j (self-hosted) | `NEO4J_PASSWORD` + variables LLM |
La selecció es fa via la factoria `backend/app/graph/factory.py` — un singleton que instancia `ZepBackend` o `GraphitiBackend` en funció de `GRAPH_BACKEND`. La validació de configuració és condicionada: si `GRAPH_BACKEND=graphiti`, `ZEP_API_KEY` no és necessari i viceversa.
### Commutació entre backends
Només cal canviar al `.env`:
```env
# Per usar Zep Cloud:
GRAPH_BACKEND=zep
ZEP_API_KEY=z_...
# Per usar Graphiti + Neo4j:
GRAPH_BACKEND=graphiti
NEO4J_URI=bolt://<host>:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=<contrasenya>
```
---
## Models LLM
El projecte usa fins a quatre grups de variables LLM, cadascun per a un ús diferent. Totes les variables `LLM_SMALL_*` i `LLM_EMBED_*` fan **fallback** als valors `LLM_*` si no s'estableixen.
### Variables de configuració
```env
# ── Model principal (generatiu, potent) ──────────────────────────────────────
LLM_API_KEY=...
LLM_BASE_URL=https://<recurs>.cognitiveservices.azure.com/openai/deployments/<model>/chat/completions?api-version=2024-05-01-preview
LLM_MODEL_NAME=gpt-5.4
# ── Model petit/ràpid (lightweight, econòmic) ────────────────────────────────
# Fallback a LLM_* si no definit
LLM_SMALL_API_KEY=...
LLM_SMALL_BASE_URL=https://<recurs>.cognitiveservices.azure.com/openai/deployments/<small-model>/chat/completions?api-version=2024-05-01-preview
LLM_SMALL_MODEL_NAME=gpt-5-mini
# ── Model d'embedding (vectorització) ────────────────────────────────────────
# Fallback a LLM_* si no definit. Requerit per Graphiti.
LLM_EMBED_API_KEY=...
LLM_EMBED_BASE_URL=https://<recurs>.services.ai.azure.com/openai/deployments/<embed-model>/embeddings?api-version=2024-05-01-preview
LLM_EMBED_MODEL_NAME=text-embedding-3-large
# ── Model boost (simulació OASIS, opcional) ──────────────────────────────────
# Fallback a LLM_* si no definit
LLM_BOOST_API_KEY=...
LLM_BOOST_BASE_URL=...
LLM_BOOST_MODEL_NAME=gpt-5.4
```
### Mapa d'usos per operació
| Grup de variables | Component | Operació |
|---|---|---|
| `LLM_*` | `OntologyGenerator` | Pas 1 — Anàlisi del document i generació d'ontologia (tipus d'entitat i relació) |
| `LLM_*` | `GraphBuilderService` (mode Zep) | Pas 2 — Extracció d'entitats i relacions del text via Zep SDK |
| `LLM_*` | Graphiti `OpenAIClient` (mode graphiti) | Pas 2 — Extracció d'entitats i relacions del text via graphiti-core |
| `LLM_*` | `OasisProfileGenerator` | Pas 2 — Generació de perfils d'agents OASIS a partir del graf |
| `LLM_*` | `ReportAgent` | Pas 4 — Generació de l'informe analític final (multi-turn, tool use) |
| `LLM_SMALL_*` | Graphiti `OpenAIRerankerClient` | Pas 2 — Reranking de resultats de cerca al graf (mode graphiti) |
| `LLM_SMALL_*` | Graphiti `ModelSize.small` | Pas 2 — Tasques lleugeres internes de graphiti (extracció simplificada) |
| `LLM_EMBED_*` | Graphiti `OpenAIEmbedder` | Pas 2 — Generació de vectors d'embedding per a indexació i cerca semàntica a Neo4j (mode graphiti) |
| `LLM_BOOST_*` | `SimulationRunner` / `run_parallel_simulation.py` | Pas 3 — Decisions d'acció de cada agent durant la simulació OASIS |
### API endpoint usada per cada component
Tots els components del projecte usen **`chat.completions`** o **`embeddings`** — mai `responses` (beta).
| Component | API endpoint | Nota |
|---|---|---|
| `LLMClient` (wrapper projecte) | `chat.completions.create` | Síncrona (`OpenAI`) |
| `OntologyGenerator` | `chat.completions.create` | Via `LLMClient` |
| `OasisProfileGenerator` | `chat.completions.create` | Client intern |
| `SimulationConfigGenerator` | `chat.completions.create` | Client intern |
| `ReportAgent` | `chat.completions.create` | Via `LLMClient` |
| Graphiti `OpenAIGenericClient` | `chat.completions.create` | AsyncOpenAI, injectable |
| Graphiti `OpenAIRerankerClient` | `chat.completions.create` | Amb `logprobs=True` per scoring |
| Graphiti `OpenAIEmbedder` | `embeddings.create` | AsyncOpenAI, injectable |
| OASIS/CAMEL-AI | `chat.completions.create` | Via `ModelFactory` (CAMEL abstraction) |
> **Nota:** graphiti-core inclou també un `OpenAIClient` que usa `responses.parse` (API beta d'OpenAI). MiroFish **no l'usa** — configura `OpenAIGenericClient` que sempre usa `chat.completions`, compatible amb Azure i qualsevol API OpenAI-compatible.
### Notes sobre Azure OpenAI
- `LLM_BASE_URL` accepta la URL completa d'Azure (`/chat/completions?api-version=...`). El codi la processa automàticament: extreu el `api-version` com a `default_query` i retalla el sufix per al SDK.
- El mateix tractament s'aplica a `LLM_EMBED_BASE_URL` (sufix `/embeddings?api-version=...`).
- `LLM_SMALL_BASE_URL` accepta directament la URL base d'Azure AI Foundry (`services.ai.azure.com/api/projects/<proj>/openai/v1/`) sense sufix ni `api-version`.
- `LLM_EMBED_BASE_URL` pot usar el domini `services.ai.azure.com` o `cognitiveservices.azure.com`.
### Recomanació de models (Azure OpenAI)
| Grup | Model recomanat | Motiu |
|------|----------------|-------|
| `LLM_*` | `gpt-5.4` | Raonament complex: ontologia, extracció de graf, informes |
| `LLM_SMALL_*` | `gpt-5-mini` | Tasques lleugeres i econòmiques: reranking, classificació |
| `LLM_EMBED_*` | `text-embedding-3-large` | Màxima qualitat d'embedding semàntic |
| `LLM_BOOST_*` | `gpt-5.4` o `gpt-5-mini` | Simulació: moltes crides curtes, prioritzar velocitat/cost |
---
## Pipeline de 5 passos
```
Pas 1 — Graph Build (ontologia)
└─ OntologyGenerator → LLM_*
Pas 2 — Graph Build (construcció)
├─ mode zep: GraphBuilderService + Zep SDK → LLM_*
└─ mode graphiti: GraphitiBackend
├─ extracció: OpenAIGenericClient → LLM_* (chat.completions)
├─ reranking: OpenAIRerankerClient → LLM_SMALL_*
└─ embedding: OpenAIEmbedder → LLM_EMBED_*
Pas 3 — Simulació OASIS
└─ SimulationRunner / run_parallel_simulation.py → LLM_BOOST_* (o LLM_*)
Pas 4 — Informe
└─ ReportAgent (multi-turn + tool use) → LLM_*
Pas 5 — Interacció live
└─ Chat amb agents simulats → LLM_*
```
---
## Internacionalització (i18n)
- Fitxers de traducció: `/locales/{ca,en,es,zh}.json` — compartits per frontend i backend.
- Instruccions de llengua per al LLM: `/locales/languages.json` (clau `llmInstruction`).
- El frontend injecta el locale actual via header `Accept-Language` a cada petició API.
- El backend detecta el locale a `backend/app/utils/locale.py:get_locale()` i l'usa per:
- Traduccions de missatges d'error (`t()`)
- Instruccions d'idioma als prompts LLM (`get_language_instruction()`)
- L'ontologia generada (descripcions, exemples, `analysis_summary`) sortirà en l'idioma de la UI. Els **noms** de tipus d'entitat i relació seguiran PascalCase/UPPER\_SNAKE\_CASE en l'idioma de la UI (p.ex. `AgenciaGovern`, `TREBALLA_PER` en català).

View File

@ -0,0 +1,958 @@
# Fase 0 — Estabilitat del Fork Actual: Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Patch 7 stability issues: fix JSONL action log data loss, cap edge memory usage, add task recovery after browser refresh, persist upload session state, fix DB connection leaks, prevent LLM tool hallucinations, and handle malformed ontology attributes.
**Architecture:** Pure bug-fix hardening — no new features. Each fix is isolated to its concern. Backend patches are Python/Flask; frontend patches are Vue 3/JS. All behavioral changes have unit tests added first (TDD).
**Tech Stack:** Python 3.11, Flask, Vue 3, pytest, Neo4j (Graphiti backend)
---
## File Map
**Create:**
- `backend/tests/__init__.py` — test package marker
- `backend/tests/test_read_action_log.py` — safe_position tests (PR #460)
- `backend/tests/test_project_task_recovery.py` — active_task_id persistence tests
- `backend/tests/test_ontology_attributes.py` — string attribute normalization tests (PR #581)
**Modify:**
- `backend/app/services/simulation_runner.py` — Fix `_read_action_log` safe_position (PR #460)
- `backend/app/graph/graphiti_backend.py` — Add `max_items` LIMIT to `get_all_edges` (PR #553 equiv)
- `backend/app/models/project.py` — Add `active_task_id: Optional[str]` field
- `backend/app/api/graph.py` — Set/clear `active_task_id` on task lifecycle
- `backend/scripts/run_twitter_simulation.py``finally` block for DB connections (PR #578)
- `backend/app/services/report_agent.py` — Strip fabricated `<tool_result>` blocks (PR #559)
- `backend/app/services/graph_builder.py` — Guard string `attr_def` (PR #581)
- `backend/app/services/ontology_generator.py` — Normalize string attributes (PR #581)
- `frontend/src/store/pendingUpload.js` — Persist requirement to sessionStorage
- `frontend/src/views/MainView.vue` — Recovery UI: reconnect active task + friendly file-lost error
- `locales/en.json`, `locales/ca.json`, `locales/es.json`, `locales/zh.json` — New i18n keys
---
## Task 1: Create feature branch
**Files:** none
- [ ] **Step 1: Create and switch to new branch**
```bash
git checkout -b fix/fase0-estabilitat
```
Expected: `Switched to a new branch 'fix/fase0-estabilitat'`
---
## Task 2: PR #460 — Fix `_read_action_log` silent data loss
**Problem:** `for line in f` (Python file iterator) can return partial lines when the file is concurrently written. The reader advances `f.tell()` past the partial data, losing it permanently. The fix: use `readline()` explicitly and only advance `safe_position` when the line ends with `\n`.
**Files:**
- Create: `backend/tests/__init__.py`
- Create: `backend/tests/test_read_action_log.py`
- Modify: `backend/app/services/simulation_runner.py`
- [ ] **Step 1: Create test package**
Create `backend/tests/__init__.py` as an empty file.
- [ ] **Step 2: Write failing tests**
Create `backend/tests/test_read_action_log.py`:
```python
import json
import os
import tempfile
import pytest
from unittest.mock import patch
def _make_state(sim_id="test-sim"):
from app.services.simulation_manager import SimulationRunState
return SimulationRunState(simulation_id=sim_id)
def _call_read(path, position, state, platform="twitter"):
from app.services.simulation_runner import SimulationRunner
with patch.dict(SimulationRunner._graph_memory_enabled, {}, clear=False):
return SimulationRunner._read_action_log(path, position, state, platform)
_ACTION = {
"action_type": "post", "agent_id": 1, "agent_name": "Alice",
"round": 1, "timestamp": "2026-01-01T00:00:00",
"action_args": {}, "result": None, "success": True,
}
def test_complete_lines_all_processed():
"""All lines ending with \\n are processed; final position equals file size."""
state = _make_state()
with tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl', delete=False) as f:
f.write(json.dumps(_ACTION) + '\n')
f.write(json.dumps({**_ACTION, "agent_id": 2, "agent_name": "Bob"}) + '\n')
path = f.name
try:
new_pos = _call_read(path, 0, state)
assert len(state.recent_actions) == 2
assert new_pos == os.path.getsize(path)
finally:
os.unlink(path)
def test_partial_last_line_not_processed():
"""Partial last line (no trailing \\n) is NOT processed; position stays before it."""
state = _make_state("test-partial")
complete = json.dumps(_ACTION) + '\n'
partial = '{"action_type": "like", "agent_id": 2' # no \n — in-progress write
with tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl', delete=False) as f:
f.write(complete)
f.write(partial)
path = f.name
try:
new_pos = _call_read(path, 0, state)
assert len(state.recent_actions) == 1
assert state.recent_actions[0].action_type == 'post'
# Position must be at end of the complete line, before the partial
assert new_pos == len(complete.encode('utf-8'))
finally:
os.unlink(path)
def test_incremental_reads_pick_up_new_lines():
"""Second read from returned position picks up lines added after first read."""
state = _make_state("test-incr")
with tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl', delete=False) as f:
f.write(json.dumps(_ACTION) + '\n')
path = f.name
try:
pos1 = _call_read(path, 0, state)
assert len(state.recent_actions) == 1
with open(path, 'a') as f:
f.write(json.dumps({**_ACTION, "agent_id": 3, "agent_name": "Charlie"}) + '\n')
pos2 = _call_read(path, pos1, state)
assert len(state.recent_actions) == 2
assert pos2 > pos1
finally:
os.unlink(path)
def test_empty_file_returns_zero():
"""Empty file returns position 0 and processes nothing."""
state = _make_state("test-empty")
with tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl', delete=False) as f:
path = f.name
try:
new_pos = _call_read(path, 0, state)
assert new_pos == 0
assert len(state.recent_actions) == 0
finally:
os.unlink(path)
```
- [ ] **Step 3: Run tests to verify they fail**
```bash
cd backend && uv run pytest tests/test_read_action_log.py -v
```
Expected: 1-4 tests FAIL (current `for line in f` does not use safe_position)
- [ ] **Step 4: Implement fix in `simulation_runner.py`**
In `backend/app/services/simulation_runner.py`, find `_read_action_log`. The outermost `try` block contains a `with open(...)` that has `for line in f:` inside.
Replace this pattern (only the outer loop structure changes; all inner processing is identical):
```python
try:
with open(log_path, 'r', encoding='utf-8') as f:
f.seek(position)
for line in f:
line = line.strip()
if line:
try:
action_data = json.loads(line)
# ... (all existing processing) ...
except json.JSONDecodeError:
pass
return f.tell()
except Exception as e:
logger.warning(f"Failed to read action log: {log_path}, error={e}")
return position
```
With:
```python
try:
with open(log_path, 'r', encoding='utf-8') as f:
f.seek(position)
safe_position = position
while True:
raw_line = f.readline()
if not raw_line: # EOF
break
if not raw_line.endswith('\n'): # Partial line — wait for flush
break
safe_position = f.tell()
line = raw_line.strip()
if not line:
continue
try:
action_data = json.loads(line)
# ... (all existing processing unchanged) ...
except json.JSONDecodeError:
pass
return safe_position
except Exception as e:
logger.warning(f"Failed to read action log: {log_path}, error={e}")
return position
```
- [ ] **Step 5: Run tests to verify they pass**
```bash
cd backend && uv run pytest tests/test_read_action_log.py -v
```
Expected: 4 PASSED
- [ ] **Step 6: Commit**
```bash
git add backend/tests/__init__.py backend/tests/test_read_action_log.py backend/app/services/simulation_runner.py
git commit -m "fix(simulation): prevent action log data loss from partial JSONL line reads"
```
---
## Task 3: PR #553 equiv — Cap `get_all_edges` memory usage
**Problem:** `get_all_edges` in `graphiti_backend.py` runs an unbounded Cypher query loading all edges into Python RAM. With large graphs this can exhaust server memory.
**Files:**
- Modify: `backend/app/graph/graphiti_backend.py`
- [ ] **Step 1: Add `LIMIT` to Cypher query and `max_items` parameter**
In `backend/app/graph/graphiti_backend.py`, find `get_all_edges` at line ~395:
```python
def get_all_edges(self, graph_id: str) -> List[Dict[str, Any]]:
results = _run_async(
self._client.driver.execute_query(
"MATCH (s)-[r]->(t) WHERE r.group_id = $gid RETURN s, r, t",
params={"gid": graph_id},
)
)
edges = []
for record in results.records:
```
Replace with:
```python
def get_all_edges(self, graph_id: str, max_items: int = 5000) -> List[Dict[str, Any]]:
results = _run_async(
self._client.driver.execute_query(
"MATCH (s)-[r]->(t) WHERE r.group_id = $gid RETURN s, r, t LIMIT $limit",
params={"gid": graph_id, "limit": max_items},
)
)
if len(results.records) >= max_items:
logger.warning(
f"get_all_edges: result truncated at {max_items} edges for graph {graph_id}"
)
edges = []
for record in results.records:
```
(All other lines in the method stay unchanged.)
- [ ] **Step 2: Verify syntax**
```bash
cd backend && uv run python -c "from app.graph.graphiti_backend import GraphitiBackend; print('OK')"
```
Expected: `OK`
- [ ] **Step 3: Commit**
```bash
git add backend/app/graph/graphiti_backend.py
git commit -m "fix(graph): cap get_all_edges to 5000 edges to prevent unbounded RAM growth"
```
---
## Task 4: Add `active_task_id` persistence (browser-refresh task recovery)
**Problem:** When the user refreshes the browser mid graph-build, `MainView.vue` calls `loadProject()` but has no way to reconnect to the running task because the task_id only lived in the frontend's memory.
**Files:**
- Create: `backend/tests/test_project_task_recovery.py`
- Modify: `backend/app/models/project.py`
- Modify: `backend/app/api/graph.py`
- Modify: `frontend/src/views/MainView.vue`
- Modify: `locales/en.json`, `locales/ca.json`, `locales/es.json`, `locales/zh.json`
- [ ] **Step 1: Write failing tests**
Create `backend/tests/test_project_task_recovery.py`:
```python
def test_project_serializes_active_task_id():
"""active_task_id is included in Project.to_dict()."""
from app.models.project import Project, ProjectStatus
p = Project(
project_id="proj-1", name="Test",
status=ProjectStatus.GRAPH_BUILDING,
created_at="2026-01-01", updated_at="2026-01-01",
active_task_id="task-abc-123",
)
assert p.to_dict()["active_task_id"] == "task-abc-123"
def test_project_deserializes_active_task_id():
"""Project.from_dict() restores active_task_id from JSON."""
from app.models.project import Project
data = {
"project_id": "proj-1", "name": "Test", "status": "graph_building",
"created_at": "2026-01-01", "updated_at": "2026-01-01",
"active_task_id": "task-abc-123",
}
assert Project.from_dict(data).active_task_id == "task-abc-123"
def test_project_active_task_id_defaults_none():
"""active_task_id defaults to None for projects without it (backward compat)."""
from app.models.project import Project
data = {
"project_id": "proj-1", "name": "Test", "status": "created",
"created_at": "2026-01-01", "updated_at": "2026-01-01",
}
assert Project.from_dict(data).active_task_id is None
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd backend && uv run pytest tests/test_project_task_recovery.py -v
```
Expected: FAIL — `Project` has no `active_task_id` field
- [ ] **Step 3: Add `active_task_id` to `Project` dataclass**
In `backend/app/models/project.py`, find the `@dataclass class Project:` definition.
After the `error: Optional[str] = None` field, add:
```python
# Active task tracking — persisted so the frontend can reconnect after a page refresh
active_task_id: Optional[str] = None
```
In `Project.to_dict()`, add to the returned dict (after `"error": self.error`):
```python
"active_task_id": self.active_task_id,
```
In `Project.from_dict()` (the classmethod that builds a Project from a dict), add:
```python
active_task_id=data.get("active_task_id"),
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
cd backend && uv run pytest tests/test_project_task_recovery.py -v
```
Expected: 3 PASSED
- [ ] **Step 5: Persist `active_task_id` in the graph build endpoint**
In `backend/app/api/graph.py`, find the graph build endpoint (the one that calls `task_manager.create_task(...)`).
**When the task starts** — find the block that does:
```python
task_id = task_manager.create_task(...)
project.graph_build_task_id = task_id
ProjectManager.save_project(project)
```
Add `project.active_task_id = task_id` before `ProjectManager.save_project(project)`:
```python
task_id = task_manager.create_task(...)
project.graph_build_task_id = task_id
project.active_task_id = task_id # ← ADD
ProjectManager.save_project(project)
```
**When the task completes** — find the async thread function where the project status is set to `GRAPH_COMPLETED` (or `graph_completed`) and `ProjectManager.save_project(project)` is called. Add before that save:
```python
project.active_task_id = None # ← ADD (task is done, no recovery needed)
ProjectManager.save_project(project)
```
**When the task fails** — find where status is set to `GRAPH_FAILED` (or similar). Add the same clear:
```python
project.active_task_id = None # ← ADD
ProjectManager.save_project(project)
```
- [ ] **Step 6: Use `active_task_id` in `MainView.vue` recovery**
In `frontend/src/views/MainView.vue`, find the `loadProject` function. Inside the `if (res.success)` block, find:
```javascript
} else if (res.data.status === 'graph_building' && res.data.graph_build_task_id) {
currentPhase.value = 1
startPollingTask(res.data.graph_build_task_id)
startGraphPolling()
}
```
Replace with:
```javascript
} else if (res.data.status === 'graph_building') {
const taskId = res.data.active_task_id || res.data.graph_build_task_id
if (taskId) {
currentPhase.value = 1
addLog(t('log.reconnectingToTask', { taskId }))
startPollingTask(taskId)
startGraphPolling()
}
}
```
- [ ] **Step 7: Add i18n key to all locale files**
`locales/en.json` — add inside the `"log"` object (or create it if it doesn't exist):
```json
"reconnectingToTask": "Reconnecting to active task {taskId}…"
```
`locales/ca.json`:
```json
"reconnectingToTask": "Reconnectant a la tasca activa {taskId}…"
```
`locales/es.json`:
```json
"reconnectingToTask": "Reconectando a la tarea activa {taskId}…"
```
`locales/zh.json`:
```json
"reconnectingToTask": "重新连接到活动任务 {taskId}…"
```
- [ ] **Step 8: Commit**
```bash
git add backend/app/models/project.py backend/app/api/graph.py \
frontend/src/views/MainView.vue \
locales/en.json locales/ca.json locales/es.json locales/zh.json \
backend/tests/test_project_task_recovery.py
git commit -m "feat(recovery): persist active_task_id to project.json for browser-refresh reconnection"
```
---
## Task 5: Fix `pendingUpload` — survive page refresh
**Problem:** `pendingUpload.js` uses a `reactive({})` object that lives only in JavaScript memory. `File` objects cannot be serialized. On refresh, files are lost. We can at least persist `simulationRequirement` (a string) and show a friendly error when files are gone.
**Files:**
- Modify: `frontend/src/store/pendingUpload.js`
- Modify: `frontend/src/views/MainView.vue`
- Modify: all locale files
- [ ] **Step 1: Rewrite `pendingUpload.js` to use sessionStorage for requirement**
Replace the full contents of `frontend/src/store/pendingUpload.js` with:
```javascript
/**
* Temporary storage for files and simulation requirement.
* - simulationRequirement: persisted to sessionStorage (survives refresh within the tab)
* - files: in-memory only (File objects are not JSON-serializable)
*/
import { reactive } from 'vue'
const state = reactive({
files: [],
simulationRequirement: sessionStorage.getItem('pendingRequirement') || '',
isPending: sessionStorage.getItem('pendingIsPending') === 'true',
importOntologyMode: false,
ontologyFile: null,
})
export function setPendingUpload(files, requirement, importOntologyMode = false, ontologyFile = null) {
state.files = files
state.simulationRequirement = requirement
state.isPending = true
state.importOntologyMode = importOntologyMode
state.ontologyFile = ontologyFile
sessionStorage.setItem('pendingRequirement', requirement)
sessionStorage.setItem('pendingIsPending', 'true')
}
export function getPendingUpload() {
return {
files: state.files,
simulationRequirement: state.simulationRequirement,
isPending: state.isPending,
importOntologyMode: state.importOntologyMode,
ontologyFile: state.ontologyFile,
}
}
export function clearPendingUpload() {
state.files = []
state.simulationRequirement = ''
state.isPending = false
state.importOntologyMode = false
state.ontologyFile = null
sessionStorage.removeItem('pendingRequirement')
sessionStorage.removeItem('pendingIsPending')
}
export default state
```
- [ ] **Step 2: Add friendly error in `MainView.vue` when files are lost**
In `frontend/src/views/MainView.vue`, find the function that reads `getPendingUpload()` and uses `pending.files` to start the upload (likely `handleNewProject` or `initProject`). Add a guard at the start of that function:
```javascript
const pending = getPendingUpload()
if (!pending.isPending) {
return // Not a new project session, nothing to handle
}
if (pending.files.length === 0) {
// Files were lost (page refresh). Requirement may still be in sessionStorage
// but File objects are gone. Show friendly error and redirect.
error.value = t('error.filesLostAfterRefresh')
addLog(t('error.filesLostAfterRefresh'))
clearPendingUpload()
setTimeout(() => router.push('/'), 3000)
return
}
// ... existing upload code continues unchanged
```
- [ ] **Step 3: Add i18n keys to locale files**
`locales/en.json`:
```json
"filesLostAfterRefresh": "Files were lost after page refresh. Redirecting to home to re-select files…"
```
`locales/ca.json`:
```json
"filesLostAfterRefresh": "Els fitxers s'han perdut en refrescar la pàgina. Redirigint a l'inici per tornar a seleccionar-los…"
```
`locales/es.json`:
```json
"filesLostAfterRefresh": "Los archivos se perdieron al refrescar la página. Redirigiendo al inicio para volver a seleccionarlos…"
```
`locales/zh.json`:
```json
"filesLostAfterRefresh": "刷新页面后文件丢失,正在跳转到首页重新选择文件…"
```
- [ ] **Step 4: Verify frontend build**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: Build succeeds without errors
- [ ] **Step 5: Commit**
```bash
git add frontend/src/store/pendingUpload.js frontend/src/views/MainView.vue \
locales/en.json locales/ca.json locales/es.json locales/zh.json
git commit -m "fix(frontend): persist upload requirement to sessionStorage; friendly error when files lost on refresh"
```
---
## Task 6: PR #578 — Fix DB connection resource leak
**Problem:** `_get_interview_result` in `run_twitter_simulation.py` opens a SQLite connection but may not close it if an exception is raised before `conn.close()`.
**Files:**
- Modify: `backend/scripts/run_twitter_simulation.py`
- [ ] **Step 1: Add `finally` block to `_get_interview_result`**
In `backend/scripts/run_twitter_simulation.py`, find `_get_interview_result`. It will have a pattern like:
```python
def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
conn = None
try:
conn = sqlite3.connect(self.db_path)
# ... cursor and query ...
return result
except Exception as e:
print(f"error: {e}")
return {}
```
Add a `finally` block:
```python
def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
conn = None
try:
conn = sqlite3.connect(self.db_path)
# ... cursor and query ... (unchanged)
return result
except Exception as e:
print(f"error: {e}")
return {}
finally:
if conn:
conn.close()
```
- [ ] **Step 2: Fix `handle_batch_interview` `agent_id` guard**
In the same file, find `handle_batch_interview`. Find the line that reads `agent_id` from `action_args`:
```python
agent_id = action_args.get("agent_id")
```
Replace with:
```python
agent_id = action_args.get("agent_id") or 0 # guard against None
```
- [ ] **Step 3: Check if `run_reddit_simulation.py` needs the same fix**
```bash
grep -n "conn.close\|sqlite3.connect" /home/ubuntu/dev/MiroFish/backend/scripts/run_reddit_simulation.py 2>/dev/null | head -20
```
If the same pattern exists (sqlite3.connect without a finally), apply the identical fix to `run_reddit_simulation.py`.
- [ ] **Step 4: Verify syntax**
```bash
cd backend && uv run python -c "import scripts.run_twitter_simulation; print('OK')" 2>/dev/null || echo "import as module failed, checking syntax directly" && cd backend && uv run python -m py_compile scripts/run_twitter_simulation.py && echo "Syntax OK"
```
- [ ] **Step 5: Commit**
```bash
git add backend/scripts/run_twitter_simulation.py
git commit -m "fix(simulation): guarantee SQLite connection close with finally block"
```
---
## Task 7: PR #559 — Strip fabricated `<tool_result>` blocks from LLM responses
**Problem:** The LLM sometimes generates its own `<tool_result>` tags in the response body, confusing the ReAct loop and causing hallucinations.
**Files:**
- Modify: `backend/app/services/report_agent.py`
- [ ] **Step 1: Add `_strip_fake_tool_results` static method to `ReportAgent`**
In `backend/app/services/report_agent.py`, verify `import re` is present at the top. If not, add it.
Find the `ReportAgent` class and add this static method near the other helpers:
```python
@staticmethod
def _strip_fake_tool_results(response: str) -> str:
"""Strip <tool_result> blocks fabricated by the LLM to prevent hallucination loops."""
cleaned = re.sub(r'<tool_result>.*?</tool_result>', '', response, flags=re.DOTALL)
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
return cleaned.strip()
```
- [ ] **Step 2: Apply strip wherever assistant response is added to messages**
In `report_agent.py`, search for all occurrences of:
```python
messages.append({"role": "assistant", "content": response})
```
Replace each one with:
```python
messages.append({"role": "assistant", "content": ReportAgent._strip_fake_tool_results(response)})
```
There should be 35 occurrences inside `_generate_section_react` and `chat`. Replace all of them.
- [ ] **Step 3: Verify syntax and import**
```bash
cd backend && uv run python -c "from app.services.report_agent import ReportAgent; print(ReportAgent._strip_fake_tool_results('<tool_result>bad</tool_result>clean'))"
```
Expected: `clean`
- [ ] **Step 4: Commit**
```bash
git add backend/app/services/report_agent.py
git commit -m "fix(report): strip fabricated tool_result blocks to prevent LLM hallucination loop"
```
---
## Task 8: PR #581 — Handle string attributes in ontology
**Problem:** When the LLM returns ontology attributes as a list of strings (`["name", "age"]`) instead of dicts (`[{"name": "name", "type": "text", ...}]`), both `ontology_generator.py` and `graph_builder.py` crash with `TypeError: string indices must be integers`.
**Files:**
- Create: `backend/tests/test_ontology_attributes.py`
- Modify: `backend/app/services/graph_builder.py`
- Modify: `backend/app/services/ontology_generator.py`
- [ ] **Step 1: Write failing tests**
Create `backend/tests/test_ontology_attributes.py`:
```python
def test_graph_builder_normalizes_string_attributes():
"""_normalize_entity_attributes converts strings to dicts without crashing."""
from app.services.graph_builder import GraphBuilderService
mixed = ["name", "age", {"name": "email", "type": "text", "description": "Email"}]
result = GraphBuilderService._normalize_entity_attributes(mixed)
assert all(isinstance(a, dict) for a in result)
assert result[0] == {"name": "name", "type": "text", "description": "name"}
assert result[1] == {"name": "age", "type": "text", "description": "age"}
assert result[2]["name"] == "email"
def test_graph_builder_normalize_empty():
"""Empty attribute list returns empty list."""
from app.services.graph_builder import GraphBuilderService
assert GraphBuilderService._normalize_entity_attributes([]) == []
def test_ontology_generator_normalizes_string_attributes():
"""_normalize_ontology_attributes converts string attrs in entities and edges."""
from app.services.ontology_generator import OntologyGenerator
raw = {
"entities": [{"name": "Person", "description": "A person", "attributes": ["name", "age"]}],
"edges": [{"name": "KNOWS", "description": "Knows", "attributes": ["since"]}],
}
result = OntologyGenerator._normalize_ontology_attributes(raw)
entity_attrs = result["entities"][0]["attributes"]
assert all(isinstance(a, dict) for a in entity_attrs)
assert entity_attrs[0] == {"name": "name", "type": "text", "description": "name"}
edge_attrs = result["edges"][0]["attributes"]
assert all(isinstance(a, dict) for a in edge_attrs)
assert edge_attrs[0] == {"name": "since", "type": "text", "description": "since"}
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd backend && uv run pytest tests/test_ontology_attributes.py -v
```
Expected: FAIL — `_normalize_entity_attributes` and `_normalize_ontology_attributes` don't exist yet
- [ ] **Step 3: Add `_normalize_entity_attributes` to `GraphBuilderService`**
In `backend/app/services/graph_builder.py`, find the `GraphBuilderService` class. Add this static method:
```python
@staticmethod
def _normalize_entity_attributes(attributes: list) -> list:
"""Ensure each attribute item is a dict; convert strings to minimal dicts."""
result = []
for attr in attributes:
if isinstance(attr, str):
result.append({"name": attr, "type": "text", "description": attr})
elif isinstance(attr, dict):
result.append(attr)
return result
```
Find all places in `GraphBuilderService` where `entity_def.get("attributes", [])` (or a similar expression) is iterated with `for attr_def in ...:` and then `attr_def["name"]` is accessed. Replace those loops with:
```python
for attr_def in GraphBuilderService._normalize_entity_attributes(entity_def.get("attributes", [])):
attr_name = safe_attr_name(attr_def["name"])
attr_desc = attr_def.get("description", attr_name)
# ... rest of loop unchanged
```
Apply the same pattern for edge attribute loops if they exist.
- [ ] **Step 4: Add `_normalize_ontology_attributes` to `OntologyGenerator`**
In `backend/app/services/ontology_generator.py`, find the `OntologyGenerator` class. Add this static method:
```python
@staticmethod
def _normalize_ontology_attributes(ontology: dict) -> dict:
"""Normalize string attributes in LLM-generated ontology to dicts (in-place)."""
for entity in ontology.get("entities", []):
entity["attributes"] = [
attr if isinstance(attr, dict)
else {"name": attr, "type": "text", "description": attr}
for attr in entity.get("attributes", [])
]
for edge in ontology.get("edges", []):
edge["attributes"] = [
attr if isinstance(attr, dict)
else {"name": attr, "type": "text", "description": attr}
for attr in edge.get("attributes", [])
]
return ontology
```
Find the method in `OntologyGenerator` that returns the parsed ontology after the LLM call (look for `json.loads(...)` of the LLM response, typically returning a dict with `"entities"` and `"edges"` keys). Call the normalizer before returning:
```python
ontology = OntologyGenerator._normalize_ontology_attributes(ontology)
return ontology
```
- [ ] **Step 5: Run all tests**
```bash
cd backend && uv run pytest tests/ -v
```
Expected: All tests pass (test_read_action_log × 4, test_project_task_recovery × 3, test_ontology_attributes × 3 = 10 total)
- [ ] **Step 6: Commit**
```bash
git add backend/app/services/graph_builder.py backend/app/services/ontology_generator.py \
backend/tests/test_ontology_attributes.py
git commit -m "fix(ontology): handle string attributes from LLM response to prevent TypeError crash"
```
---
## Task 9: Final verification and push
- [ ] **Step 1: Run full backend test suite**
```bash
cd backend && uv run pytest tests/ -v
```
Expected: All 10 tests PASS
- [ ] **Step 2: Run frontend build**
```bash
cd /home/ubuntu/dev/MiroFish/frontend && npm run build 2>&1 | tail -15
```
Expected: Build succeeds, no errors
- [ ] **Step 3: Quick smoke-check imports**
```bash
cd backend && uv run python -c "
from app.services.simulation_runner import SimulationRunner
from app.graph.graphiti_backend import GraphitiBackend
from app.models.project import Project
from app.services.report_agent import ReportAgent
from app.services.graph_builder import GraphBuilderService
from app.services.ontology_generator import OntologyGenerator
print('All imports OK')
"
```
Expected: `All imports OK`
- [ ] **Step 4: Push branch**
```bash
git push -u origin fix/fase0-estabilitat
```
- [ ] **Step 5: Create PR**
```bash
gh pr create \
--title "fix: Fase 0 — Estabilitat del Fork (7 stability patches)" \
--base main \
--body "$(cat <<'EOF'
## Summary
Hardening pass resolving 7 stability issues identified in the enterprise roadmap (2026-04-26):
- **PR #460 equiv** — Fix `_read_action_log` partial JSONL reads; `safe_position` only advances on complete lines ending with `\n`
- **PR #553 equiv** — Cap `get_all_edges` to 5000 items with Cypher `LIMIT` to prevent unbounded RAM growth on large graphs
- **PR #578 equiv** — Guarantee SQLite connection close with `finally` block; guard `agent_id` against None
- **PR #559 equiv** — Strip fabricated `<tool_result>` blocks from LLM responses to prevent ReAct hallucination loops
- **PR #581 equiv** — Normalize string attributes in LLM-generated ontology to prevent `TypeError: string indices must be integers`
- **New** — Persist `active_task_id` to `project.json`; `MainView.vue` reconnects polling after browser refresh
- **New** — Persist upload `simulationRequirement` to `sessionStorage`; friendly redirect error when files are lost on refresh
## Test plan
- [ ] `cd backend && uv run pytest tests/ -v` — 10 tests, all green
- [ ] `cd frontend && npm run build` — no errors
- [ ] Manual: start a graph build → refresh browser mid-build → should show "Reconnecting to active task…" and resume polling
- [ ] Manual: select files on Home → refresh MainView before files upload → should show friendly error and redirect to Home after 3s
- [ ] Manual: start a simulation with large document → server RAM should not spike unboundedly
EOF
)"
```
---
## Self-Review
**Spec coverage check:**
| Roadmap item | Task |
|---|---|
| Integrar PR #460 — Fix data loss action log | Task 2 ✅ |
| Integrar PR #553 — Memory limit per a grafs grans | Task 3 ✅ |
| Fix TaskManager persistence (task_id recovery) | Task 4 ✅ |
| Fix pendingUpload (sessionStorage + friendly error) | Task 5 ✅ |
| Integrar PR #578 — DB resource management | Task 6 ✅ |
| Integrar PR #559 — LLM hallucination fix | Task 7 ✅ |
| Integrar PR #581 — Ontology string attributes fix | Task 8 ✅ |
**No placeholders:** All steps contain complete code or exact shell commands. The only abbreviation is `# ... (existing processing unchanged)` in Task 2 Step 4, which refers to processing code that is genuinely not modified.
**Type consistency:** `active_task_id` is `Optional[str]` throughout (Project dataclass, to_dict, from_dict, JS frontend). `_normalize_entity_attributes` and `_normalize_ontology_attributes` method names are consistent across test file and implementation.

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
# Spec F2-A: Edició, Regeneració, Creació i Clonació d'Agents
# Spec F2-A+B: Agents Configurables i Paràmetres de Simulació
**Data:** 2026-05-03
**Fase:** Fase 2-A del roadmap enterprise (2026-04-26-enterprise-roadmap.md)
**Fases:** F2-A i F2-B del roadmap enterprise (2026-04-26-enterprise-roadmap.md)
**Estat:** Aprovat per disseny
---
@ -11,67 +11,100 @@
La Fase 2 del roadmap enterprise té com a objectiu un flux iteratiu:
construir graf → ajustar agents → simular → ajustar → re-simular.
Fins ara, MiroFish genera N agents a partir del graf de forma automàtica i no
permet modificar-los ni reutilitzar-los entre simulacions. L'usuari ha de
Fins ara el Step 2 és un pas únic i automàtic: genera perfils d'agents i
config de simulació sense pauses ni possibilitat d'edició. L'usuari ha de
reconstruir tot el projecte si vol ajustar qualsevol cosa.
Aquesta spec cobreix:
1. **Selector de nombre d'agents** — triar top-N per connectivitat abans de generar
2. **Edició d'agents individuals** — editar camps LLM-generats d'un agent concret
3. **Regeneració, creació i eliminació d'agents individuals** — regenerar un agent, crear-ne un de nou basat en una entitat del graf, o eliminar-ne un
4. **Clonació de simulació** — crear una nova simulació dins el mateix projecte partint dels agents i config d'una simulació anterior
5. **Aïllament de grafs per simulació** (ex F2-D) — cada simulació escriu les converses al seu propi `group_id` Neo4j via clonatge APOC
1. **Redisseny del flux Step 2** en tres fases amb confirmació explícita
2. **Selector de nombre d'agents** — top-N per connectivitat
3. **Edició, regeneració, creació i eliminació d'agents individuals** (Fase A)
4. **Edició de paràmetres de comportament i simulació** (Fase B)
5. **Clonació de simulació** — reutilitzar agents i config d'una simulació anterior
6. **Aïllament de grafs per simulació** — cada simulació té el seu propi `group_id` Neo4j
---
## Redisseny del flux Step 2
El Step 2 passa de ser un pas únic a tenir tres fases seqüencials:
```
FASE A — Generació i edició de personalitats
El sistema genera els perfils d'agents (nom, bio, persona, mbti, etc.)
L'usuari pot retocar, regenerar, crear o eliminar agents.
→ L'usuari prem "Continuar" quan està satisfet
FASE B — Generació i edició de config de comportament
El sistema genera la config de comportament dels agents restants
(posts_per_hour, active_hours, etc.) i els paràmetres globals de simulació
(total_simulation_hours, pesos de plataforma, etc.).
L'usuari pot editar qualsevol d'aquests paràmetres.
→ L'usuari prem "Iniciar simulació" per passar al Step 3
FASE C — Step 3: Llançament
(sense canvis respecte a l'actual)
```
**Camps editables per fase:**
- **Fase A** (per agent): `name`, `bio`, `persona`, `age`, `gender`, `mbti`,
`country`, `profession`, `interested_topics`, `stance`, `sentiment_bias`
- **Fase B** (per agent): `posts_per_hour`, `comments_per_hour`, `active_hours`,
`response_delay_min`, `response_delay_max`, `activity_level`, `influence_weight`
- **Fase B** (globals simulació): `total_simulation_hours`, `minutes_per_round`,
`agents_per_hour_min`, `agents_per_hour_max`, `following_probability`, `recsys_type`,
`recency_weight`, `popularity_weight`, `relevance_weight`, `viral_threshold`,
`echo_chamber_strength`
---
## Model de dades
### Canvi a la BD: `parent_simulation_id`
### Nou estat de simulació: `profiles_ready`
Afegir una columna nullable a la taula `simulations`:
El cicle d'estats s'amplia:
```
created → preparing → profiles_ready → configuring → prepared → running → completed
→ stopped
→ failed
```
- `profiles_ready`: perfils d'agents generats, esperant confirmació de l'usuari per generar config
- `configuring`: generant config de comportament i simulació (async)
- `prepared`: config generada, llest per llançar (estat actual)
### Canvi a la BD: `parent_simulation_id`
```sql
ALTER TABLE simulations
ADD COLUMN parent_simulation_id VARCHAR(64) REFERENCES simulations(simulation_id);
```
- `NULL` → simulació nova (comportament actual)
- `NULL` → simulació nova
- Valor → simulació clonada d'una existent
No hi ha canvis a cap altra taula ni model existent.
### Canvi a la BD: `graph_id_simulation`
Afegir una columna nullable a la taula `simulations`:
```sql
ALTER TABLE simulations
ADD COLUMN graph_id_simulation VARCHAR(128);
```
Conté el `group_id` Neo4j exclusiu d'aquesta simulació (creat per clonatge APOC
just abans de llançar). `NULL` fins que la simulació s'inicia.
Conté el `group_id` Neo4j exclusiu d'aquesta simulació. `NULL` fins que s'inicia.
### Immutabilitat de simulacions completades
### Immutabilitat
- `status IN ('completed', 'running')` → no es pot editar
- `status IN ('created', 'prepared', 'stopped', 'failed')` → editable
### Camps editables d'un perfil d'agent
Editables (LLM-generats): `name`, `bio`, `persona`, `age`, `gender`, `mbti`,
`country`, `profession`, `interested_topics`, `stance`, `sentiment_bias`,
`activity_level`.
Immutables (OASIS els necessita intactes): `user_id`, `source_entity_uuid`,
`source_entity_type`.
- `status IN ('created', 'preparing', 'profiles_ready', 'configuring', 'prepared', 'stopped', 'failed')` → editable
### Flag de protecció contra sobreescriptura
Cada agent porta un flag `manually_edited: bool` (default `False`) a
`reddit_profiles.json` i `simulation_config.json`. El generador en batch salta
els agents amb `manually_edited: True`.
Cada agent porta `manually_edited: bool` (default `False`) a `reddit_profiles.json`
i `simulation_config.json`. El generador en batch salta els agents amb
`manually_edited: True`.
---
@ -79,57 +112,53 @@ els agents amb `manually_edited: True`.
### Comportament
Abans del botó "Generar agents" al Step 2, el sistema consulta quantes entitats
hi ha disponibles al graf i mostra el total com a suggeriment (ex: "66 agents
disponibles"). L'usuari pot reduir el número. Si no toca res, el comportament
és idèntic a l'actual.
Abans d'iniciar la Fase A, el sistema consulta quantes entitats hi ha disponibles
al graf i mostra el total com a suggeriment. L'usuari pot reduir el número.
Si no toca res, el comportament és idèntic a l'actual (es generen tots).
Si l'usuari redueix a N, el backend selecciona les **top-N entitats per grau de
connectivitat** (edges entrants + sortints al graf Zep). Les entitats més
connectades representen els actors amb més relacions al document i generen
connectivitat** (edges entrants + sortints). Les entitats més connectades generen
simulacions socialment més riques.
**Mínim recomanat:** 15 agents. El frontend mostra un avís si el valor és inferior.
### Canvis al backend
`POST /api/simulation/prepare` — nou paràmetre opcional:
```json
{ "max_agents": 40 }
```
Si `max_agents` és present, `ZepEntityReader` ordena les entitats filtrades per
grau de connectivitat descendent i agafa les primeres N.
`POST /api/simulation/prepare` — nou paràmetre opcional `max_agents: int`.
Nova funció: `ZepEntityReader.get_entities_by_connectivity(graph_id, max_n)`
obté totes les entitats, les ordena per `len(edges)` i retorna les top-N.
obté totes les entitats, les ordena per nombre d'edges i retorna les top-N.
### Canvis al frontend (Step 2)
Just abans del botó "Generar agents":
Just abans d'iniciar la generació:
- Crida a `GET /api/simulation/entities/{graph_id}` (ja existent) per obtenir el total
- `GET /api/simulation/entities/{graph_id}` per obtenir el total disponible
- Camp numèric amb valor default = total disponible
- Avís visual si valor < 15
- Desplegable "Nova simulació / Des de simulació anterior" (vegeu Subsistema 5)
- El valor es passa a `POST /api/simulation/prepare` com a `max_agents`
---
## Subsistema 2: Edició d'agents individuals
## Subsistema 2: Fase A — Edició de personalitats d'agents
### Comportament
El modal de visualització d'agents (ja existent a `Step2EnvSetup.vue`) afegeix
un botó "Editar" que converteix els camps en inputs editables. En desar, el
backend actualitza el perfil i marca `manually_edited: True`.
La Fase A comença quan el primer agent és generat. L'usuari veu les cards
d'agents aparèixer progressivament. En completar-se la generació, apareix el
botó "Continuar →" que activa la Fase B.
L'edició és possible durant la generació (l'agent editat queda protegit) i
després de la generació completa. No és possible si `status: running` o `completed`.
Mentre la generació és en curs o un cop completada, l'usuari pot:
### Canvis al backend
- **Editar** qualsevol agent ja generat
- **Regenerar** un agent (un cop la generació completa, `status: profiles_ready`)
- **Crear** un agent nou basat en una entitat del graf
- **Eliminar** un agent
`PATCH /api/simulation/{sim_id}/agent/{user_id}`
### 2a. Edició d'un agent
**Backend:** `PATCH /api/simulation/{sim_id}/agent/{user_id}`
```json
{ "bio": "...", "stance": "opposing" }
@ -137,33 +166,19 @@ després de la generació completa. No és possible si `status: running` o `comp
Acció:
1. Valida que la simulació no és `running` ni `completed`
2. Carrega `reddit_profiles.json` i `simulation_config.json`
3. Localitza l'agent per `user_id`
4. Aplica els canvis + `manually_edited: True`
5. Desa atòmicament (backup → escriptura → elimina backup si OK, restaura si falla)
6. Retorna el perfil actualitzat
1. Valida `status NOT IN ('running', 'completed')`
2. Carrega `reddit_profiles.json`
3. Localitza l'agent per `user_id`, aplica canvis + `manually_edited: True`
4. Desa atòmicament (backup → escriptura → elimina backup si OK, restaura si falla)
5. Retorna el perfil actualitzat
### Canvis al frontend (Step 2)
**Frontend:** modal d'agent existent amb botó "Editar" que activa mode edició.
Camps editables de Fase A com `<input>` / `<textarea>` / `<select>`.
Botó "Desa" / "Cancel·la". Indicador "Editat manualment" a la card si `manually_edited: True`.
Al modal d'agent (`Step2EnvSetup.vue`):
### 2b. Regeneració d'un agent existent
- Botó "Editar" → converteix camps en `<input>` / `<textarea>` / `<select>`
- Botó "Desa" → `PATCH` → refrescar el modal
- Botó "Cancel·la" → descarta canvis locals
- Indicador visual "Editat manualment" a la card si `manually_edited: True`
---
## Subsistema 3: Regeneració, creació i eliminació d'agents individuals
### 3a. Regeneració d'un agent existent
Disponible **només** quan `status: prepared`. No disponible si `running` o `completed`.
L'usuari pot afegir instruccions opcionals per al LLM (ex: "Fes-lo més escèptic").
La regeneració és asíncrona (5-15 s). El modal mostra un spinner mentre el
`task_id` és en curs i es refrescar en completar.
Disponible només quan `status: profiles_ready`.
**Backend:** `POST /api/simulation/{sim_id}/agent/{user_id}/regenerate`
@ -173,61 +188,119 @@ La regeneració és asíncrona (5-15 s). El modal mostra un spinner mentre el
Acció:
1. Valida `status: prepared`
2. Llegeix `source_entity_uuid` de l'agent actual
1. Valida `status: profiles_ready`
2. Llegeix `source_entity_uuid` de l'agent
3. Consulta Zep per obtenir el context de l'entitat original
4. Crida `OasisProfileGenerator.generate_profile_from_entity()` amb `extra_instructions`
5. Actualitza `reddit_profiles.json`, `twitter_profiles.csv` i `simulation_config.json`
6. Retorna `task_id` per polling (reutilitza `GET /api/.../task/{task_id}`)
5. Actualitza `reddit_profiles.json`
6. Retorna `task_id` per polling
**Frontend:** botó "Regenera" al modal (visible si `is_preparing === False`),
camp de text opcional per a instruccions, spinner durant el task, refresc en completar.
**Frontend:** botó "Regenera" al modal (visible si `status: profiles_ready`),
camp opcional d'instruccions, spinner durant el task, refresc en completar.
### 3b. Creació d'un agent nou
### 2c. Creació d'un agent nou
Permet afegir un agent basat en una **entitat existent al graf** que no té agent
assignat (entitat sense `user_id` a `agent_profiles.json`). L'usuari tria el tipus
d'entitat (en base a l'ontologia del projecte), selecciona una entitat concreta, i
opcionalment afegeix un prompt que guia la generació del perfil.
Permet afegir un agent basat en una entitat existent al graf sense agent assignat.
**Backend:** `POST /api/simulation/{sim_id}/agent`
```json
{
"source_entity_uuid": "uuid-de-lentitat",
"extra_instructions": "..."
}
{ "source_entity_uuid": "...", "extra_instructions": "..." }
```
Acció:
1. Valida que `status IN ('prepared', 'created')` i que l'entitat no té ja un agent
2. Assigna el proper `user_id` disponible (max existent + 1)
3. Consulta Zep per obtenir el context de l'entitat
4. Crida `OasisProfileGenerator.generate_profile_from_entity()` amb `extra_instructions`
5. Afegeix el nou agent a `reddit_profiles.json`, `twitter_profiles.csv` i `simulation_config.json`
6. Retorna `task_id` per polling
1. Valida `status IN ('profiles_ready', 'created')`
2. Valida que l'entitat no té ja un agent assignat
3. Assigna el proper `user_id` disponible (max existent + 1)
4. Consulta Zep per obtenir el context de l'entitat
5. Crida `OasisProfileGenerator.generate_profile_from_entity()` amb `extra_instructions`
6. Afegeix el nou agent a `reddit_profiles.json`
7. Retorna `task_id` per polling
**Frontend:** botó "Afegeix agent" al panell d'agents (visible si `status: prepared`).
Flux:
**Frontend:** botó "Afegeix agent" al panell. Flux:
1. Desplegable de tipus d'entitat (basats en l'ontologia del projecte)
2. Llista d'entitats disponibles d'aquell tipus sense agent assignat
3. Camp de text opcional per a instruccions addicionals
2. Llista d'entitats disponibles sense agent assignat
3. Camp d'instruccions opcional
4. Confirmar → spinner → nou agent apareix a les cards en completar
### 3c. Eliminació d'un agent
Permet eliminar un agent si la simulació és `prepared`. No és possible si
`running` o `completed`.
### 2d. Eliminació d'un agent
**Backend:** `DELETE /api/simulation/{sim_id}/agent/{user_id}`
Acció: elimina l'agent de `reddit_profiles.json`, `twitter_profiles.csv` i
`simulation_config.json`. No reassigna `user_id` dels agents restants
(OASIS treballa amb IDs fixos).
Valida `status NOT IN ('running', 'completed')`. Elimina l'agent de
`reddit_profiles.json`. No reassigna `user_id` dels restants.
**Frontend:** botó "Elimina" al modal d'agent, amb confirmació explícita.
**Frontend:** botó "Elimina" al modal amb confirmació explícita.
### 2e. Confirmació de Fase A → Fase B
**Backend:** `POST /api/simulation/{sim_id}/generate-config`
Acció:
1. Valida `status: profiles_ready`
2. Canvia `status → configuring`
3. Llança async la generació de config de comportament i simulació
(crida `SimulationConfigGenerator` amb els agents que han quedat)
4. En completar, canvia `status → prepared`
5. Retorna `task_id` per polling
**Frontend:** botó "Continuar →" (visible quan `status: profiles_ready`).
En clicar, inicia polling del `task_id`. En `completed`, passa a mostrar la Fase B.
---
## Subsistema 3: Fase B — Edició de paràmetres de comportament i simulació
### Comportament
Un cop la config de comportament és generada (`status: prepared`), el Step 2
mostra una nova secció editable amb:
- Paràmetres globals de simulació
- Paràmetres de comportament per a cada agent
L'usuari pot editar qualsevol valor. En clicar "Iniciar simulació" es passa al Step 3.
### 3a. Edició de paràmetres globals
**Backend:** `PATCH /api/simulation/{sim_id}/config`
```json
{
"total_simulation_hours": 48,
"minutes_per_round": 60,
"agents_per_hour_min": 5,
"agents_per_hour_max": 20,
"following_probability": 0.05,
"recsys_type": "random",
"twitter_config": {
"recency_weight": 0.4,
"popularity_weight": 0.3,
"relevance_weight": 0.3,
"viral_threshold": 10,
"echo_chamber_strength": 0.5
}
}
```
Acció: valida `status: prepared`, actualitza `simulation_config.json` atòmicament,
retorna la config actualitzada.
**Frontend:** formulari de paràmetres globals amb inputs numèrics i selectors.
Valors actuals carregats de `GET /api/simulation/{sim_id}/config`.
### 3b. Edició de comportament per agent
**Backend:** `PATCH /api/simulation/{sim_id}/agent/{user_id}` (el mateix endpoint
de Subsistema 2a) — ara accepta també els camps de comportament:
`posts_per_hour`, `comments_per_hour`, `active_hours`, `response_delay_min`,
`response_delay_max`, `activity_level`, `influence_weight`.
**Frontend:** cada agent card a la Fase B mostra els seus paràmetres de comportament
editables inline (sense modal), amb inputs numèrics i selector d'hores actives.
---
@ -235,21 +308,22 @@ Acció: elimina l'agent de `reddit_profiles.json`, `twitter_profiles.csv` i
### Comportament
Al Step 2, just sobre el botó "Generar agents", un desplegable:
Al desplegable de pre-generació (Subsistema 1):
- **"Nova simulació"** (default) → comportament actual, genera des de zero
- **"Des de [nom/data sim anterior]"** → clona agents i config d'una simulació existent
- **"Nova simulació"** (default) → genera des de zero
- **"Des de [nom/data sim anterior]"** → clona agents de sim anterior
Simulacions clonables: qualsevol del mateix projecte amb `status != 'created'`
(inclou `prepared`, `running`, `stopped`, `failed`, `completed`).
Simulacions clonables: qualsevol del mateix projecte amb
`status NOT IN ('created')`.
En triar una simulació font, el Step 2 crida `POST /clone` i carrega
directament amb els agents clonats (salta la generació, `status: prepared`).
En triar una simulació font, el sistema:
1. Crida `POST /clone` → crea nova simulació amb `status: profiles_ready`
2. Carrega directament la Fase A amb els agents clonats (salta la generació)
3. L'usuari pot editar agents i continuar a Fase B
### Canvis al backend
`GET /api/simulation/list?project_id={id}` — ja existent; el frontend filtra les clonables.
`POST /api/simulation/{sim_id}/clone`
```json
@ -258,42 +332,34 @@ directament amb els agents clonats (salta la generació, `status: prepared`).
Acció:
1. Valida que `sim_id` té agents (`status != 'created'`)
2. Crea nova simulació al projecte amb `status: prepared`
1. Valida `status != 'created'`
2. Crea nova simulació amb `status: profiles_ready`
3. Guarda `parent_simulation_id = sim_id`
4. Copia fitxers: `reddit_profiles.json`, `twitter_profiles.csv`, `simulation_config.json`, `agent_profiles.json`
5. Retorna `new_simulation_id`
### Canvis al frontend (Step 2)
- Desplegable que carrega les simulacions clonables del projecte
- En seleccionar → `POST /clone` → redirigir al Step 2 amb el nou `simulation_id`
- Step 2 detecta `status: prepared` i mostra els agents directament
4. Copia `reddit_profiles.json`, `twitter_profiles.csv`, `agent_profiles.json`
5. **No copia** `simulation_config.json` (es regenerarà a la Fase B)
6. Retorna `new_simulation_id`
---
## Subsistema 5: Aïllament de grafs per simulació (ex F2-D)
## Subsistema 5: Aïllament de grafs per simulació
### Problema
Actualment `enable_graph_memory_update` és `true` al frontend (hardcodejat a
`Step3Simulation.vue:402`), cosa que fa que totes les simulacions escriguin les
seves converses al mateix `graph_id` del projecte. El resultat del report de
sim2 inclou converses de sim1.
Ara `enable_graph_memory_update` és `true` hardcodejat al frontend. Totes les
simulacions escriuen converses al mateix `graph_id` del projecte.
El report de sim2 inclou converses de sim1.
### Solució
Cada simulació té el seu propi `group_id` Neo4j (`graph_id_simulation`), creat
per clonatge APOC de `graph_id_document` just abans de llançar la simulació.
`graph_id_simulation` conté una còpia exacta del document original, i és on els
agents escriuen les seves converses. `graph_id_document` queda immutable i serveix
de font per a futurs clonatges.
El ReportAgent consulta **únicament** `graph_id_simulation`.
per clonatge APOC de `graph_id_document` just abans de llançar. `graph_id_simulation`
conté una còpia exacta del document original i és on els agents escriuen les
converses. `graph_id_document` queda immutable. El ReportAgent consulta
**únicament** `graph_id_simulation`.
### Flux
1. **Clonatge APOC** (just abans de `POST /api/simulation/start`):
**Clonatge APOC** just abans de `POST /api/simulation/start`:
```python
# Clona nodes
@ -315,18 +381,12 @@ await session.run("""
""", src=graph_id_document, dst=graph_id_simulation)
```
Neo4j Aura Cloud inclou APOC de sèrie, per tant no cal cap dependència addicional.
2. **Llançament de simulació**: `enable_graph_memory_update: true` usant `graph_id_simulation`.
Els agents escriuen converses a `graph_id_simulation`, que ja conté el contingut del document.
3. **Report**: `ReportAgent` rep i consulta únicament `graph_id_simulation`.
No cal fusionar grafs ni canviar `ZepToolsService`.
Neo4j Aura Cloud inclou APOC de sèrie.
### Canvis al backend
**`graphiti_backend.py`** — nova funció `clone_graph(src_group_id, dst_group_id)` que
executa les dues queries APOC via `execute_query()`.
**`graphiti_backend.py`** — nova funció `clone_graph(src_group_id, dst_group_id)`
via `execute_query()`.
**`POST /api/simulation/start`** — si `enable_graph_memory_update: true`:
@ -336,11 +396,10 @@ executa les dues queries APOC via `execute_query()`.
4. Llança la simulació usant `graph_id_simulation`
**`POST /api/report/generate`** — passa `graph_id_simulation` al `ReportAgent`
si existeix; si no (simulació sense graph update), passa `graph_id_document` com ara.
si existeix; si no, passa `graph_id_document`.
**`DELETE /api/simulation/{sim_id}`** (endpoint d'esborrat ja existent) — si la
simulació té `graph_id_simulation`, esborrar tots els nodes i relacions d'aquell
`group_id` a Neo4j abans d'esborrar la simulació de la BD:
**`DELETE /api/simulation/{sim_id}`** — si existeix `graph_id_simulation`,
esborrar el graf Neo4j associat:
```python
await session.run("""
@ -350,72 +409,74 @@ await session.run("""
### Canvis al frontend (Step 3)
Eliminar el hardcodejat `enable_graph_memory_update: true` i convertir-ho en
una opció configurable a la UI de Step 3 (checkbox "Actualitza el graf amb les
converses de la simulació", default `true`).
Convertir `enable_graph_memory_update` de hardcodejat a checkbox configurable
a la UI del Step 3 (default `true`).
---
## Ordre d'implementació recomanat
1. `PATCH /agent/{user_id}` + edició modal
2. Selector de N agents pre-generació
3. `DELETE /agent/{user_id}` + botó eliminar modal
4. `POST /agent` (creació) + UI selector d'entitat
5. `POST /agent/{user_id}/regenerate` + UI polling
6. `POST /clone` + desplegable Step 2 + `parent_simulation_id` a la BD
7. Subsistema 5 (aïllament de grafs): `clone_graph`, `search_graph_multi`, `graph_id_simulation` a BD
1. Nou estat `profiles_ready` a la BD i backend
2. `PATCH /agent/{user_id}` (camps Fase A) + edició modal
3. Botó "Continuar →" + `POST /generate-config` + polling Fase B
4. Selector de N agents pre-generació
5. `DELETE /agent/{user_id}` + botó eliminar modal
6. `POST /agent` (creació) + UI selector d'entitat
7. `POST /agent/{user_id}/regenerate` + UI polling
8. `PATCH /simulation/{sim_id}/config` + formulari Fase B globals
9. `PATCH /agent/{user_id}` (camps comportament Fase B) + edició inline
10. `POST /clone` + desplegable pre-generació + `parent_simulation_id` a la BD
11. Subsistema 5: `clone_graph`, `graph_id_simulation`, `enable_graph_memory_update` configurable
---
## Verificació end-to-end
### Test 1: Edició d'agent
### Test 1: Flux complet Fase A → B → simulació
- Generar agents → obrir modal → modificar `bio` i `stance` → desar
- Verificar modal actualitzat i card amb indicador "Editat manualment"
- Iniciar generació d'un altre agent → verificar que l'agent editat no es sobreescriu
- Generar agents → editar bio d'un → clicar "Continuar →"
- Verificar que la config de comportament es genera amb els agents actuals
- Editar `total_simulation_hours` i `posts_per_hour` d'un agent
- Iniciar simulació → verificar que els valors editats s'apliquen
### Test 2: Regeneració individual
### Test 2: Edició protegida durant generació
- Completar generació → obrir modal → "Regenera" amb instrucció "Fes-lo més escèptic"
- Verificar spinner → perfil canviat coherentment amb la instrucció
- Editar un agent mentre altres es generen
- Verificar que l'agent editat NO es sobreescriu al finalitzar la generació
### Test 3: Creació d'agent nou
### Test 3: Regeneració individual
- Projecte amb entitats sense agent → clicar "Afegeix agent"
- Triar tipus d'entitat → seleccionar entitat → afegir instruccions → confirmar
- Verificar que el nou agent apareix a les cards i és coherent amb l'entitat
- `status: profiles_ready` → regenerar agent amb instrucció "Fes-lo més escèptic"
- Verificar spinner → perfil canviat coherentment
### Test 4: Eliminació d'agent
### Test 4: Creació i eliminació d'agents
- Modal d'un agent → "Elimina" → confirmació → agent desapareix de les cards
- Verificar que la simulació segueix consistent
- Afegir agent nou (entitat existent sense agent) → verificar a cards
- Eliminar un agent → verificar que desapareix i la config és consistent
### Test 5: Selector N agents
- 50 entitats disponibles → reduir a 30 → verificar exactament 30 agents generats
- 50 entitats disponibles → reduir a 30 → verificar 30 agents generats
- Verificar que corresponen a les entitats més connectades
- No tocar → verificar 50 agents (comportament actual)
### Test 6: Clonació
- Completar sim1 → Step 2 del mateix projecte → "Des de sim1"
- Verificar que Step 2 carrega amb els mateixos agents que sim1
- Editar un agent → llançar simulació → verificar que sim1 queda intacta
- Completar sim1 → Nova simulació "Des de sim1" al mateix projecte
- Verificar Fase A amb mateixos agents que sim1
- Editar un agent → Continuar → editar config → llançar
- Verificar que sim1 queda intacta
### Test 7: Aïllament de grafs
- Llançar sim1 amb `enable_graph_memory_update: true` → completar
- Llançar sim2 amb `enable_graph_memory_update: true` → completar
- Llançar sim1 i sim2 amb `enable_graph_memory_update: true`
- Verificar que el report de sim1 NO inclou converses de sim2 i viceversa
- Verificar que ambdós reports inclouen les entitats del `graph_document`
- Esborrar sim2 → verificar que el seu `graph_id_simulation` s'esborra de Neo4j
---
## Dependències i notes futures
- **F2-B** (paràmetres simulació): independent, es pot fer en paral·lel
- **F3** (UI de llistat de simulacions): `parent_simulation_id` prepara l'arbre de versions
- **Subsistema 5** depèn de Graphiti/Neo4j com a backend actiu (no Zep Cloud);
verificar configuració de l'entorn abans d'implementar

View File

@ -223,6 +223,14 @@ export const regenerateAgent = (simulationId, userId, data = {}) => {
return requestWithRetry(() => service.post(`/api/simulation/${simulationId}/agent/${userId}/regenerate`, data), 3, 1000)
}
/**
* Generic task status poll (for regenerate_agent and other async tasks)
* @param {string} taskId
*/
export const getTaskStatus = (taskId) => {
return requestWithRetry(() => service.get(`/api/simulation/task/${taskId}`), 3, 1000)
}
/**
* Trigger Fase A Fase B transition (generate behavior config)
* @param {string} simulationId
@ -248,3 +256,11 @@ export const patchSimulationConfig = (simulationId, fields) => {
export const cloneSimulation = (simulationId, projectId) => {
return requestWithRetry(() => service.post(`/api/simulation/${simulationId}/clone`, { project_id: projectId }), 3, 1000)
}
/**
* Get the count of available entities for a graph (fast endpoint, no entity data returned)
* @param {string} graphId
*/
export const getGraphEntityCount = (graphId) => {
return service.get(`/api/simulation/entities/${graphId}/count`)
}

View File

@ -61,6 +61,38 @@
{{ $t('step2.generateAgentPersonaDesc') }}
</p>
<!-- Fase Pre: agent count selector -->
<div v-if="currentPhase === 'phase_pre'" class="phase-pre-section">
<div v-if="entityCountLoading" class="phase-pre-loading">
{{ $t('step2.loadingEntityCount') }}
</div>
<div v-else class="phase-pre-form">
<div class="phase-pre-info">
<span class="phase-pre-label">{{ $t('step2.availableEntities') }}</span>
<span class="phase-pre-count">{{ availableEntityCount ?? '—' }}</span>
</div>
<div class="phase-pre-input-row">
<label class="phase-pre-input-label">{{ $t('step2.maxAgentsLabel') }}</label>
<input
v-model.number="maxAgentsInput"
type="number"
:min="1"
:max="availableEntityCount || 9999"
class="phase-pre-input"
:placeholder="availableEntityCount ?? ''"
/>
<span v-if="maxAgentsInput !== null && maxAgentsInput < 15" class="phase-pre-warn">
{{ $t('step2.minAgentsWarning') }}
</span>
</div>
<div class="phase-pre-footer">
<button class="continue-btn" @click="confirmPrePhase">
{{ $t('step2.startGeneration') }}
</button>
</div>
</div>
</div>
<!-- Profiles Stats -->
<div v-if="profiles.length > 0" class="stats-grid">
<div class="stat-card">
@ -87,13 +119,13 @@
v-for="(profile, idx) in profiles"
:key="idx"
class="profile-card"
@click="selectProfile(profile)"
:class="{ 'profile-card--clickable': currentPhase === 'phase_a' || currentPhase === 'generating' }"
@click="(currentPhase === 'phase_a' || currentPhase === 'generating') ? openAgentModal(profile) : selectProfile(profile)"
>
<div class="profile-header">
<span class="profile-realname">{{ profile.username || 'Unknown' }}</span>
<span class="profile-username">@{{ profile.name || `agent_${idx}` }}</span>
<span v-if="profile.manually_edited" class="manually-edited-badge">{{ $t('step2.manuallyEditedBadge') }}</span>
<button v-if="currentPhase === 'phase_a'" class="agent-action-btn" @click.stop="openAgentModal(profile)">···</button>
</div>
<div class="profile-meta">
<span class="profile-profession">{{ profile.profession || $t('step2.unknownProfession') }}</span>
@ -125,41 +157,6 @@
</div>
</div>
<!-- Fase B: Global parameters form -->
<div v-if="currentPhase === 'phase_b'" class="phase-b-section">
<h3>{{ $t('step2.phaseBTitle') }}</h3>
<p class="section-subtitle">{{ $t('step2.phaseBSubtitle') }}</p>
<div class="config-form">
<div class="config-field">
<label>{{ $t('step2.totalHours') }}</label>
<input type="number" v-model.number="configForm.total_simulation_hours" min="1" max="720" />
</div>
<div class="config-field">
<label>{{ $t('step2.minutesPerRound') }}</label>
<input type="number" v-model.number="configForm.minutes_per_round" min="1" max="1440" />
</div>
<div class="config-field">
<label>{{ $t('step2.followingProbability') }}</label>
<input type="number" v-model.number="configForm.following_probability" min="0" max="1" step="0.01" />
</div>
<div class="config-field">
<label>{{ $t('step2.recsysType') }}</label>
<select v-model="configForm.recsys_type">
<option value="random">Random</option>
<option value="interest">Interest-based</option>
<option value="twhin">TWHIN</option>
</select>
</div>
</div>
<div class="phase-b-footer">
<button class="action-btn secondary" @click="currentPhase = 'phase_a'"> Back</button>
<button class="continue-btn" :disabled="phaseBSaving" @click="launchSimulation">
{{ $t('step2.launchSimulation') }}
</button>
</div>
</div>
</div>
</div>
@ -183,18 +180,42 @@
{{ $t('step2.dualPlatformConfigDesc') }}
</p>
<!-- Config Preview -->
<!-- Config Preview + Edit -->
<div v-if="simulationConfig" class="config-detail-panel">
<!-- 时间配置 -->
<!-- Toolbar: Edit / Save buttons -->
<div class="config-toolbar">
<button v-if="!step3EditMode" class="action-btn secondary" @click="step3EditMode = true">
{{ $t('common.edit') }}
</button>
<template v-else>
<button class="action-btn secondary" @click="step3EditMode = false">{{ $t('common.cancel') }}</button>
<button class="continue-btn" :disabled="configSaving" @click="saveFullConfig">
<span v-if="configSaving">{{ $t('common.loading') }}</span>
<span v-else>{{ $t('common.save') }} </span>
</button>
</template>
</div>
<!-- Time config -->
<div class="config-block">
<div class="config-grid">
<div class="config-block-header">
<span class="config-block-title">{{ $t('step2.simulationDuration') }}</span>
</div>
<div class="config-grid" :class="{ editable: step3EditMode }">
<div class="config-item">
<span class="config-item-label">{{ $t('step2.simulationDuration') }}</span>
<span class="config-item-value">{{ simulationConfig.time_config?.total_simulation_hours || '-' }} {{ $t('common.hours') }}</span>
<input v-if="step3EditMode" class="inline-input" type="number" min="1" max="720"
v-model.number="simulationConfig.time_config.total_simulation_hours" />
<span v-else class="config-item-value">{{ simulationConfig.time_config?.total_simulation_hours }}</span>
<span class="config-unit">{{ $t('common.hours') }}</span>
</div>
<div class="config-item">
<span class="config-item-label">{{ $t('step2.roundDuration') }}</span>
<span class="config-item-value">{{ simulationConfig.time_config?.minutes_per_round || '-' }} {{ $t('common.minutes') }}</span>
<input v-if="step3EditMode" class="inline-input" type="number" min="1" max="1440"
v-model.number="simulationConfig.time_config.minutes_per_round" />
<span v-else class="config-item-value">{{ simulationConfig.time_config?.minutes_per_round }}</span>
<span class="config-unit">{{ $t('common.minutes') }}</span>
</div>
<div class="config-item">
<span class="config-item-label">{{ $t('step2.totalRounds') }}</span>
@ -202,46 +223,88 @@
</div>
<div class="config-item">
<span class="config-item-label">{{ $t('step2.activePerHour') }}</span>
<span class="config-item-value">{{ simulationConfig.time_config?.agents_per_hour_min }}-{{ simulationConfig.time_config?.agents_per_hour_max }}</span>
<template v-if="step3EditMode">
<input class="inline-input small" type="number" min="1"
v-model.number="simulationConfig.time_config.agents_per_hour_min" />
<span class="config-unit"></span>
<input class="inline-input small" type="number" min="1"
v-model.number="simulationConfig.time_config.agents_per_hour_max" />
</template>
<span v-else class="config-item-value">{{ simulationConfig.time_config?.agents_per_hour_min }}{{ simulationConfig.time_config?.agents_per_hour_max }}</span>
</div>
</div>
<div class="time-periods">
<div class="time-periods" :class="{ editable: step3EditMode }">
<div class="period-item">
<span class="period-label">{{ $t('step2.peakHours') }}</span>
<span class="period-hours">{{ simulationConfig.time_config?.peak_hours?.join(':00, ') }}:00</span>
<span class="period-multiplier">×{{ simulationConfig.time_config?.peak_activity_multiplier }}</span>
<span class="period-mult-label">×</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="0.1" max="5" step="0.1"
v-model.number="simulationConfig.time_config.peak_activity_multiplier" />
<span v-else class="config-item-value">{{ simulationConfig.time_config?.peak_activity_multiplier }}</span>
</div>
<div class="period-item">
<span class="period-label">{{ $t('step2.workHours') }}</span>
<span class="period-hours">{{ simulationConfig.time_config?.work_hours?.[0] }}:00-{{ simulationConfig.time_config?.work_hours?.slice(-1)[0] }}:00</span>
<span class="period-multiplier">×{{ simulationConfig.time_config?.work_activity_multiplier }}</span>
<span class="period-mult-label">×</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="0.1" max="5" step="0.1"
v-model.number="simulationConfig.time_config.work_activity_multiplier" />
<span v-else class="config-item-value">{{ simulationConfig.time_config?.work_activity_multiplier }}</span>
</div>
<div class="period-item">
<span class="period-label">{{ $t('step2.morningHours') }}</span>
<span class="period-hours">{{ simulationConfig.time_config?.morning_hours?.[0] }}:00-{{ simulationConfig.time_config?.morning_hours?.slice(-1)[0] }}:00</span>
<span class="period-multiplier">×{{ simulationConfig.time_config?.morning_activity_multiplier }}</span>
<span class="period-mult-label">×</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="0.1" max="5" step="0.1"
v-model.number="simulationConfig.time_config.morning_activity_multiplier" />
<span v-else class="config-item-value">{{ simulationConfig.time_config?.morning_activity_multiplier }}</span>
</div>
<div class="period-item">
<span class="period-label">{{ $t('step2.offPeakHours') }}</span>
<span class="period-hours">{{ simulationConfig.time_config?.off_peak_hours?.[0] }}:00-{{ simulationConfig.time_config?.off_peak_hours?.slice(-1)[0] }}:00</span>
<span class="period-multiplier">×{{ simulationConfig.time_config?.off_peak_activity_multiplier }}</span>
<span class="period-mult-label">×</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="0.1" max="5" step="0.1"
v-model.number="simulationConfig.time_config.off_peak_activity_multiplier" />
<span v-else class="config-item-value">{{ simulationConfig.time_config?.off_peak_activity_multiplier }}</span>
</div>
</div>
</div>
<!-- Agent 配置 -->
<!-- Global sim params -->
<div class="config-block">
<div class="config-block-header">
<span class="config-block-title">{{ $t('step2.phaseBTitle') }}</span>
</div>
<div class="config-grid" :class="{ editable: step3EditMode }">
<div class="config-item">
<span class="config-item-label">{{ $t('step2.followingProbability') }}</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="0" max="1" step="0.01"
v-model.number="simulationConfig.following_probability" />
<span v-else class="config-item-value">{{ simulationConfig.following_probability }}</span>
</div>
<div class="config-item">
<span class="config-item-label">{{ $t('step2.recsysType') }}</span>
<select v-if="step3EditMode" class="inline-input" v-model="simulationConfig.recsys_type">
<option value="random">Random</option>
<option value="interest">Interest-based</option>
<option value="twhin">TWHIN</option>
</select>
<span v-else class="config-item-value">{{ simulationConfig.recsys_type }}</span>
</div>
</div>
</div>
<!-- Agent configs -->
<div class="config-block">
<div class="config-block-header">
<span class="config-block-title">{{ $t('step2.agentConfig') }}</span>
<span class="config-block-badge">{{ simulationConfig.agent_configs?.length || 0 }} {{ $t('common.items') }}</span>
</div>
<div class="agents-cards">
<div
v-for="agent in simulationConfig.agent_configs"
:key="agent.agent_id"
<div
v-for="agent in simulationConfig.agent_configs"
:key="agent.agent_id"
class="agent-card"
>
<!-- 卡片头部 -->
<div class="agent-card-header">
<div class="agent-identity">
<span class="agent-id">Agent {{ agent.agent_id }}</span>
@ -252,61 +315,71 @@
<span class="agent-stance" :class="'stance-' + agent.stance">{{ agent.stance }}</span>
</div>
</div>
<!-- 活跃时间轴 -->
<!-- Active hours timeline (clickable only in edit mode) -->
<div class="agent-timeline">
<span class="timeline-label">{{ $t('step2.activeTimePeriod') }}</span>
<div class="mini-timeline">
<div
v-for="hour in 24"
:key="hour - 1"
<div
v-for="hour in 24"
:key="hour - 1"
class="timeline-hour"
:class="{ 'active': agent.active_hours?.includes(hour - 1) }"
:class="{ 'active': agent.active_hours?.includes(hour - 1), 'clickable': step3EditMode }"
:title="`${hour - 1}:00`"
@click="step3EditMode && toggleAgentHour(agent, hour - 1)"
></div>
</div>
<div class="timeline-marks">
<span>0</span>
<span>6</span>
<span>12</span>
<span>18</span>
<span>24</span>
<span>0</span><span>6</span><span>12</span><span>18</span><span>24</span>
</div>
</div>
<!-- 行为参数 -->
<!-- Behaviour params (editable or read-only) -->
<div class="agent-params">
<div class="param-group">
<div class="param-item">
<span class="param-label">{{ $t('step2.postsPerHour') }}</span>
<span class="param-value">{{ agent.posts_per_hour }}</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="0" step="0.1"
v-model.number="agent.posts_per_hour" />
<span v-else class="config-item-value">{{ agent.posts_per_hour }}</span>
</div>
<div class="param-item">
<span class="param-label">{{ $t('step2.commentsPerHour') }}</span>
<span class="param-value">{{ agent.comments_per_hour }}</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="0" step="0.1"
v-model.number="agent.comments_per_hour" />
<span v-else class="config-item-value">{{ agent.comments_per_hour }}</span>
</div>
<div class="param-item">
<span class="param-label">{{ $t('step2.responseDelay') }}</span>
<span class="param-value">{{ agent.response_delay_min }}-{{ agent.response_delay_max }}min</span>
<template v-if="step3EditMode">
<input class="inline-input small" type="number" min="0"
v-model.number="agent.response_delay_min" />
<span class="config-unit"></span>
<input class="inline-input small" type="number" min="0"
v-model.number="agent.response_delay_max" />
<span class="config-unit">min</span>
</template>
<span v-else class="config-item-value">{{ agent.response_delay_min }}{{ agent.response_delay_max }} min</span>
</div>
</div>
<div class="param-group">
<div class="param-item">
<span class="param-label">{{ $t('step2.activityLevel') }}</span>
<span class="param-value with-bar">
<span class="mini-bar" :style="{ width: (agent.activity_level * 100) + '%' }"></span>
{{ (agent.activity_level * 100).toFixed(0) }}%
</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="0" max="1" step="0.01"
v-model.number="agent.activity_level" />
<span v-else class="config-item-value">{{ agent.activity_level }}</span>
</div>
<div class="param-item">
<span class="param-label">{{ $t('step2.sentimentBias') }}</span>
<span class="param-value" :class="agent.sentiment_bias > 0 ? 'positive' : agent.sentiment_bias < 0 ? 'negative' : 'neutral'">
{{ agent.sentiment_bias > 0 ? '+' : '' }}{{ agent.sentiment_bias?.toFixed(1) }}
</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="-1" max="1" step="0.1"
v-model.number="agent.sentiment_bias" />
<span v-else class="config-item-value">{{ agent.sentiment_bias }}</span>
</div>
<div class="param-item">
<span class="param-label">{{ $t('step2.influenceWeight') }}</span>
<span class="param-value highlight">{{ agent.influence_weight?.toFixed(1) }}</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="0" step="0.1"
v-model.number="agent.influence_weight" />
<span v-else class="config-item-value">{{ agent.influence_weight }}</span>
</div>
</div>
</div>
@ -314,7 +387,7 @@
</div>
</div>
<!-- 平台配置 -->
<!-- Platform configs -->
<div class="config-block">
<div class="config-block-header">
<span class="config-block-title">{{ $t('step2.recommendAlgoConfig') }}</span>
@ -325,25 +398,11 @@
<span class="platform-name">{{ $t('step2.platform1Name') }}</span>
</div>
<div class="platform-params">
<div class="param-row">
<span class="param-label">{{ $t('step2.recencyWeight') }}</span>
<span class="param-value">{{ simulationConfig.twitter_config.recency_weight }}</span>
</div>
<div class="param-row">
<span class="param-label">{{ $t('step2.popularityWeight') }}</span>
<span class="param-value">{{ simulationConfig.twitter_config.popularity_weight }}</span>
</div>
<div class="param-row">
<span class="param-label">{{ $t('step2.relevanceWeight') }}</span>
<span class="param-value">{{ simulationConfig.twitter_config.relevance_weight }}</span>
</div>
<div class="param-row">
<span class="param-label">{{ $t('step2.viralThreshold') }}</span>
<span class="param-value">{{ simulationConfig.twitter_config.viral_threshold }}</span>
</div>
<div class="param-row">
<span class="param-label">{{ $t('step2.echoChamberStrength') }}</span>
<span class="param-value">{{ simulationConfig.twitter_config.echo_chamber_strength }}</span>
<div class="param-row" v-for="key in ['recency_weight','popularity_weight','relevance_weight','viral_threshold','echo_chamber_strength']" :key="key">
<span class="param-label">{{ $t('step2.' + key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())) }}</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="0" max="1" step="0.01"
v-model.number="simulationConfig.twitter_config[key]" />
<span v-else class="config-item-value">{{ simulationConfig.twitter_config[key] }}</span>
</div>
</div>
</div>
@ -352,46 +411,33 @@
<span class="platform-name">{{ $t('step2.platform2Name') }}</span>
</div>
<div class="platform-params">
<div class="param-row">
<span class="param-label">{{ $t('step2.recencyWeight') }}</span>
<span class="param-value">{{ simulationConfig.reddit_config.recency_weight }}</span>
</div>
<div class="param-row">
<span class="param-label">{{ $t('step2.popularityWeight') }}</span>
<span class="param-value">{{ simulationConfig.reddit_config.popularity_weight }}</span>
</div>
<div class="param-row">
<span class="param-label">{{ $t('step2.relevanceWeight') }}</span>
<span class="param-value">{{ simulationConfig.reddit_config.relevance_weight }}</span>
</div>
<div class="param-row">
<span class="param-label">{{ $t('step2.viralThreshold') }}</span>
<span class="param-value">{{ simulationConfig.reddit_config.viral_threshold }}</span>
</div>
<div class="param-row">
<span class="param-label">{{ $t('step2.echoChamberStrength') }}</span>
<span class="param-value">{{ simulationConfig.reddit_config.echo_chamber_strength }}</span>
<div class="param-row" v-for="key in ['recency_weight','popularity_weight','relevance_weight','viral_threshold','echo_chamber_strength']" :key="key">
<span class="param-label">{{ $t('step2.' + key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())) }}</span>
<input v-if="step3EditMode" class="inline-input small" type="number" min="0" max="1" step="0.01"
v-model.number="simulationConfig.reddit_config[key]" />
<span v-else class="config-item-value">{{ simulationConfig.reddit_config[key] }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- LLM 配置推理 -->
<!-- LLM reasoning -->
<div v-if="simulationConfig.generation_reasoning" class="config-block">
<div class="config-block-header">
<span class="config-block-title">{{ $t('step2.llmConfigReasoning') }}</span>
</div>
<div class="reasoning-content">
<div
v-for="(reason, idx) in simulationConfig.generation_reasoning.split('|').slice(0, 2)"
:key="idx"
<div
v-for="(reason, idx) in simulationConfig.generation_reasoning.split('|').slice(0, 2)"
:key="idx"
class="reasoning-item"
>
<p class="reasoning-text">{{ reason.trim() }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
@ -469,14 +515,14 @@
</div>
<!-- Step 05: 准备完成 -->
<div class="step-card" :class="{ 'active': phase === 4 }">
<div class="step-card" :class="{ 'active': phase >= 3 }">
<div class="card-header">
<div class="step-info">
<span class="step-num">05</span>
<span class="step-title">{{ $t('step2.setupComplete') }}</span>
</div>
<div class="step-status">
<span v-if="phase >= 4" class="badge processing">{{ $t('step1.inProgress') }}</span>
<span v-if="phase >= 3" class="badge processing">{{ $t('step1.inProgress') }}</span>
<span v-else class="badge pending">{{ $t('common.pending') }}</span>
</div>
</div>
@ -568,7 +614,7 @@
</button>
<button
class="action-btn primary"
:disabled="phase < 4"
:disabled="phase < 3"
@click="handleStartSimulation"
>
{{ $t('step2.startDualWorldSim') }}
@ -589,7 +635,7 @@
<div v-if="agentModalMode === 'edit'" class="modal-body">
<div class="field-group" v-for="field in ['name', 'bio', 'persona', 'age', 'gender', 'mbti', 'country', 'profession', 'stance']" :key="field">
<label>{{ field }}</label>
<label>{{ $t('step2.agentField_' + field) }}</label>
<textarea v-if="['bio', 'persona'].includes(field)" v-model="editForm[field]" rows="3" />
<input v-else v-model="editForm[field]" />
</div>
@ -728,7 +774,10 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import {
prepareSimulation,
getGraphEntityCount,
getSimulation,
getPrepareStatus,
getTaskStatus,
getSimulationProfilesRealtime,
getSimulationConfig,
getSimulationConfigRealtime,
@ -772,8 +821,13 @@ let lastLoggedConfigStage = ''
const useCustomRounds = ref(false) // 使
const customMaxRounds = ref(40) // 40
// Fase pre (agent count selector)
const availableEntityCount = ref(null) // total entities available in the graph
const maxAgentsInput = ref(null) // user-selected max agents (null = all)
const entityCountLoading = ref(false)
// Fase A/B state
const currentPhase = ref('generating') // 'generating' | 'phase_a' | 'phase_b'
const currentPhase = ref('phase_pre') // 'phase_pre' | 'generating' | 'phase_a' | 'phase_b'
const agentModalOpen = ref(false)
const agentModalMode = ref('view') // 'view' | 'edit' | 'regen'
const selectedAgent = ref(null)
@ -785,29 +839,28 @@ const deleteConfirmAgent = ref(null)
const generateConfigLoading = ref(false)
const generateConfigTaskId = ref(null)
// Fase B state
const phaseBConfig = ref(null)
const phaseBSaving = ref(false)
const configForm = ref({
total_simulation_hours: 24,
minutes_per_round: 60,
following_probability: 0.05,
recsys_type: 'random',
})
// Step 03 inline editing state
const step3EditMode = ref(false)
const configSaving = ref(false)
// Watch stage to update phase
// Watch stage to update phase.
// In the F2A/B flow, config is generated in a separate step (generate-config endpoint),
// so startConfigPolling must NOT be triggered here only in loadPreparedData / continueToPhaseB.
watch(currentStage, (newStage) => {
if (newStage === '生成Agent人设' || newStage === 'generating_profiles') {
phase.value = 1
} else if (newStage === '生成模拟配置' || newStage === 'generating_config') {
phase.value = 2
//
if (!configTimer) {
addLog(t('log.startGeneratingConfig'))
startConfigPolling()
// Only activate step-03 and config polling when we are past phase_a (i.e. config was
// explicitly triggered via continueToPhaseB, not during profile generation).
if (currentPhase.value === 'phase_b' || currentPhase.value === 'generating' && phase.value >= 2) {
phase.value = 2
if (!configTimer) {
addLog(t('log.startGeneratingConfig'))
startConfigPolling()
}
}
} else if (newStage === '准备模拟脚本' || newStage === 'copying_scripts') {
phase.value = 2 //
phase.value = 2
}
})
@ -860,20 +913,16 @@ const addLog = (msg) => {
emit('add-log', msg)
}
//
const handleStartSimulation = () => {
//
// Step 05 launch button emits next-step
const handleStartSimulation = async () => {
phase.value = 4
const params = {}
if (useCustomRounds.value) {
// max_rounds
params.maxRounds = customMaxRounds.value
addLog(t('log.startSimCustomRounds', { rounds: customMaxRounds.value }))
} else {
// max_rounds
addLog(t('log.startSimAutoRounds', { rounds: autoGeneratedRounds.value }))
}
emit('next-step', params)
}
@ -888,6 +937,12 @@ const selectProfile = (profile) => {
selectedProfile.value = profile
}
// Kick off preparation after the user confirms the pre-phase selector
const confirmPrePhase = () => {
currentPhase.value = 'generating'
startPrepareSimulation()
}
//
const startPrepareSimulation = async () => {
if (!props.simulationId) {
@ -895,24 +950,40 @@ const startPrepareSimulation = async () => {
emit('update-status', 'error')
return
}
// Ensure generating phase is active regardless of how this was invoked
if (currentPhase.value === 'phase_pre') {
currentPhase.value = 'generating'
}
//
phase.value = 1
addLog(t('log.simInstanceCreated', { id: props.simulationId }))
addLog(t('log.preparingSimEnv'))
emit('update-status', 'processing')
try {
const res = await prepareSimulation({
const preparePayload = {
simulation_id: props.simulationId,
use_llm_for_profiles: true,
parallel_profile_count: 5
})
}
if (maxAgentsInput.value && maxAgentsInput.value < (availableEntityCount.value ?? Infinity)) {
preparePayload.max_agents = maxAgentsInput.value
}
const res = await prepareSimulation(preparePayload)
if (res.success && res.data) {
if (res.data.already_prepared) {
addLog(t('log.detectedExistingPrep'))
await loadPreparedData()
if (res.data.status === 'profiles_ready') {
await fetchProfilesRealtime()
addLog(t('log.loadedAgentProfiles', { count: profiles.value.length }))
currentPhase.value = 'phase_a'
emit('update-status', 'profiles_ready')
} else {
await loadPreparedData()
}
return
}
@ -1011,8 +1082,13 @@ const pollPrepareStatus = async () => {
}
}
//
if (data.status === 'profiles_ready') {
// Check completion using simulation_status (set by backend alongside task status).
// data.status is the task status (processing/completed/failed).
// data.simulation_status is the actual simulation FSM state.
const simStatus = data.simulation_status
const taskStatus = data.status
if (simStatus === 'profiles_ready') {
addLog(t('log.prepareComplete'))
stopPolling()
stopProfilesPolling()
@ -1020,12 +1096,12 @@ const pollPrepareStatus = async () => {
addLog(t('log.loadedAgentProfiles', { count: profiles.value.length }))
currentPhase.value = 'phase_a'
emit('update-status', 'profiles_ready')
} else if (data.status === 'completed' || data.status === 'ready' || data.already_prepared) {
} else if (simStatus === 'ready' || (data.already_prepared && simStatus && simStatus !== 'profiles_ready')) {
addLog(t('log.prepareComplete'))
stopPolling()
stopProfilesPolling()
await loadPreparedData()
} else if (data.status === 'failed') {
} else if (taskStatus === 'failed') {
addLog(t('log.prepareFailedWithError', { error: data.error || t('common.unknownError') }))
stopPolling()
stopProfilesPolling()
@ -1111,36 +1187,12 @@ const fetchConfigRealtime = async () => {
}
}
//
// Si la config ja és generada, actualitzem simulationConfig però NO saltem a phase=4.
// La transició phase_b phase=4 la gestiona exclusivament launchSimulation/loadPreparedData,
// per garantir que l'usuari vegi el formulari de Fase B (step 03-05) abans de llançar.
if (data.config_generated && data.config) {
simulationConfig.value = data.config
addLog(t('log.configComplete'))
//
if (data.summary) {
addLog(t('log.configSummaryAgents', { count: data.summary.total_agents }))
addLog(t('log.configSummaryHours', { hours: data.summary.simulation_hours }))
addLog(t('log.configSummaryPosts', { count: data.summary.initial_posts_count }))
addLog(t('log.configSummaryTopics', { count: data.summary.hot_topics_count }))
addLog(t('log.configSummaryPlatforms', { twitter: data.summary.has_twitter_config ? '✓' : '✗', reddit: data.summary.has_reddit_config ? '✓' : '✗' }))
}
//
if (data.config.time_config) {
const tc = data.config.time_config
addLog(t('log.timeConfigDetail', { minutes: tc.minutes_per_round, rounds: Math.floor((tc.total_simulation_hours * 60) / tc.minutes_per_round) }))
}
//
if (data.config.event_config?.narrative_direction) {
const narrative = data.config.event_config.narrative_direction
addLog(t('log.narrativeDirection', { direction: narrative.length > 50 ? narrative.substring(0, 50) + '...' : narrative }))
}
stopConfigPolling()
phase.value = 4
addLog(t('log.envSetupComplete'))
emit('update-status', 'completed')
}
}
} catch (err) {
@ -1163,16 +1215,9 @@ const loadPreparedData = async () => {
if (res.data.config_generated && res.data.config) {
simulationConfig.value = res.data.config
addLog(t('log.configLoadSuccess'))
//
if (res.data.summary) {
addLog(t('log.configSummaryAgents', { count: res.data.summary.total_agents }))
addLog(t('log.configSummaryHours', { hours: res.data.summary.simulation_hours }))
addLog(t('log.configSummaryPostsAlt', { count: res.data.summary.initial_posts_count }))
}
addLog(t('log.envSetupComplete'))
phase.value = 4
phase.value = 3
currentPhase.value = 'phase_b'
emit('update-status', 'completed')
} else {
//
@ -1188,21 +1233,45 @@ const loadPreparedData = async () => {
// ---- Fase A/B helpers ----
// Generic task poller: polls simulation status directly until status reaches targetStatuses.
// Used for generate-config (which moves sim through configuring ready).
const pollSimStatusUntil = async (targetStatuses, onComplete, intervalMs = 2000, maxWaitMs = 300000) => {
const deadline = Date.now() + maxWaitMs
return new Promise((resolve) => {
const interval = setInterval(async () => {
try {
if (Date.now() > deadline) { clearInterval(interval); resolve(); return }
const res = await getSimulation(props.simulationId)
const simStatus = res?.data?.status
if (targetStatuses.includes(simStatus)) {
clearInterval(interval)
onComplete && onComplete()
resolve()
} else if (simStatus === 'failed') {
clearInterval(interval)
resolve()
}
} catch {
clearInterval(interval)
resolve()
}
}, intervalMs)
})
}
const pollTaskUntilDone = async (taskId, onComplete, intervalMs = 2000) => {
if (!taskId) { onComplete && onComplete(); return }
return new Promise((resolve) => {
const interval = setInterval(async () => {
try {
const res = await getPrepareStatus({
task_id: taskId,
simulation_id: props.simulationId
})
const status = res.data?.status || res.data?.data?.status
if (status === 'completed') {
const res = await getTaskStatus(taskId)
const d = res.data || {}
const taskStatus = d.status
if (taskStatus === 'completed') {
clearInterval(interval)
onComplete && onComplete(res.data?.result || res.data?.data?.result)
onComplete && onComplete(d.result)
resolve()
} else if (status === 'failed') {
} else if (taskStatus === 'failed') {
clearInterval(interval)
resolve()
}
@ -1232,9 +1301,10 @@ const saveAgent = async () => {
editLoading.value = true
try {
const res = await patchAgent(props.simulationId, selectedAgent.value.user_id, editForm.value)
if (res.data?.success) {
// axios interceptor: res = { success, data: updatedProfile }
if (res.success) {
const idx = profiles.value.findIndex(p => p.user_id === selectedAgent.value.user_id)
if (idx !== -1) profiles.value[idx] = res.data.data
if (idx !== -1) profiles.value[idx] = res.data
closeAgentModal()
}
} finally {
@ -1246,7 +1316,7 @@ const confirmDeleteAgent = async (agent) => {
if (!confirm(t('step2.deleteAgentConfirm'))) return
try {
const res = await deleteAgent(props.simulationId, agent.user_id)
if (res.data?.success) {
if (res.success) {
profiles.value = profiles.value.filter(p => p.user_id !== agent.user_id)
closeAgentModal()
}
@ -1262,9 +1332,9 @@ const doRegenerate = async () => {
const res = await regenerateAgent(props.simulationId, selectedAgent.value.user_id, {
extra_instructions: regenInstructions.value
})
if (res.data?.success) {
await pollTaskUntilDone(res.data.data?.task_id, () => {})
// Refresh profiles
// res = { success, data: { task_id } }
if (res.success) {
await pollTaskUntilDone(res.data?.task_id, () => {})
await fetchProfilesRealtime()
closeAgentModal()
}
@ -1275,53 +1345,63 @@ const doRegenerate = async () => {
const continueToPhaseB = async () => {
generateConfigLoading.value = true
phase.value = 2 // activar step 03 mentre es genera la config
addLog(t('log.startGeneratingConfig'))
startConfigPolling()
try {
const res = await generateConfig(props.simulationId)
if (res.data?.success) {
generateConfigTaskId.value = res.data.data?.task_id
await pollTaskUntilDone(res.data.data?.task_id, async () => {
// Load config for display
if (res.success) {
// Poll sim status directly (configuring ready) to avoid conflicts with prepare/status endpoint
await pollSimStatusUntil(['ready'], async () => {
stopConfigPolling()
const configRes = await getSimulationConfig(props.simulationId)
if (configRes.data?.success) {
phaseBConfig.value = configRes.data.data
const tc = phaseBConfig.value?.time_config || {}
configForm.value = {
total_simulation_hours: tc.total_simulation_hours ?? 24,
minutes_per_round: tc.minutes_per_round ?? 60,
following_probability: phaseBConfig.value?.following_probability ?? 0.05,
recsys_type: phaseBConfig.value?.recsys_type ?? 'random',
}
if (configRes.success) {
simulationConfig.value = configRes.data
}
// phase_b mostra el formulari de paràmetres globals dins step 02,
// step 03 ja és visible (phase=2) amb la config generada,
// step 05 s'activa quan l'usuari prem "Llança"
phase.value = 3
currentPhase.value = 'phase_b'
addLog(t('log.configComplete'))
addLog(t('log.envSetupComplete'))
emit('update-status', 'completed')
})
// If task_id is null, generateConfig was synchronous set phase_b directly
if (!res.data.data?.task_id) {
currentPhase.value = 'phase_b'
}
} else {
stopConfigPolling()
phase.value = 1
}
} finally {
generateConfigLoading.value = false
}
}
const saveGlobalConfig = async () => {
phaseBSaving.value = true
const saveFullConfig = async () => {
if (!props.simulationId || !simulationConfig.value) return
configSaving.value = true
try {
await patchSimulationConfig(props.simulationId, configForm.value)
await patchSimulationConfig(props.simulationId, simulationConfig.value)
step3EditMode.value = false
addLog(t('log.configSaved'))
} catch (err) {
addLog(`Config save failed: ${err.message}`)
} finally {
phaseBSaving.value = false
configSaving.value = false
}
}
const launchSimulation = async () => {
await saveGlobalConfig()
const params = {}
if (useCustomRounds.value) {
params.maxRounds = customMaxRounds.value
const toggleAgentHour = (agent, hour) => {
if (!agent.active_hours) agent.active_hours = []
const idx = agent.active_hours.indexOf(hour)
if (idx === -1) {
agent.active_hours.push(hour)
agent.active_hours.sort((a, b) => a - b)
} else {
agent.active_hours.splice(idx, 1)
}
emit('next-step', params)
}
// Scroll log to bottom
const logContent = ref(null)
watch(() => props.systemLogs?.length, () => {
@ -1332,11 +1412,53 @@ watch(() => props.systemLogs?.length, () => {
})
})
onMounted(() => {
//
if (props.simulationId) {
addLog(t('log.step2Init'))
startPrepareSimulation()
const fetchEntityCount = async (graphId) => {
if (!graphId || availableEntityCount.value !== null) return
entityCountLoading.value = true
try {
const res = await getGraphEntityCount(graphId)
const count = res?.data?.filtered_count ?? res?.filtered_count ?? null
availableEntityCount.value = count
if (maxAgentsInput.value === null) maxAgentsInput.value = count
} catch (err) {
console.warn('[Step2] getGraphEntityCount failed:', err?.message ?? err)
} finally {
entityCountLoading.value = false
}
}
// Watch graph_id so entity count loads even when projectData arrives after mount
watch(() => props.projectData?.graph_id, (graphId) => {
if (graphId && currentPhase.value === 'phase_pre') fetchEntityCount(graphId)
})
onMounted(async () => {
if (!props.simulationId) return
addLog(t('log.step2Init'))
// Check current simulation state and restore the correct phase without re-launching preparation
try {
const simRes = await getSimulation(props.simulationId)
const simStatus = simRes?.data?.status
if (simStatus && simStatus !== 'created') {
if (simStatus === 'profiles_ready') {
// Agents generated, awaiting Fase A load profiles and show edit controls
currentPhase.value = 'generating'
await fetchProfilesRealtime()
addLog(t('log.loadedAgentProfiles', { count: profiles.value.length }))
currentPhase.value = 'phase_a'
emit('update-status', 'profiles_ready')
} else {
// Any other non-created status: let startPrepareSimulation detect already_prepared
startPrepareSimulation()
}
return
}
} catch { /* ignore */ }
// Fresh simulation (status=created) fetch entity count for phase_pre selector
if (props.projectData?.graph_id) {
fetchEntityCount(props.projectData.graph_id)
}
})
@ -1622,6 +1744,11 @@ onUnmounted(() => {
background: #FFF;
}
.profile-card--clickable:hover {
border-color: #1a1a1a;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.profile-header {
display: flex;
align-items: baseline;
@ -1690,6 +1817,19 @@ onUnmounted(() => {
margin-top: 16px;
}
.config-toolbar {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 8px;
}
.config-toolbar .action-btn,
.config-toolbar .continue-btn {
padding: 6px 14px;
font-size: 12px;
}
.config-block {
margin-top: 16px;
border-top: 1px solid #E5E5E5;
@ -2868,6 +3008,20 @@ onUnmounted(() => {
}
/* Fase A footer */
.phase-pre-section { padding: 12px 0 4px; }
.phase-pre-loading { color: #888; font-size: 13px; }
.phase-pre-info { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 14px; color: #444; }
.phase-pre-count { font-weight: 600; font-size: 20px; color: #1a1a1a; }
.phase-pre-input-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
.phase-pre-input-label { font-size: 13px; color: #555; white-space: nowrap; }
.phase-pre-input {
width: 90px; padding: 6px 10px; border: 1px solid #D0D0D0; border-radius: 6px;
font-size: 14px; text-align: center;
}
.phase-pre-input:focus { outline: none; border-color: #1a1a1a; }
.phase-pre-warn { font-size: 12px; color: #E07B00; }
.phase-pre-footer { display: flex; justify-content: flex-end; padding-top: 12px; }
.phase-a-footer {
display: flex;
justify-content: flex-end;

View File

@ -371,6 +371,18 @@
<span>Report Generation Complete</span>
</div>
</template>
<!-- Report Error (inline log entry) -->
<template v-if="log.action === 'error'">
<div class="error-banner">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span>{{ $t('step4.reportFailedBanner') }}</span>
</div>
</template>
</div>
<!-- Footer: Elapsed Time + Action Buttons -->
@ -400,10 +412,28 @@
</TransitionGroup>
<!-- Empty State -->
<div v-if="agentLogs.length === 0 && !isComplete" class="workflow-empty">
<div v-if="agentLogs.length === 0 && !isComplete && !reportFailed" class="workflow-empty">
<div class="empty-pulse"></div>
<span>Waiting for agent activity...</span>
</div>
<!-- Failed State Banner (persistent) -->
<div v-if="reportFailed" class="failed-state-banner">
<div class="failed-banner-content">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0; color:#D32F2F">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<div class="failed-banner-text">
<strong>{{ $t('step4.reportFailedTitle') }}</strong>
<span v-if="reportErrorMessage" class="failed-error-msg">{{ reportErrorMessage }}</span>
</div>
<button class="retry-btn" @click="retryReport" :disabled="!simulationId">
{{ $t('step4.retryBtn') }}
</button>
</div>
</div>
</div>
</div>
</div>
@ -427,7 +457,7 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { getAgentLog, getConsoleLog } from '../api/report'
import { getAgentLog, getConsoleLog, generateReport, getReport } from '../api/report'
import service from '../api'
const router = useRouter()
@ -486,6 +516,8 @@ const expandedContent = ref(new Set())
const expandedLogs = ref(new Set())
const collapsedSections = ref(new Set())
const isComplete = ref(false)
const reportFailed = ref(false)
const reportErrorMessage = ref('')
const showDownloadMenu = ref(false)
const startTime = ref(null)
const leftPanel = ref(null)
@ -1769,17 +1801,37 @@ const QuickSearchDisplay = {
// Computed
const statusClass = computed(() => {
if (reportFailed.value) return 'error'
if (isComplete.value) return 'completed'
if (agentLogs.value.length > 0) return 'processing'
return 'pending'
})
const statusText = computed(() => {
if (isComplete.value) return 'Completed'
if (agentLogs.value.length > 0) return 'Generating...'
return 'Waiting'
if (reportFailed.value) return t('step4.statusFailed')
if (isComplete.value) return t('step4.statusCompleted')
if (agentLogs.value.length > 0) return t('step4.statusGenerating')
return t('step4.statusWaiting')
})
const retryReport = async () => {
if (!props.simulationId) return
reportFailed.value = false
reportErrorMessage.value = ''
try {
const res = await generateReport({
simulation_id: props.simulationId,
force_regenerate: true
})
if (res.success && res.data?.report_id) {
router.push({ name: 'Report', params: { reportId: res.data.report_id } })
}
} catch (err) {
reportFailed.value = true
reportErrorMessage.value = t('step4.retryFailed')
}
}
const totalSections = computed(() => {
return reportOutline.value?.sections?.length || 0
})
@ -2121,6 +2173,13 @@ const fetchAgentLog = async () => {
stopPolling()
// nextTick
}
if (log.action === 'error') {
reportFailed.value = true
reportErrorMessage.value = log.details?.error_message || log.details?.message || t('step4.reportFailedGeneric')
emit('update-status', 'error')
stopPolling()
}
if (log.action === 'report_start') {
startTime.value = new Date(log.timestamp)
@ -2216,12 +2275,27 @@ const fetchConsoleLog = async () => {
}
}
const checkInitialReportStatus = async () => {
if (!props.reportId) return
try {
const res = await getReport(props.reportId)
if (res.success && res.data?.status === 'failed') {
reportFailed.value = true
reportErrorMessage.value = res.data.error || t('step4.reportFailedGeneric')
emit('update-status', 'error')
stopPolling()
}
} catch (err) {
console.warn('Could not check initial report status:', err)
}
}
const startPolling = () => {
if (agentLogTimer || consoleLogTimer) return
fetchAgentLog()
fetchConsoleLog()
agentLogTimer = setInterval(fetchAgentLog, 2000)
consoleLogTimer = setInterval(fetchConsoleLog, 1500)
}
@ -2241,6 +2315,7 @@ const stopPolling = () => {
onMounted(() => {
if (props.reportId) {
addLog(`Report Agent initialized: ${props.reportId}`)
checkInitialReportStatus()
startPolling()
}
document.addEventListener('click', handleClickOutside)
@ -2264,8 +2339,11 @@ watch(() => props.reportId, (newId) => {
expandedLogs.value = new Set()
collapsedSections.value = new Set()
isComplete.value = false
reportFailed.value = false
reportErrorMessage.value = ''
startTime.value = null
checkInitialReportStatus()
startPolling()
}
}, { immediate: true })
@ -3466,6 +3544,74 @@ watch(() => props.reportId, (newId) => {
font-size: 14px;
}
.error-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #FFF3F3;
border: 1px solid #FFCDD2;
border-radius: 8px;
color: #D32F2F;
font-weight: 600;
font-size: 14px;
}
.failed-state-banner {
margin: 16px 0;
padding: 16px;
border: 1px solid #FFCDD2;
border-radius: 8px;
background: #FFF3F3;
}
.failed-banner-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.failed-banner-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.failed-banner-text strong {
font-size: 14px;
font-weight: 700;
color: #D32F2F;
}
.failed-error-msg {
font-size: 12px;
color: #666;
word-break: break-word;
}
.retry-btn {
padding: 8px 16px;
border-radius: 6px;
background: #000;
color: #FFF;
border: none;
cursor: pointer;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.retry-btn:hover:not(:disabled) {
background: #333;
}
.retry-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.next-step-btn {
display: flex;
align-items: center;

View File

@ -19,7 +19,7 @@ const savedLocale = localStorage.getItem('locale') || 'ca'
const i18n = createI18n({
legacy: false,
locale: savedLocale,
fallbackLocale: 'zh',
fallbackLocale: 'en',
messages
})

View File

@ -2,6 +2,7 @@
"common": {
"confirm": "Confirma",
"cancel": "Cancel·la",
"save": "Desa",
"loading": "Carregant...",
"error": "Error",
"success": "Èxit",
@ -23,7 +24,8 @@
"minutes": "minuts",
"rounds": "rondes",
"items": "elements",
"files": "fitxers"
"files": "fitxers",
"edit": "Edita"
},
"meta": {
"title": "MiroFish - Prediu-ho tot",
@ -201,7 +203,51 @@
"genderOther": "Altre",
"yearsOld": "anys",
"initializing": "Inicialitzant",
"generating": "Generant"
"generating": "Generant",
"agentCount": "Nombre d'agents",
"agentCountHint": "Selecció per connectivitat del graf (els més connectats primer)",
"agentCountWarning": "Menys de 15 agents pot produir simulacions menys riques",
"phaseATitle": "Personalitats dels agents",
"phaseASubtitle": "Revisa i edita els perfils d'agents generats",
"continueToPhaseB": "Continua →",
"phaseBTitle": "Paràmetres de simulació",
"phaseBSubtitle": "Edita els paràmetres de comportament i configuració de la simulació",
"launchSimulation": "Llança la simulació",
"editAgent": "Edita",
"deleteAgent": "Elimina",
"deleteAgentConfirm": "Elimines aquest agent? Aquesta acció no es pot desfer.",
"regenerateAgent": "Regenera",
"regenerateAgentHint": "Instruccions addicionals (opcional)",
"createAgent": "Afegeix agent",
"createAgentTitle": "Afegeix un nou agent",
"selectEntityType": "Tipus d'entitat",
"selectEntity": "Selecciona entitat",
"extraInstructions": "Instruccions addicionals (opcional)",
"manuallyEditedBadge": "Editat",
"generatingConfig": "Generant configuració de comportament...",
"cloneFrom": "Clona d'una simulació anterior",
"newSimulation": "Nova simulació",
"simulationSource": "Origen de la simulació",
"behaviorParams": "Paràmetres de comportament",
"globalParams": "Paràmetres globals",
"totalHours": "Hores totals",
"minutesPerRound": "Minuts per ronda",
"followingProbability": "Probabilitat de seguiment",
"recsysType": "Sistema de recomanació",
"loadingEntityCount": "Carregant entitats disponibles...",
"availableEntities": "Entitats disponibles al graf:",
"maxAgentsLabel": "Nombre d'agents a generar:",
"minAgentsWarning": "Menys de 15 agents pot produir simulacions menys riques",
"startGeneration": "Inicia la generació",
"agentField_name": "Nom",
"agentField_bio": "Bio",
"agentField_persona": "Persona",
"agentField_age": "Edat",
"agentField_gender": "Gènere",
"agentField_mbti": "MBTI",
"agentField_country": "País",
"agentField_profession": "Professió",
"agentField_stance": "Postura"
},
"step3": {
"startGenerateReport": "Genera l'informe",
@ -597,7 +643,8 @@
"enterStep": "Entrant al pas {step}: {name}",
"returnToStep": "Tornant al pas {step}: {name}",
"customSimRounds": "Rondes de simulació personalitzades: {rounds} rondes",
"reconnectingToTask": "Reconnectant a la tasca activa {taskId}…"
"reconnectingToTask": "Reconnectant a la tasca activa {taskId}…",
"configSaved": "✓ Configuració desada"
},
"report": {
"taskStarted": "Tasca de generació de l'informe iniciada",

View File

@ -2,6 +2,7 @@
"common": {
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"loading": "Loading...",
"error": "Error",
"success": "Success",
@ -23,7 +24,8 @@
"minutes": "minutes",
"rounds": "rounds",
"items": "items",
"files": "files"
"files": "files",
"edit": "Edit"
},
"meta": {
"title": "MiroFish - Predict Everything",
@ -231,7 +233,21 @@
"totalHours": "Total Hours",
"minutesPerRound": "Minutes per Round",
"followingProbability": "Following Probability",
"recsysType": "Recommendation System"
"recsysType": "Recommendation System",
"loadingEntityCount": "Loading available entities...",
"availableEntities": "Available entities in graph:",
"maxAgentsLabel": "Number of agents to generate:",
"minAgentsWarning": "Fewer than 15 agents may produce less rich simulations",
"startGeneration": "Start Generation",
"agentField_name": "Name",
"agentField_bio": "Bio",
"agentField_persona": "Persona",
"agentField_age": "Age",
"agentField_gender": "Gender",
"agentField_mbti": "MBTI",
"agentField_country": "Country",
"agentField_profession": "Profession",
"agentField_stance": "Stance"
},
"step3": {
"startGenerateReport": "Generate Report",
@ -646,7 +662,8 @@
"enterStep": "Entering Step {step}: {name}",
"returnToStep": "Returning to Step {step}: {name}",
"customSimRounds": "Custom simulation rounds: {rounds} rounds",
"reconnectingToTask": "Reconnecting to active task {taskId}…"
"reconnectingToTask": "Reconnecting to active task {taskId}…",
"configSaved": "✓ Configuration saved"
},
"report": {
"taskStarted": "Report generation task started",

View File

@ -2,6 +2,7 @@
"common": {
"confirm": "Confirmar",
"cancel": "Cancelar",
"save": "Guardar",
"loading": "Cargando...",
"error": "Error",
"success": "Éxito",
@ -23,7 +24,8 @@
"minutes": "minutos",
"rounds": "rondas",
"items": "elementos",
"files": "archivos"
"files": "archivos",
"edit": "Editar"
},
"meta": {
"title": "MiroFish - Predecir Todo",
@ -201,7 +203,51 @@
"genderOther": "Otro",
"yearsOld": "años",
"initializing": "Inicializando",
"generating": "Generando"
"generating": "Generando",
"agentCount": "Número de agentes",
"agentCountHint": "Selección por conectividad del grafo (los más conectados primero)",
"agentCountWarning": "Menos de 15 agentes puede producir simulaciones menos ricas",
"phaseATitle": "Personalidades de los agentes",
"phaseASubtitle": "Revisa y edita los perfiles de agentes generados",
"continueToPhaseB": "Continuar →",
"phaseBTitle": "Parámetros de simulación",
"phaseBSubtitle": "Edita los parámetros de comportamiento y configuración de la simulación",
"launchSimulation": "Lanzar simulación",
"editAgent": "Editar",
"deleteAgent": "Eliminar",
"deleteAgentConfirm": "¿Eliminar este agente? Esta acción no se puede deshacer.",
"regenerateAgent": "Regenerar",
"regenerateAgentHint": "Instrucciones adicionales (opcional)",
"createAgent": "Añadir agente",
"createAgentTitle": "Añadir nuevo agente",
"selectEntityType": "Tipo de entidad",
"selectEntity": "Seleccionar entidad",
"extraInstructions": "Instrucciones adicionales (opcional)",
"manuallyEditedBadge": "Editado",
"generatingConfig": "Generando configuración de comportamiento...",
"cloneFrom": "Clonar de simulación anterior",
"newSimulation": "Nueva simulación",
"simulationSource": "Origen de la simulación",
"behaviorParams": "Parámetros de comportamiento",
"globalParams": "Parámetros globales",
"totalHours": "Horas totales",
"minutesPerRound": "Minutos por ronda",
"followingProbability": "Probabilidad de seguimiento",
"recsysType": "Sistema de recomendación",
"loadingEntityCount": "Cargando entidades disponibles...",
"availableEntities": "Entidades disponibles en el grafo:",
"maxAgentsLabel": "Número de agentes a generar:",
"minAgentsWarning": "Menos de 15 agentes puede producir simulaciones menos ricas",
"startGeneration": "Iniciar generación",
"agentField_name": "Nombre",
"agentField_bio": "Bio",
"agentField_persona": "Persona",
"agentField_age": "Edad",
"agentField_gender": "Género",
"agentField_mbti": "MBTI",
"agentField_country": "País",
"agentField_profession": "Profesión",
"agentField_stance": "Postura"
},
"step3": {
"startGenerateReport": "Generar informe",
@ -597,7 +643,8 @@
"enterStep": "Entrando al Paso {step}: {name}",
"returnToStep": "Volviendo al Paso {step}: {name}",
"customSimRounds": "Rondas de simulación personalizadas: {rounds} rondas",
"reconnectingToTask": "Reconectando a la tarea activa {taskId}…"
"reconnectingToTask": "Reconectando a la tarea activa {taskId}…",
"configSaved": "✓ Configuración guardada"
},
"report": {
"taskStarted": "Tarea de generación de informe iniciada",

View File

@ -2,6 +2,7 @@
"common": {
"confirm": "确认",
"cancel": "取消",
"save": "保存",
"loading": "加载中...",
"error": "错误",
"success": "成功",
@ -23,7 +24,8 @@
"minutes": "分钟",
"rounds": "轮",
"items": "个",
"files": "个文件"
"files": "个文件",
"edit": "编辑"
},
"meta": {
"title": "MiroFish - 预测万物",
@ -231,7 +233,21 @@
"totalHours": "总小时数",
"minutesPerRound": "每轮分钟数",
"followingProbability": "关注概率",
"recsysType": "推荐系统类型"
"recsysType": "推荐系统类型",
"loadingEntityCount": "正在加载可用实体数量...",
"availableEntities": "图谱中可用实体数:",
"maxAgentsLabel": "要生成的智能体数量:",
"minAgentsWarning": "少于15个智能体可能导致模拟效果不够丰富",
"startGeneration": "开始生成",
"agentField_name": "姓名",
"agentField_bio": "简介",
"agentField_persona": "人设",
"agentField_age": "年龄",
"agentField_gender": "性别",
"agentField_mbti": "MBTI",
"agentField_country": "国家",
"agentField_profession": "职业",
"agentField_stance": "立场"
},
"step3": {
"startGenerateReport": "开始生成结果报告",
@ -646,7 +662,8 @@
"enterStep": "进入 Step {step}: {name}",
"returnToStep": "返回 Step {step}: {name}",
"customSimRounds": "自定义模拟轮数: {rounds} 轮",
"reconnectingToTask": "重新连接到活动任务 {taskId}…"
"reconnectingToTask": "重新连接到活动任务 {taskId}…",
"configSaved": "✓ 配置已保存"
},
"report": {
"taskStarted": "报告生成任务开始",