diff --git a/Dockerfile b/Dockerfile index 7900df47..3b6bad01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py index db44c397..78c994db 100644 --- a/backend/app/api/simulation.py +++ b/backend/app/api/simulation.py @@ -46,6 +46,31 @@ def optimize_interview_prompt(prompt: str) -> str: # ============== Entity retrieval endpoints ============== +@simulation_bp.route('/entities//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/', 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/', 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 diff --git a/backend/app/services/oasis_profile_generator.py b/backend/app/services/oasis_profile_generator.py index dec288d0..00a34fc7 100644 --- a/backend/app/services/oasis_profile_generator.py +++ b/backend/app/services/oasis_profile_generator.py @@ -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 = { diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py index f9857c3e..8f075b15 100644 --- a/backend/app/services/simulation_manager.py +++ b/backend/app/services/simulation_manager.py @@ -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}, " diff --git a/backend/app/services/simulation_runner.py b/backend/app/services/simulation_runner.py index 19cf16ad..59934592 100644 --- a/backend/app/services/simulation_runner.py +++ b/backend/app/services/simulation_runner.py @@ -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, diff --git a/backend/scripts/run_parallel_simulation.py b/backend/scripts/run_parallel_simulation.py index 2a627ffd..639850c2 100644 --- a/backend/scripts/run_parallel_simulation.py +++ b/backend/scripts/run_parallel_simulation.py @@ -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://.cognitiveservices.azure.com/openai/deployments//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//, extract 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") diff --git a/backend/uv.lock b/backend/uv.lock index 0095a32e..f8fa46c9 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" diff --git a/docs/TechnicalDesign.md b/docs/TechnicalDesign.md new file mode 100644 index 00000000..7ecb3b13 --- /dev/null +++ b/docs/TechnicalDesign.md @@ -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://:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD= +``` + +--- + +## 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://.cognitiveservices.azure.com/openai/deployments//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://.cognitiveservices.azure.com/openai/deployments//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://.services.ai.azure.com/openai/deployments//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//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à). diff --git a/docs/superpowers/plans/2026-04-26-fase0-estabilitat.md b/docs/superpowers/plans/2026-04-26-fase0-estabilitat.md new file mode 100644 index 00000000..1039f15d --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-fase0-estabilitat.md @@ -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 `` 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 `` blocks from LLM responses + +**Problem:** The LLM sometimes generates its own `` 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 blocks fabricated by the LLM to prevent hallucination loops.""" + cleaned = re.sub(r'.*?', '', 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 3–5 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('badclean'))" +``` + +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 `` 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. diff --git a/docs/superpowers/plans/2026-05-03-f1-1-graph-api-fix-and-project-list.md b/docs/superpowers/plans/2026-05-03-f1-1-graph-api-fix-and-project-list.md new file mode 100644 index 00000000..9cc3be2a --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-f1-1-graph-api-fix-and-project-list.md @@ -0,0 +1,1164 @@ +# F1-1: Correcció graph.py + Projectes llistables i Recovery UI + +> **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:** Corregir el `graph.py` trencat (usa accés a objecte però ProjectManager retorna dicts), afegir persistència d'ontologia/graph a BD, i exposar una Recovery UI mínima a la pàgina Home. + +**Architecture:** Totes les crides a `project.attribute` es substitueixen per `project["attribute"]`. Les operacions de mutació (`project.status = ...`) es fan acumulant un dict parcial i cridant `ProjectManager.save_project()`. L'ontologia es desa a `OntologyModel` i el graph_id a `GraphModel`. La Recovery UI és el component `HistoryDatabase.vue` existent adaptat per usar `GET /api/graph/project/list` en comptes de `getSimulationHistory`. + +**Tech Stack:** Flask/SQLAlchemy (backend), Vue 3 + Axios (frontend), pytest (tests) + +--- + +## Mapa de fitxers + +| Fitxer | Acció | Responsabilitat | +|--------|-------|----------------| +| `backend/app/api/graph.py` | Modificar | Fix accés dict; injectar storage; persistir ontologia/graph | +| `backend/app/models/project.py` | Modificar | Afegir `save_ontology()` i `get_ontology()` i `save_graph_record()` i `get_latest_graph_id()` | +| `backend/tests/test_graph_api.py` | Crear | Tests per als endpoints corregits | +| `frontend/src/api/graph.js` | Modificar | Afegir `listProjects()` | +| `frontend/src/components/HistoryDatabase.vue` | Modificar | Usar `listProjects()` en comptes de `getSimulationHistory`; navegar a `/process/:projectId` | + +--- + +## Task 0: Afegir helpers de persistència a ProjectManager + +**Files:** +- Modify: `backend/app/models/project.py` + +- [ ] **Step 1: Llegir el fitxer actual per orientar-se** + +```bash +cat -n backend/app/models/project.py +``` + +- [ ] **Step 2: Afegir quatre mètodes al `ProjectManager`** + +Afegir just abans del mètode `_to_dict` a `backend/app/models/project.py`: + +```python + @classmethod + def save_ontology(cls, project_id: str, entity_types: list, edge_types: list) -> str: + """Crea o actualitza el registre d'ontologia. Retorna l'id de l'OntologyModel.""" + from .db_models import OntologyModel + from sqlalchemy import select + with get_session() as db: + stmt = select(OntologyModel).where(OntologyModel.project_id == project_id).order_by(OntologyModel.version.desc()) + existing = db.execute(stmt).scalars().first() + if existing: + existing.entity_types = entity_types + existing.edge_types = edge_types + db.commit() + return existing.id + else: + rec = OntologyModel( + id=str(uuid.uuid4()), + project_id=project_id, + version=1, + entity_types=entity_types, + edge_types=edge_types, + ) + db.add(rec) + db.commit() + return rec.id + + @classmethod + def get_ontology(cls, project_id: str) -> Optional[Dict[str, Any]]: + """Retorna l'ontologia del projecte o None si no existeix.""" + from .db_models import OntologyModel + from sqlalchemy import select + with get_session() as db: + stmt = select(OntologyModel).where(OntologyModel.project_id == project_id).order_by(OntologyModel.version.desc()) + rec = db.execute(stmt).scalars().first() + if rec is None: + return None + return {"entity_types": rec.entity_types or [], "edge_types": rec.edge_types or []} + + @classmethod + def save_graph_record(cls, project_id: str, external_id: str, ontology_id: Optional[str] = None) -> str: + """Crea o actualitza el GraphModel. Retorna l'id del GraphModel.""" + from .db_models import GraphModel + from sqlalchemy import select + with get_session() as db: + stmt = select(GraphModel).where(GraphModel.project_id == project_id).order_by(GraphModel.created_at.desc()) + existing = db.execute(stmt).scalars().first() + if existing: + existing.external_id = external_id + existing.status = "building" + if ontology_id: + existing.ontology_id = ontology_id + db.commit() + return existing.id + else: + rec = GraphModel( + id=str(uuid.uuid4()), + project_id=project_id, + external_id=external_id, + ontology_id=ontology_id, + status="building", + backend=Config.GRAPH_BACKEND, + ) + db.add(rec) + db.commit() + return rec.id + + @classmethod + def get_latest_graph_external_id(cls, project_id: str) -> Optional[str]: + """Retorna l'external_id del GraphModel més recent del projecte.""" + from .db_models import GraphModel + from sqlalchemy import select + with get_session() as db: + stmt = select(GraphModel).where(GraphModel.project_id == project_id).order_by(GraphModel.created_at.desc()) + rec = db.execute(stmt).scalars().first() + return rec.external_id if rec else None + + @classmethod + def complete_graph_record(cls, project_id: str, node_count: int, edge_count: int) -> None: + """Marca el GraphModel com a ready i actualitza comptadors.""" + from .db_models import GraphModel + from sqlalchemy import select + with get_session() as db: + stmt = select(GraphModel).where(GraphModel.project_id == project_id).order_by(GraphModel.created_at.desc()) + rec = db.execute(stmt).scalars().first() + if rec: + rec.status = "ready" + rec.node_count = node_count + rec.edge_count = edge_count + db.commit() +``` + +**Nota**: Cal afegir `from ..config import Config` als imports de `project.py` per a `save_graph_record`. + +- [ ] **Step 3: Afegir `graph_id` als camps del `_to_dict`** + +A `_to_dict`, substituir la línia: +```python + "graph_id": None, +``` +per: +```python + "graph_id": cls.get_latest_graph_external_id(proj.id), +``` + +Però com que `_to_dict` és `@staticmethod`, s'ha de convertir a `@classmethod` o usar una crida directa. La manera més simple és canviar el `_to_dict` a `@classmethod` i passar `cls`: + +```python + @classmethod + def _to_dict(cls, proj: "ProjectModel") -> Dict[str, Any]: + from .db_models import GraphModel, OntologyModel + from sqlalchemy import select + with get_session() as db: + graph_stmt = select(GraphModel).where(GraphModel.project_id == proj.id).order_by(GraphModel.created_at.desc()) + graph_rec = db.execute(graph_stmt).scalars().first() + ont_stmt = select(OntologyModel).where(OntologyModel.project_id == proj.id).order_by(OntologyModel.version.desc()) + ont_rec = db.execute(ont_stmt).scalars().first() + + ontology = {"entity_types": ont_rec.entity_types or [], "edge_types": ont_rec.edge_types or []} if ont_rec else None + graph_external_id = graph_rec.external_id if graph_rec else None + + return { + "id": proj.id, + "project_id": proj.id, + "name": proj.name, + "status": proj.status, + "analysis_summary": proj.analysis_summary, + "simulation_requirement": proj.simulation_requirement, + "chunk_size": proj.chunk_size, + "chunk_overlap": proj.chunk_overlap, + "active_task_id": proj.active_task_id, + "created_at": proj.created_at.isoformat(), + "updated_at": proj.updated_at.isoformat(), + "files": [], + "total_text_length": 0, + "ontology": ontology, + "graph_id": graph_external_id, + "graph_build_task_id": None, + "error": None, + } +``` + +**IMPORTANT**: `_to_dict` és cridat des de dins d'un bloc `with get_session()`. Obrir una nova sessió aniuada falla amb SQLite. Solució: moure la consulta fora, o passar els registres com a paràmetre opcional. La manera correcta és fer una funció `_to_dict_with_related` que pren els registres ja carregats: + +```python + @classmethod + def _to_dict(cls, proj: "ProjectModel") -> Dict[str, Any]: + from .db_models import GraphModel, OntologyModel + from sqlalchemy import select + graph_external_id = None + ontology = None + # Sessions aniuades no funcionen; usem una sessió nova independent + with get_session() as db2: + graph_rec = db2.execute( + select(GraphModel).where(GraphModel.project_id == proj.id).order_by(GraphModel.created_at.desc()) + ).scalars().first() + ont_rec = db2.execute( + select(OntologyModel).where(OntologyModel.project_id == proj.id).order_by(OntologyModel.version.desc()) + ).scalars().first() + if graph_rec: + graph_external_id = graph_rec.external_id + if ont_rec: + ontology = {"entity_types": ont_rec.entity_types or [], "edge_types": ont_rec.edge_types or []} + return { + "id": proj.id, + "project_id": proj.id, + "name": proj.name, + "status": proj.status, + "analysis_summary": proj.analysis_summary, + "simulation_requirement": proj.simulation_requirement, + "chunk_size": proj.chunk_size, + "chunk_overlap": proj.chunk_overlap, + "active_task_id": proj.active_task_id, + "created_at": proj.created_at.isoformat(), + "updated_at": proj.updated_at.isoformat(), + "files": [], + "total_text_length": 0, + "ontology": ontology, + "graph_id": graph_external_id, + "graph_build_task_id": None, + "error": None, + } +``` + +**Nota sobre sessions aniuades a SQLite**: SQLAlchemy amb `StaticPool` (test) permet múltiples connexions al mateix fil. En producció (SQLite amb `check_same_thread=False`) una sessió aniuada nova és correcta. Verifiquem que `get_session()` no usa la mateixa connexió però sí el mateix pool. + +- [ ] **Step 4: Afegir `Config` als imports de `project.py`** + +A la secció d'imports, afegir: +```python +from ..config import Config +``` + +- [ ] **Step 5: Executar tests existents per verificar no-regression** + +```bash +cd /home/ubuntu/dev/MiroFish && backend/.venv/bin/pytest backend/tests/test_project_manager_db.py -v +``` + +Expected: tots els tests passen. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/models/project.py +git commit -m "feat(project): add ontology/graph persistence helpers to ProjectManager" +``` + +--- + +## Task 1: Corregir `graph.py` — Fase A: Endpoints de projecte i tasca + +**Files:** +- Modify: `backend/app/api/graph.py` + +Els bugs a corregir en aquesta fase: +- `get_project`: `project.to_dict()` → `project` ja és dict, retornar directament +- `list_projects`: `[p.to_dict() for p in projects]` → `projects` ja és list of dicts +- `reset_project`: accés a `project.ontology`, `project.status = ...`, `project.to_dict()` +- `get_task`/`list_tasks`: `task.to_dict()`, `[t.to_dict() for t in tasks]` +- `delete_project`: cal passar `storage` a `ProjectManager.delete_project` + +- [ ] **Step 1: Corregir `get_project` (línia 52)** + +Substituir: +```python + return jsonify({ + "success": True, + "data": project.to_dict() + }) +``` +per: +```python + return jsonify({ + "success": True, + "data": project + }) +``` + +- [ ] **Step 2: Corregir `list_projects` (línia 66)** + +Substituir: +```python + return jsonify({ + "success": True, + "data": [p.to_dict() for p in projects], + "count": len(projects) + }) +``` +per: +```python + return jsonify({ + "success": True, + "data": projects, + "count": len(projects) + }) +``` + +- [ ] **Step 3: Corregir `delete_project` — injectar storage** + +La signatura actual és `ProjectManager.delete_project(project_id)`. Cal passar `storage`. + +Afegir l'import al top del fitxer: +```python +from ..__init__ import get_storage +``` + +Substituir: +```python + success = ProjectManager.delete_project(project_id) +``` +per: +```python + storage = get_storage() + success = ProjectManager.delete_project(project_id, storage=storage) +``` + +- [ ] **Step 4: Corregir `reset_project`** + +Substituir tot el bloc de l'endpoint `reset_project` (línies 90–118) per: + +```python +@graph_bp.route('/project//reset', methods=['POST']) +def reset_project(project_id: str): + project = ProjectManager.get_project(project_id) + + if not project: + return jsonify({ + "success": False, + "error": t('api.projectNotFound', id=project_id) + }), 404 + + new_status = ProjectStatus.ONTOLOGY_GENERATED if project.get("ontology") else ProjectStatus.CREATED + ProjectManager.save_project({ + "id": project_id, + "status": new_status, + "active_task_id": None, + }) + updated = ProjectManager.get_project(project_id) + + return jsonify({ + "success": True, + "message": t('api.projectReset', id=project_id), + "data": updated + }) +``` + +- [ ] **Step 5: Corregir `get_task` i `list_tasks`** + +Substituir (línies 672–673): +```python + return jsonify({ + "success": True, + "data": task.to_dict() + }) +``` +per: +```python + return jsonify({ + "success": True, + "data": task + }) +``` + +Substituir (línies 684–686): +```python + return jsonify({ + "success": True, + "data": [t.to_dict() for t in tasks], + "count": len(tasks) + }) +``` +per (nota: `t` és la variable de traducció, no shadows): +```python + return jsonify({ + "success": True, + "data": tasks, + "count": len(tasks) + }) +``` + +- [ ] **Step 6: Executar tests** + +```bash +cd /home/ubuntu/dev/MiroFish && backend/.venv/bin/pytest backend/tests/ -v -k "not test_config_graph_backend_default" 2>&1 | tail -20 +``` + +Expected: tots passen excepte el test pre-existent `test_config_graph_backend_default`. + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/api/graph.py +git commit -m "fix(graph-api): fix project/task dict access after ProjectManager refactor" +``` + +--- + +## Task 2: Corregir `graph.py` — Fase B: Endpoints `generate_ontology` i `import_ontology` + +**Files:** +- Modify: `backend/app/api/graph.py` + +Bugs a corregir: +- `project.simulation_requirement = ...` → acumular al dict i cridar `save_project` +- `ProjectManager.save_file_to_project(project.project_id, file, file.filename)` → manquen `storage` i crides correctes +- `FileParser.extract_text(file_info["path"])` → el nou `save_file_to_project` no retorna "path", retorna "storage_path". Cal baixar des de StorageService. +- `project.files.append(...)` → no persisteix res; la UI no necessita la llista de fitxers en la resposta immediata +- `ProjectManager.save_extracted_text(project_id, all_text)` → manquen `storage` +- `project.ontology = ...`, `project.analysis_summary = ...`, `project.status = ...` → acumular i cridar `save_project` + `save_ontology` +- `project.total_text_length = ...` → no és un camp del model, és calculat + +### Estratègia de refactorització + +En lloc d'intentar mutar el dict `project` (que no persisteix), acumulem tots els canvis en variables locals i cridem `save_project()` una sola vegada al final. L'extracció de text usarà `storage.download(storage_path)` per llegir el fitxer pujat. + +- [ ] **Step 1: Afegir imports necessaris a `graph.py`** + +A la secció d'imports, afegir: +```python +from ..__init__ import get_storage +``` + +(Si ja s'ha afegit al Task 1, no duplicar.) + +- [ ] **Step 2: Reescriure el cos de `generate_ontology`** + +Substituir el cos complet de la funció (línies 151–256) per: + +```python +@graph_bp.route('/ontology/generate', methods=['POST']) +def generate_ontology(): + try: + logger.info("=== Starting ontology generation ===") + storage = get_storage() + + simulation_requirement = request.form.get('simulation_requirement', '') + project_name = request.form.get('project_name', 'Unnamed Project') + additional_context = request.form.get('additional_context', '') + + if not simulation_requirement: + return jsonify({"success": False, "error": t('api.requireSimulationRequirement')}), 400 + + uploaded_files = request.files.getlist('files') + if not uploaded_files or all(not f.filename for f in uploaded_files): + return jsonify({"success": False, "error": t('api.requireFileUpload')}), 400 + + project = ProjectManager.create_project(name=project_name, storage=storage) + project_id = project["project_id"] + logger.info(f"Project created: {project_id}") + + document_texts = [] + all_text = "" + + for file in uploaded_files: + if file and file.filename and allowed_file(file.filename): + file_info = ProjectManager.save_file_to_project( + project_id, file, file.filename, storage + ) + raw = storage.download(file_info["storage_path"]) + import tempfile, os + ext = os.path.splitext(file.filename)[1].lower() + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: + tmp.write(raw) + tmp_path = tmp.name + try: + text = FileParser.extract_text(tmp_path) + finally: + os.unlink(tmp_path) + text = TextProcessor.preprocess_text(text) + document_texts.append(text) + all_text += f"\n\n=== {file.filename} ===\n{text}" + + if not document_texts: + ProjectManager.delete_project(project_id, storage=storage) + return jsonify({"success": False, "error": t('api.noDocProcessed')}), 400 + + ProjectManager.save_extracted_text(project_id, all_text, storage) + logger.info(f"Text extraction complete, total {len(all_text)} characters") + + logger.info("Calling LLM to generate ontology definition...") + generator = OntologyGenerator() + ontology = generator.generate( + document_texts=document_texts, + simulation_requirement=simulation_requirement, + additional_context=additional_context if additional_context else None + ) + + entity_types = ontology.get("entity_types", []) + edge_types = ontology.get("edge_types", []) + analysis_summary = ontology.get("analysis_summary", "") + logger.info(f"Ontology generation complete: {len(entity_types)} entity types, {len(edge_types)} relationship types") + + ProjectManager.save_ontology(project_id, entity_types, edge_types) + ProjectManager.save_project({ + "id": project_id, + "simulation_requirement": simulation_requirement, + "analysis_summary": analysis_summary, + "status": ProjectStatus.ONTOLOGY_GENERATED, + }) + logger.info(f"=== Ontology generation complete === Project ID: {project_id}") + + return jsonify({ + "success": True, + "data": { + "project_id": project_id, + "project_name": project_name, + "ontology": {"entity_types": entity_types, "edge_types": edge_types}, + "analysis_summary": analysis_summary, + "files": [], + "total_text_length": len(all_text) + } + }) + + except Exception as e: + return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500 +``` + +- [ ] **Step 3: Reescriure el cos de `import_ontology`** + +Substituir el cos complet de la funció `import_ontology` (línies 261–376) per: + +```python +@graph_bp.route('/ontology/import', methods=['POST']) +def import_ontology(): + try: + logger.info("=== Starting ontology import ===") + storage = get_storage() + + simulation_requirement = request.form.get('simulation_requirement', '') + project_name = request.form.get('project_name', 'Unnamed Project') + ontology_json = request.form.get('ontology', '') + + if not simulation_requirement: + return jsonify({"success": False, "error": t('api.requireSimulationRequirement')}), 400 + if not ontology_json: + return jsonify({"success": False, "error": t('api.requireOntologyJson')}), 400 + try: + ontology = json.loads(ontology_json) + except (ValueError, TypeError): + return jsonify({"success": False, "error": t('api.invalidOntologyJson')}), 400 + if not isinstance(ontology.get('entity_types'), list) or not isinstance(ontology.get('edge_types'), list): + return jsonify({"success": False, "error": t('api.invalidOntologyStructure')}), 400 + + uploaded_files = request.files.getlist('files') + if not uploaded_files or all(not f.filename for f in uploaded_files): + return jsonify({"success": False, "error": t('api.requireFileUpload')}), 400 + + project = ProjectManager.create_project(name=project_name, storage=storage) + project_id = project["project_id"] + logger.info(f"Project created for import: {project_id}") + + document_texts = [] + all_text = "" + + for file in uploaded_files: + if file and file.filename and allowed_file(file.filename): + file_info = ProjectManager.save_file_to_project( + project_id, file, file.filename, storage + ) + raw = storage.download(file_info["storage_path"]) + import tempfile, os + ext = os.path.splitext(file.filename)[1].lower() + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: + tmp.write(raw) + tmp_path = tmp.name + try: + text = FileParser.extract_text(tmp_path) + finally: + os.unlink(tmp_path) + text = TextProcessor.preprocess_text(text) + document_texts.append(text) + all_text += f"\n\n=== {file.filename} ===\n{text}" + + if not document_texts: + ProjectManager.delete_project(project_id, storage=storage) + return jsonify({"success": False, "error": t('api.noDocProcessed')}), 400 + + ProjectManager.save_extracted_text(project_id, all_text, storage) + + entity_types = ontology.get("entity_types", []) + edge_types = ontology.get("edge_types", []) + analysis_summary = ontology.get("analysis_summary", "") + + ProjectManager.save_ontology(project_id, entity_types, edge_types) + ProjectManager.save_project({ + "id": project_id, + "simulation_requirement": simulation_requirement, + "analysis_summary": analysis_summary, + "status": ProjectStatus.ONTOLOGY_GENERATED, + }) + logger.info(f"=== Ontology import complete === Project ID: {project_id}") + + return jsonify({ + "success": True, + "data": { + "project_id": project_id, + "project_name": project_name, + "ontology": {"entity_types": entity_types, "edge_types": edge_types}, + "analysis_summary": analysis_summary, + "files": [], + "total_text_length": len(all_text) + } + }) + + except Exception as e: + return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500 +``` + +- [ ] **Step 4: Executar tests** + +```bash +cd /home/ubuntu/dev/MiroFish && backend/.venv/bin/pytest backend/tests/ -v -k "not test_config_graph_backend_default" 2>&1 | tail -20 +``` + +Expected: tots passen. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/graph.py +git commit -m "fix(graph-api): rewrite generate_ontology/import_ontology to use dict ProjectManager" +``` + +--- + +## Task 3: Corregir `graph.py` — Fase C: Endpoint `build_graph` + +**Files:** +- Modify: `backend/app/api/graph.py` + +Bugs a corregir: +- `project.status == ProjectStatus.CREATED` → `project["status"] == ProjectStatus.CREATED` +- `project.status == ProjectStatus.GRAPH_BUILDING` → `project["status"] == ...` +- `project.graph_build_task_id` → `project.get("graph_build_task_id")` +- `project.status = ...`, `project.graph_id = None`, etc. → acumular i cridar `save_project` +- `project.name` → `project["name"]` +- `project.chunk_size`, `project.chunk_overlap` → `project.get("chunk_size")`, `project.get("chunk_overlap")` +- `ProjectManager.get_extracted_text(project_id)` → `ProjectManager.get_extracted_text(project_id, storage)` +- `project.ontology` → `project.get("ontology")` +- A la tasca de fons (`build_task`): `project.graph_id = graph_id` → cridar `save_graph_record` + `save_project` +- Al final: `project.status = ProjectStatus.GRAPH_COMPLETED; project.active_task_id = None` → `save_project` +- A l'error: `project.status = ProjectStatus.FAILED; project.error = str(e)` → `save_project` + +- [ ] **Step 1: Reescriure el bloc de validació (línies 418–493) dins de `build_graph`** + +Substituir des de `# Get project` fins a `# Get configuration` per: + +```python + # Get project + project = ProjectManager.get_project(project_id) + if not project: + return jsonify({"success": False, "error": t('api.projectNotFound', id=project_id)}), 404 + storage = get_storage() + + # Check project status + force = data.get('force', False) + + if project["status"] == ProjectStatus.CREATED: + return jsonify({"success": False, "error": t('api.ontologyNotGenerated')}), 400 + + if project["status"] == ProjectStatus.GRAPH_BUILDING and not force: + return jsonify({ + "success": False, + "error": t('api.graphBuilding'), + "task_id": project.get("active_task_id") + }), 400 + + # If force rebuild, reset status + if force and project["status"] in [ProjectStatus.GRAPH_BUILDING, ProjectStatus.FAILED, ProjectStatus.GRAPH_COMPLETED]: + ProjectManager.save_project({"id": project_id, "status": ProjectStatus.ONTOLOGY_GENERATED, "active_task_id": None}) + project = ProjectManager.get_project(project_id) + + # Get configuration + graph_name = data.get('graph_name', project["name"] or 'MiroFish Graph') + chunk_size = data.get('chunk_size', project.get("chunk_size") or Config.DEFAULT_CHUNK_SIZE) + chunk_overlap = data.get('chunk_overlap', project.get("chunk_overlap") or Config.DEFAULT_CHUNK_OVERLAP) + ProjectManager.save_project({"id": project_id, "chunk_size": chunk_size, "chunk_overlap": chunk_overlap}) + + # Get extracted text + text = ProjectManager.get_extracted_text(project_id, storage) + if not text: + return jsonify({"success": False, "error": t('api.textNotFound')}), 400 + + # Get ontology + ontology = project.get("ontology") or ProjectManager.get_ontology(project_id) + if not ontology: + return jsonify({"success": False, "error": t('api.ontologyNotFound')}), 400 +``` + +- [ ] **Step 2: Corregir la creació de tasca i l'update de projecte (línies ~485–493)** + +Substituir: +```python + # Update project status + project.status = ProjectStatus.GRAPH_BUILDING + project.graph_build_task_id = task_id + project.active_task_id = task_id + ProjectManager.save_project(project) +``` +per: +```python + # Update project status + ProjectManager.save_project({ + "id": project_id, + "status": ProjectStatus.GRAPH_BUILDING, + "active_task_id": task_id, + }) +``` + +- [ ] **Step 3: Corregir el cos de `build_task` — update graph_id** + +Substituir: +```python + # Update project graph_id + project.graph_id = graph_id + ProjectManager.save_project(project) +``` +per: +```python + # Persist graph record + ont = ProjectManager.get_ontology(project_id) + ontology_db_id = None + if ont: + from ..models.db_models import OntologyModel + from sqlalchemy import select + from ..db import get_session + with get_session() as db: + rec = db.execute(select(OntologyModel).where(OntologyModel.project_id == project_id).order_by(OntologyModel.version.desc())).scalars().first() + ontology_db_id = rec.id if rec else None + ProjectManager.save_graph_record(project_id, graph_id, ontology_id=ontology_db_id) +``` + +- [ ] **Step 4: Corregir els `save_project` de la tasca de fons** + +Substituir (al bloc de success de build_task): +```python + # Update project status + project.status = ProjectStatus.GRAPH_COMPLETED + project.active_task_id = None + ProjectManager.save_project(project) +``` +per: +```python + # Update project status + ProjectManager.complete_graph_record(project_id, node_count, edge_count) + ProjectManager.save_project({ + "id": project_id, + "status": ProjectStatus.GRAPH_COMPLETED, + "active_task_id": None, + }) +``` + +Substituir (al bloc d'error de build_task): +```python + project.status = ProjectStatus.FAILED + project.error = str(e) + project.active_task_id = None + ProjectManager.save_project(project) +``` +per: +```python + ProjectManager.save_project({ + "id": project_id, + "status": ProjectStatus.FAILED, + "active_task_id": None, + }) +``` + +- [ ] **Step 5: Corregir el `result` final de la tasca per usar `project_id`** + +La variable `project_id` és accessible per closura dins de `build_task`. El `task_manager.update_task(..., result={...})` ja usa `project_id` directament — no cal canvi si l'hem deixat com a variable local. + +- [ ] **Step 6: Executar tests** + +```bash +cd /home/ubuntu/dev/MiroFish && backend/.venv/bin/pytest backend/tests/ -v -k "not test_config_graph_backend_default" 2>&1 | tail -30 +``` + +Expected: tots passen. + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/api/graph.py backend/app/models/project.py +git commit -m "fix(graph-api): fix build_graph endpoint for dict-based ProjectManager" +``` + +--- + +## Task 4: Tests de l'API d'endpoints de projecte + +**Files:** +- Create: `backend/tests/test_graph_api_project.py` + +Estos tests usen un client Flask de test (`app.test_client()`) per verificar que els endpoints retornen les estructures esperades sense cridar serveis externs. + +- [ ] **Step 1: Escriure els tests** + +Crear `backend/tests/test_graph_api_project.py`: + +```python +"""Tests d'integració per als endpoints de projecte de graph.py.""" +import pytest +from unittest.mock import patch, MagicMock + + +@pytest.fixture +def client(in_memory_db): + """Flask test client amb BD en memòria.""" + from backend.app import create_app + app = create_app() + app.config['TESTING'] = True + # Sobreescriure storage per a tests + mock_storage = MagicMock() + mock_storage.exists.return_value = False + app.extensions['storage'] = mock_storage + with app.test_client() as c: + yield c + + +def test_list_projects_empty(client): + res = client.get('/api/graph/project/list') + assert res.status_code == 200 + data = res.get_json() + assert data['success'] is True + assert data['data'] == [] + assert data['count'] == 0 + + +def test_get_project_not_found(client): + res = client.get('/api/graph/project/nonexistent-id') + assert res.status_code == 404 + data = res.get_json() + assert data['success'] is False + + +def test_create_and_get_project(client, in_memory_db): + from backend.app.models.project import ProjectManager + proj = ProjectManager.create_project(name="Test Project") + project_id = proj['project_id'] + + res = client.get(f'/api/graph/project/{project_id}') + assert res.status_code == 200 + data = res.get_json() + assert data['success'] is True + assert data['data']['name'] == 'Test Project' + assert data['data']['graph_id'] is None + assert data['data']['ontology'] is None + + +def test_list_projects_returns_created(client, in_memory_db): + from backend.app.models.project import ProjectManager + ProjectManager.create_project(name="Alpha") + ProjectManager.create_project(name="Beta") + + res = client.get('/api/graph/project/list') + assert res.status_code == 200 + data = res.get_json() + assert data['count'] == 2 + names = [p['name'] for p in data['data']] + assert 'Alpha' in names + assert 'Beta' in names + + +def test_delete_project(client, in_memory_db): + from backend.app.models.project import ProjectManager + proj = ProjectManager.create_project(name="ToDelete") + project_id = proj['project_id'] + + res = client.delete(f'/api/graph/project/{project_id}') + assert res.status_code == 200 + assert res.get_json()['success'] is True + + # Verificar que ja no existeix + res2 = client.get(f'/api/graph/project/{project_id}') + assert res2.status_code == 404 + + +def test_reset_project(client, in_memory_db): + from backend.app.models.project import ProjectManager + proj = ProjectManager.create_project(name="ToReset") + project_id = proj['project_id'] + ProjectManager.save_project({"id": project_id, "status": "graph_completed"}) + + res = client.post(f'/api/graph/project/{project_id}/reset') + assert res.status_code == 200 + data = res.get_json() + assert data['success'] is True + # Sense ontologia, ha de tornar a 'created' + assert data['data']['status'] == 'created' +``` + +- [ ] **Step 2: Executar els tests nous** + +```bash +cd /home/ubuntu/dev/MiroFish && backend/.venv/bin/pytest backend/tests/test_graph_api_project.py -v 2>&1 +``` + +Expected: tots passen. Si algun falla per la configuració del `create_app` (AUTH_SECRET, etc.), afegir variables d'entorn mínimes al fixture: + +```python +import os +os.environ.setdefault('SECRET_KEY', 'test') +os.environ.setdefault('JWT_SECRET_KEY', 'test') +``` + +- [ ] **Step 3: Executar tots els tests** + +```bash +cd /home/ubuntu/dev/MiroFish && backend/.venv/bin/pytest backend/tests/ -v -k "not test_config_graph_backend_default" 2>&1 | tail -30 +``` + +- [ ] **Step 4: Commit** + +```bash +git add backend/tests/test_graph_api_project.py +git commit -m "test(graph-api): add integration tests for project CRUD endpoints" +``` + +--- + +## Task 5: Frontend — `listProjects()` a `api/graph.js` + +**Files:** +- Modify: `frontend/src/api/graph.js` + +- [ ] **Step 1: Afegir la funció `listProjects`** + +Afegir al final de `frontend/src/api/graph.js`: + +```javascript +/** + * Llista tots els projectes (per a la Recovery UI) + * @param {Number} limit - Màxim de projectes a retornar (default 50) + * @returns {Promise} + */ +export function listProjects(limit = 50) { + return service({ + url: `/api/graph/project/list?limit=${limit}`, + method: 'get' + }) +} +``` + +- [ ] **Step 2: Verificar que la funció és importable** + +```bash +grep -n "listProjects" /home/ubuntu/dev/MiroFish/frontend/src/api/graph.js +``` + +Expected: la funció apareix. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/api/graph.js +git commit -m "feat(frontend-api): add listProjects() to graph API client" +``` + +--- + +## Task 6: Frontend — Adaptar `HistoryDatabase.vue` per a Recovery UI + +**Files:** +- Modify: `frontend/src/components/HistoryDatabase.vue` + +`HistoryDatabase.vue` existeix i mostra projectes des de `getSimulationHistory`. Cal canviar-la per usar `listProjects()` i navegar correctament a `/process/:projectId`. + +L'estructura actual del component usa camps de simulació (`simulation_id`, `report_id`, etc.). Ara hem d'adaptar-la per mostrar projectes de MiroFish amb els camps disponibles: +- `project_id` — per navegar a `/process/:projectId` +- `name` — nom del projecte +- `status` — estat (`created`, `ontology_generated`, `graph_building`, `graph_completed`, `failed`) +- `simulation_requirement` — com a descripció +- `created_at` — data de creació +- `ontology` — si `!= null`, hi ha ontologia +- `graph_id` — si `!= null`, hi ha graph + +- [ ] **Step 1: Llegir el component actual (primera vegada en la sessió)** + +```bash +wc -l /home/ubuntu/dev/MiroFish/frontend/src/components/HistoryDatabase.vue +``` + +- [ ] **Step 2: Localitzar les crides a `getSimulationHistory`** + +```bash +grep -n "getSimulationHistory\|simulation_id\|report_id" /home/ubuntu/dev/MiroFish/frontend/src/components/HistoryDatabase.vue | head -30 +``` + +- [ ] **Step 3: Modificar la importació i la crida de dades** + +A la secció `