From fe0ec2051115a55f33eb23fe7e5b078ba4a480e7 Mon Sep 17 00:00:00 2001
From: ak-asu
Date: Sat, 21 Mar 2026 02:20:07 -0700
Subject: [PATCH 01/50] agent 3
---
backend/app/agents/__init__.py | 0
backend/app/agents/agent3/__init__.py | 16 +
backend/app/agents/agent3/config.py | 81 ++
backend/app/agents/agent3/graph.py | 173 +++
backend/app/agents/agent3/llm.py | 31 +
backend/app/agents/agent3/models.py | 86 ++
backend/app/agents/agent3/nodes/__init__.py | 0
backend/app/agents/agent3/nodes/assembler.py | 56 +
backend/app/agents/agent3/nodes/code_fixer.py | 62 +
.../app/agents/agent3/nodes/code_generator.py | 56 +
.../app/agents/agent3/nodes/code_validator.py | 32 +
.../app/agents/agent3/nodes/orchestrator.py | 311 ++++
.../app/agents/agent3/nodes/parse_input.py | 192 +++
.../app/agents/agent3/nodes/test_generator.py | 59 +
backend/app/agents/agent3/nodes/tf_fixer.py | 47 +
.../app/agents/agent3/nodes/tf_generator.py | 60 +
backend/app/agents/agent3/prompts/__init__.py | 0
.../app/agents/agent3/prompts/code_prompts.py | 57 +
.../agent3/prompts/orchestrator_prompts.py | 14 +
backend/app/agents/agent3/prompts/renderer.py | 39 +
.../prompts/templates/code_fix_system.j2 | 6 +
.../agent3/prompts/templates/code_fix_user.j2 | 12 +
.../templates/code_generation_system.j2 | 24 +
.../prompts/templates/code_generation_user.j2 | 49 +
.../prompts/templates/orchestrator_system.j2 | 29 +
.../templates/test_generation_system.j2 | 26 +
.../prompts/templates/test_generation_user.j2 | 27 +
.../agent3/prompts/templates/tf_fix_system.j2 | 20 +
.../agent3/prompts/templates/tf_fix_user.j2 | 12 +
.../prompts/templates/tf_generation_system.j2 | 24 +
.../prompts/templates/tf_generation_user.j2 | 31 +
.../app/agents/agent3/prompts/test_prompts.py | 26 +
.../app/agents/agent3/prompts/tf_prompts.py | 43 +
backend/app/agents/agent3/state.py | 139 ++
.../app/agents/agent3/subgraphs/__init__.py | 0
.../agent3/subgraphs/code_generation_loop.py | 99 ++
.../agent3/subgraphs/tf_validation_loop.py | 139 ++
backend/app/agents/agent3/tools/__init__.py | 0
.../app/agents/agent3/tools/syntax_tools.py | 98 ++
backend/app/agents/agent3/tools/task_tools.py | 58 +
backend/app/agents/agent3/tools/tf_tools.py | 235 +++
backend/app/agents/agent3/utils.py | 79 +
backend/app/main.py | 2 +
backend/app/routers/agent3.py | 271 ++++
backend/pyproject.toml | 13 +
backend/uv.lock | 1265 ++++++++++++++++-
46 files changed, 4095 insertions(+), 4 deletions(-)
create mode 100644 backend/app/agents/__init__.py
create mode 100644 backend/app/agents/agent3/__init__.py
create mode 100644 backend/app/agents/agent3/config.py
create mode 100644 backend/app/agents/agent3/graph.py
create mode 100644 backend/app/agents/agent3/llm.py
create mode 100644 backend/app/agents/agent3/models.py
create mode 100644 backend/app/agents/agent3/nodes/__init__.py
create mode 100644 backend/app/agents/agent3/nodes/assembler.py
create mode 100644 backend/app/agents/agent3/nodes/code_fixer.py
create mode 100644 backend/app/agents/agent3/nodes/code_generator.py
create mode 100644 backend/app/agents/agent3/nodes/code_validator.py
create mode 100644 backend/app/agents/agent3/nodes/orchestrator.py
create mode 100644 backend/app/agents/agent3/nodes/parse_input.py
create mode 100644 backend/app/agents/agent3/nodes/test_generator.py
create mode 100644 backend/app/agents/agent3/nodes/tf_fixer.py
create mode 100644 backend/app/agents/agent3/nodes/tf_generator.py
create mode 100644 backend/app/agents/agent3/prompts/__init__.py
create mode 100644 backend/app/agents/agent3/prompts/code_prompts.py
create mode 100644 backend/app/agents/agent3/prompts/orchestrator_prompts.py
create mode 100644 backend/app/agents/agent3/prompts/renderer.py
create mode 100644 backend/app/agents/agent3/prompts/templates/code_fix_system.j2
create mode 100644 backend/app/agents/agent3/prompts/templates/code_fix_user.j2
create mode 100644 backend/app/agents/agent3/prompts/templates/code_generation_system.j2
create mode 100644 backend/app/agents/agent3/prompts/templates/code_generation_user.j2
create mode 100644 backend/app/agents/agent3/prompts/templates/orchestrator_system.j2
create mode 100644 backend/app/agents/agent3/prompts/templates/test_generation_system.j2
create mode 100644 backend/app/agents/agent3/prompts/templates/test_generation_user.j2
create mode 100644 backend/app/agents/agent3/prompts/templates/tf_fix_system.j2
create mode 100644 backend/app/agents/agent3/prompts/templates/tf_fix_user.j2
create mode 100644 backend/app/agents/agent3/prompts/templates/tf_generation_system.j2
create mode 100644 backend/app/agents/agent3/prompts/templates/tf_generation_user.j2
create mode 100644 backend/app/agents/agent3/prompts/test_prompts.py
create mode 100644 backend/app/agents/agent3/prompts/tf_prompts.py
create mode 100644 backend/app/agents/agent3/state.py
create mode 100644 backend/app/agents/agent3/subgraphs/__init__.py
create mode 100644 backend/app/agents/agent3/subgraphs/code_generation_loop.py
create mode 100644 backend/app/agents/agent3/subgraphs/tf_validation_loop.py
create mode 100644 backend/app/agents/agent3/tools/__init__.py
create mode 100644 backend/app/agents/agent3/tools/syntax_tools.py
create mode 100644 backend/app/agents/agent3/tools/task_tools.py
create mode 100644 backend/app/agents/agent3/tools/tf_tools.py
create mode 100644 backend/app/agents/agent3/utils.py
create mode 100644 backend/app/routers/agent3.py
diff --git a/backend/app/agents/__init__.py b/backend/app/agents/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/agents/agent3/__init__.py b/backend/app/agents/agent3/__init__.py
new file mode 100644
index 0000000..bebb87b
--- /dev/null
+++ b/backend/app/agents/agent3/__init__.py
@@ -0,0 +1,16 @@
+from app.agents.agent3.graph import compile_graph, get_graph
+from app.agents.agent3.models import GenerateRequest, GenerationResult, HumanFeedback, StatusResponse
+from app.agents.agent3.state import AgentState, CodeError, TaskItem, ValidationResult
+
+__all__ = [
+ "compile_graph",
+ "get_graph",
+ "AgentState",
+ "TaskItem",
+ "ValidationResult",
+ "CodeError",
+ "GenerateRequest",
+ "GenerationResult",
+ "HumanFeedback",
+ "StatusResponse",
+]
diff --git a/backend/app/agents/agent3/config.py b/backend/app/agents/agent3/config.py
new file mode 100644
index 0000000..3152895
--- /dev/null
+++ b/backend/app/agents/agent3/config.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+# ---------------------------------------------------------------------------
+# LLM
+# ---------------------------------------------------------------------------
+
+DEFAULT_MODEL = "claude-sonnet-4-6"
+FAST_MODEL = "claude-haiku-4-5-20251001" # used for lighter tasks like code fixing
+
+# ---------------------------------------------------------------------------
+# Retry / iteration limits
+# ---------------------------------------------------------------------------
+
+TF_MAX_RETRIES = 3
+CODE_MAX_RETRIES = 3
+ORCHESTRATOR_MAX_ITERATIONS = 10
+
+# ---------------------------------------------------------------------------
+# Subprocess timeouts (seconds)
+# ---------------------------------------------------------------------------
+
+TERRAFORM_TIMEOUT = 60
+TFLINT_TIMEOUT = 30
+CHECKOV_TIMEOUT = 120
+TSC_TIMEOUT = 30
+
+# ---------------------------------------------------------------------------
+# Service type registry
+# ---------------------------------------------------------------------------
+
+SUPPORTED_SERVICE_TYPES = [
+ "lambda",
+ "s3",
+ "rds",
+ "vpc",
+ "api_gateway",
+ "dynamodb",
+ "sns",
+ "sqs",
+ "cloudfront",
+ "ecs",
+ "ec2",
+ "elasticache",
+ "kinesis",
+ "glue",
+ "step_functions",
+]
+
+# Default language for generated application code per service type
+SERVICE_LANGUAGE_MAP: dict[str, str] = {
+ "lambda": "python",
+ "ecs": "python",
+ "ec2": "python",
+ "glue": "python",
+ "step_functions": "python",
+ "api_gateway": "typescript",
+}
+
+DEFAULT_LANGUAGE = "python"
+
+# ---------------------------------------------------------------------------
+# Language → file extension map (used across code-gen nodes)
+# ---------------------------------------------------------------------------
+
+EXT_MAP: dict[str, str] = {
+ "python": "py",
+ "typescript": "ts",
+ "javascript": "js",
+}
+
+# ---------------------------------------------------------------------------
+# ReAct agent recursion limit multiplier (steps per task)
+# ---------------------------------------------------------------------------
+
+RECURSION_STEPS_PER_TASK = 3
+
+# ---------------------------------------------------------------------------
+# Terraform file names that are always generated
+# ---------------------------------------------------------------------------
+
+TF_BASE_FILES = ["main.tf", "variables.tf", "outputs.tf", "providers.tf"]
diff --git a/backend/app/agents/agent3/graph.py b/backend/app/agents/agent3/graph.py
new file mode 100644
index 0000000..a81a910
--- /dev/null
+++ b/backend/app/agents/agent3/graph.py
@@ -0,0 +1,173 @@
+from __future__ import annotations
+
+import threading
+from typing import Any, Literal
+
+from langgraph.checkpoint.memory import MemorySaver
+from langgraph.graph import END, START, StateGraph
+
+from app.agents.agent3.nodes.assembler import assembler_node, error_handler_node
+from app.agents.agent3.nodes.orchestrator import make_orchestrator_node
+from app.agents.agent3.nodes.parse_input import parse_input_node
+from app.agents.agent3.nodes.tf_generator import tf_generator_node
+from app.agents.agent3.state import AgentState
+from app.agents.agent3.subgraphs.code_generation_loop import compile_code_generation_subgraph
+from app.agents.agent3.subgraphs.tf_validation_loop import compile_tf_validation_subgraph
+
+
+# ---------------------------------------------------------------------------
+# State mappers: AgentState <-> TFValidationState
+# ---------------------------------------------------------------------------
+
+
+def _tf_subgraph_input(state: AgentState) -> dict[str, Any]:
+ """Slice AgentState into TFValidationState fields."""
+ return {
+ "tf_files": state.get("tf_files", {}),
+ "validation_results": [],
+ "fix_attempts": state.get("tf_fix_attempts", 0),
+ "max_retries": state.get("tf_max_retries", 3),
+ "error_summary": state.get("tf_error_summary"),
+ "validated": False,
+ "human_review_required": False,
+ "human_review_message": None,
+ }
+
+
+def _tf_subgraph_output(sub_result: dict[str, Any]) -> dict[str, Any]:
+ """Map TFValidationState output back to AgentState fields."""
+ validated: bool = sub_result.get("validated", False)
+ human_review: bool = sub_result.get("human_review_required", False)
+
+ if validated:
+ phase = "orchestration"
+ elif human_review:
+ phase = "tf_validation" # paused mid-pipeline awaiting human
+ else:
+ phase = "tf_validation"
+
+ return {
+ "tf_files": sub_result.get("tf_files", {}),
+ "tf_validation_results": sub_result.get("validation_results", []),
+ "tf_validated": validated,
+ "tf_fix_attempts": sub_result.get("fix_attempts", 0),
+ "tf_error_summary": sub_result.get("error_summary"),
+ "human_review_required": human_review,
+ "human_review_message": sub_result.get("human_review_message"),
+ "current_phase": phase,
+ }
+
+
+def _make_tf_validation_node(compiled_tf_subgraph: Any):
+ """Wrap the TF validation subgraph with input/output transformers."""
+
+ def tf_validation_node(state: AgentState) -> dict[str, Any]:
+ sub_input = _tf_subgraph_input(state)
+ sub_result = compiled_tf_subgraph.invoke(sub_input)
+ return _tf_subgraph_output(sub_result)
+
+ return tf_validation_node
+
+
+# ---------------------------------------------------------------------------
+# Top-level routing
+# ---------------------------------------------------------------------------
+
+
+def _route_after_parsing(state: AgentState) -> Literal["tf_generator", "error_handler"]:
+ return "error_handler" if state.get("current_phase") == "error" else "tf_generator"
+
+
+def _route_after_tf_generation(state: AgentState) -> Literal["tf_validation_loop", "error_handler"]:
+ return "error_handler" if state.get("current_phase") == "error" else "tf_validation_loop"
+
+
+def _route_after_tf_validation(
+ state: AgentState,
+) -> Literal["orchestrator", "assembler", "error_handler"]:
+ if state.get("human_review_required"):
+ return "assembler" # partial output with human_review flag
+ if state.get("current_phase") == "error":
+ return "error_handler"
+ if state.get("tf_validated"):
+ return "orchestrator"
+ # TF never validated and no human flag means something odd — assemble partial
+ return "assembler"
+
+
+def _route_after_orchestration(state: AgentState) -> Literal["assembler"]:
+ return "assembler"
+
+
+# ---------------------------------------------------------------------------
+# Graph factory
+# ---------------------------------------------------------------------------
+
+
+def compile_graph(checkpointer=None):
+ """Compile and return the top-level agent3 StateGraph."""
+ tf_subgraph = compile_tf_validation_subgraph()
+ code_subgraph = compile_code_generation_subgraph()
+
+ builder = StateGraph(AgentState)
+
+ builder.add_node("parse_input", parse_input_node)
+ builder.add_node("tf_generator", tf_generator_node)
+ builder.add_node("tf_validation_loop", _make_tf_validation_node(tf_subgraph))
+ builder.add_node("orchestrator", make_orchestrator_node(code_subgraph))
+ builder.add_node("assembler", assembler_node)
+ builder.add_node("error_handler", error_handler_node)
+
+ builder.add_edge(START, "parse_input")
+
+ builder.add_conditional_edges(
+ "parse_input",
+ _route_after_parsing,
+ {"tf_generator": "tf_generator", "error_handler": "error_handler"},
+ )
+
+ builder.add_conditional_edges(
+ "tf_generator",
+ _route_after_tf_generation,
+ {"tf_validation_loop": "tf_validation_loop", "error_handler": "error_handler"},
+ )
+
+ builder.add_conditional_edges(
+ "tf_validation_loop",
+ _route_after_tf_validation,
+ {
+ "orchestrator": "orchestrator",
+ "assembler": "assembler",
+ "error_handler": "error_handler",
+ },
+ )
+
+ builder.add_conditional_edges(
+ "orchestrator",
+ _route_after_orchestration,
+ {"assembler": "assembler"},
+ )
+
+ builder.add_edge("assembler", END)
+ builder.add_edge("error_handler", END)
+
+ cp = checkpointer if checkpointer is not None else MemorySaver()
+ return builder.compile(checkpointer=cp)
+
+
+# ---------------------------------------------------------------------------
+# Singleton accessor
+# ---------------------------------------------------------------------------
+
+_graph = None
+_graph_lock = threading.Lock()
+
+
+def get_graph():
+ """Return the compiled agent3 graph singleton. Thread-safe initialisation."""
+ global _graph
+ if _graph is None:
+ with _graph_lock:
+ if _graph is None:
+ _graph = compile_graph()
+ return _graph
diff --git a/backend/app/agents/agent3/llm.py b/backend/app/agents/agent3/llm.py
new file mode 100644
index 0000000..30e955a
--- /dev/null
+++ b/backend/app/agents/agent3/llm.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import threading
+
+from langchain_anthropic import ChatAnthropic
+
+from app.agents.agent3.config import DEFAULT_MODEL, FAST_MODEL
+
+_lock = threading.Lock()
+_clients: dict[str, ChatAnthropic] = {}
+
+
+def get_llm(model: str = DEFAULT_MODEL, temperature: float = 0.0) -> ChatAnthropic:
+ """
+ Return a module-level singleton ChatAnthropic client for a given (model, temperature) pair.
+ Thread-safe; avoids re-creating clients on every node invocation.
+ """
+ key = f"{model}:{temperature}"
+ if key not in _clients:
+ with _lock:
+ if key not in _clients:
+ _clients[key] = ChatAnthropic(model=model, temperature=temperature)
+ return _clients[key]
+
+
+def get_default_llm() -> ChatAnthropic:
+ return get_llm(DEFAULT_MODEL, 0.0)
+
+
+def get_fast_llm() -> ChatAnthropic:
+ return get_llm(FAST_MODEL, 0.0)
diff --git a/backend/app/agents/agent3/models.py b/backend/app/agents/agent3/models.py
new file mode 100644
index 0000000..a8c9248
--- /dev/null
+++ b/backend/app/agents/agent3/models.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+
+class ServiceNodeInput(BaseModel):
+ id: str
+ service_type: Literal[
+ "lambda",
+ "s3",
+ "rds",
+ "vpc",
+ "api_gateway",
+ "dynamodb",
+ "sns",
+ "sqs",
+ "cloudfront",
+ "ecs",
+ "ec2",
+ "elasticache",
+ "kinesis",
+ "glue",
+ "step_functions",
+ ]
+ label: str
+ config: dict[str, Any] = Field(default_factory=dict)
+
+
+class ConnectionInput(BaseModel):
+ source: str
+ target: str
+ relationship: str = "connects_to"
+
+
+class TopologyInput(BaseModel):
+ services: list[ServiceNodeInput]
+ connections: list[ConnectionInput] = Field(default_factory=list)
+ cloud_provider: str = "aws"
+ # Optional per-service language overrides inside the topology JSON itself
+ language_overrides: dict[str, str] = Field(default_factory=dict)
+
+
+class GenerateRequest(BaseModel):
+ topology: str = Field(description="Raw JSON or YAML string describing the cloud topology")
+ input_format: Literal["json", "yaml"] = "json"
+ tf_max_retries: int = Field(default=3, ge=0, le=10)
+ orchestrator_max_iterations: int = Field(default=10, ge=1, le=20)
+ # Per-service language overrides; merged with any overrides inside the topology JSON
+ language_overrides: dict[str, str] = Field(
+ default_factory=dict,
+ description="Override language per service id, e.g. {'fn1': 'typescript'}",
+ )
+
+
+class GenerationResult(BaseModel):
+ thread_id: str
+ artifacts: dict[str, str]
+ tf_validation_passed: bool
+ tf_fix_attempts: int
+ tasks_completed: int
+ tasks_total: int
+ human_review_required: bool
+ human_review_message: str | None = None
+ generation_metadata: dict[str, Any] = Field(default_factory=dict)
+
+
+class HumanFeedback(BaseModel):
+ message: str
+ corrected_files: dict[str, str] = Field(
+ default_factory=dict,
+ description="Optional manual corrections: filename -> corrected content",
+ )
+
+
+class StatusResponse(BaseModel):
+ thread_id: str
+ current_phase: str
+ human_review_required: bool
+ human_review_message: str | None = None
+ artifacts: dict[str, str] | None = None
+ interrupted: bool
+ tf_fix_attempts: int
+ tasks_completed: int
+ tasks_total: int
diff --git a/backend/app/agents/agent3/nodes/__init__.py b/backend/app/agents/agent3/nodes/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/agents/agent3/nodes/assembler.py b/backend/app/agents/agent3/nodes/assembler.py
new file mode 100644
index 0000000..2140866
--- /dev/null
+++ b/backend/app/agents/agent3/nodes/assembler.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import time
+from typing import Any
+
+from app.agents.agent3.state import AgentState
+
+
+def _count_tasks(task_list: list, status: str) -> int:
+ return sum(1 for t in task_list if t["status"] == status)
+
+
+def assembler_node(state: AgentState) -> dict[str, Any]:
+ """Merge all generated artifacts into the final output dict."""
+ artifacts: dict[str, str] = {}
+ artifacts.update(state.get("tf_files") or {})
+ artifacts.update(state.get("code_files") or {})
+ artifacts.update(state.get("test_files") or {})
+
+ task_list = state.get("task_list") or []
+ code_errors = state.get("code_errors") or []
+
+ metadata: dict[str, Any] = {
+ "tf_fix_attempts": state.get("tf_fix_attempts", 0),
+ "tf_validated": state.get("tf_validated", False),
+ "tasks_total": len(task_list),
+ "tasks_done": _count_tasks(task_list, "done"),
+ "tasks_failed": _count_tasks(task_list, "failed"),
+ "orchestrator_iterations": state.get("orchestrator_iterations", 0),
+ "human_review_required": state.get("human_review_required", False),
+ "code_errors": [dict(e) for e in code_errors],
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ }
+
+ return {
+ "artifacts": artifacts,
+ "generation_metadata": metadata,
+ "current_phase": "done",
+ }
+
+
+def error_handler_node(state: AgentState) -> dict[str, Any]:
+ """Collect whatever partial artifacts exist and surface pipeline errors."""
+ artifacts: dict[str, str] = {}
+ artifacts.update(state.get("tf_files") or {})
+ artifacts.update(state.get("code_files") or {})
+ artifacts.update(state.get("test_files") or {})
+
+ return {
+ "artifacts": artifacts,
+ "generation_metadata": {
+ "errors": state.get("pipeline_errors") or [],
+ "phase_at_failure": state.get("current_phase", "unknown"),
+ },
+ "current_phase": "error",
+ }
diff --git a/backend/app/agents/agent3/nodes/code_fixer.py b/backend/app/agents/agent3/nodes/code_fixer.py
new file mode 100644
index 0000000..07bd41b
--- /dev/null
+++ b/backend/app/agents/agent3/nodes/code_fixer.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from app.agents.agent3.config import EXT_MAP
+from app.agents.agent3.llm import get_fast_llm
+from app.agents.agent3.prompts.code_prompts import code_fix_system, code_fix_user
+from app.agents.agent3.state import CodeGenState
+from app.agents.agent3.utils import strip_code_fences
+
+
+def code_fixer_node(state: CodeGenState) -> dict[str, Any]:
+ """Call Claude (fast model) to fix syntax errors in generated code."""
+ task = state["task"]
+ language = task["language"]
+ ext = EXT_MAP.get(language, language)
+ service_id = task["service_id"]
+ task_type = task["task_type"]
+ errors = state.get("syntax_errors") or []
+
+ if task_type == "test_gen":
+ code = state.get("generated_tests") or ""
+ filename = f"services/{service_id}/test_handler.{ext}"
+ else:
+ code = state.get("generated_code") or ""
+ filename = f"services/{service_id}/handler.{ext}"
+
+ if not code:
+ return {
+ "fix_attempts": state["fix_attempts"] + 1,
+ "syntax_errors": ["Nothing to fix — code is empty"],
+ }
+
+ system_msg = code_fix_system(language=language)
+ user_msg = code_fix_user(
+ attempt=state["fix_attempts"] + 1,
+ max_attempts=state["max_retries"],
+ language=language,
+ filename=filename,
+ errors=errors[-10:], # cap to last 10 errors to keep context tight
+ code=code,
+ )
+
+ try:
+ response = get_fast_llm().invoke(
+ [SystemMessage(content=system_msg), HumanMessage(content=user_msg)]
+ )
+ fixed = strip_code_fences(response.content)
+
+ update: dict[str, Any] = {"fix_attempts": state["fix_attempts"] + 1}
+ if task_type == "test_gen":
+ update["generated_tests"] = fixed
+ else:
+ update["generated_code"] = fixed
+ return update
+ except Exception as e:
+ return {
+ "fix_attempts": state["fix_attempts"] + 1,
+ "syntax_errors": [f"Code fix failed: {e}"],
+ }
diff --git a/backend/app/agents/agent3/nodes/code_generator.py b/backend/app/agents/agent3/nodes/code_generator.py
new file mode 100644
index 0000000..454ca61
--- /dev/null
+++ b/backend/app/agents/agent3/nodes/code_generator.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from app.agents.agent3.config import EXT_MAP
+from app.agents.agent3.llm import get_default_llm
+from app.agents.agent3.prompts.code_prompts import code_generation_system, code_generation_user
+from app.agents.agent3.state import CodeGenState
+from app.agents.agent3.utils import strip_code_fences
+
+
+def code_generator_node(state: CodeGenState) -> dict[str, Any]:
+ """Call Claude to generate application code for a specific service."""
+ task = state["task"]
+ language = task["language"]
+ ext = EXT_MAP.get(language, language)
+
+ # Parse the JSON architecture context blob built by the orchestrator
+ try:
+ ctx: dict[str, Any] = json.loads(state.get("architecture_context") or "{}")
+ except (json.JSONDecodeError, TypeError):
+ ctx = {}
+
+ service_id = task["service_id"]
+ service_type = ctx.get("service_type", "lambda")
+ label = ctx.get("label", service_id)
+ config: dict[str, Any] = ctx.get("config") or {}
+ incoming: list[dict[str, str]] = ctx.get("incoming") or []
+ outgoing: list[dict[str, str]] = ctx.get("outgoing") or []
+
+ system_msg = code_generation_system(language=language)
+ user_msg = code_generation_user(
+ language=language,
+ service_id=service_id,
+ service_type=service_type,
+ label=label,
+ config=config,
+ incoming=incoming,
+ outgoing=outgoing,
+ tf_context=state.get("tf_context") or "",
+ ext=ext,
+ )
+
+ try:
+ response = get_default_llm().invoke(
+ [SystemMessage(content=system_msg), HumanMessage(content=user_msg)]
+ )
+ code = strip_code_fences(response.content)
+ if not code.strip():
+ return {"syntax_errors": ["Code generator returned empty output"]}
+ return {"generated_code": code}
+ except Exception as e:
+ return {"syntax_errors": [f"Code generation failed: {e}"]}
diff --git a/backend/app/agents/agent3/nodes/code_validator.py b/backend/app/agents/agent3/nodes/code_validator.py
new file mode 100644
index 0000000..8a5c0bf
--- /dev/null
+++ b/backend/app/agents/agent3/nodes/code_validator.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from typing import Any
+
+from app.agents.agent3.config import EXT_MAP
+from app.agents.agent3.state import CodeGenState
+from app.agents.agent3.tools.syntax_tools import check_syntax
+
+
+def code_validator_node(state: CodeGenState) -> dict[str, Any]:
+ """Check syntax of generated_code (or generated_tests for test tasks)."""
+ task = state["task"]
+ language = task["language"]
+ ext = EXT_MAP.get(language, language)
+ service_id = task["service_id"]
+ task_type = task["task_type"]
+
+ if task_type == "test_gen":
+ code = state.get("generated_tests") or ""
+ filename = f"services/{service_id}/test_handler.{ext}"
+ else:
+ code = state.get("generated_code") or ""
+ filename = f"services/{service_id}/handler.{ext}"
+
+ if not code:
+ return {"syntax_errors": ["No code to validate"]}
+
+ errors = check_syntax(code, language, filename)
+ if errors:
+ return {"syntax_errors": errors}
+
+ return {"done": True}
diff --git a/backend/app/agents/agent3/nodes/orchestrator.py b/backend/app/agents/agent3/nodes/orchestrator.py
new file mode 100644
index 0000000..b0d2a75
--- /dev/null
+++ b/backend/app/agents/agent3/nodes/orchestrator.py
@@ -0,0 +1,311 @@
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from typing import Any
+
+from langchain_core.messages import HumanMessage
+from langchain_core.tools import tool
+from langgraph.prebuilt import create_react_agent
+
+from app.agents.agent3.config import CODE_MAX_RETRIES, EXT_MAP, RECURSION_STEPS_PER_TASK
+from app.agents.agent3.llm import get_default_llm
+from app.agents.agent3.prompts.orchestrator_prompts import orchestrator_system
+from app.agents.agent3.state import AgentState, CodeError, CodeGenState, TaskItem
+from app.agents.agent3.tools.task_tools import (
+ build_architecture_summary,
+ describe_service,
+ extract_tf_context_for_service,
+)
+
+# ---------------------------------------------------------------------------
+# Mutable context shared between tool closures and the orchestrator node
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class _OrchestratorCtx:
+ """All mutable state updated by tools during an orchestrator invocation."""
+
+ code_files: dict[str, str] = field(default_factory=dict)
+ test_files: dict[str, str] = field(default_factory=dict)
+ task_list: list[TaskItem] = field(default_factory=list)
+ code_errors: list[CodeError] = field(default_factory=list)
+
+ # -- Task helpers --
+
+ def find_task(self, service_id: str, task_type: str) -> TaskItem | None:
+ return next(
+ (t for t in self.task_list if t["service_id"] == service_id and t["task_type"] == task_type),
+ None,
+ )
+
+ def set_task_status(self, task_id: str, status: str, error: str | None = None) -> None:
+ for t in self.task_list:
+ if t["task_id"] == task_id:
+ t["status"] = status # type: ignore[typeddict-item]
+ if error is not None:
+ t["error_message"] = error
+ return
+
+
+def _build_arch_ctx_json(state: AgentState, service_id: str) -> str:
+ """Build a JSON context blob for a single service (used inside tool closures)."""
+ services = state.get("services", [])
+ connections = state.get("connections", [])
+ svc = next((s for s in services if s["id"] == service_id), None)
+ if not svc:
+ return json.dumps({"service_id": service_id})
+
+ outgoing = [c for c in connections if c["source"] == service_id]
+ incoming = [c for c in connections if c["target"] == service_id]
+
+ return json.dumps(
+ {
+ "service_id": service_id,
+ "service_type": svc["service_type"],
+ "label": svc["label"],
+ "config": svc["config"],
+ "connections": describe_service(svc, connections),
+ "incoming": [{"from": c["source"], "via": c["relationship"]} for c in incoming],
+ "outgoing": [{"to": c["target"], "via": c["relationship"]} for c in outgoing],
+ },
+ indent=2,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Tool factory — builds @tool functions closed over ctx + state + code_subgraph
+# ---------------------------------------------------------------------------
+
+
+def _build_tools(ctx: _OrchestratorCtx, state: AgentState, code_subgraph: Any) -> list:
+ """Return a list of @tool functions with shared context injected via closure."""
+
+ @tool
+ def get_pending_tasks() -> str:
+ """Return a JSON list of all pending tasks (code_gen and test_gen)."""
+ pending = [t for t in ctx.task_list if t["status"] == "pending"]
+ return json.dumps(pending, indent=2) if pending else "[]"
+
+ @tool
+ def get_architecture_summary() -> str:
+ """Return a human-readable summary of the cloud architecture and service dependencies."""
+ return build_architecture_summary(
+ state.get("services", []), state.get("connections", [])
+ )
+
+ @tool
+ def generate_service_code(service_id: str, language: str) -> str:
+ """
+ Generate application code for the specified cloud service.
+ Returns the generated file path on success, or an error description.
+ """
+ task = ctx.find_task(service_id, "code_gen")
+ if task is None:
+ return f"ERROR: No code_gen task found for service_id='{service_id}'"
+
+ ctx.set_task_status(task["task_id"], "in_progress")
+
+ ext = EXT_MAP.get(language, language)
+ arch_ctx = _build_arch_ctx_json(state, service_id)
+ tf_ctx = extract_tf_context_for_service(state.get("tf_files", {}), service_id)
+
+ sub_state = CodeGenState(
+ task=TaskItem(
+ task_id=task["task_id"],
+ service_id=service_id,
+ task_type="code_gen",
+ language=language,
+ status="in_progress",
+ retry_count=task["retry_count"],
+ error_message=None,
+ ),
+ tf_context=tf_ctx,
+ architecture_context=arch_ctx,
+ generated_code=None,
+ generated_tests=None,
+ syntax_errors=[],
+ fix_attempts=0,
+ max_retries=CODE_MAX_RETRIES,
+ done=False,
+ human_review_required=False,
+ human_review_message=None,
+ )
+
+ try:
+ result = code_subgraph.invoke(sub_state)
+ code = result.get("generated_code")
+
+ if not code:
+ errors = result.get("syntax_errors") or ["unknown error"]
+ ctx.set_task_status(task["task_id"], "failed", error="; ".join(errors))
+ ctx.code_errors.append(
+ CodeError(
+ service_id=service_id,
+ task_type="code_gen",
+ file=f"services/{service_id}/handler.{ext}",
+ errors=errors,
+ )
+ )
+ return f"FAILED: {'; '.join(errors)}"
+
+ file_path = f"services/{service_id}/handler.{ext}"
+ ctx.code_files[file_path] = code
+ ctx.set_task_status(task["task_id"], "done")
+ return f"OK: {file_path}"
+ except Exception as e:
+ error_msg = str(e)
+ ctx.set_task_status(task["task_id"], "failed", error=error_msg)
+ ctx.code_errors.append(
+ CodeError(
+ service_id=service_id,
+ task_type="code_gen",
+ file=f"services/{service_id}/handler.{ext}",
+ errors=[error_msg],
+ )
+ )
+ return f"ERROR: {error_msg}"
+
+ @tool
+ def generate_service_tests(service_id: str, language: str) -> str:
+ """
+ Generate unit tests for the specified service.
+ Requires generate_service_code to be called first for this service.
+ Returns the generated test file path on success, or an error description.
+ """
+ ext = EXT_MAP.get(language, language)
+ code_path = f"services/{service_id}/handler.{ext}"
+ source_code = ctx.code_files.get(code_path, "")
+
+ task = ctx.find_task(service_id, "test_gen")
+ if task is None:
+ return f"ERROR: No test_gen task found for service_id='{service_id}'"
+
+ ctx.set_task_status(task["task_id"], "in_progress")
+ arch_ctx = _build_arch_ctx_json(state, service_id)
+
+ # Reuse the code_generation subgraph with test_gen task type
+ sub_state = CodeGenState(
+ task=TaskItem(
+ task_id=task["task_id"],
+ service_id=service_id,
+ task_type="test_gen",
+ language=language,
+ status="in_progress",
+ retry_count=task["retry_count"],
+ error_message=None,
+ ),
+ tf_context="",
+ architecture_context=arch_ctx,
+ generated_code=source_code,
+ generated_tests=None,
+ syntax_errors=[],
+ fix_attempts=0,
+ max_retries=CODE_MAX_RETRIES,
+ done=False,
+ human_review_required=False,
+ human_review_message=None,
+ )
+
+ try:
+ result = code_subgraph.invoke(sub_state)
+ tests = result.get("generated_tests")
+
+ if not tests:
+ errors = result.get("syntax_errors") or ["unknown error"]
+ ctx.set_task_status(task["task_id"], "failed", error="; ".join(errors))
+ ctx.code_errors.append(
+ CodeError(
+ service_id=service_id,
+ task_type="test_gen",
+ file=f"services/{service_id}/test_handler.{ext}",
+ errors=errors,
+ )
+ )
+ return f"FAILED: {'; '.join(errors)}"
+
+ test_path = f"services/{service_id}/test_handler.{ext}"
+ ctx.test_files[test_path] = tests
+ ctx.set_task_status(task["task_id"], "done")
+ return f"OK: {test_path}"
+ except Exception as e:
+ error_msg = str(e)
+ ctx.set_task_status(task["task_id"], "failed", error=error_msg)
+ return f"ERROR: {error_msg}"
+
+ @tool
+ def mark_task_done(task_id: str) -> str:
+ """Explicitly mark a specific task as done by its task_id."""
+ task = next((t for t in ctx.task_list if t["task_id"] == task_id), None)
+ if task is None:
+ return f"ERROR: Task '{task_id}' not found"
+ ctx.set_task_status(task_id, "done")
+ return f"OK: Task '{task_id}' marked done"
+
+ return [get_pending_tasks, get_architecture_summary, generate_service_code, generate_service_tests, mark_task_done]
+
+
+# ---------------------------------------------------------------------------
+# Node factory
+# ---------------------------------------------------------------------------
+
+
+def make_orchestrator_node(compiled_code_subgraph: Any):
+ """Factory: returns a LangGraph node function closed over the compiled code subgraph."""
+
+ def orchestrator_node(state: AgentState) -> dict[str, Any]:
+ # Seed context from current state (handles multi-iteration correctly)
+ ctx = _OrchestratorCtx(
+ code_files=dict(state.get("code_files") or {}),
+ test_files=dict(state.get("test_files") or {}),
+ task_list=list(state.get("task_list") or []),
+ code_errors=[],
+ )
+
+ tools = _build_tools(ctx, state, compiled_code_subgraph)
+
+ # Build dynamic system prompt with current architecture + TF context
+ arch_summary = build_architecture_summary(
+ state.get("services", []), state.get("connections", [])
+ )
+ system_prompt = orchestrator_system(
+ architecture_summary=arch_summary,
+ tf_file_names=list(state.get("tf_files", {}).keys()),
+ )
+
+ # `prompt` is the current API (state_modifier is deprecated in langgraph-prebuilt 1.x)
+ agent = create_react_agent(get_default_llm(), tools, prompt=system_prompt)
+
+ # Build initial message if this is the first orchestrator iteration
+ prior_messages = list(state.get("orchestrator_messages") or [])
+ if not prior_messages:
+ pending_count = sum(1 for t in ctx.task_list if t["status"] == "pending")
+ prior_messages = [
+ HumanMessage(
+ content=(
+ f"Start generating code. There are {pending_count} pending tasks. "
+ "Call get_pending_tasks() first to see the full task list, "
+ "then work through them systematically."
+ )
+ )
+ ]
+
+ # recursion_limit guards against the ReAct agent looping indefinitely
+ max_iter = state.get("orchestrator_max_iterations", 10)
+ result = agent.invoke(
+ {"messages": prior_messages},
+ config={"recursion_limit": max_iter * RECURSION_STEPS_PER_TASK},
+ )
+
+ return {
+ "orchestrator_messages": result["messages"],
+ "code_files": ctx.code_files,
+ "test_files": ctx.test_files,
+ "task_list": ctx.task_list,
+ "code_errors": ctx.code_errors,
+ "orchestrator_iterations": state.get("orchestrator_iterations", 0) + 1,
+ "current_phase": "assembly",
+ }
+
+ return orchestrator_node
diff --git a/backend/app/agents/agent3/nodes/parse_input.py b/backend/app/agents/agent3/nodes/parse_input.py
new file mode 100644
index 0000000..214fc52
--- /dev/null
+++ b/backend/app/agents/agent3/nodes/parse_input.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+import json
+import uuid
+from typing import Any
+
+import yaml
+
+from app.agents.agent3.config import (
+ DEFAULT_LANGUAGE,
+ ORCHESTRATOR_MAX_ITERATIONS,
+ SERVICE_LANGUAGE_MAP,
+ SUPPORTED_SERVICE_TYPES,
+ TF_MAX_RETRIES,
+)
+from app.agents.agent3.state import AgentState, Connection, ServiceNode, TaskItem
+
+# Maximum raw input size (bytes) to protect against accidental huge payloads
+_MAX_INPUT_BYTES = 512 * 1024 # 512 KB
+
+
+def _infer_language(
+ service_type: str,
+ overrides: dict[str, str],
+ service_id: str,
+) -> str:
+ """Determine the target language for a service, respecting per-service overrides."""
+ if service_id in overrides:
+ return overrides[service_id]
+ return SERVICE_LANGUAGE_MAP.get(service_type, DEFAULT_LANGUAGE)
+
+
+def parse_input_node(state: AgentState) -> dict[str, Any]:
+ """
+ Parse raw JSON/YAML topology input into structured services + connections.
+ Initialises all phase-specific fields and builds the task_list.
+ """
+ raw = state.get("raw_input", "")
+
+ # Guard against excessively large inputs
+ if len(raw.encode()) > _MAX_INPUT_BYTES:
+ return {
+ "current_phase": "error",
+ "pipeline_errors": [
+ f"Input too large ({len(raw.encode())} bytes, max {_MAX_INPUT_BYTES})"
+ ],
+ }
+
+ fmt = state.get("input_format", "json")
+
+ try:
+ if fmt == "yaml":
+ data = yaml.safe_load(raw)
+ else:
+ data = json.loads(raw)
+ except Exception as e:
+ return {
+ "current_phase": "error",
+ "pipeline_errors": [f"Failed to parse input ({fmt}): {e}"],
+ }
+
+ if not isinstance(data, dict):
+ return {
+ "current_phase": "error",
+ "pipeline_errors": ["Input must be a JSON/YAML object (not a list or scalar)"],
+ }
+
+ raw_services = data.get("services", [])
+ if not isinstance(raw_services, list):
+ return {
+ "current_phase": "error",
+ "pipeline_errors": ["'services' must be a list"],
+ }
+ if not raw_services:
+ return {
+ "current_phase": "error",
+ "pipeline_errors": ["'services' list is empty — nothing to generate"],
+ }
+
+ raw_connections: list[dict] = data.get("connections", []) or []
+ cloud_provider: str = data.get("cloud_provider", "aws")
+
+ # Merge language overrides: topology-embedded overrides < request-level overrides
+ # (request-level take precedence)
+ topology_overrides: dict[str, str] = data.get("language_overrides", {}) or {}
+ request_overrides: dict[str, str] = state.get("language_overrides", {}) or {}
+ lang_overrides: dict[str, str] = {**topology_overrides, **request_overrides}
+
+ # Build ServiceNode list with input validation
+ services: list[ServiceNode] = []
+ parse_warnings: list[str] = []
+
+ for raw_svc in raw_services:
+ if not isinstance(raw_svc, dict):
+ parse_warnings.append(f"Skipping non-dict service entry: {raw_svc!r}")
+ continue
+ svc_id = raw_svc.get("id")
+ if not svc_id:
+ parse_warnings.append("Skipping service with missing 'id'")
+ continue
+ svc_type = raw_svc.get("service_type", "lambda")
+ if svc_type not in SUPPORTED_SERVICE_TYPES:
+ parse_warnings.append(
+ f"Service '{svc_id}' has unknown type '{svc_type}' — proceeding anyway"
+ )
+ services.append(
+ ServiceNode(
+ id=str(svc_id),
+ service_type=svc_type,
+ label=str(raw_svc.get("label", svc_id)),
+ config=raw_svc.get("config") or {},
+ )
+ )
+
+ if not services:
+ return {
+ "current_phase": "error",
+ "pipeline_errors": ["No valid services found after parsing"],
+ }
+
+ # Build Connection list
+ connections: list[Connection] = []
+ svc_ids = {s["id"] for s in services}
+ for raw_conn in raw_connections:
+ if not isinstance(raw_conn, dict):
+ continue
+ src = raw_conn.get("source")
+ tgt = raw_conn.get("target")
+ if not src or not tgt:
+ continue
+ if src not in svc_ids or tgt not in svc_ids:
+ parse_warnings.append(
+ f"Connection {src!r} -> {tgt!r} references unknown service(s) — skipped"
+ )
+ continue
+ connections.append(
+ Connection(
+ source=str(src),
+ target=str(tgt),
+ relationship=str(raw_conn.get("relationship", "connects_to")),
+ )
+ )
+
+ # Build task_list: one code_gen + one test_gen per service
+ task_list: list[TaskItem] = []
+ for svc in services:
+ lang = _infer_language(svc["service_type"], lang_overrides, svc["id"])
+ task_list.append(
+ TaskItem(
+ task_id=str(uuid.uuid4()),
+ service_id=svc["id"],
+ task_type="code_gen",
+ language=lang,
+ status="pending",
+ retry_count=0,
+ error_message=None,
+ )
+ )
+ task_list.append(
+ TaskItem(
+ task_id=str(uuid.uuid4()),
+ service_id=svc["id"],
+ task_type="test_gen",
+ language=lang,
+ status="pending",
+ retry_count=0,
+ error_message=None,
+ )
+ )
+
+ update: dict[str, Any] = {
+ "services": services,
+ "connections": connections,
+ "cloud_provider": cloud_provider,
+ "task_list": task_list,
+ "tf_fix_attempts": 0,
+ "tf_max_retries": state.get("tf_max_retries") or TF_MAX_RETRIES,
+ "tf_validated": False,
+ "tf_files": {},
+ "code_files": {},
+ "test_files": {},
+ "artifacts": {},
+ "orchestrator_iterations": 0,
+ "orchestrator_max_iterations": (
+ state.get("orchestrator_max_iterations") or ORCHESTRATOR_MAX_ITERATIONS
+ ),
+ "human_review_required": False,
+ "current_phase": "tf_generation",
+ }
+ if parse_warnings:
+ update["pipeline_errors"] = parse_warnings
+ return update
diff --git a/backend/app/agents/agent3/nodes/test_generator.py b/backend/app/agents/agent3/nodes/test_generator.py
new file mode 100644
index 0000000..3ba3e49
--- /dev/null
+++ b/backend/app/agents/agent3/nodes/test_generator.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from app.agents.agent3.config import EXT_MAP
+from app.agents.agent3.llm import get_default_llm
+from app.agents.agent3.prompts.test_prompts import test_generation_system, test_generation_user
+from app.agents.agent3.state import CodeGenState
+from app.agents.agent3.utils import strip_code_fences
+
+
+def test_generator_node(state: CodeGenState) -> dict[str, Any]:
+ """Call Claude to generate unit tests for the already-generated service code."""
+ task = state["task"]
+ language = task["language"]
+ ext = EXT_MAP.get(language, language)
+
+ try:
+ ctx: dict[str, Any] = json.loads(state.get("architecture_context") or "{}")
+ except (json.JSONDecodeError, TypeError):
+ ctx = {}
+
+ service_id = task["service_id"]
+ service_type = ctx.get("service_type", "lambda")
+ source_code = state.get("generated_code") or "# source code not available"
+
+ # Build a concise architecture description for the test prompt
+ incoming = ctx.get("incoming") or []
+ outgoing = ctx.get("outgoing") or []
+ arch_lines: list[str] = []
+ if incoming:
+ arch_lines.append("Receives from: " + ", ".join(f"{c['from']} ({c['via']})" for c in incoming))
+ if outgoing:
+ arch_lines.append("Calls/triggers: " + ", ".join(f"{c['to']} ({c['via']})" for c in outgoing))
+ arch_description = "\n".join(arch_lines)
+
+ system_msg = test_generation_system(language=language)
+ user_msg = test_generation_user(
+ language=language,
+ service_id=service_id,
+ service_type=service_type,
+ source_code=source_code,
+ architecture_context=arch_description,
+ ext=ext,
+ )
+
+ try:
+ response = get_default_llm().invoke(
+ [SystemMessage(content=system_msg), HumanMessage(content=user_msg)]
+ )
+ tests = strip_code_fences(response.content)
+ if not tests.strip():
+ return {"syntax_errors": ["Test generator returned empty output"]}
+ return {"generated_tests": tests}
+ except Exception as e:
+ return {"syntax_errors": [f"Test generation failed: {e}"]}
diff --git a/backend/app/agents/agent3/nodes/tf_fixer.py b/backend/app/agents/agent3/nodes/tf_fixer.py
new file mode 100644
index 0000000..2c392b3
--- /dev/null
+++ b/backend/app/agents/agent3/nodes/tf_fixer.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from app.agents.agent3.llm import get_default_llm
+from app.agents.agent3.prompts.tf_prompts import tf_fix_system, tf_fix_user
+from app.agents.agent3.state import TFValidationState
+from app.agents.agent3.utils import safe_json_extract
+
+
+def tf_fixer_node(state: TFValidationState) -> dict[str, Any]:
+ """Call Claude to fix Terraform validation errors and return corrected files."""
+ system_msg = tf_fix_system(run_checkov=True)
+ user_msg = tf_fix_user(
+ attempt=state["fix_attempts"] + 1,
+ max_attempts=state["max_retries"],
+ error_summary=state.get("error_summary") or "Unknown errors — review all files",
+ tf_files=state["tf_files"],
+ )
+
+ try:
+ response = get_default_llm().invoke(
+ [SystemMessage(content=system_msg), HumanMessage(content=user_msg)]
+ )
+ data = safe_json_extract(response.content)
+ corrected: dict[str, str] = {
+ f["name"]: f["content"]
+ for f in data.get("files", [])
+ if isinstance(f, dict) and "name" in f and "content" in f
+ }
+
+ # Merge only the corrected files; leave others unchanged
+ updated_files = {**state["tf_files"], **corrected}
+
+ return {
+ "tf_files": updated_files,
+ "fix_attempts": state["fix_attempts"] + 1,
+ "error_summary": None, # cleared — will be repopulated after next validation pass
+ }
+ except Exception as e:
+ # Still increment attempt count so we don't loop forever
+ return {
+ "fix_attempts": state["fix_attempts"] + 1,
+ "error_summary": (state.get("error_summary") or "") + f"\n[Fixer error: {e}]",
+ }
diff --git a/backend/app/agents/agent3/nodes/tf_generator.py b/backend/app/agents/agent3/nodes/tf_generator.py
new file mode 100644
index 0000000..8024e60
--- /dev/null
+++ b/backend/app/agents/agent3/nodes/tf_generator.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from typing import Any
+
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from app.agents.agent3.llm import get_default_llm
+from app.agents.agent3.prompts.tf_prompts import tf_generation_system, tf_generation_user
+from app.agents.agent3.state import AgentState
+from app.agents.agent3.utils import safe_json_extract
+
+
+def tf_generator_node(state: AgentState) -> dict[str, Any]:
+ """Call Claude to generate Terraform HCL files from the parsed topology."""
+ services = state.get("services", [])
+ connections = state.get("connections", [])
+ cloud_provider = state.get("cloud_provider", "aws")
+
+ # Decide if a modules structure is warranted (>= 3 services)
+ use_modules = len(services) >= 3
+
+ system_msg = tf_generation_system(use_modules=use_modules)
+ user_msg = tf_generation_user(
+ cloud_provider=cloud_provider,
+ services=[dict(s) for s in services],
+ connections=[dict(c) for c in connections],
+ )
+
+ try:
+ response = get_default_llm().invoke(
+ [SystemMessage(content=system_msg), HumanMessage(content=user_msg)]
+ )
+ data = safe_json_extract(response.content)
+
+ tf_files: dict[str, str] = {
+ f["name"]: f["content"]
+ for f in data.get("files", [])
+ if isinstance(f, dict) and "name" in f and "content" in f
+ }
+
+ if not tf_files:
+ return {
+ "current_phase": "error",
+ "pipeline_errors": ["TF generator returned no files — LLM response was empty or malformed"],
+ }
+
+ return {
+ "tf_files": tf_files,
+ "current_phase": "tf_validation",
+ }
+ except ValueError as e:
+ return {
+ "current_phase": "error",
+ "pipeline_errors": [f"TF generator JSON parse error: {e}"],
+ }
+ except Exception as e:
+ return {
+ "current_phase": "error",
+ "pipeline_errors": [f"TF generation failed: {e}"],
+ }
diff --git a/backend/app/agents/agent3/prompts/__init__.py b/backend/app/agents/agent3/prompts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/agents/agent3/prompts/code_prompts.py b/backend/app/agents/agent3/prompts/code_prompts.py
new file mode 100644
index 0000000..b0dbdf3
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/code_prompts.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from typing import Any
+
+from app.agents.agent3.prompts.renderer import render
+
+
+def code_generation_system(language: str) -> str:
+ return render("code_generation_system.j2", language=language)
+
+
+def code_generation_user(
+ language: str,
+ service_id: str,
+ service_type: str,
+ label: str,
+ config: dict[str, Any],
+ incoming: list[dict[str, str]],
+ outgoing: list[dict[str, str]],
+ tf_context: str,
+ ext: str,
+) -> str:
+ return render(
+ "code_generation_user.j2",
+ language=language,
+ service_id=service_id,
+ service_type=service_type,
+ label=label,
+ config=config,
+ incoming=incoming,
+ outgoing=outgoing,
+ tf_context=tf_context,
+ ext=ext,
+ )
+
+
+def code_fix_system(language: str) -> str:
+ return render("code_fix_system.j2", language=language)
+
+
+def code_fix_user(
+ attempt: int,
+ max_attempts: int,
+ language: str,
+ filename: str,
+ errors: list[str],
+ code: str,
+) -> str:
+ return render(
+ "code_fix_user.j2",
+ attempt=attempt,
+ max_attempts=max_attempts,
+ language=language,
+ filename=filename,
+ errors=errors,
+ code=code,
+ )
diff --git a/backend/app/agents/agent3/prompts/orchestrator_prompts.py b/backend/app/agents/agent3/prompts/orchestrator_prompts.py
new file mode 100644
index 0000000..1fcc41c
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/orchestrator_prompts.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from app.agents.agent3.prompts.renderer import render
+
+
+def orchestrator_system(
+ architecture_summary: str,
+ tf_file_names: list[str],
+) -> str:
+ return render(
+ "orchestrator_system.j2",
+ architecture_summary=architecture_summary,
+ tf_file_names=tf_file_names,
+ )
diff --git a/backend/app/agents/agent3/prompts/renderer.py b/backend/app/agents/agent3/prompts/renderer.py
new file mode 100644
index 0000000..45a4602
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/renderer.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from jinja2 import Environment, FileSystemLoader, StrictUndefined
+
+_TEMPLATES_DIR = Path(__file__).parent / "templates"
+
+_env = Environment(
+ loader=FileSystemLoader(str(_TEMPLATES_DIR)),
+ undefined=StrictUndefined,
+ trim_blocks=True,
+ lstrip_blocks=True,
+ autoescape=False, # prompts are plain text, not HTML
+ keep_trailing_newline=True,
+)
+
+# Custom filters
+_env.filters["tojson"] = lambda v, indent=None: json.dumps(v, indent=indent, default=str)
+_env.filters["upper"] = str.upper
+_env.filters["lower"] = str.lower
+
+
+def render(template_name: str, **kwargs: Any) -> str:
+ """
+ Render a Jinja2 template from the templates/ directory.
+
+ Args:
+ template_name: filename relative to prompts/templates/ (e.g. "tf_generation_user.j2")
+ **kwargs: variables injected into the template context
+
+ Raises:
+ TemplateNotFound: if the template file doesn't exist
+ jinja2.UndefinedError: if a required variable is missing (StrictUndefined)
+ """
+ template = _env.get_template(template_name)
+ return template.render(**kwargs).strip()
diff --git a/backend/app/agents/agent3/prompts/templates/code_fix_system.j2 b/backend/app/agents/agent3/prompts/templates/code_fix_system.j2
new file mode 100644
index 0000000..babf346
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/templates/code_fix_system.j2
@@ -0,0 +1,6 @@
+You are an expert {{ language }} debugger. Fix ALL syntax errors in the provided code.
+
+Rules:
+- Fix only what is broken — do not restructure or refactor
+- Maintain the original logic and intent exactly
+- Respond ONLY with the corrected raw code — no markdown fences, no explanation
diff --git a/backend/app/agents/agent3/prompts/templates/code_fix_user.j2 b/backend/app/agents/agent3/prompts/templates/code_fix_user.j2
new file mode 100644
index 0000000..7c029b5
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/templates/code_fix_user.j2
@@ -0,0 +1,12 @@
+Fix attempt {{ attempt }} of {{ max_attempts }}.
+
+## File
+`{{ filename }}`
+
+## Syntax Errors
+{% for error in errors %}
+- {{ error }}
+{% endfor %}
+
+## Current Code
+{{ code }}
diff --git a/backend/app/agents/agent3/prompts/templates/code_generation_system.j2 b/backend/app/agents/agent3/prompts/templates/code_generation_system.j2
new file mode 100644
index 0000000..e5063dc
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/templates/code_generation_system.j2
@@ -0,0 +1,24 @@
+You are an expert cloud application developer. Generate production-quality application code for a specific AWS cloud service.
+
+Language: {{ language }}
+{% if language == "python" %}
+- Use type annotations (typing module)
+- Use structured logging (import logging; logger = logging.getLogger(__name__))
+- Import only: standard library, boto3, and os/json/logging
+- Use environment variables for all configuration (os.environ.get)
+- Follow PEP 8
+{% elif language == "typescript" %}
+- Use strict TypeScript with proper interfaces and types
+- Use @aws-sdk/client-* packages (v3 modular SDK)
+- Export handler as a named export
+- Use console.log/error for logging with structured JSON
+{% else %}
+- Write clean, idiomatic {{ language }} code
+- Follow language best practices and conventions
+{% endif %}
+
+General rules:
+- Include error handling with meaningful error messages
+- Use environment variables for all AWS resource references (ARNs, table names, bucket names, etc.)
+- Include a brief module-level docstring/comment explaining the service's role
+- Respond ONLY with raw code — no markdown fences, no explanations
diff --git a/backend/app/agents/agent3/prompts/templates/code_generation_user.j2 b/backend/app/agents/agent3/prompts/templates/code_generation_user.j2
new file mode 100644
index 0000000..9efb700
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/templates/code_generation_user.j2
@@ -0,0 +1,49 @@
+Generate {{ language }} application code for the following AWS service.
+
+## Service Details
+- ID: {{ service_id }}
+- Type: {{ service_type | upper }}
+- Label: {{ label }}
+{% if config %}
+- Configuration:
+{{ config | tojson(indent=2) }}
+{% endif %}
+
+{% if incoming or outgoing %}
+## Connections
+{% if incoming %}
+This service **receives** from:
+{% for conn in incoming %}
+ - `{{ conn.from }}` via {{ conn.via }}
+{% endfor %}
+{% endif %}
+{% if outgoing %}
+This service **calls / triggers**:
+{% for conn in outgoing %}
+ - `{{ conn.to }}` via {{ conn.via }}
+{% endfor %}
+{% endif %}
+{% endif %}
+
+{% if tf_context %}
+## Relevant Terraform Configuration
+The following Terraform resources are provisioned for this service — use the resource names/ARNs as environment variable references:
+```hcl
+{{ tf_context }}
+```
+{% endif %}
+
+## Task
+Generate the handler/entry point file: `services/{{ service_id }}/handler.{{ ext }}`
+
+{% if service_type == "lambda" %}
+The handler must export a function with signature: `def handler(event, context)` (Python) or `export const handler = async (event: APIGatewayProxyEvent): Promise` (TypeScript).
+{% elif service_type == "ecs" or service_type == "ec2" %}
+Generate a long-running service entrypoint appropriate for a container or server process.
+{% elif service_type == "glue" %}
+Generate a PySpark/Glue ETL script with a main() function.
+{% elif service_type == "step_functions" %}
+Generate Lambda handler functions for each state in the state machine.
+{% endif %}
+
+Output ONLY the raw code content. No markdown. No explanation.
diff --git a/backend/app/agents/agent3/prompts/templates/orchestrator_system.j2 b/backend/app/agents/agent3/prompts/templates/orchestrator_system.j2
new file mode 100644
index 0000000..9da2e3f
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/templates/orchestrator_system.j2
@@ -0,0 +1,29 @@
+You are a cloud infrastructure code generation orchestrator — similar to an agentic coding assistant.
+
+Your mission: generate complete application code and tests for every service in the cloud architecture below, using the available tools.
+
+## Architecture Overview
+{{ architecture_summary }}
+
+## Terraform Context
+The infrastructure has been provisioned via Terraform. Generated files:
+{% for filename in tf_file_names %}
+- {{ filename }}
+{% endfor %}
+
+## How to Work (follow this exactly)
+
+1. Call `get_pending_tasks()` to see all pending code generation and test generation tasks
+2. For each pending **code_gen** task:
+ a. Call `generate_service_code(service_id=..., language=...)`
+ b. On success, call `mark_task_done(task_id=...)`
+3. After completing code for a service, generate its tests:
+ a. Call `generate_service_tests(service_id=..., language=...)`
+ b. On success, call `mark_task_done(task_id=...)`
+4. Repeat until `get_pending_tasks()` returns an empty list
+
+## Rules
+- Always complete **code_gen before test_gen** for each service
+- If `generate_service_code` fails, log the error, mark the task failed, and move to the next service — do not retry more than once
+- Prioritize services that other services depend on (check `get_architecture_summary()` for dependency order)
+- When all tasks are done or failed, stop — do not generate extra files or improvise
diff --git a/backend/app/agents/agent3/prompts/templates/test_generation_system.j2 b/backend/app/agents/agent3/prompts/templates/test_generation_system.j2
new file mode 100644
index 0000000..42a7ff3
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/templates/test_generation_system.j2
@@ -0,0 +1,26 @@
+You are an expert cloud application test engineer. Generate comprehensive unit tests for the provided {{ language }} code.
+
+{% if language == "python" %}
+Testing framework: pytest
+- Use `moto` for AWS service mocking (e.g. `@mock_aws` decorator)
+- Use `pytest.fixture` for shared setup
+- Use `monkeypatch` or `os.environ` mocking for environment variables
+- Structure: one test file, multiple test functions prefixed with `test_`
+- Test the happy path AND at least 2 error/edge cases per function
+{% elif language == "typescript" %}
+Testing framework: Jest
+- Use `aws-sdk-client-mock` for AWS SDK mocking
+- Use `describe` / `it` / `expect` structure
+- Mock environment variables with `process.env`
+- Test the happy path AND at least 2 error/edge cases per exported function
+{% else %}
+- Use the standard testing framework for {{ language }}
+- Mock all external dependencies
+- Test happy paths and error cases
+{% endif %}
+
+Rules:
+- Import only: the file under test, testing framework, and AWS mocking libraries
+- Use meaningful, descriptive test names that explain the scenario
+- Include assertions for return values, side effects, and error messages
+- Respond ONLY with raw code — no markdown fences, no explanations
diff --git a/backend/app/agents/agent3/prompts/templates/test_generation_user.j2 b/backend/app/agents/agent3/prompts/templates/test_generation_user.j2
new file mode 100644
index 0000000..ba39e7b
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/templates/test_generation_user.j2
@@ -0,0 +1,27 @@
+Generate {{ language }} unit tests for the following service.
+
+## Service
+- ID: {{ service_id }}
+- Type: {{ service_type | upper }}
+
+## Source Code to Test
+`services/{{ service_id }}/handler.{{ ext }}`
+
+```{{ language }}
+{{ source_code }}
+```
+
+{% if architecture_context %}
+## Architecture Context
+{{ architecture_context }}
+{% endif %}
+
+## Task
+Generate test file: `services/{{ service_id }}/test_handler.{{ ext }}`
+
+Cover:
+1. Happy path for each exported function / handler
+2. Error handling (missing input, AWS service errors, env var not set)
+3. Edge cases relevant to the service type (empty events, malformed payloads, etc.)
+
+Output ONLY the raw test code. No markdown. No explanation.
diff --git a/backend/app/agents/agent3/prompts/templates/tf_fix_system.j2 b/backend/app/agents/agent3/prompts/templates/tf_fix_system.j2
new file mode 100644
index 0000000..b877917
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/templates/tf_fix_system.j2
@@ -0,0 +1,20 @@
+You are an expert Terraform HCL debugger. You will be given:
+1. The current Terraform files that have validation errors
+2. A consolidated error report from: terraform fmt, terraform validate, tflint{% if run_checkov %}, and checkov{% endif %}
+
+Your job is to return corrected versions of ALL files that need changes.
+
+Rules:
+- Fix ALL reported errors
+- Do not change files that are not broken
+- Maintain the original intent and structure
+- Respond ONLY with a valid JSON object in this exact format:
+
+{
+ "files": [
+ {"name": "main.tf", "content": "...corrected HCL..."}
+ ]
+}
+
+Only include files that were modified. Do not include unchanged files.
+Do not include any explanation outside the JSON.
diff --git a/backend/app/agents/agent3/prompts/templates/tf_fix_user.j2 b/backend/app/agents/agent3/prompts/templates/tf_fix_user.j2
new file mode 100644
index 0000000..02cb1e4
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/templates/tf_fix_user.j2
@@ -0,0 +1,12 @@
+Fix attempt {{ attempt }} of {{ max_attempts }}.
+
+## Validation Errors
+{{ error_summary }}
+
+## Current Terraform Files
+{% for filename, content in tf_files.items() %}
+### {{ filename }}
+```hcl
+{{ content }}
+```
+{% endfor %}
diff --git a/backend/app/agents/agent3/prompts/templates/tf_generation_system.j2 b/backend/app/agents/agent3/prompts/templates/tf_generation_system.j2
new file mode 100644
index 0000000..3fa65f4
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/templates/tf_generation_system.j2
@@ -0,0 +1,24 @@
+You are an expert AWS Terraform engineer. Given a cloud architecture topology, generate production-grade HCL Terraform configuration files.
+
+Rules:
+- Always use the hashicorp/aws provider ~> 5.0
+- Follow Terraform best practices: separate files per concern, use variables for all configurable values, use outputs for all resource IDs/ARNs
+- Always generate at minimum: main.tf, variables.tf, outputs.tf, providers.tf
+{% if use_modules %}
+- Use a modules/ structure for complex or reusable components
+{% endif %}
+- Use resource naming: __
+- Never hard-code AWS account IDs, region, or credentials — use variables
+- Include required tags on all resources: Name, Environment, ManagedBy=Terraform
+- Respond ONLY with a valid JSON object in this exact format:
+
+{
+ "files": [
+ {"name": "providers.tf", "content": "...HCL content..."},
+ {"name": "variables.tf", "content": "...HCL content..."},
+ {"name": "main.tf", "content": "...HCL content..."},
+ {"name": "outputs.tf", "content": "...HCL content..."}
+ ]
+}
+
+Do not include any explanation outside the JSON.
diff --git a/backend/app/agents/agent3/prompts/templates/tf_generation_user.j2 b/backend/app/agents/agent3/prompts/templates/tf_generation_user.j2
new file mode 100644
index 0000000..72f4a51
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/templates/tf_generation_user.j2
@@ -0,0 +1,31 @@
+Generate Terraform HCL for the following cloud architecture:
+
+Cloud Provider: {{ cloud_provider | upper }}
+
+## Services ({{ services | length }} total)
+{% for service in services %}
+- [{{ service.service_type | upper }}] {{ service.label }} (id: `{{ service.id }}`)
+ {%- if service.config %}
+ Config: {{ service.config | tojson }}
+ {%- endif %}
+{% endfor %}
+
+{% if connections %}
+## Connections / Data Flow
+{% for conn in connections %}
+- `{{ conn.source }}` --[{{ conn.relationship }}]--> `{{ conn.target }}`
+{% endfor %}
+{% else %}
+## Connections
+No explicit connections defined — resources are standalone.
+{% endif %}
+
+{% if has_configs %}
+## Detailed Service Configurations
+{% for service in services %}
+{% if service.config %}
+### {{ service.id }} ({{ service.service_type }})
+{{ service.config | tojson(indent=2) }}
+{% endif %}
+{% endfor %}
+{% endif %}
diff --git a/backend/app/agents/agent3/prompts/test_prompts.py b/backend/app/agents/agent3/prompts/test_prompts.py
new file mode 100644
index 0000000..b64edf4
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/test_prompts.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from app.agents.agent3.prompts.renderer import render
+
+
+def test_generation_system(language: str) -> str:
+ return render("test_generation_system.j2", language=language)
+
+
+def test_generation_user(
+ language: str,
+ service_id: str,
+ service_type: str,
+ source_code: str,
+ architecture_context: str,
+ ext: str,
+) -> str:
+ return render(
+ "test_generation_user.j2",
+ language=language,
+ service_id=service_id,
+ service_type=service_type,
+ source_code=source_code,
+ architecture_context=architecture_context,
+ ext=ext,
+ )
diff --git a/backend/app/agents/agent3/prompts/tf_prompts.py b/backend/app/agents/agent3/prompts/tf_prompts.py
new file mode 100644
index 0000000..13f434d
--- /dev/null
+++ b/backend/app/agents/agent3/prompts/tf_prompts.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from typing import Any
+
+from app.agents.agent3.prompts.renderer import render
+
+
+def tf_generation_system(use_modules: bool = False) -> str:
+ return render("tf_generation_system.j2", use_modules=use_modules)
+
+
+def tf_generation_user(
+ cloud_provider: str,
+ services: list[dict[str, Any]],
+ connections: list[dict[str, Any]],
+) -> str:
+ has_configs = any(s.get("config") for s in services)
+ return render(
+ "tf_generation_user.j2",
+ cloud_provider=cloud_provider,
+ services=services,
+ connections=connections,
+ has_configs=has_configs,
+ )
+
+
+def tf_fix_system(run_checkov: bool = True) -> str:
+ return render("tf_fix_system.j2", run_checkov=run_checkov)
+
+
+def tf_fix_user(
+ attempt: int,
+ max_attempts: int,
+ error_summary: str,
+ tf_files: dict[str, str],
+) -> str:
+ return render(
+ "tf_fix_user.j2",
+ attempt=attempt,
+ max_attempts=max_attempts,
+ error_summary=error_summary,
+ tf_files=tf_files,
+ )
diff --git a/backend/app/agents/agent3/state.py b/backend/app/agents/agent3/state.py
new file mode 100644
index 0000000..3c1827e
--- /dev/null
+++ b/backend/app/agents/agent3/state.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+from operator import add
+from typing import Annotated, Any, Literal, TypedDict
+
+from langchain_core.messages import BaseMessage
+from langgraph.graph.message import add_messages
+
+
+# ---------------------------------------------------------------------------
+# Atomic data types
+# ---------------------------------------------------------------------------
+
+
+class ServiceNode(TypedDict):
+ id: str
+ service_type: str # "lambda", "s3", "rds", "vpc", "api_gateway", etc.
+ label: str
+ config: dict[str, Any]
+
+
+class Connection(TypedDict):
+ source: str
+ target: str
+ relationship: str # "triggers", "reads", "writes", "routes_to", "connects_to"
+
+
+class TaskItem(TypedDict):
+ task_id: str
+ service_id: str
+ task_type: Literal["code_gen", "test_gen"]
+ language: str # "python", "typescript", etc.
+ status: Literal["pending", "in_progress", "done", "failed"]
+ retry_count: int
+ error_message: str | None
+
+
+class ValidationResult(TypedDict):
+ tool: str # "terraform_fmt", "terraform_validate", "tflint", "checkov"
+ passed: bool
+ output: str
+ errors: list[str]
+
+
+class CodeError(TypedDict):
+ service_id: str
+ task_type: str
+ file: str
+ errors: list[str]
+
+
+# ---------------------------------------------------------------------------
+# Top-level graph state
+# ---------------------------------------------------------------------------
+
+
+class AgentState(TypedDict):
+ # Input
+ thread_id: str
+ raw_input: str
+ input_format: Literal["json", "yaml"]
+ language_overrides: dict[str, str] # service_id -> language, e.g. {"fn1": "typescript"}
+
+ # Parsed topology
+ services: list[ServiceNode]
+ connections: list[Connection]
+ cloud_provider: str
+
+ # Terraform phase
+ tf_files: dict[str, str] # filename -> HCL content
+ tf_validation_results: Annotated[list[ValidationResult], add]
+ tf_fix_attempts: int
+ tf_max_retries: int
+ tf_validated: bool
+ tf_error_summary: str | None
+
+ # Orchestrator phase
+ task_list: list[TaskItem]
+ orchestrator_messages: Annotated[list[BaseMessage], add_messages]
+ orchestrator_iterations: int
+ orchestrator_max_iterations: int
+
+ # Code artifacts
+ code_files: dict[str, str] # path -> content
+ test_files: dict[str, str]
+ code_errors: Annotated[list[CodeError], add] # append-only error log
+
+ # Pipeline control
+ current_phase: Literal[
+ "parsing",
+ "tf_generation",
+ "tf_validation",
+ "orchestration",
+ "assembly",
+ "done",
+ "error",
+ ]
+ pipeline_errors: Annotated[list[str], add]
+ human_review_required: bool
+ human_review_message: str | None
+
+ # Final output
+ artifacts: dict[str, str]
+ generation_metadata: dict[str, Any]
+
+
+# ---------------------------------------------------------------------------
+# TF validation subgraph state
+# ---------------------------------------------------------------------------
+
+
+class TFValidationState(TypedDict):
+ tf_files: dict[str, str]
+ validation_results: Annotated[list[ValidationResult], add]
+ fix_attempts: int
+ max_retries: int
+ error_summary: str | None
+ validated: bool
+ human_review_required: bool
+ human_review_message: str | None
+
+
+# ---------------------------------------------------------------------------
+# Code generation subgraph state
+# ---------------------------------------------------------------------------
+
+
+class CodeGenState(TypedDict):
+ task: TaskItem
+ tf_context: str
+ architecture_context: str # JSON string: {service_type, label, config, incoming, outgoing}
+ generated_code: str | None
+ generated_tests: str | None
+ syntax_errors: Annotated[list[str], add]
+ fix_attempts: int
+ max_retries: int
+ done: bool
+ human_review_required: bool
+ human_review_message: str | None
diff --git a/backend/app/agents/agent3/subgraphs/__init__.py b/backend/app/agents/agent3/subgraphs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/agents/agent3/subgraphs/code_generation_loop.py b/backend/app/agents/agent3/subgraphs/code_generation_loop.py
new file mode 100644
index 0000000..3c803f7
--- /dev/null
+++ b/backend/app/agents/agent3/subgraphs/code_generation_loop.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from langgraph.graph import END, START, StateGraph
+from langgraph.types import interrupt
+
+from app.agents.agent3.nodes.code_fixer import code_fixer_node
+from app.agents.agent3.nodes.code_generator import code_generator_node
+from app.agents.agent3.nodes.code_validator import code_validator_node
+from app.agents.agent3.nodes.test_generator import test_generator_node
+from app.agents.agent3.state import CodeGenState
+
+
+# ---------------------------------------------------------------------------
+# Routing
+# ---------------------------------------------------------------------------
+
+
+def _route_entry(state: CodeGenState) -> Literal["code_gen", "test_gen"]:
+ """Route to code generation or test generation based on the task type."""
+ return state["task"]["task_type"]
+
+
+def _route_after_validation(state: CodeGenState) -> Literal["passed", "fix", "human"]:
+ if state.get("done"):
+ return "passed"
+ # syntax_errors is an append-only Annotated list; check if the last entry is non-empty
+ errors = state.get("syntax_errors") or []
+ has_errors = bool(errors)
+ if has_errors and state.get("fix_attempts", 0) >= state.get("max_retries", 3):
+ return "human"
+ if has_errors:
+ return "fix"
+ # done=False but no errors yet — shouldn't normally happen; treat as passed
+ return "passed"
+
+
+def _human_interrupt_node(state: CodeGenState) -> dict:
+ """
+ Signal that human intervention is needed for code generation.
+ `interrupt()` pauses the graph; on resume, this return dict is merged.
+ """
+ task = state["task"]
+ errors = state.get("syntax_errors") or []
+ msg = (
+ f"Code generation for service '{task['service_id']}' ({task['task_type']}) "
+ f"failed after {state.get('fix_attempts', 0)} fix attempts.\n"
+ f"Errors: {'; '.join(errors[-5:])}\n\n"
+ "Please review the errors and resume with corrected code via the resume endpoint."
+ )
+ interrupt(msg)
+ # After resumption, mark that we need human review and stop retrying
+ return {
+ "human_review_required": True,
+ "human_review_message": msg,
+ "done": False,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Graph assembly
+# ---------------------------------------------------------------------------
+
+
+def compile_code_generation_subgraph():
+ builder = StateGraph(CodeGenState)
+
+ builder.add_node("code_gen", code_generator_node)
+ builder.add_node("test_gen", test_generator_node)
+ builder.add_node("validate_syntax", code_validator_node)
+ builder.add_node("fix_code", code_fixer_node)
+ builder.add_node("human_interrupt", _human_interrupt_node)
+
+ # Entry: route to code_gen or test_gen based on task type
+ builder.add_conditional_edges(
+ START,
+ _route_entry,
+ {"code_gen": "code_gen", "test_gen": "test_gen"},
+ )
+
+ builder.add_edge("code_gen", "validate_syntax")
+ builder.add_edge("test_gen", "validate_syntax")
+
+ builder.add_conditional_edges(
+ "validate_syntax",
+ _route_after_validation,
+ {
+ "passed": END,
+ "fix": "fix_code",
+ "human": "human_interrupt",
+ },
+ )
+
+ # After fixing, re-validate
+ builder.add_edge("fix_code", "validate_syntax")
+ builder.add_edge("human_interrupt", END)
+
+ return builder.compile()
diff --git a/backend/app/agents/agent3/subgraphs/tf_validation_loop.py b/backend/app/agents/agent3/subgraphs/tf_validation_loop.py
new file mode 100644
index 0000000..f6c43e3
--- /dev/null
+++ b/backend/app/agents/agent3/subgraphs/tf_validation_loop.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from langgraph.graph import END, START, StateGraph
+from langgraph.types import interrupt
+
+from app.agents.agent3.nodes.tf_fixer import tf_fixer_node
+from app.agents.agent3.state import TFValidationState, ValidationResult
+from app.agents.agent3.tools.tf_tools import (
+ aggregate_validation_errors,
+ run_checkov,
+ run_terraform_fmt,
+ run_terraform_validate,
+ run_tflint,
+)
+
+
+# ---------------------------------------------------------------------------
+# Validation runner nodes
+# ---------------------------------------------------------------------------
+
+
+def _run_fmt_node(state: TFValidationState) -> dict:
+ return {"validation_results": [run_terraform_fmt(state["tf_files"])]}
+
+
+def _run_validate_node(state: TFValidationState) -> dict:
+ return {"validation_results": [run_terraform_validate(state["tf_files"])]}
+
+
+def _run_tflint_node(state: TFValidationState) -> dict:
+ return {"validation_results": [run_tflint(state["tf_files"])]}
+
+
+def _run_checkov_node(state: TFValidationState) -> dict:
+ return {"validation_results": [run_checkov(state["tf_files"])]}
+
+
+def _get_latest_per_tool(all_results: list[ValidationResult]) -> list[ValidationResult]:
+ """
+ Return the most recent result for each unique tool name.
+ This is robust to any number of tools — no magic number needed.
+ """
+ seen: dict[str, ValidationResult] = {}
+ for r in reversed(all_results):
+ if r["tool"] not in seen:
+ seen[r["tool"]] = r
+ return list(seen.values())
+
+
+def _aggregate_errors_node(state: TFValidationState) -> dict:
+ """
+ Find the latest result per tool and decide whether this pass succeeded.
+ Works regardless of how many validation tools are registered.
+ """
+ all_results: list[ValidationResult] = state.get("validation_results") or []
+ latest = _get_latest_per_tool(all_results)
+
+ all_passed = bool(latest) and all(r["passed"] for r in latest)
+ error_summary = aggregate_validation_errors(latest) if not all_passed else None
+
+ return {
+ "validated": all_passed,
+ "error_summary": error_summary,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Routing
+# ---------------------------------------------------------------------------
+
+
+def _route_after_validation(state: TFValidationState) -> Literal["passed", "fix", "human"]:
+ if state.get("validated"):
+ return "passed"
+ if state.get("fix_attempts", 0) >= state.get("max_retries", 3):
+ return "human"
+ return "fix"
+
+
+def _human_interrupt_node(state: TFValidationState) -> dict:
+ """
+ Signal that human intervention is needed.
+ `interrupt()` pauses the graph; when resumed, this function's return dict
+ is merged into state. We set human_review_required and a descriptive message.
+ """
+ msg = (
+ f"Terraform validation failed after {state.get('fix_attempts', 0)} fix attempts.\n"
+ f"Last errors:\n{state.get('error_summary') or '(none captured)'}\n\n"
+ "Please review and correct the Terraform files manually, then resume this run "
+ "via POST /agent3/resume/{thread_id} with corrected_files."
+ )
+ interrupt(msg)
+ # Returned after resumption — mark state clearly so the parent graph can route correctly
+ return {
+ "human_review_required": True,
+ "human_review_message": msg,
+ "validated": False,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Graph assembly
+# ---------------------------------------------------------------------------
+
+
+def compile_tf_validation_subgraph():
+ builder = StateGraph(TFValidationState)
+
+ builder.add_node("run_fmt", _run_fmt_node)
+ builder.add_node("run_validate", _run_validate_node)
+ builder.add_node("run_tflint", _run_tflint_node)
+ builder.add_node("run_checkov", _run_checkov_node)
+ builder.add_node("aggregate_errors", _aggregate_errors_node)
+ builder.add_node("llm_fixer", tf_fixer_node)
+ builder.add_node("human_interrupt", _human_interrupt_node)
+
+ builder.add_edge(START, "run_fmt")
+ builder.add_edge("run_fmt", "run_validate")
+ builder.add_edge("run_validate", "run_tflint")
+ builder.add_edge("run_tflint", "run_checkov")
+ builder.add_edge("run_checkov", "aggregate_errors")
+
+ builder.add_conditional_edges(
+ "aggregate_errors",
+ _route_after_validation,
+ {
+ "passed": END,
+ "fix": "llm_fixer",
+ "human": "human_interrupt",
+ },
+ )
+
+ # After fixing, restart the full validation chain
+ builder.add_edge("llm_fixer", "run_fmt")
+ builder.add_edge("human_interrupt", END)
+
+ return builder.compile()
diff --git a/backend/app/agents/agent3/tools/__init__.py b/backend/app/agents/agent3/tools/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/agents/agent3/tools/syntax_tools.py b/backend/app/agents/agent3/tools/syntax_tools.py
new file mode 100644
index 0000000..5bf7ad1
--- /dev/null
+++ b/backend/app/agents/agent3/tools/syntax_tools.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+import ast
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+
+from app.agents.agent3.config import TSC_TIMEOUT
+
+
+def check_python_syntax(code: str) -> list[str]:
+ """
+ Check Python syntax using ast.parse.
+ Returns a list of error strings (empty = no errors).
+ """
+ try:
+ ast.parse(code)
+ return []
+ except SyntaxError as e:
+ return [f"SyntaxError at line {e.lineno}: {e.msg}"]
+ except Exception as e:
+ return [f"Parse error: {e}"]
+
+
+def check_typescript_syntax(code: str, filename: str = "handler.ts") -> list[str]:
+ """
+ Check TypeScript syntax by writing to a temp file and running tsc --noEmit.
+ Returns a list of error strings (empty = no errors).
+ """
+ try:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ ts_file = Path(tmpdir) / filename
+ ts_file.write_text(code, encoding="utf-8")
+
+ # Minimal tsconfig to avoid needing @types packages
+ tsconfig = {
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "strict": True,
+ "noEmit": True,
+ "skipLibCheck": True,
+ }
+ }
+ (Path(tmpdir) / "tsconfig.json").write_text(
+ json.dumps(tsconfig), encoding="utf-8"
+ )
+
+ result = subprocess.run(
+ ["tsc", "--noEmit", "--project", str(Path(tmpdir) / "tsconfig.json")],
+ cwd=tmpdir,
+ capture_output=True,
+ text=True,
+ timeout=TSC_TIMEOUT,
+ )
+ if result.returncode == 0:
+ return []
+ # Parse tsc output: "file.ts(line,col): error TSxxxx: message"
+ errors: list[str] = []
+ for line in (result.stdout + result.stderr).splitlines():
+ if "error TS" in line:
+ errors.append(line.strip())
+ return errors or [result.stdout + result.stderr]
+ except FileNotFoundError:
+ # tsc not installed — fall back to basic bracket matching
+ return _basic_ts_check(code)
+ except subprocess.TimeoutExpired:
+ return ["tsc timed out — syntax check skipped"]
+ except Exception as e:
+ return [f"TypeScript check error: {e}"]
+
+
+def _basic_ts_check(code: str) -> list[str]:
+ """Minimal fallback syntax check when tsc is not available."""
+ errors: list[str] = []
+ open_braces = code.count("{") - code.count("}")
+ open_parens = code.count("(") - code.count(")")
+ open_brackets = code.count("[") - code.count("]")
+ if open_braces != 0:
+ errors.append(f"Unbalanced braces: {open_braces:+d}")
+ if open_parens != 0:
+ errors.append(f"Unbalanced parentheses: {open_parens:+d}")
+ if open_brackets != 0:
+ errors.append(f"Unbalanced brackets: {open_brackets:+d}")
+ return errors
+
+
+def check_syntax(code: str, language: str, filename: str | None = None) -> list[str]:
+ """Dispatch to the appropriate syntax checker based on language."""
+ if language == "python":
+ return check_python_syntax(code)
+ elif language == "typescript":
+ fname = filename or "handler.ts"
+ return check_typescript_syntax(code, fname)
+ else:
+ # Unknown language — no check
+ return []
diff --git a/backend/app/agents/agent3/tools/task_tools.py b/backend/app/agents/agent3/tools/task_tools.py
new file mode 100644
index 0000000..6685ba7
--- /dev/null
+++ b/backend/app/agents/agent3/tools/task_tools.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import json
+
+from app.agents.agent3.state import Connection, ServiceNode
+
+
+def describe_service(service: ServiceNode, connections: list[Connection]) -> str:
+ """Human-readable description of a service and its connections."""
+ outgoing = [c for c in connections if c["source"] == service["id"]]
+ incoming = [c for c in connections if c["target"] == service["id"]]
+
+ lines = [
+ f"Service: {service['label']} (id={service['id']}, type={service['service_type']})",
+ f"Config: {json.dumps(service['config'], indent=2)}",
+ ]
+ if incoming:
+ lines.append("Receives from:")
+ for c in incoming:
+ lines.append(f" - {c['source']} via {c['relationship']}")
+ if outgoing:
+ lines.append("Calls / triggers:")
+ for c in outgoing:
+ lines.append(f" - {c['target']} via {c['relationship']}")
+ return "\n".join(lines)
+
+
+def build_architecture_summary(
+ services: list[ServiceNode], connections: list[Connection]
+) -> str:
+ """Full architecture summary for the orchestrator system prompt."""
+ parts = ["=== Architecture Summary ===\n"]
+ for svc in services:
+ parts.append(describe_service(svc, connections))
+ parts.append("")
+ if connections:
+ parts.append("=== Connections ===")
+ for c in connections:
+ parts.append(f" {c['source']} --[{c['relationship']}]--> {c['target']}")
+ return "\n".join(parts)
+
+
+def extract_tf_context_for_service(tf_files: dict[str, str], service_id: str) -> str:
+ """
+ Extract lines from TF files that reference the given service_id.
+ Falls back to the first 3000 chars of main.tf if nothing matches.
+ """
+ needle = service_id.lower().replace("-", "_")
+ relevant_lines: list[str] = []
+ for fname, content in tf_files.items():
+ hits = [line for line in content.splitlines() if needle in line.lower()]
+ if hits:
+ relevant_lines.append(f"# From {fname}:")
+ relevant_lines.extend(hits)
+ if not relevant_lines:
+ main = tf_files.get("main.tf", "")
+ return main[:3000] if main else ""
+ return "\n".join(relevant_lines)
diff --git a/backend/app/agents/agent3/tools/tf_tools.py b/backend/app/agents/agent3/tools/tf_tools.py
new file mode 100644
index 0000000..3b66178
--- /dev/null
+++ b/backend/app/agents/agent3/tools/tf_tools.py
@@ -0,0 +1,235 @@
+from __future__ import annotations
+
+import json
+import subprocess
+import tempfile
+from pathlib import Path
+
+from app.agents.agent3.config import CHECKOV_TIMEOUT, TERRAFORM_TIMEOUT, TFLINT_TIMEOUT
+from app.agents.agent3.state import ValidationResult
+
+
+def _write_tf_files(tmpdir: str, tf_files: dict[str, str]) -> None:
+ """Write tf_files dict to a temporary directory, creating subdirs as needed."""
+ for fname, content in tf_files.items():
+ fpath = Path(tmpdir) / fname
+ fpath.parent.mkdir(parents=True, exist_ok=True)
+ fpath.write_text(content, encoding="utf-8")
+
+
+def run_terraform_fmt(tf_files: dict[str, str]) -> ValidationResult:
+ """Run `terraform fmt -check -recursive` against the provided HCL files."""
+ tool = "terraform_fmt"
+ try:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ _write_tf_files(tmpdir, tf_files)
+ result = subprocess.run(
+ ["terraform", "fmt", "-check", "-recursive", "-diff"],
+ cwd=tmpdir,
+ capture_output=True,
+ text=True,
+ timeout=TERRAFORM_TIMEOUT,
+ )
+ passed = result.returncode == 0
+ output = result.stdout + result.stderr
+ errors = [output] if not passed and output.strip() else []
+ return ValidationResult(
+ tool=tool,
+ passed=passed,
+ output=output,
+ errors=errors,
+ )
+ except FileNotFoundError:
+ return ValidationResult(
+ tool=tool,
+ passed=True, # skip gracefully if terraform not installed
+ output="terraform CLI not found — fmt check skipped",
+ errors=[],
+ )
+ except subprocess.TimeoutExpired:
+ return ValidationResult(
+ tool=tool,
+ passed=False,
+ output="terraform fmt timed out",
+ errors=["terraform fmt timed out"],
+ )
+
+
+def run_terraform_validate(tf_files: dict[str, str]) -> ValidationResult:
+ """Run `terraform init` + `terraform validate -json` against the provided HCL files."""
+ tool = "terraform_validate"
+ try:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ _write_tf_files(tmpdir, tf_files)
+ # Init without backend to avoid network calls
+ subprocess.run(
+ ["terraform", "init", "-backend=false", "-input=false", "-no-color"],
+ cwd=tmpdir,
+ capture_output=True,
+ text=True,
+ timeout=TERRAFORM_TIMEOUT,
+ )
+ result = subprocess.run(
+ ["terraform", "validate", "-json", "-no-color"],
+ cwd=tmpdir,
+ capture_output=True,
+ text=True,
+ timeout=TERRAFORM_TIMEOUT,
+ )
+ try:
+ data = json.loads(result.stdout)
+ passed = data.get("valid", False)
+ diagnostics = data.get("diagnostics", [])
+ errors = [
+ f"{d.get('severity','error').upper()}: {d.get('summary','')} — {d.get('detail','')}"
+ for d in diagnostics
+ if d.get("severity") in ("error", "warning")
+ ]
+ except json.JSONDecodeError:
+ passed = result.returncode == 0
+ errors = [result.stdout + result.stderr] if not passed else []
+ return ValidationResult(
+ tool=tool,
+ passed=passed,
+ output=result.stdout + result.stderr,
+ errors=errors,
+ )
+ except FileNotFoundError:
+ return ValidationResult(
+ tool=tool,
+ passed=True,
+ output="terraform CLI not found — validate skipped",
+ errors=[],
+ )
+ except subprocess.TimeoutExpired:
+ return ValidationResult(
+ tool=tool,
+ passed=False,
+ output="terraform validate timed out",
+ errors=["terraform validate timed out"],
+ )
+
+
+def run_tflint(tf_files: dict[str, str]) -> ValidationResult:
+ """Run tflint with JSON output against the provided HCL files."""
+ tool = "tflint"
+ try:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ _write_tf_files(tmpdir, tf_files)
+ result = subprocess.run(
+ ["tflint", "--format=json", "--no-color"],
+ cwd=tmpdir,
+ capture_output=True,
+ text=True,
+ timeout=TFLINT_TIMEOUT,
+ )
+ try:
+ data = json.loads(result.stdout)
+ issues = data.get("issues", [])
+ errors = [
+ f"{i.get('rule',{}).get('name','unknown')} [{i.get('rule',{}).get('severity','warning')}]: "
+ f"{i.get('message','')} at {i.get('range',{}).get('filename','?')}:"
+ f"{i.get('range',{}).get('start',{}).get('line','?')}"
+ for i in issues
+ if i.get("rule", {}).get("severity") == "error"
+ ]
+ passed = len(errors) == 0
+ except json.JSONDecodeError:
+ passed = result.returncode == 0
+ errors = [result.stdout + result.stderr] if not passed else []
+ return ValidationResult(
+ tool=tool,
+ passed=passed,
+ output=result.stdout + result.stderr,
+ errors=errors,
+ )
+ except FileNotFoundError:
+ return ValidationResult(
+ tool=tool,
+ passed=True,
+ output="tflint not found — lint check skipped",
+ errors=[],
+ )
+ except subprocess.TimeoutExpired:
+ return ValidationResult(
+ tool=tool,
+ passed=False,
+ output="tflint timed out",
+ errors=["tflint timed out"],
+ )
+
+
+def run_checkov(tf_files: dict[str, str]) -> ValidationResult:
+ """Run checkov security scan against the provided HCL files."""
+ tool = "checkov"
+ try:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ _write_tf_files(tmpdir, tf_files)
+ result = subprocess.run(
+ [
+ "checkov",
+ "-d",
+ tmpdir,
+ "--framework",
+ "terraform",
+ "--output",
+ "json",
+ "--quiet",
+ "--compact",
+ ],
+ cwd=tmpdir,
+ capture_output=True,
+ text=True,
+ timeout=CHECKOV_TIMEOUT,
+ )
+ try:
+ raw = result.stdout.strip()
+ # checkov may prepend non-JSON lines; find the first '{'
+ json_start = raw.find("{")
+ data = json.loads(raw[json_start:]) if json_start >= 0 else {}
+ summary = data.get("summary", {})
+ failed = summary.get("failed", 0)
+ passed_count = summary.get("passed", 0)
+ passed = failed == 0
+ errors: list[str] = []
+ for check in data.get("results", {}).get("failed_checks", []):
+ errors.append(
+ f"FAILED [{check.get('check_id')}] {check.get('check_type','')} — "
+ f"{check.get('resource','')} in {check.get('file_path','')}"
+ )
+ except (json.JSONDecodeError, ValueError):
+ passed = result.returncode == 0
+ errors = [result.stdout[:2000]] if not passed else []
+ passed_count = 0
+ failed = 0
+ output = f"passed={passed_count} failed={failed}\n{result.stdout[:500]}"
+ return ValidationResult(
+ tool=tool,
+ passed=passed,
+ output=output,
+ errors=errors,
+ )
+ except FileNotFoundError:
+ return ValidationResult(
+ tool=tool,
+ passed=True,
+ output="checkov not found — security scan skipped",
+ errors=[],
+ )
+ except subprocess.TimeoutExpired:
+ return ValidationResult(
+ tool=tool,
+ passed=False,
+ output="checkov timed out",
+ errors=["checkov timed out"],
+ )
+
+
+def aggregate_validation_errors(results: list[ValidationResult]) -> str:
+ """Build a consolidated human-readable error summary from multiple ValidationResult items."""
+ lines: list[str] = []
+ for r in results:
+ if not r["passed"] and r["errors"]:
+ lines.append(f"=== {r['tool'].upper()} ===")
+ lines.extend(r["errors"])
+ return "\n".join(lines) if lines else ""
diff --git a/backend/app/agents/agent3/utils.py b/backend/app/agents/agent3/utils.py
new file mode 100644
index 0000000..6f88fd4
--- /dev/null
+++ b/backend/app/agents/agent3/utils.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+
+# ---------------------------------------------------------------------------
+# Code / markdown helpers
+# ---------------------------------------------------------------------------
+
+_FENCE_RE = re.compile(
+ r"^```(?:[a-zA-Z0-9_+-]*)?\n(.*?)^```[ \t]*$",
+ re.MULTILINE | re.DOTALL,
+)
+
+
+def strip_code_fences(text: str) -> str:
+ """
+ Remove markdown code fences from LLM output.
+ Handles ``` `` `python, ```typescript, etc.
+ Returns the interior content if a fence is found, else the original text.
+ """
+ text = text.strip()
+ match = _FENCE_RE.search(text)
+ if match:
+ return match.group(1).rstrip()
+ return text
+
+
+# ---------------------------------------------------------------------------
+# JSON extraction helpers
+# ---------------------------------------------------------------------------
+
+
+def safe_json_extract(text: str) -> Any:
+ """
+ Extract and parse the first JSON object or array from a string.
+ Strips markdown fences first, then tries direct parse, then first-brace scan.
+ Raises ValueError if no valid JSON can be found.
+ """
+ clean = strip_code_fences(text)
+
+ # Try direct parse first
+ try:
+ return json.loads(clean)
+ except json.JSONDecodeError:
+ pass
+
+ # Scan for first '{' or '['
+ for start_char, end_char in (("{", "}"), ("[", "]")):
+ idx = clean.find(start_char)
+ if idx >= 0:
+ # Find the matching closing brace by counting depth
+ depth = 0
+ for i, ch in enumerate(clean[idx:], start=idx):
+ if ch == start_char:
+ depth += 1
+ elif ch == end_char:
+ depth -= 1
+ if depth == 0:
+ try:
+ return json.loads(clean[idx : i + 1])
+ except json.JSONDecodeError:
+ break
+
+ raise ValueError(f"No valid JSON found in response (first 200 chars): {clean[:200]!r}")
+
+
+# ---------------------------------------------------------------------------
+# State helpers
+# ---------------------------------------------------------------------------
+
+
+def truncate_list(items: list, max_items: int) -> list:
+ """Keep only the last `max_items` elements of a list to prevent unbounded growth."""
+ if len(items) > max_items:
+ return items[-max_items:]
+ return items
diff --git a/backend/app/main.py b/backend/app/main.py
index 8ff1773..c7a4a8e 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,6 +1,7 @@
from fastapi import FastAPI
from app.config import settings
from app.routers import health
+from app.routers import agent3
app = FastAPI(
title=settings.app_name,
@@ -8,6 +9,7 @@
)
app.include_router(health.router)
+app.include_router(agent3.router)
@app.get("/")
diff --git a/backend/app/routers/agent3.py b/backend/app/routers/agent3.py
new file mode 100644
index 0000000..6199692
--- /dev/null
+++ b/backend/app/routers/agent3.py
@@ -0,0 +1,271 @@
+from __future__ import annotations
+
+import json
+import uuid
+from typing import Any, AsyncIterator
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse
+
+from app.agents.agent3 import (
+ GenerateRequest,
+ GenerationResult,
+ HumanFeedback,
+ StatusResponse,
+ get_graph,
+)
+
+router = APIRouter(prefix="/agent3", tags=["agent3"])
+
+# Nodes whose start/end events are surfaced as SSE progress messages
+_PROGRESS_NODES = frozenset({
+ "parse_input",
+ "tf_generator",
+ "tf_validation_loop",
+ "orchestrator",
+ "assembler",
+ "error_handler",
+})
+
+
+def _sse(data: dict[str, Any]) -> str:
+ return f"data: {json.dumps(data)}\n\n"
+
+
+def _build_initial_state(request: GenerateRequest, thread_id: str) -> dict[str, Any]:
+ return {
+ "thread_id": thread_id,
+ "raw_input": request.topology,
+ "input_format": request.input_format,
+ # language_overrides from the request are merged in parse_input_node
+ # with any overrides embedded in the topology JSON itself
+ "language_overrides": request.language_overrides,
+ "tf_max_retries": request.tf_max_retries,
+ "orchestrator_max_iterations": request.orchestrator_max_iterations,
+ # Fields below are fully initialised by parse_input_node;
+ # we set empty defaults here so LangGraph state is valid from the start
+ "tf_fix_attempts": 0,
+ "tf_validated": False,
+ "tf_files": {},
+ "tf_validation_results": [],
+ "tf_error_summary": None,
+ "services": [],
+ "connections": [],
+ "cloud_provider": "aws",
+ "task_list": [],
+ "orchestrator_messages": [],
+ "orchestrator_iterations": 0,
+ "code_files": {},
+ "test_files": {},
+ "code_errors": [],
+ "current_phase": "parsing",
+ "pipeline_errors": [],
+ "human_review_required": False,
+ "human_review_message": None,
+ "artifacts": {},
+ "generation_metadata": {},
+ }
+
+
+async def _stream_events(
+ initial_state: dict[str, Any] | None,
+ config: dict[str, Any],
+) -> AsyncIterator[str]:
+ graph = get_graph()
+ thread_id: str = config["configurable"]["thread_id"]
+
+ try:
+ async for event in graph.astream_events(initial_state, config, version="v2"):
+ event_type: str = event.get("event", "")
+ node_name: str = event.get("name", "")
+ data = event.get("data", {})
+
+ # --- Node started ---
+ if event_type == "on_chain_start" and node_name in _PROGRESS_NODES:
+ yield _sse({"phase": node_name, "status": "started", "thread_id": thread_id})
+
+ # --- Node finished ---
+ elif event_type == "on_chain_end" and node_name in _PROGRESS_NODES:
+ output = data.get("output", {}) or {}
+
+ if node_name == "tf_validation_loop":
+ yield _sse({
+ "phase": "tf_validation_loop",
+ "status": "done",
+ "tf_fix_attempts": output.get("tf_fix_attempts", 0),
+ "tf_validated": output.get("tf_validated", False),
+ "thread_id": thread_id,
+ })
+
+ elif node_name == "orchestrator":
+ task_list = output.get("task_list") or []
+ yield _sse({
+ "phase": "orchestrator",
+ "status": "done",
+ "tasks_done": sum(1 for t in task_list if t["status"] == "done"),
+ "tasks_failed": sum(1 for t in task_list if t["status"] == "failed"),
+ "tasks_total": len(task_list),
+ "thread_id": thread_id,
+ })
+
+ elif node_name == "assembler":
+ meta = output.get("generation_metadata", {}) or {}
+ yield _sse({
+ "phase": "complete",
+ "thread_id": thread_id,
+ "artifacts": output.get("artifacts", {}),
+ "tf_fix_attempts": meta.get("tf_fix_attempts", 0),
+ "tf_validated": meta.get("tf_validated", False),
+ "tasks_completed": meta.get("tasks_done", 0),
+ "tasks_failed": meta.get("tasks_failed", 0),
+ "tasks_total": meta.get("tasks_total", 0),
+ "human_review_required": meta.get("human_review_required", False),
+ "code_errors": meta.get("code_errors", []),
+ })
+ return # stream complete
+
+ elif node_name == "error_handler":
+ meta = output.get("generation_metadata", {}) or {}
+ yield _sse({
+ "phase": "error",
+ "thread_id": thread_id,
+ "errors": meta.get("errors", []),
+ "phase_at_failure": meta.get("phase_at_failure", "unknown"),
+ "artifacts": output.get("artifacts", {}),
+ })
+ return
+
+ else:
+ yield _sse({"phase": node_name, "status": "done", "thread_id": thread_id})
+
+ except Exception as e:
+ # Distinguish graph interrupts (expected pauses) from genuine errors
+ # by inspecting whether the graph has a pending next-step.
+ try:
+ snapshot = graph.get_state(config)
+ if snapshot and snapshot.next:
+ values = snapshot.values or {}
+ yield _sse({
+ "phase": "human_review_required",
+ "thread_id": thread_id,
+ "message": values.get("human_review_message") or str(e) or "Human review required.",
+ })
+ return
+ except Exception:
+ pass # fall through to generic error
+ yield _sse({"phase": "error", "thread_id": thread_id, "message": str(e)})
+ return
+
+ # Stream ended cleanly without reaching a terminal node (assembler/error_handler).
+ # This can happen when the graph paused for human review and astream_events
+ # drained naturally.
+ try:
+ snapshot = graph.get_state(config)
+ if snapshot and snapshot.next:
+ values = snapshot.values or {}
+ yield _sse({
+ "phase": "human_review_required",
+ "thread_id": thread_id,
+ "message": values.get("human_review_message") or "Human review required.",
+ })
+ else:
+ yield _sse({"phase": "complete", "thread_id": thread_id, "partial": True})
+ except Exception as e:
+ yield _sse({"phase": "error", "thread_id": thread_id, "message": f"Stream ended unexpectedly: {e}"})
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+
+@router.post("/generate")
+async def generate(request: GenerateRequest) -> StreamingResponse:
+ """
+ Start an agent3 generation run.
+ Returns a Server-Sent Events stream with progress updates.
+ Final event has phase='complete' and includes all generated artifacts.
+ """
+ thread_id = str(uuid.uuid4())
+ config = {"configurable": {"thread_id": thread_id}}
+ initial_state = _build_initial_state(request, thread_id)
+
+ return StreamingResponse(
+ _stream_events(initial_state, config),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ "X-Thread-ID": thread_id,
+ },
+ )
+
+
+@router.get("/status/{thread_id}", response_model=StatusResponse)
+async def get_status(thread_id: str) -> StatusResponse:
+ """Poll the current state of any generation run by thread_id."""
+ graph = get_graph()
+ config = {"configurable": {"thread_id": thread_id}}
+
+ try:
+ snapshot = graph.get_state(config)
+ except Exception as e:
+ raise HTTPException(status_code=404, detail=f"Thread '{thread_id}' not found: {e}")
+
+ values: dict[str, Any] = snapshot.values or {}
+ task_list = values.get("task_list") or []
+
+ return StatusResponse(
+ thread_id=thread_id,
+ current_phase=values.get("current_phase", "unknown"),
+ human_review_required=values.get("human_review_required", False),
+ human_review_message=values.get("human_review_message"),
+ artifacts=values.get("artifacts") or None,
+ interrupted=bool(snapshot.next),
+ tf_fix_attempts=values.get("tf_fix_attempts", 0),
+ tasks_completed=sum(1 for t in task_list if t["status"] == "done"),
+ tasks_total=len(task_list),
+ )
+
+
+@router.post("/resume/{thread_id}")
+async def resume(thread_id: str, feedback: HumanFeedback) -> StreamingResponse:
+ """
+ Resume an interrupted generation run after human review.
+ Supply corrected_files to inject manual TF/code fixes before resuming.
+ """
+ graph = get_graph()
+ config = {"configurable": {"thread_id": thread_id}}
+
+ try:
+ snapshot = graph.get_state(config)
+ except Exception as e:
+ raise HTTPException(status_code=404, detail=f"Thread '{thread_id}' not found: {e}")
+
+ if not snapshot.next:
+ raise HTTPException(
+ status_code=400,
+ detail="This run is not interrupted — nothing to resume",
+ )
+
+ # Inject human feedback and any corrected files
+ update: dict[str, Any] = {
+ "human_review_message": feedback.message,
+ "human_review_required": False,
+ }
+ if feedback.corrected_files:
+ current_tf: dict[str, str] = dict(snapshot.values.get("tf_files") or {})
+ current_tf.update(feedback.corrected_files)
+ update["tf_files"] = current_tf
+
+ graph.update_state(config, update)
+
+ # Resume streaming — pass None as input since we're continuing an existing thread
+ return StreamingResponse(
+ _stream_events(None, config),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ },
+ )
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 9b8805c..83dbb08 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -10,9 +10,22 @@ dependencies = [
"langchain-anthropic>=1.4.0",
"langchain-community>=0.4.1",
"langgraph>=1.1.3",
+ "langgraph-prebuilt>=1.0.8",
"langgraph-cli>=0.4.19",
+ "langgraph-checkpoint-sqlite>=2.0.0",
"langsmith>=0.7.22",
"pydantic>=2.12.5",
"python-dotenv>=1.2.2",
"uvicorn[standard]>=0.42.0",
+ "pyyaml>=6.0.2",
+ "checkov>=3.2.0",
+ "jsonschema>=4.23.0",
+ "jinja2>=3.1.4",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.3.0",
+ "pytest-asyncio>=0.24.0",
+ "httpx>=0.28.0",
]
diff --git a/backend/uv.lock b/backend/uv.lock
index cf53a69..891bf4c 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -2,10 +2,23 @@ version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
- "python_full_version >= '3.13'",
+ "python_full_version >= '3.14'",
+ "python_full_version == '3.13.*'",
"python_full_version < '3.13'",
]
+[[package]]
+name = "aiodns"
+version = "3.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycares" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/85/2f/9d1ee4f937addda60220f47925dac6c6b3782f6851fd578987284a8d2491/aiodns-3.6.1.tar.gz", hash = "sha256:b0e9ce98718a5b8f7ca8cd16fc393163374bc2412236b91f6c851d066e3324b6", size = 15143, upload-time = "2025-12-11T12:53:07.785Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/09/e3/9f777774ebe8f664bcd564f9de3936490a16effa82a969372161c9b0fb21/aiodns-3.6.1-py3-none-any.whl", hash = "sha256:46233ccad25f2037903828c5d05b64590eaa756e51d12b4a5616e2defcbc98c7", size = 7975, upload-time = "2025-12-11T12:53:06.387Z" },
+]
+
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
@@ -100,6 +113,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
]
+[[package]]
+name = "aiomultiprocess"
+version = "0.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/d4/1e69e17dda5df91734b70d03dbbf9f222ddb438e1f3bf4ea8fa135ce46de/aiomultiprocess-0.9.1.tar.gz", hash = "sha256:f0231dbe0291e15325d7896ebeae0002d95a4f2675426ca05eb35f24c60e495b", size = 24514, upload-time = "2024-04-23T08:26:04.223Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/14/c48c2f5c96960f5649a72b96a0a31d45384b37d89a63f7ccea76bf4fceba/aiomultiprocess-0.9.1-py3-none-any.whl", hash = "sha256:3a7b3bb3c38dbfb4d9d1194ece5934b6d32cf0280e8edbe64a7d215bba1322c6", size = 17517, upload-time = "2024-04-23T08:26:01.649Z" },
+]
+
[[package]]
name = "aiosignal"
version = "1.4.0"
@@ -113,6 +135,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
+[[package]]
+name = "aiosqlite"
+version = "0.22.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
+]
+
[[package]]
name = "annotated-doc"
version = "0.0.4"
@@ -163,6 +194,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
+[[package]]
+name = "argcomplete"
+version = "3.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" },
+]
+
+[[package]]
+name = "asteval"
+version = "1.0.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/f0/ad92c4bc565918713f9a4b54f06d06ec370e48079fdb50cf432befabee8b/asteval-1.0.6.tar.gz", hash = "sha256:1aa8e7304b2e171a90d64dd269b648cacac4e46fe5de54ac0db24776c0c4a19f", size = 52079, upload-time = "2025-01-19T21:44:03.291Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/ac/19dbba27e891f39feb4170b884da449ee2699ef4ebb88eefeda364bbbbcf/asteval-1.0.6-py3-none-any.whl", hash = "sha256:5e119ed306e39199fd99c881cea0e306b3f3807f050c9be79829fe274c6378dc", size = 22406, upload-time = "2025-01-19T21:44:01.323Z" },
+]
+
[[package]]
name = "attrs"
version = "26.1.0"
@@ -177,31 +226,170 @@ name = "backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
+ { name = "checkov" },
{ name = "fastapi" },
+ { name = "jinja2" },
+ { name = "jsonschema" },
{ name = "langchain" },
{ name = "langchain-anthropic" },
{ name = "langchain-community" },
{ name = "langgraph" },
+ { name = "langgraph-checkpoint-sqlite" },
{ name = "langgraph-cli" },
+ { name = "langgraph-prebuilt" },
{ name = "langsmith" },
{ name = "pydantic" },
{ name = "python-dotenv" },
+ { name = "pyyaml" },
{ name = "uvicorn", extra = ["standard"] },
]
+[package.optional-dependencies]
+dev = [
+ { name = "httpx" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+]
+
[package.metadata]
requires-dist = [
+ { name = "checkov", specifier = ">=3.2.0" },
{ name = "fastapi", specifier = ">=0.135.1" },
+ { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" },
+ { name = "jinja2", specifier = ">=3.1.4" },
+ { name = "jsonschema", specifier = ">=4.23.0" },
{ name = "langchain", specifier = ">=1.2.13" },
{ name = "langchain-anthropic", specifier = ">=1.4.0" },
{ name = "langchain-community", specifier = ">=0.4.1" },
{ name = "langgraph", specifier = ">=1.1.3" },
+ { name = "langgraph-checkpoint-sqlite", specifier = ">=2.0.0" },
{ name = "langgraph-cli", specifier = ">=0.4.19" },
+ { name = "langgraph-prebuilt", specifier = ">=1.0.8" },
{ name = "langsmith", specifier = ">=0.7.22" },
{ name = "pydantic", specifier = ">=2.12.5" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
+ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
+ { name = "pyyaml", specifier = ">=6.0.2" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.42.0" },
]
+provides-extras = ["dev"]
+
+[[package]]
+name = "bc-detect-secrets"
+version = "1.5.47"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "unidiff" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/82/fb/624aa462ea738cd21e56b1a5b7bbe375403e4114f7bc92a7cded7f516da0/bc_detect_secrets-1.5.47.tar.gz", hash = "sha256:a9be28a2e564f2b19731991df39e63ae6372cc84d828ee24e50c094cbb4c154c", size = 91339, upload-time = "2026-03-17T14:05:50.24Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/54/6039515fe99f712801fe6dc6a67c4565ffd23a5b83fb1813b64c9fdb73a8/bc_detect_secrets-1.5.47-py3-none-any.whl", hash = "sha256:46f88c710b0fd8c5f2e54b361d793b5e1469197884da73cfc6f488b614366fc3", size = 121200, upload-time = "2026-03-17T14:05:49.218Z" },
+]
+
+[[package]]
+name = "bc-jsonpath-ng"
+version = "1.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "decorator" },
+ { name = "ply" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/ad/b6745e21e050fac1ea499fdcafb689391ebf2ff01f2a96da275bb189c2ed/bc-jsonpath-ng-1.6.1.tar.gz", hash = "sha256:6ea4e379c4400a511d07605b8d981950292dd098a5619d143328af4e841a2320", size = 36478, upload-time = "2023-11-26T13:29:31.081Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/88/27b4b4374e96bfd6b8e49cdde4e5aaa61eb9046b8ead9b18dd2d3ad6a154/bc_jsonpath_ng-1.6.1-py3-none-any.whl", hash = "sha256:2c85bb1d194376808fe1fc49558dd484e39024b15c719995e22de811e6ba4dc8", size = 29783, upload-time = "2023-11-26T13:29:28.789Z" },
+]
+
+[[package]]
+name = "bc-python-hcl2"
+version = "0.4.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lark" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/60/43/8ee6ea8a19952045c15e6c9b164a9f88c2575b4bb86655c6da861b874986/bc_python_hcl2-0.4.3.tar.gz", hash = "sha256:fae62b2a41a675ad330d134d82576526db755f72bbd0e5a850de3d85fc24c40e", size = 12366, upload-time = "2025-07-14T11:09:37.448Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/f7/e064b2e767c094ac26512ddf5242b63a0cbca11678bf551545cd45d4f500/bc_python_hcl2-0.4.3-py3-none-any.whl", hash = "sha256:b0cce4cea16823f7da7fefa0f8177dfb91f51a1befe64ef59d8fe4d5ac616eec", size = 15015, upload-time = "2025-07-14T11:09:36.289Z" },
+]
+
+[[package]]
+name = "beartype"
+version = "0.22.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
+]
+
+[[package]]
+name = "boolean-py"
+version = "5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
+]
+
+[[package]]
+name = "boto3"
+version = "1.35.49"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+ { name = "jmespath" },
+ { name = "s3transfer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/73/c6/a18789b17138bc4f3001bfee42c07f85b9432475f5e8188c5699d481a376/boto3-1.35.49.tar.gz", hash = "sha256:ddecb27f5699ca9f97711c52b6c0652c2e63bf6c2bfbc13b819b4f523b4d30ff", size = 111007, upload-time = "2024-10-25T19:37:55.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/4e/181f3fb8bb54b34a6cfa1e36f9088f66ce8f00c8bf5d1d78a07db9193f9a/boto3-1.35.49-py3-none-any.whl", hash = "sha256:b660c649a27a6b47a34f6f858f5bd7c3b0a798a16dec8dda7cbebeee80fd1f60", size = 139160, upload-time = "2024-10-25T19:37:53.881Z" },
+]
+
+[[package]]
+name = "botocore"
+version = "1.35.99"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jmespath" },
+ { name = "python-dateutil" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7c/9c/1df6deceee17c88f7170bad8325aa91452529d683486273928eecfd946d8/botocore-1.35.99.tar.gz", hash = "sha256:1eab44e969c39c5f3d9a3104a0836c24715579a455f12b3979a31d7cde51b3c3", size = 13490969, upload-time = "2025-01-14T20:20:11.419Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/dd/d87e2a145fad9e08d0ec6edcf9d71f838ccc7acdd919acc4c0d4a93515f8/botocore-1.35.99-py3-none-any.whl", hash = "sha256:b22d27b6b617fc2d7342090d6129000af2efd20174215948c0d7ae2da0fab445", size = 13293216, upload-time = "2025-01-14T20:20:06.427Z" },
+]
+
+[[package]]
+name = "cached-property"
+version = "2.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/4b/3d870836119dbe9a5e3c9a61af8cc1a8b69d75aea564572e385882d5aefb/cached_property-2.0.1.tar.gz", hash = "sha256:484d617105e3ee0e4f1f58725e72a8ef9e93deee462222dbd51cd91230897641", size = 10574, upload-time = "2024-10-25T15:43:55.667Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/11/0e/7d8225aab3bc1a0f5811f8e1b557aa034ac04bdf641925b30d3caf586b28/cached_property-2.0.1-py3-none-any.whl", hash = "sha256:f617d70ab1100b7bcf6e42228f9ddcb78c676ffa167278d9f730d1c2fba69ccb", size = 7428, upload-time = "2024-10-25T15:43:54.711Z" },
+]
+
+[[package]]
+name = "cachetools"
+version = "5.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" },
+]
[[package]]
name = "certifi"
@@ -212,6 +400,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
[[package]]
name = "charset-normalizer"
version = "3.4.6"
@@ -285,6 +530,59 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
]
+[[package]]
+name = "checkov"
+version = "3.2.510"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiodns" },
+ { name = "aiohttp" },
+ { name = "aiomultiprocess" },
+ { name = "argcomplete" },
+ { name = "asteval" },
+ { name = "bc-detect-secrets" },
+ { name = "bc-jsonpath-ng" },
+ { name = "bc-python-hcl2" },
+ { name = "boto3" },
+ { name = "cachetools" },
+ { name = "charset-normalizer" },
+ { name = "click" },
+ { name = "cloudsplaining" },
+ { name = "colorama" },
+ { name = "configargparse" },
+ { name = "cyclonedx-python-lib" },
+ { name = "docker" },
+ { name = "dockerfile-parse" },
+ { name = "dpath" },
+ { name = "gitpython" },
+ { name = "importlib-metadata" },
+ { name = "jmespath" },
+ { name = "jsonschema" },
+ { name = "junit-xml" },
+ { name = "license-expression" },
+ { name = "networkx" },
+ { name = "packageurl-python" },
+ { name = "packaging" },
+ { name = "prettytable" },
+ { name = "pycep-parser" },
+ { name = "pydantic" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "rustworkx" },
+ { name = "schema" },
+ { name = "spdx-tools" },
+ { name = "tabulate" },
+ { name = "termcolor" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+ { name = "urllib3" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ba/47/81dc68e33ba323b973c90a7792b547e44d80afb687e402cf00412d709371/checkov-3.2.510.tar.gz", hash = "sha256:db065e2d3257440a9626543184e2f89ba04779b51757f3db65e0af3f1961e538", size = 991353, upload-time = "2026-03-18T10:06:37.484Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/90/1c5ce9c189456bc94f35ad04fecb68cbf06648f33f9d677e9908e55f755d/checkov-3.2.510-py3-none-any.whl", hash = "sha256:36c3ce1982d46cc635438d33a3a86ac98349557f202d9cdd82c5aba8d7079689", size = 2301052, upload-time = "2026-03-18T10:06:35.306Z" },
+]
+
[[package]]
name = "click"
version = "8.3.1"
@@ -297,6 +595,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
+[[package]]
+name = "click-option-group"
+version = "0.5.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/ff/d291d66595b30b83d1cb9e314b2c9be7cfc7327d4a0d40a15da2416ea97b/click_option_group-0.5.9.tar.gz", hash = "sha256:f94ed2bc4cf69052e0f29592bd1e771a1789bd7bfc482dd0bc482134aff95823", size = 22222, upload-time = "2025-10-09T09:38:01.474Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/75/45/54bb2d8d4138964a94bef6e9afe48b0be4705ba66ac442ae7d8a8dc4ffef/click_option_group-0.5.9-py3-none-any.whl", hash = "sha256:ad2599248bd373e2e19bec5407967c3eec1d0d4fc4a5e77b08a0481e75991080", size = 11553, upload-time = "2025-10-09T09:38:00.066Z" },
+]
+
+[[package]]
+name = "cloudsplaining"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "boto3" },
+ { name = "botocore" },
+ { name = "cached-property" },
+ { name = "click" },
+ { name = "click-option-group" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "policy-sentry" },
+ { name = "pyyaml" },
+ { name = "schema" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/27/c3/a41d0974e00291798d3a5a18c7c0c7fd2880d8fbf69ebe115e89325bde85/cloudsplaining-0.7.0.tar.gz", hash = "sha256:2d8a1d1a3261368a39359bb23aa7d6ac9add274728ff24877b710cdfa96d96af", size = 1742513, upload-time = "2024-09-15T16:46:07.04Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/df/f33a9ce500c64262b4e6793cdf69844458dc0db432cd70c55273551f2478/cloudsplaining-0.7.0-py3-none-any.whl", hash = "sha256:8e93c7b1671c8353f520627cdf7917ec543581c9b9936b3d344817bb4747174e", size = 1795724, upload-time = "2024-09-15T16:46:04.752Z" },
+]
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -306,6 +637,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "configargparse"
+version = "1.7.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/30328302903c55218ffc5199646d0e9d28348ff26c02ba77b2ffc58d294a/configargparse-1.7.5.tar.gz", hash = "sha256:e3f9a7bb6be34d66b2e3c4a2f58e3045f8dfae47b0dc039f87bcfaa0f193fb0f", size = 53548, upload-time = "2026-03-11T02:19:38.144Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/19/3ba5e1b0bcc7b91aeab6c258afd70e4907d220fed3972febe38feb40db30/configargparse-1.7.5-py3-none-any.whl", hash = "sha256:1e63fdffedf94da9cd435fc13a1cd24777e76879dd2343912c1f871d4ac8c592", size = 27692, upload-time = "2026-03-11T02:19:36.442Z" },
+]
+
+[[package]]
+name = "contextlib2"
+version = "21.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/13/37ea7805ae3057992e96ecb1cffa2fa35c2ef4498543b846f90dd2348d8f/contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869", size = 43795, upload-time = "2021-06-27T06:54:40.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/56/6d6872f79d14c0cb02f1646cbb4592eef935857c0951a105874b7b62a0c3/contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f", size = 13277, upload-time = "2021-06-27T06:54:20.972Z" },
+]
+
+[[package]]
+name = "cyclonedx-python-lib"
+version = "7.6.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "license-expression" },
+ { name = "packageurl-python" },
+ { name = "py-serializable" },
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/8f/a2de02ce7263312b51cb3946593b608ef996949295b69b31a9ed0e71ec92/cyclonedx_python_lib-7.6.2.tar.gz", hash = "sha256:31186c5725ac0cfcca433759a407b1424686cdc867b47cc86e6cf83691310903", size = 1124315, upload-time = "2024-10-07T13:21:28.128Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cf/27/9ec1959eb4c23bdbec690f17a64562e664746762c0b8becf3ec2e95579d7/cyclonedx_python_lib-7.6.2-py3-none-any.whl", hash = "sha256:c42fab352cc0f7418d1b30def6751d9067ebcf0e8e4be210fc14d6e742a9edcc", size = 361381, upload-time = "2024-10-07T13:21:25.718Z" },
+]
+
[[package]]
name = "dataclasses-json"
version = "0.6.7"
@@ -319,6 +683,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" },
]
+[[package]]
+name = "decorator"
+version = "5.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
+]
+
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
+]
+
[[package]]
name = "distro"
version = "1.9.0"
@@ -328,6 +710,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
+[[package]]
+name = "docker"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
+ { name = "requests" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
+]
+
+[[package]]
+name = "dockerfile-parse"
+version = "2.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" },
+]
+
[[package]]
name = "docstring-parser"
version = "0.17.0"
@@ -337,6 +742,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
]
+[[package]]
+name = "dpath"
+version = "2.1.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/2c/a4213cdbbc43b8fdf34b6e2afb415fd5d46e171d32a4bb92e7924548aa9f/dpath-2.1.3.tar.gz", hash = "sha256:d1a7a0e6427d0a4156c792c82caf1f0109603f68ace792e36ca4596fd2cb8d9d", size = 24016, upload-time = "2022-12-13T07:27:22.108Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c9/12/02fdb87afeab1987442164a2db470a995122c800f15265e9b7a5103a3fd9/dpath-2.1.3-py3-none-any.whl", hash = "sha256:d9560e03ccd83b3c6f29988b0162ce9b34fd28b9d8dbda46663b20c68d9cdae3", size = 17232, upload-time = "2022-12-13T07:27:20.023Z" },
+]
+
[[package]]
name = "fastapi"
version = "0.135.1"
@@ -442,6 +856,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
+[[package]]
+name = "gitdb"
+version = "4.0.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "smmap" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
+]
+
+[[package]]
+name = "gitpython"
+version = "3.1.46"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "gitdb" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" },
+]
+
[[package]]
name = "greenlet"
version = "3.3.2"
@@ -565,6 +1003,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
+[[package]]
+name = "importlib-metadata"
+version = "7.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "zipp" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/72/33d1bb4be61f1327d3cd76fc41e2d001a6b748a0648d944c646643f123fe/importlib_metadata-7.2.1.tar.gz", hash = "sha256:509ecb2ab77071db5137c655e24ceb3eee66e7bbc6574165d0d114d9fc4bbe68", size = 52834, upload-time = "2024-06-23T15:17:54.753Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/28/7daa5f782f5e2cbbec00556bf23ca106023470ebab3ae1040ee778269af1/importlib_metadata-7.2.1-py3-none-any.whl", hash = "sha256:ffef94b0b66046dd8ea2d619b701fe978d9264d38f3998bc4c27ec3b146a87c8", size = 25037, upload-time = "2024-06-23T15:17:52.117Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
[[package]]
name = "jiter"
version = "0.13.0"
@@ -633,6 +1104,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" },
]
+[[package]]
+name = "jmespath"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
+]
+
[[package]]
name = "jsonpatch"
version = "1.33"
@@ -654,6 +1134,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/25/cebb241a435cbf4626b5ea096d8385c04416d7ca3082a15299b746e248fa/jsonpointer-3.1.0-py3-none-any.whl", hash = "sha256:f82aa0f745001f169b96473348370b43c3f581446889c41c807bab1db11c8e7b", size = 7651, upload-time = "2026-03-20T21:47:08.792Z" },
]
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "jsonschema-specifications" },
+ { name = "referencing" },
+ { name = "rpds-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
+[[package]]
+name = "junit-xml"
+version = "1.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/af/bc988c914dd1ea2bc7540ecc6a0265c2b6faccc6d9cdb82f20e2094a8229/junit-xml-1.9.tar.gz", hash = "sha256:de16a051990d4e25a3982b2dd9e89d671067548718866416faec14d9de56db9f", size = 7349, upload-time = "2023-01-24T18:42:00.836Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/93/2d896b5fd3d79b4cadd8882c06650e66d003f465c9d12c488d92853dff78/junit_xml-1.9-py2.py3-none-any.whl", hash = "sha256:ec5ca1a55aefdd76d28fcc0b135251d156c7106fa979686a4b48d62b761b4732", size = 7130, upload-time = "2020-02-22T20:41:37.661Z" },
+]
+
[[package]]
name = "langchain"
version = "1.2.13"
@@ -784,6 +1303,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/65/4c/09a4a0c42f5d2fc38d6c4d67884788eff7fd2cfdf367fdf7033de908b4c0/langgraph_checkpoint-4.0.1-py3-none-any.whl", hash = "sha256:e3adcd7a0e0166f3b48b8cf508ce0ea366e7420b5a73aa81289888727769b034", size = 50453, upload-time = "2026-02-27T21:06:14.293Z" },
]
+[[package]]
+name = "langgraph-checkpoint-sqlite"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiosqlite" },
+ { name = "langgraph-checkpoint" },
+ { name = "sqlite-vec" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/04/61/40b7f8f29d6de92406e668c35265f409f57064907e31eae84ab3f2a3e3e1/langgraph_checkpoint_sqlite-3.0.3.tar.gz", hash = "sha256:438c234d37dabda979218954c9c6eb1db73bee6492c2f1d3a00552fe23fa34ed", size = 123876, upload-time = "2026-01-19T00:38:44.473Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/d8/84ef22ee1cc485c4910df450108fd5e246497379522b3c6cfba896f71bf6/langgraph_checkpoint_sqlite-3.0.3-py3-none-any.whl", hash = "sha256:02eb683a79aa6fcda7cd4de43861062a5d160dbbb990ef8a9fd76c979998a952", size = 33593, upload-time = "2026-01-19T00:38:43.288Z" },
+]
+
[[package]]
name = "langgraph-cli"
version = "0.4.19"
@@ -845,6 +1378,99 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/94/1f5d72655ab6534129540843776c40eff757387b88e798d8b3bf7e313fd4/langsmith-0.7.22-py3-none-any.whl", hash = "sha256:6e9d5148314d74e86748cb9d3898632cad0320c9323d95f70f969e5bc078eee4", size = 359927, upload-time = "2026-03-19T22:45:21.603Z" },
]
+[[package]]
+name = "lark"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" },
+]
+
+[[package]]
+name = "license-expression"
+version = "30.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "boolean-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
+]
+
+[[package]]
+name = "markdown"
+version = "3.10.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
[[package]]
name = "marshmallow"
version = "3.26.2"
@@ -965,6 +1591,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
+[[package]]
+name = "networkx"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/97/ae/7497bc5e1c84af95e585e3f98585c9f06c627fac6340984c4243053e8f44/networkx-2.6.3.tar.gz", hash = "sha256:c0946ed31d71f1b732b5aaa6da5a0388a345019af232ce2f49c766e2d6795c51", size = 1844862, upload-time = "2021-09-09T22:09:42.029Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/93/aa6613aa70d6eb4868e667068b5a11feca9645498fd31b954b6c4bb82fa5/networkx-2.6.3-py3-none-any.whl", hash = "sha256:80b6b89c77d1dfb64a4c7854981b60aeea6360ac02c6d4e4913319e0a313abef", size = 1927288, upload-time = "2021-09-09T22:09:39.016Z" },
+]
+
[[package]]
name = "numpy"
version = "2.4.3"
@@ -1118,13 +1753,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" },
]
+[[package]]
+name = "packageurl-python"
+version = "0.13.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/33/e50adf6a6cd4cde7ccd140e4538d898cea7a609f7aee5d6365e5cd44b6c8/packageurl-python-0.13.4.tar.gz", hash = "sha256:6eb5e995009cc73387095e0b507ab65df51357d25ddc5fce3d3545ad6dcbbee8", size = 37915, upload-time = "2024-01-08T20:32:17.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6f/d6/dc41590e65a95198ad7490ed0fb34a1148e8eb5032c35c8d157b55aa496d/packageurl_python-0.13.4-py3-none-any.whl", hash = "sha256:62aa13d60a0082ff115784fefdfe73a12f310e455365cca7c6d362161067f35f", size = 26203, upload-time = "2024-01-08T20:32:16.456Z" },
+]
+
[[package]]
name = "packaging"
-version = "26.0"
+version = "23.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fb/2b/9b9c33ffed44ee921d0967086d653047286054117d584f1b1a7c22ceaf7b/packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", size = 146714, upload-time = "2023-10-01T13:50:05.279Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/1a/610693ac4ee14fcdf2d9bf3c493370e4f2ef7ae2e19217d7a237ff42367d/packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7", size = 53011, upload-time = "2023-10-01T13:50:03.745Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "ply"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" },
+]
+
+[[package]]
+name = "policy-sentry"
+version = "0.13.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beautifulsoup4" },
+ { name = "click" },
+ { name = "orjson" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "schema" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a4/05/75e8953eb5fa564e45fc5afc61696d38ce779169309ca270224561926fa8/policy_sentry-0.13.2.tar.gz", hash = "sha256:db2b39f92989077f83fc4dd1d064e3ff20b69cfed82168ebdc060e7dce292e77", size = 1055327, upload-time = "2024-12-01T11:26:53.65Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/fa/abd81b7ce825250e3e7e02f2a94315707db28a4426c8c35ca5bde92a75cf/policy_sentry-0.13.2-py3-none-any.whl", hash = "sha256:e82c3bc1783606449399c4221f67d05f6b08d8a184ba2fee87d04541d7282b86", size = 966631, upload-time = "2024-12-01T11:26:42.603Z" },
+]
+
+[[package]]
+name = "prettytable"
+version = "3.17.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433, upload-time = "2025-11-14T17:33:19.093Z" },
]
[[package]]
@@ -1211,6 +1902,104 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
]
+[[package]]
+name = "py-serializable"
+version = "1.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "defusedxml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/16/cf/6e482507764034d6c41423a19f33fdd59655052fdb2ca4358faa3b0bcfd1/py_serializable-1.1.2.tar.gz", hash = "sha256:89af30bc319047d4aa0d8708af412f6ce73835e18bacf1a080028bb9e2f42bdb", size = 55844, upload-time = "2024-10-01T15:55:43.642Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/f2/3483060562245668bb07193b65277f0ea619cabf530deb351911eb0453eb/py_serializable-1.1.2-py3-none-any.whl", hash = "sha256:801be61b0a1ba64c3861f7c624f1de5cfbbabf8b458acc9cdda91e8f7e5effa1", size = 22786, upload-time = "2024-10-01T15:55:42.498Z" },
+]
+
+[[package]]
+name = "pycares"
+version = "4.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8d/ad/9d1e96486d2eb5a2672c4d9a2dd372d015b8d7a332c6ac2722c4c8e6bbbf/pycares-4.11.0.tar.gz", hash = "sha256:c863d9003ca0ce7df26429007859afd2a621d3276ed9fef154a9123db9252557", size = 654473, upload-time = "2025-09-09T15:18:21.849Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e2/4e/4821b66feefaaa8ec03494c1a11614c430983572e54ff062b4589441e199/pycares-4.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b93d624560ba52287873bacff70b42c99943821ecbc810b959b0953560f53c36", size = 145906, upload-time = "2025-09-09T15:16:53.204Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/81/93a505dcbb7533254b0ce1da519591dcda889d6a66dcdfa5737e3280e18a/pycares-4.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:775d99966e28c8abd9910ddef2de0f1e173afc5a11cea9f184613c747373ab80", size = 141972, upload-time = "2025-09-09T15:16:54.43Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/d6/76994c8b21316e48ea6c3ce3298574c28f90c9c41428a3349a57104621c9/pycares-4.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:84fde689557361764f052850a2d68916050adbfd9321f6105aca1d8f1a9bd49b", size = 637832, upload-time = "2025-09-09T15:16:55.523Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/a4/5ca7e316d0edb714d78974cb34f4883f63fe9f580644c2db99fb62b05f56/pycares-4.11.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:30ceed06f3bf5eff865a34d21562c25a7f3dad0ed336b9dd415330e03a6c50c4", size = 687751, upload-time = "2025-09-09T15:16:57.55Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/8d/c5c578fdd335d7b1dcaea88fae3497390095b5b05a1ba34a29f62d037abb/pycares-4.11.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:97d971b3a88a803bb95ff8a40ea4d68da59319eb8b59e924e318e2560af8c16d", size = 678362, upload-time = "2025-09-09T15:16:58.859Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/96/9be4d838a9348dd2e72a90c34d186b918b66d499af5be79afa18a6ba2808/pycares-4.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2d5cac829da91ade70ce1af97dad448c6cd4778b48facbce1b015e16ced93642", size = 641069, upload-time = "2025-09-09T15:17:00.046Z" },
+ { url = "https://files.pythonhosted.org/packages/39/d6/8ea9b5dcef6b566cde034aa2b68743f7b0a19fa0fba9ea01a4f98b8a57fb/pycares-4.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee1ea367835eb441d246164c09d1f9703197af4425fc6865cefcde9e2ca81f85", size = 622357, upload-time = "2025-09-09T15:17:01.205Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f8/3401e89b5d2970e30e02f9beb29ad59e2a8f19ef2c68c978de2b764cacb0/pycares-4.11.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3139ec1f4450a4b253386035c5ecd2722582ae3320a456df5021ffe3f174260a", size = 670290, upload-time = "2025-09-09T15:17:02.413Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/c4/ff6a166e1d1d1987339548a19d0b1d52ec3ead8b3a8a2247a0d96e56013c/pycares-4.11.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5d70324ca1d82c6c4b00aa678347f7560d1ef2ce1d181978903459a97751543a", size = 652958, upload-time = "2025-09-09T15:17:04.203Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/7c/fc084b395921c9b862d31a83f809fe649c24314b51b527ad0ab0df33edd4/pycares-4.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2f8d9cfe0eb3a2997fde5df99b1aaea5a46dabfcfcac97b2d05f027c2cd5e28", size = 629239, upload-time = "2025-09-09T15:17:05.477Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/7f/2f26062bea95ab657f979217d50df563dc9fd9cc4c5dd21a6e7323e9efe7/pycares-4.11.0-cp312-cp312-win32.whl", hash = "sha256:1571a7055c03a95d5270c914034eac7f8bfa1b432fc1de53d871b821752191a4", size = 118918, upload-time = "2025-09-09T15:17:06.882Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/86/277473d20f3df4e00fa7e0ebb21955b2830b15247462aaf8f3fc8c4950be/pycares-4.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:7570e0b50db619b2ee370461c462617225dc3a3f63f975c6f117e2f0c94f82ca", size = 144560, upload-time = "2025-09-09T15:17:07.891Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/f9/d65ad17ec921d8b7eb42161dec2024ee2f5c9f1c44cabf0dd1b7f4fac6c5/pycares-4.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:f199702740f3b766ed8c70efb885538be76cb48cd0cb596b948626f0b825e07a", size = 115695, upload-time = "2025-09-09T15:17:09.333Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/a9/62fea7ad72ac1fed2ac9dd8e9a7379b7eb0288bf2b3ea5731642c3a6f7de/pycares-4.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c296ab94d1974f8d2f76c499755a9ce31ffd4986e8898ef19b90e32525f7d84", size = 145909, upload-time = "2025-09-09T15:17:10.491Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/ac/0317d6d0d3bd7599c53b8f1db09ad04260647d2f6842018e322584791fd5/pycares-4.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0fcd3a8bac57a0987d9b09953ba0f8703eb9dca7c77f7051d8c2ed001185be8", size = 141974, upload-time = "2025-09-09T15:17:11.634Z" },
+ { url = "https://files.pythonhosted.org/packages/63/11/731b565ae1e81c43dac247a248ee204628186f6df97c9927bd06c62237f8/pycares-4.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:bac55842047567ddae177fb8189b89a60633ac956d5d37260f7f71b517fd8b87", size = 637796, upload-time = "2025-09-09T15:17:12.815Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/30/a2631fe2ffaa85475cdbff7df1d9376bc0b2a6ae77ca55d53233c937a5da/pycares-4.11.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:4da2e805ed8c789b9444ef4053f6ef8040cd13b0c1ca6d3c4fe6f9369c458cb4", size = 687734, upload-time = "2025-09-09T15:17:14.015Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/b7/b3a5f99d4ab776662e71d5a56e8f6ea10741230ff988d1f502a8d429236b/pycares-4.11.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:ea785d1f232b42b325578f0c8a2fa348192e182cc84a1e862896076a4a2ba2a7", size = 678320, upload-time = "2025-09-09T15:17:15.442Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/77/a00d962b90432993afbf3bd05da8fe42117e0d9037cd7fd428dc41094d7b/pycares-4.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:aa160dc9e785212c49c12bb891e242c949758b99542946cc8e2098ef391f93b0", size = 641012, upload-time = "2025-09-09T15:17:16.728Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/fb/9266979ba59d37deee1fd74452b2ae32a7395acafe1bee510ac023c6c9a5/pycares-4.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7830709c23bbc43fbaefbb3dde57bdd295dc86732504b9d2e65044df8fd5e9fb", size = 622363, upload-time = "2025-09-09T15:17:17.835Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c2/16dbc3dc33781a3c79cbdd76dd1cda808d98ba078d9a63a725d6a1fad181/pycares-4.11.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ef1ab7abbd238bb2dbbe871c3ea39f5a7fc63547c015820c1e24d0d494a1689", size = 670294, upload-time = "2025-09-09T15:17:19.214Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/75/f003905e55298a6dd5e0673a2dc11e31518a5141393b925dc05fcaba9fb4/pycares-4.11.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a4060d8556c908660512d42df1f4a874e4e91b81f79e3a9090afedc7690ea5ba", size = 652973, upload-time = "2025-09-09T15:17:20.388Z" },
+ { url = "https://files.pythonhosted.org/packages/55/2a/eafb235c371979e11f8998d686cbaa91df6a84a34ffe4d997dfe57c45445/pycares-4.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a98fac4a3d4f780817016b6f00a8a2c2f41df5d25dfa8e5b1aa0d783645a6566", size = 629235, upload-time = "2025-09-09T15:17:21.92Z" },
+ { url = "https://files.pythonhosted.org/packages/05/99/60f19eb1c8eb898882dd8875ea51ad0aac3aff5780b27247969e637cc26a/pycares-4.11.0-cp313-cp313-win32.whl", hash = "sha256:faa8321bc2a366189dcf87b3823e030edf5ac97a6b9a7fc99f1926c4bf8ef28e", size = 118918, upload-time = "2025-09-09T15:17:23.327Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/14/bc89ad7225cba73068688397de09d7cad657d67b93641c14e5e18b88e685/pycares-4.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:6f74b1d944a50fa12c5006fd10b45e1a45da0c5d15570919ce48be88e428264c", size = 144556, upload-time = "2025-09-09T15:17:24.341Z" },
+ { url = "https://files.pythonhosted.org/packages/af/88/4309576bd74b5e6fc1f39b9bc5e4b578df2cadb16bdc026ac0cc15663763/pycares-4.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f7581793d8bb3014028b8397f6f80b99db8842da58f4409839c29b16397ad", size = 115692, upload-time = "2025-09-09T15:17:25.637Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/70/a723bc79bdcac60361b40184b649282ac0ab433b90e9cc0975370c2ff9c9/pycares-4.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:df0a17f4e677d57bca3624752bbb515316522ad1ce0de07ed9d920e6c4ee5d35", size = 145910, upload-time = "2025-09-09T15:17:26.774Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/46311ef5a384b5f0bb206851135dde8f86b3def38fdbee9e3c03475d35ae/pycares-4.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b44e54cad31d3c3be5e8149ac36bc1c163ec86e0664293402f6f846fb22ad00", size = 142053, upload-time = "2025-09-09T15:17:27.956Z" },
+ { url = "https://files.pythonhosted.org/packages/74/23/d236fc4f134d6311e4ad6445571e8285e84a3e155be36422ff20c0fbe471/pycares-4.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:80752133442dc7e6dd9410cec227c49f69283c038c316a8585cca05ec32c2766", size = 637878, upload-time = "2025-09-09T15:17:29.173Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/92/6edd41282b3f0e3d9defaba7b05c39730d51c37c165d9d3b319349c975aa/pycares-4.11.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:84b0b402dd333403fdce0e204aef1ef834d839c439c0c1aa143dc7d1237bb197", size = 687865, upload-time = "2025-09-09T15:17:30.549Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/a9/4d7cf4d72600fd47d9518f9ce99703a3e8711fb08d2ef63d198056cdc9a9/pycares-4.11.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:c0eec184df42fc82e43197e073f9cc8f93b25ad2f11f230c64c2dc1c80dbc078", size = 678396, upload-time = "2025-09-09T15:17:32.304Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/4b/e546eeb1d8ff6559e2e3bef31a6ea0c6e57ec826191941f83a3ce900ca89/pycares-4.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ee751409322ff10709ee867d5aea1dc8431eec7f34835f0f67afd016178da134", size = 640786, upload-time = "2025-09-09T15:17:33.602Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/f5/b4572d9ee9c26de1f8d1dc80730df756276b9243a6794fa3101bbe56613d/pycares-4.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1732db81e348bfce19c9bf9448ba660aea03042eeeea282824da1604a5bd4dcf", size = 621857, upload-time = "2025-09-09T15:17:34.74Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f2/639090376198bcaeff86562b25e1bce05a481cfb1e605f82ce62285230cd/pycares-4.11.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:702d21823996f139874aba5aa9bb786d69e93bde6e3915b99832eb4e335d31ae", size = 670130, upload-time = "2025-09-09T15:17:35.982Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c4/cf40773cd9c36a12cebbe1e9b6fb120f9160dc9bfe0398d81a20b6c69972/pycares-4.11.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:218619b912cef7c64a339ab0e231daea10c994a05699740714dff8c428b9694a", size = 653133, upload-time = "2025-09-09T15:17:37.179Z" },
+ { url = "https://files.pythonhosted.org/packages/32/6b/06054d977b0a9643821043b59f523f3db5e7684c4b1b4f5821994d5fa780/pycares-4.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:719f7ddff024fdacde97b926b4b26d0cc25901d5ef68bb994a581c420069936d", size = 629344, upload-time = "2025-09-09T15:17:38.308Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/6f/14bb0c2171a286d512e3f02d6168e608ffe5f6eceab78bf63e3073091ae3/pycares-4.11.0-cp314-cp314-win32.whl", hash = "sha256:d552fb2cb513ce910d1dc22dbba6420758a991a356f3cd1b7ec73a9e31f94d01", size = 121804, upload-time = "2025-09-09T15:17:39.388Z" },
+ { url = "https://files.pythonhosted.org/packages/24/dc/6822f9ad6941027f70e1cf161d8631456531a87061588ed3b1dcad07d49d/pycares-4.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:23d50a0842e8dbdddf870a7218a7ab5053b68892706b3a391ecb3d657424d266", size = 148005, upload-time = "2025-09-09T15:17:40.44Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/24/24ff3a80aa8471fbb62785c821a8e90f397ca842e0489f83ebf7ee274397/pycares-4.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:836725754c32363d2c5d15b931b3ebd46b20185c02e850672cb6c5f0452c1e80", size = 119239, upload-time = "2025-09-09T15:17:42.094Z" },
+ { url = "https://files.pythonhosted.org/packages/54/fe/2f3558d298ff8db31d5c83369001ab72af3b86a0374d9b0d40dc63314187/pycares-4.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c9d839b5700542b27c1a0d359cbfad6496341e7c819c7fea63db9588857065ed", size = 146408, upload-time = "2025-09-09T15:17:43.74Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/c8/516901e46a1a73b3a75e87a35f3a3a4fe085f1214f37d954c9d7e782bd6d/pycares-4.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:31b85ad00422b38f426e5733a71dfb7ee7eb65a99ea328c508d4f552b1760dc8", size = 142371, upload-time = "2025-09-09T15:17:45.186Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/99/c3fba0aa575f331ebed91f87ba960ffbe0849211cdf103ab275bc0107ac6/pycares-4.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cdac992206756b024b371760c55719eb5cd9d6b2cb25a8d5a04ae1b0ff426232", size = 647504, upload-time = "2025-09-09T15:17:46.503Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e4/1cdc3ec9c92f8069ec18c58b016b2df7c44a088e2849f37ed457554961aa/pycares-4.11.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:ffb22cee640bc12ee0e654eba74ecfb59e2e0aebc5bccc3cc7ef92f487008af7", size = 697122, upload-time = "2025-09-09T15:17:47.772Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d5/bd8f370b97bb73e5bdd55dc2a78e18d6f49181cf77e88af0599d16f5c073/pycares-4.11.0-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:00538826d2eaf4a0e4becb0753b0ac8d652334603c445c9566c9eb273657eb4c", size = 687543, upload-time = "2025-09-09T15:17:49.183Z" },
+ { url = "https://files.pythonhosted.org/packages/33/38/49b77b9cf5dffc0b1fdd86656975c3bc1a58b79bdc883a9ef749b17a013c/pycares-4.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:29daa36548c04cdcd1a78ae187a4b7b003f0b357a2f4f1f98f9863373eedc759", size = 649565, upload-time = "2025-09-09T15:17:51.03Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/23/f6d57bfb99d00a6a7363f95c8d3a930fe82a868d9de24c64c8048d66f16a/pycares-4.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cf306f3951740d7bed36149a6d8d656a7d5432dd4bbc6af3bb6554361fc87401", size = 631242, upload-time = "2025-09-09T15:17:52.298Z" },
+ { url = "https://files.pythonhosted.org/packages/33/a2/7b9121c71cfe06a8474e221593f83a78176fae3b79e5853d2dfd13ab01cc/pycares-4.11.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:386da2581db4ea2832629e275c061103b0be32f9391c5dfaea7f6040951950ad", size = 680304, upload-time = "2025-09-09T15:17:53.638Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/07/dfe76807f637d8b80e1a59dfc4a1bceabdd0205a45b2ebf78b415ae72af3/pycares-4.11.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:45d3254a694459fdb0640ef08724ca9d4b4f6ff6d7161c9b526d7d2e2111379e", size = 661039, upload-time = "2025-09-09T15:17:55.024Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/9b/55d50c5acd46cbe95d0da27740a83e721d89c0ce7e42bff9891a9f29a855/pycares-4.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eddf5e520bb88b23b04ac1f28f5e9a7c77c718b8b4af3a4a7a2cc4a600f34502", size = 637560, upload-time = "2025-09-09T15:17:56.492Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/79/2b2e723d1b929dbe7f99e80a56abb29a4f86988c1f73195d960d706b1629/pycares-4.11.0-cp314-cp314t-win32.whl", hash = "sha256:8a75a406432ce39ce0ca41edff7486df6c970eb0fe5cfbe292f195a6b8654461", size = 122235, upload-time = "2025-09-09T15:17:57.576Z" },
+ { url = "https://files.pythonhosted.org/packages/93/fe/bf3b3ed9345a38092e72cd9890a5df5c2349fc27846a714d823a41f0ee27/pycares-4.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3784b80d797bcc2ff2bf3d4b27f46d8516fe1707ff3b82c2580dc977537387f9", size = 148575, upload-time = "2025-09-09T15:17:58.699Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/20/c0c5cfcf89725fe533b27bc5f714dc4efa8e782bf697c36f9ddf04ba975d/pycares-4.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:afc6503adf8b35c21183b9387be64ca6810644ef54c9ef6c99d1d5635c01601b", size = 119690, upload-time = "2025-09-09T15:17:59.809Z" },
+]
+
+[[package]]
+name = "pycep-parser"
+version = "0.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lark" },
+ { name = "regex" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a5/fa/be9c4c78d36f095ce4801021f367febe232b4f299e172bb271e4a895968e/pycep_parser-0.5.1.tar.gz", hash = "sha256:683bb001077c09f98408285b1b6ba10cfb3941610966c45d0638a0e1a5e1d2a4", size = 22979, upload-time = "2024-11-03T17:24:54.71Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/56/01afa944d13dcc2586086c4d1312c43e5bad7f8f9b3473bf0a777a616d90/pycep_parser-0.5.1-py3-none-any.whl", hash = "sha256:8c3f99c0dc1301193b1bcbe0a44c6b2763f6d2daf24964ca48dcdfbb73087fa0", size = 23606, upload-time = "2024-11-03T17:24:53.12Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.12.5"
@@ -1311,6 +2100,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -1320,6 +2168,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
+[[package]]
+name = "pywin32"
+version = "311"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
+ { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
+ { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
+]
+
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -1366,6 +2230,120 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
+[[package]]
+name = "rdflib"
+version = "7.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyparsing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" },
+]
+
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
+[[package]]
+name = "regex"
+version = "2026.2.28"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" },
+ { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" },
+ { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" },
+ { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" },
+ { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" },
+ { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311, upload-time = "2026-02-28T02:17:22.591Z" },
+ { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285, upload-time = "2026-02-28T02:17:24.355Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051, upload-time = "2026-02-28T02:17:26.722Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842, upload-time = "2026-02-28T02:17:29.064Z" },
+ { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083, upload-time = "2026-02-28T02:17:31.363Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412, upload-time = "2026-02-28T02:17:33.248Z" },
+ { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101, upload-time = "2026-02-28T02:17:35.053Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260, upload-time = "2026-02-28T02:17:37.692Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311, upload-time = "2026-02-28T02:17:39.855Z" },
+ { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876, upload-time = "2026-02-28T02:17:42.317Z" },
+ { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632, upload-time = "2026-02-28T02:17:45.073Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320, upload-time = "2026-02-28T02:17:47.192Z" },
+ { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152, upload-time = "2026-02-28T02:17:49.067Z" },
+ { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398, upload-time = "2026-02-28T02:17:50.744Z" },
+ { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282, upload-time = "2026-02-28T02:17:53.074Z" },
+ { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382, upload-time = "2026-02-28T02:17:54.888Z" },
+ { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541, upload-time = "2026-02-28T02:17:56.813Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984, upload-time = "2026-02-28T02:17:58.538Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509, upload-time = "2026-02-28T02:18:00.208Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429, upload-time = "2026-02-28T02:18:02.328Z" },
+ { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422, upload-time = "2026-02-28T02:18:04.23Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175, upload-time = "2026-02-28T02:18:06.791Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044, upload-time = "2026-02-28T02:18:08.736Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056, upload-time = "2026-02-28T02:18:10.777Z" },
+ { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743, upload-time = "2026-02-28T02:18:13.025Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633, upload-time = "2026-02-28T02:18:16.84Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862, upload-time = "2026-02-28T02:18:18.892Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788, upload-time = "2026-02-28T02:18:21.475Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184, upload-time = "2026-02-28T02:18:23.492Z" },
+ { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137, upload-time = "2026-02-28T02:18:25.375Z" },
+ { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682, upload-time = "2026-02-28T02:18:27.205Z" },
+ { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735, upload-time = "2026-02-28T02:18:29.015Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" },
+ { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" },
+ { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" },
+ { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" },
+ { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" },
+ { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825, upload-time = "2026-02-28T02:18:59.202Z" },
+ { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548, upload-time = "2026-02-28T02:19:01.049Z" },
+ { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444, upload-time = "2026-02-28T02:19:03.255Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" },
+ { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" },
+ { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" },
+ { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" },
+ { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" },
+ { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936, upload-time = "2026-02-28T02:19:36.313Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779, upload-time = "2026-02-28T02:19:38.625Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010, upload-time = "2026-02-28T02:19:40.65Z" },
+]
+
[[package]]
name = "requests"
version = "2.32.5"
@@ -1393,6 +2371,160 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
]
+[[package]]
+name = "rpds-py"
+version = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
+ { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
+ { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
+ { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
+ { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
+ { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
+ { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
+ { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
+ { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
+ { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
+ { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
+ { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
+ { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
+ { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
+ { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
+ { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
+ { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
+ { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
+ { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
+ { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
+ { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
+]
+
+[[package]]
+name = "rustworkx"
+version = "0.17.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e7/b0/66d96f02120f79eeed86b5c5be04029b6821155f31ed4907a4e9f1460671/rustworkx-0.17.1.tar.gz", hash = "sha256:59ea01b4e603daffa4e8827316c1641eef18ae9032f0b1b14aa0181687e3108e", size = 399407, upload-time = "2025-09-15T16:29:46.429Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/24/8972ed631fa05fdec05a7bb7f1fc0f8e78ee761ab37e8a93d1ed396ba060/rustworkx-0.17.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c08fb8db041db052da404839b064ebfb47dcce04ba9a3e2eb79d0c65ab011da4", size = 2257491, upload-time = "2025-08-13T01:43:31.466Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ae/7b6bbae5e0487ee42072dc6a46edf5db9731a0701ed648db22121fb7490c/rustworkx-0.17.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:4ef8e327dadf6500edd76fedb83f6d888b9266c58bcdbffd5a40c33835c9dd26", size = 2040175, upload-time = "2025-08-13T01:43:33.762Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/ea/c17fb9428c8f0dcc605596f9561627a5b9ef629d356204ee5088cfcf52c6/rustworkx-0.17.1-cp39-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b809e0aa2927c68574b196f993233e269980918101b0dd235289c4f3ddb2115", size = 2324771, upload-time = "2025-08-13T01:43:35.553Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/40/ec8b3b8b0f8c0b768690c454b8dcc2781b4f2c767f9f1215539c7909e35b/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7e82c46a92fb0fd478b7372e15ca524c287485fdecaed37b8bb68f4df2720f2", size = 2068584, upload-time = "2025-08-13T01:43:37.261Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/22/713b900d320d06ce8677e71bba0ec5df0037f1d83270bff5db3b271c10d7/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42170075d8a7319e89ff63062c2f1d1116ced37b6f044f3bf36d10b60a107aa4", size = 2380949, upload-time = "2025-08-13T01:52:17.435Z" },
+ { url = "https://files.pythonhosted.org/packages/20/4b/54be84b3b41a19caf0718a2b6bb280dde98c8626c809c969f16aad17458f/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65cba97fa95470239e2d65eb4db1613f78e4396af9f790ff771b0e5476bfd887", size = 2562069, upload-time = "2025-08-13T02:09:27.222Z" },
+ { url = "https://files.pythonhosted.org/packages/39/5b/281bb21d091ab4e36cf377088366d55d0875fa2347b3189c580ec62b44c7/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246cc252053f89e36209535b9c58755960197e6ae08d48d3973760141c62ac95", size = 2221186, upload-time = "2025-08-13T01:43:38.598Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/2d/30a941a21b81e9db50c4c3ef8a64c5ee1c8eea3a90506ca0326ce39d021f/rustworkx-0.17.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c10d25e9f0e87d6a273d1ea390b636b4fb3fede2094bf0cb3fe565d696a91b48", size = 2123510, upload-time = "2025-08-13T01:43:40.288Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/ef/c9199e4b6336ee5a9f1979c11b5779c5cf9ab6f8386e0b9a96c8ffba7009/rustworkx-0.17.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:48784a673cf8d04f3cd246fa6b53fd1ccc4d83304503463bd561c153517bccc1", size = 2302783, upload-time = "2025-08-13T01:43:42.073Z" },
+ { url = "https://files.pythonhosted.org/packages/30/3d/a49ab633e99fca4ccbb9c9f4bd41904186c175ebc25c530435529f71c480/rustworkx-0.17.1-cp39-abi3-win32.whl", hash = "sha256:5dbc567833ff0a8ad4580a4fe4bde92c186d36b4c45fca755fb1792e4fafe9b5", size = 1931541, upload-time = "2025-08-13T01:43:43.415Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/ec/cee878c1879b91ab8dc7d564535d011307839a2fea79d2a650413edf53be/rustworkx-0.17.1-cp39-abi3-win_amd64.whl", hash = "sha256:d0a48fb62adabd549f9f02927c3a159b51bf654c7388a12fc16d45452d5703ea", size = 2055049, upload-time = "2025-08-13T01:43:44.926Z" },
+]
+
+[[package]]
+name = "s3transfer"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/0a/1cdbabf9edd0ea7747efdf6c9ab4e7061b085aa7f9bfc36bb1601563b069/s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7", size = 145287, upload-time = "2024-11-20T21:06:05.981Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/66/05/7957af15543b8c9799209506df4660cba7afc4cf94bfb60513827e96bed6/s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", size = 83175, upload-time = "2024-11-20T21:06:03.961Z" },
+]
+
+[[package]]
+name = "schema"
+version = "0.7.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "contextlib2" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4e/e8/01e1b46d9e04cdaee91c9c736d9117304df53361a191144c8eccda7f0ee9/schema-0.7.5.tar.gz", hash = "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197", size = 48173, upload-time = "2021-12-01T20:49:24.038Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/93/ca8aa5a772efd69043d0a745172d92bee027caa7565c7f774a2f44b91207/schema-0.7.5-py2.py3-none-any.whl", hash = "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c", size = 17603, upload-time = "2021-12-01T20:49:21.252Z" },
+]
+
+[[package]]
+name = "semantic-version"
+version = "2.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "smmap"
+version = "5.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" },
+]
+
[[package]]
name = "sniffio"
version = "1.3.1"
@@ -1402,6 +2534,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
+]
+
+[[package]]
+name = "spdx-tools"
+version = "0.8.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beartype" },
+ { name = "click" },
+ { name = "license-expression" },
+ { name = "ply" },
+ { name = "pyyaml" },
+ { name = "rdflib" },
+ { name = "semantic-version" },
+ { name = "uritools" },
+ { name = "xmltodict" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2f/99/33383f587b59cbd191cc1c0fd5408e550b9275e0090d32561cbddc973fdc/spdx_tools-0.8.5.tar.gz", hash = "sha256:be600beb2f762f0116025e05490d399e724f668bef84025a7c421bb266688bdb", size = 696323, upload-time = "2026-03-13T09:29:23.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/f5/1ac402e1f8d253fa60ac167c4a1fb7eeb0f29a04942788e2d43c984cd749/spdx_tools-0.8.5-py3-none-any.whl", hash = "sha256:7c2d5865941be9d2e898f5b084e8d5422dd298dc5a29320ddb198fec304f59c4", size = 286573, upload-time = "2026-03-13T09:29:20.907Z" },
+]
+
[[package]]
name = "sqlalchemy"
version = "2.0.48"
@@ -1448,6 +2618,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" },
]
+[[package]]
+name = "sqlite-vec"
+version = "0.1.7"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/50/7ad59cfd3003a2110cc366e526293de4c2520486f5ddaa8dc78b265f8d3e/sqlite_vec-0.1.7-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:c34a136caecff4ae17d4c0cc268fcda89764ee870039caa21431e8e3fb2f4d48", size = 131171, upload-time = "2026-03-17T07:42:50.438Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/c9/1cd2f59b539096cd2ce6b540247b2dfe3c47ba04d9368b5e8e3dc86498d4/sqlite_vec-0.1.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d272593d1b45ec7ea289b160ee6e5fafbaa6e1f5ba15f1305c012b0bda43653", size = 165434, upload-time = "2026-03-17T07:42:51.555Z" },
+ { url = "https://files.pythonhosted.org/packages/75/91/30c3c382140dcc7bc6e3a07eac7ca610a2b5b70eb9bc7066dc3e7f748d58/sqlite_vec-0.1.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d27746d8e254a390bd15574aed899a0b9bb915b5321eb130a9c09722898cc03", size = 160076, upload-time = "2026-03-17T07:42:52.451Z" },
+ { url = "https://files.pythonhosted.org/packages/59/56/6ff304d917ee79da769708dad0aed5fd34c72cbd0ae5e38bcc56cdc652a4/sqlite_vec-0.1.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:ad654283cb9c059852ce2d82018c757b06a705ada568f8b126022a131189818e", size = 163388, upload-time = "2026-03-17T07:42:53.516Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/27/fb1b6e3f9072854fe405f7aa99c46d4b465e84c9cec2ff7778edf29ecbbd/sqlite_vec-0.1.7-py3-none-win_amd64.whl", hash = "sha256:0c67877a87cb49426237b950237e82dbeb77778ab2ba89bea859f391fd169382", size = 292804, upload-time = "2026-03-17T07:42:54.325Z" },
+]
+
[[package]]
name = "starlette"
version = "0.52.1"
@@ -1461,6 +2643,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
+[[package]]
+name = "tabulate"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" },
+]
+
[[package]]
name = "tenacity"
version = "9.1.4"
@@ -1470,6 +2661,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
+[[package]]
+name = "termcolor"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b8/85/147a0529b4e80b6b9d021ca8db3a820fcac53ec7374b87073d004aaf444c/termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a", size = 12163, upload-time = "2023-04-23T19:45:24.004Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/e1/434566ffce04448192369c1a282931cf4ae593e91907558eaecd2e9f2801/termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475", size = 6872, upload-time = "2023-04-23T19:45:22.671Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -1504,6 +2716,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
+[[package]]
+name = "unidiff"
+version = "0.7.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/48/81be0ac96e423a877754153699731ef439fd7b80b4c8b5425c94ed079ebd/unidiff-0.7.5.tar.gz", hash = "sha256:2e5f0162052248946b9f0970a40e9e124236bf86c82b70821143a6fc1dea2574", size = 20931, upload-time = "2023-03-10T01:05:39.185Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/54/57c411a6e8f7bd7848c8b66e4dcaffa586bf4c02e63f2280db0327a4e6eb/unidiff-0.7.5-py2.py3-none-any.whl", hash = "sha256:c93bf2265cc1ba2a520e415ab05da587370bc2a3ae9e0414329f54f0c2fc09e8", size = 14386, upload-time = "2023-03-10T01:05:36.594Z" },
+]
+
+[[package]]
+name = "uritools"
+version = "6.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dd/f7/6651d145bedd535a5bdd6dad108329ec1fec89d38ec611f8d98834eb5378/uritools-6.0.1.tar.gz", hash = "sha256:2f9e9cb954e7877232b2c863f724a44a06eb98d9c7ebdd69914876e9487b94f8", size = 22857, upload-time = "2025-12-21T18:58:54.446Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/d7/e1542857c3f7615a1a9afa6b602b87cb5a33885db41c686aa7bf5092d4f0/uritools-6.0.1-py3-none-any.whl", hash = "sha256:d9507b82206c857d2f93d8fcc84f3b05ae4174096761102be690aa76a360cc1b", size = 10466, upload-time = "2025-12-21T18:58:52.903Z" },
+]
+
[[package]]
name = "urllib3"
version = "2.6.3"
@@ -1661,6 +2891,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
+[[package]]
+name = "wcwidth"
+version = "0.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
+]
+
[[package]]
name = "websockets"
version = "16.0"
@@ -1706,6 +2945,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
+[[package]]
+name = "xmltodict"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" },
+]
+
[[package]]
name = "xxhash"
version = "3.6.0"
@@ -1893,6 +3141,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
]
+[[package]]
+name = "zipp"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
+]
+
[[package]]
name = "zstandard"
version = "0.25.0"
From 15556a3fb34afecac6006ffdea858447ccda6441 Mon Sep 17 00:00:00 2001
From: ak-asu
Date: Sat, 21 Mar 2026 06:36:23 -0700
Subject: [PATCH 02/50] test
---
backend/app/agents/agent3/config.py | 6 +-
backend/app/agents/agent3/graph.py | 4 +
backend/app/agents/agent3/llm.py | 22 +-
.../app/agents/agent3/nodes/orchestrator.py | 48 +-
.../app/agents/agent3/nodes/parse_input.py | 8 +-
.../prompts/templates/orchestrator_system.j2 | 24 +-
backend/pyproject.toml | 1 +
backend/tests/test_agent3_full.py | 591 ++++++++++++++++++
backend/uv.lock | 32 +
9 files changed, 704 insertions(+), 32 deletions(-)
create mode 100644 backend/tests/test_agent3_full.py
diff --git a/backend/app/agents/agent3/config.py b/backend/app/agents/agent3/config.py
index 3152895..2575be6 100644
--- a/backend/app/agents/agent3/config.py
+++ b/backend/app/agents/agent3/config.py
@@ -4,8 +4,8 @@
# LLM
# ---------------------------------------------------------------------------
-DEFAULT_MODEL = "claude-sonnet-4-6"
-FAST_MODEL = "claude-haiku-4-5-20251001" # used for lighter tasks like code fixing
+DEFAULT_MODEL = "llama-3.1-8b-instant" # Groq: strong model with tool-use support
+FAST_MODEL = "llama-3.1-8b-instant" # Groq: fast model for lighter tasks like code fixing
# ---------------------------------------------------------------------------
# Retry / iteration limits
@@ -72,7 +72,7 @@
# ReAct agent recursion limit multiplier (steps per task)
# ---------------------------------------------------------------------------
-RECURSION_STEPS_PER_TASK = 3
+RECURSION_STEPS_PER_TASK = 6 # ~2 super-steps per tool call × ~3 tool calls per task
# ---------------------------------------------------------------------------
# Terraform file names that are always generated
diff --git a/backend/app/agents/agent3/graph.py b/backend/app/agents/agent3/graph.py
index a81a910..675ada9 100644
--- a/backend/app/agents/agent3/graph.py
+++ b/backend/app/agents/agent3/graph.py
@@ -90,6 +90,10 @@ def _route_after_tf_validation(
if state.get("current_phase") == "error":
return "error_handler"
if state.get("tf_validated"):
+ # Skip orchestrator entirely for infra-only architectures with no code tasks
+ task_list = state.get("task_list") or []
+ if not task_list:
+ return "assembler"
return "orchestrator"
# TF never validated and no human flag means something odd — assemble partial
return "assembler"
diff --git a/backend/app/agents/agent3/llm.py b/backend/app/agents/agent3/llm.py
index 30e955a..3cd0fa1 100644
--- a/backend/app/agents/agent3/llm.py
+++ b/backend/app/agents/agent3/llm.py
@@ -2,30 +2,38 @@
import threading
-from langchain_anthropic import ChatAnthropic
+from langchain_groq import ChatGroq
from app.agents.agent3.config import DEFAULT_MODEL, FAST_MODEL
_lock = threading.Lock()
-_clients: dict[str, ChatAnthropic] = {}
+_clients: dict[str, ChatGroq] = {}
-def get_llm(model: str = DEFAULT_MODEL, temperature: float = 0.0) -> ChatAnthropic:
+def get_llm(model: str = DEFAULT_MODEL, temperature: float = 0.0) -> ChatGroq:
"""
- Return a module-level singleton ChatAnthropic client for a given (model, temperature) pair.
+ Return a module-level singleton ChatGroq client for a given (model, temperature) pair.
Thread-safe; avoids re-creating clients on every node invocation.
+
+ parallel_tool_calls=False prevents models from generating multiple tool calls in a
+ single response turn, which causes format errors on models not well-calibrated for
+ parallel tool-call JSON output.
"""
key = f"{model}:{temperature}"
if key not in _clients:
with _lock:
if key not in _clients:
- _clients[key] = ChatAnthropic(model=model, temperature=temperature)
+ _clients[key] = ChatGroq(
+ model=model,
+ temperature=temperature,
+ model_kwargs={"parallel_tool_calls": False},
+ )
return _clients[key]
-def get_default_llm() -> ChatAnthropic:
+def get_default_llm() -> ChatGroq:
return get_llm(DEFAULT_MODEL, 0.0)
-def get_fast_llm() -> ChatAnthropic:
+def get_fast_llm() -> ChatGroq:
return get_llm(FAST_MODEL, 0.0)
diff --git a/backend/app/agents/agent3/nodes/orchestrator.py b/backend/app/agents/agent3/nodes/orchestrator.py
index b0d2a75..0de3c66 100644
--- a/backend/app/agents/agent3/nodes/orchestrator.py
+++ b/backend/app/agents/agent3/nodes/orchestrator.py
@@ -174,6 +174,15 @@ def generate_service_tests(service_id: str, language: str) -> str:
Requires generate_service_code to be called first for this service.
Returns the generated test file path on success, or an error description.
"""
+ # Enforce ordering: code must exist before tests can be generated
+ code_task = ctx.find_task(service_id, "code_gen")
+ if code_task is not None and code_task["status"] != "done":
+ return (
+ f"ERROR: Cannot generate tests for '{service_id}' — "
+ f"code_gen task is '{code_task['status']}'. "
+ "Call generate_service_code first."
+ )
+
ext = EXT_MAP.get(language, language)
code_path = f"services/{service_id}/handler.{ext}"
source_code = ctx.code_files.get(code_path, "")
@@ -277,7 +286,8 @@ def orchestrator_node(state: AgentState) -> dict[str, Any]:
# `prompt` is the current API (state_modifier is deprecated in langgraph-prebuilt 1.x)
agent = create_react_agent(get_default_llm(), tools, prompt=system_prompt)
- # Build initial message if this is the first orchestrator iteration
+ # Build initial message if this is the first orchestrator iteration.
+ # Plain-language description avoids confusing the LLM about tool call syntax.
prior_messages = list(state.get("orchestrator_messages") or [])
if not prior_messages:
pending_count = sum(1 for t in ctx.task_list if t["status"] == "pending")
@@ -285,21 +295,38 @@ def orchestrator_node(state: AgentState) -> dict[str, Any]:
HumanMessage(
content=(
f"Start generating code. There are {pending_count} pending tasks. "
- "Call get_pending_tasks() first to see the full task list, "
- "then work through them systematically."
+ "Use the get_pending_tasks tool first to see the full task list, "
+ "then work through each service systematically."
)
)
]
- # recursion_limit guards against the ReAct agent looping indefinitely
+ # Compute a recursion limit that scales with actual task count.
+ # Each task needs ~RECURSION_STEPS_PER_TASK super-steps (LLM call + tool call per step).
+ # We take the max of the user-configured floor and the task-count-based estimate.
max_iter = state.get("orchestrator_max_iterations", 10)
- result = agent.invoke(
- {"messages": prior_messages},
- config={"recursion_limit": max_iter * RECURSION_STEPS_PER_TASK},
+ num_tasks = len(ctx.task_list)
+ recursion_limit = max(
+ max_iter * RECURSION_STEPS_PER_TASK, # user-configured floor
+ num_tasks * RECURSION_STEPS_PER_TASK + 10, # task-count-based estimate
)
- return {
- "orchestrator_messages": result["messages"],
+ agent_messages = prior_messages
+ agent_error: str | None = None
+ try:
+ agent_result = agent.invoke(
+ {"messages": prior_messages},
+ config={"recursion_limit": recursion_limit},
+ )
+ agent_messages = agent_result["messages"]
+ except Exception as e:
+ # The agent can fail due to model tool-call format errors or transient API
+ # issues. We preserve whatever partial state was accumulated in ctx and
+ # surface the error so the pipeline can still assemble partial artifacts.
+ agent_error = str(e)
+
+ partial = {
+ "orchestrator_messages": agent_messages,
"code_files": ctx.code_files,
"test_files": ctx.test_files,
"task_list": ctx.task_list,
@@ -307,5 +334,8 @@ def orchestrator_node(state: AgentState) -> dict[str, Any]:
"orchestrator_iterations": state.get("orchestrator_iterations", 0) + 1,
"current_phase": "assembly",
}
+ if agent_error:
+ partial["pipeline_errors"] = [f"Orchestrator agent error: {agent_error}"]
+ return partial
return orchestrator_node
diff --git a/backend/app/agents/agent3/nodes/parse_input.py b/backend/app/agents/agent3/nodes/parse_input.py
index 214fc52..d26a495 100644
--- a/backend/app/agents/agent3/nodes/parse_input.py
+++ b/backend/app/agents/agent3/nodes/parse_input.py
@@ -141,9 +141,15 @@ def parse_input_node(state: AgentState) -> dict[str, Any]:
)
)
- # Build task_list: one code_gen + one test_gen per service
+ # Build task_list: one code_gen + one test_gen per service that has application code.
+ # Pure-infrastructure services (s3, rds, dynamodb, vpc, etc.) that are not in
+ # SERVICE_LANGUAGE_MAP and have no explicit language override are skipped — they
+ # produce only Terraform, not application code.
task_list: list[TaskItem] = []
for svc in services:
+ has_code = svc["service_type"] in SERVICE_LANGUAGE_MAP or svc["id"] in lang_overrides
+ if not has_code:
+ continue
lang = _infer_language(svc["service_type"], lang_overrides, svc["id"])
task_list.append(
TaskItem(
diff --git a/backend/app/agents/agent3/prompts/templates/orchestrator_system.j2 b/backend/app/agents/agent3/prompts/templates/orchestrator_system.j2
index 9da2e3f..86936ca 100644
--- a/backend/app/agents/agent3/prompts/templates/orchestrator_system.j2
+++ b/backend/app/agents/agent3/prompts/templates/orchestrator_system.j2
@@ -11,19 +11,19 @@ The infrastructure has been provisioned via Terraform. Generated files:
- {{ filename }}
{% endfor %}
-## How to Work (follow this exactly)
+## How to Work
-1. Call `get_pending_tasks()` to see all pending code generation and test generation tasks
-2. For each pending **code_gen** task:
- a. Call `generate_service_code(service_id=..., language=...)`
- b. On success, call `mark_task_done(task_id=...)`
-3. After completing code for a service, generate its tests:
- a. Call `generate_service_tests(service_id=..., language=...)`
- b. On success, call `mark_task_done(task_id=...)`
-4. Repeat until `get_pending_tasks()` returns an empty list
+1. Use the `get_pending_tasks` tool to retrieve the full list of pending work
+2. For each service with pending tasks, follow this strict order:
+ a. Use `generate_service_code` with the service_id and language from the task — this generates and syntax-validates the handler code
+ b. Only after code generation succeeds: use `generate_service_tests` with the same service_id and language — this generates unit tests for the handler
+3. Both tools automatically mark their task as done on success — do not call `mark_task_done` separately
+4. After completing each service, use `get_pending_tasks` again to check remaining work
+5. Stop when `get_pending_tasks` returns an empty list
## Rules
-- Always complete **code_gen before test_gen** for each service
-- If `generate_service_code` fails, log the error, mark the task failed, and move to the next service — do not retry more than once
-- Prioritize services that other services depend on (check `get_architecture_summary()` for dependency order)
+- Never use `generate_service_tests` before `generate_service_code` has succeeded for the same service — the tool enforces this and will return an error
+- If a tool returns a FAILED or ERROR result, record the failure and move on to the next service
+- Prioritize services that others depend on — check the architecture summary for dependency order
- When all tasks are done or failed, stop — do not generate extra files or improvise
+- `mark_task_done` is only for manually resolving stuck tasks, not for normal workflow
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 83dbb08..aa3c64a 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -21,6 +21,7 @@ dependencies = [
"checkov>=3.2.0",
"jsonschema>=4.23.0",
"jinja2>=3.1.4",
+ "langchain-groq>=1.1.2",
]
[project.optional-dependencies]
diff --git a/backend/tests/test_agent3_full.py b/backend/tests/test_agent3_full.py
new file mode 100644
index 0000000..a452474
--- /dev/null
+++ b/backend/tests/test_agent3_full.py
@@ -0,0 +1,591 @@
+"""
+Agent3 full-flow integration tests.
+Run: .venv/Scripts/python tests/test_agent3_full.py
+"""
+from __future__ import annotations
+
+import json
+import os
+import sys
+import textwrap
+import time
+from pathlib import Path
+from typing import Any
+
+# ---------------------------------------------------------------------------
+# Bootstrap: load .env before importing anything that touches the LLM
+# ---------------------------------------------------------------------------
+
+_BACKEND_DIR = Path(__file__).parent.parent
+sys.path.insert(0, str(_BACKEND_DIR))
+
+from dotenv import load_dotenv
+load_dotenv(_BACKEND_DIR / ".env")
+
+# ---------------------------------------------------------------------------
+# Now it's safe to import project modules
+# ---------------------------------------------------------------------------
+
+from app.agents.agent3.graph import compile_graph # noqa: E402
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+PASS = "[ok]"
+FAIL = "[!!]"
+INFO = " -"
+WARN = " !"
+SEP = "-" * 72
+
+
+def _hdr(title: str) -> None:
+ print(f"\n{SEP}\n {title}\n{SEP}")
+
+
+def _ok(msg: str) -> None:
+ print(f" {PASS} {msg}")
+
+
+def _err(msg: str) -> None:
+ print(f" {FAIL} {msg}")
+
+
+def _info(msg: str) -> None:
+ print(f" {INFO} {msg}")
+
+
+def _warn(msg: str) -> None:
+ print(f" {WARN} {msg}")
+
+
+def _dump_artifact(name: str, content: str, max_lines: int = 30) -> None:
+ lines = content.splitlines()
+ shown = lines[:max_lines]
+ print(f"\n [{name}] ({len(lines)} lines)")
+ for ln in shown:
+ print(f" {ln}")
+ if len(lines) > max_lines:
+ print(f" ... +{len(lines) - max_lines} more lines")
+
+
+def _assert(cond: bool, msg: str) -> bool:
+ if cond:
+ _ok(msg)
+ else:
+ _err(msg)
+ return cond
+
+
+def _rate_limited(result: dict) -> bool:
+ """Return True if the result was caused by a Groq quota/rate-limit error."""
+ errors = result.get("pipeline_errors") or []
+ return any(
+ "429" in str(e) or "rate_limit_exceeded" in str(e).lower()
+ for e in errors
+ )
+
+
+def _skip_if_rate_limited(result: dict) -> bool | None:
+ """
+ If the run was blocked by a rate limit, print a warning and return True (skip).
+ Returns None when no rate limit was detected so the caller can continue.
+ """
+ if _rate_limited(result):
+ errors = result.get("pipeline_errors") or []
+ _warn("Groq quota/rate-limit hit — scenario skipped (not a code failure)")
+ _info(f"Rate-limit error: {errors[0][:120]}..." if errors else "")
+ return True
+ return None
+
+
+def _build_state(topology: dict, *, fmt: str = "json", **overrides) -> dict[str, Any]:
+ import uuid as _uuid
+ raw = json.dumps(topology) if fmt == "json" else _to_yaml(topology)
+ base: dict[str, Any] = {
+ "thread_id": str(_uuid.uuid4()),
+ "raw_input": raw,
+ "input_format": fmt,
+ "language_overrides": {},
+ "tf_max_retries": 1,
+ "orchestrator_max_iterations": 10,
+ "tf_fix_attempts": 0,
+ "tf_validated": False,
+ "tf_files": {},
+ "tf_validation_results": [],
+ "tf_error_summary": None,
+ "services": [],
+ "connections": [],
+ "cloud_provider": "aws",
+ "task_list": [],
+ "orchestrator_messages": [],
+ "orchestrator_iterations": 0,
+ "code_files": {},
+ "test_files": {},
+ "code_errors": [],
+ "current_phase": "parsing",
+ "pipeline_errors": [],
+ "human_review_required": False,
+ "human_review_message": None,
+ "artifacts": {},
+ "generation_metadata": {},
+ }
+ base.update(overrides)
+ return base
+
+
+def _to_yaml(data: dict) -> str:
+ """Minimal dict → YAML converter (no external dep needed here)."""
+ import yaml
+ return yaml.dump(data, default_flow_style=False)
+
+
+def _run(graph, state: dict[str, Any]) -> dict[str, Any]:
+ """Invoke the graph synchronously and return final state."""
+ import uuid
+ thread_id = state.get("thread_id") or str(uuid.uuid4())
+ config = {"configurable": {"thread_id": thread_id}}
+ return graph.invoke(state, config=config)
+
+
+# ---------------------------------------------------------------------------
+# Test cases
+# ---------------------------------------------------------------------------
+
+
+def test_single_lambda(graph) -> bool:
+ _hdr("SCENARIO 1 — Single Lambda function (simplest full flow)")
+ topology = {
+ "services": [
+ {
+ "id": "fn1",
+ "service_type": "lambda",
+ "label": "ProcessOrders",
+ "config": {"runtime": "python3.12", "memory": 256, "timeout": 30},
+ }
+ ],
+ "connections": [],
+ }
+ t0 = time.time()
+ result = _run(graph, _build_state(topology))
+ elapsed = time.time() - t0
+
+ _info(f"Elapsed: {elapsed:.1f}s")
+ _info(f"Phase: {result.get('current_phase')}")
+ _info(f"TF validated: {result.get('tf_validated')}")
+ _info(f"Pipeline errors: {result.get('pipeline_errors') or []}")
+
+ if (skip := _skip_if_rate_limited(result)) is not None:
+ return skip
+
+ passed = True
+ passed &= _assert(result.get("current_phase") == "done", "phase == done")
+ passed &= _assert(bool(result.get("tf_files")), "tf_files non-empty")
+ passed &= _assert(bool(result.get("artifacts")), "artifacts non-empty")
+
+ artifacts = result.get("artifacts") or {}
+ tf_names = [k for k in artifacts if k.endswith(".tf")]
+ code_names = [k for k in artifacts if k.endswith(".py")]
+ test_names = [k for k in artifacts if "test_" in k]
+
+ passed &= _assert(len(tf_names) >= 1, f"at least 1 .tf file (got {tf_names})")
+ passed &= _assert(len(code_names) >= 1, f"at least 1 .py code file (got {code_names})")
+ passed &= _assert(len(test_names) >= 1, f"at least 1 test file (got {test_names})")
+
+ # Content quality checks
+ main_tf = artifacts.get("main.tf", "")
+ if main_tf:
+ passed &= _assert("resource" in main_tf.lower() or "module" in main_tf.lower(),
+ "main.tf contains terraform resource/module blocks")
+ passed &= _assert("aws_lambda" in main_tf.lower() or "lambda" in main_tf.lower(),
+ "main.tf references lambda")
+
+ handler = next((v for k, v in artifacts.items() if "handler.py" in k), "")
+ if handler:
+ passed &= _assert(len(handler.strip()) > 50, "handler.py has meaningful content")
+ _info(f"handler.py first line: {handler.splitlines()[0] if handler else '(empty)'}")
+
+ for name in sorted(artifacts):
+ _dump_artifact(name, artifacts[name], max_lines=20)
+
+ meta = result.get("generation_metadata") or {}
+ _info(f"Metadata: tasks_done={meta.get('tasks_done')}/{meta.get('tasks_total')}, "
+ f"tf_fix_attempts={meta.get('tf_fix_attempts')}")
+
+ return passed
+
+
+def test_multi_service_with_connections(graph) -> bool:
+ _hdr("SCENARIO 2 — Lambda + DynamoDB + API Gateway (connected topology)")
+ topology = {
+ "services": [
+ {
+ "id": "api",
+ "service_type": "api_gateway",
+ "label": "OrdersAPI",
+ "config": {"stage": "prod", "endpoint_type": "REGIONAL"},
+ },
+ {
+ "id": "processor",
+ "service_type": "lambda",
+ "label": "OrderProcessor",
+ "config": {"runtime": "python3.12", "memory": 512, "timeout": 60},
+ },
+ {
+ "id": "orders_db",
+ "service_type": "dynamodb",
+ "label": "OrdersTable",
+ "config": {"billing_mode": "PAY_PER_REQUEST", "hash_key": "order_id"},
+ },
+ ],
+ "connections": [
+ {"source": "api", "target": "processor", "relationship": "triggers"},
+ {"source": "processor", "target": "orders_db", "relationship": "reads_writes"},
+ ],
+ }
+ t0 = time.time()
+ result = _run(graph, _build_state(topology))
+ elapsed = time.time() - t0
+
+ _info(f"Elapsed: {elapsed:.1f}s")
+ _info(f"Phase: {result.get('current_phase')}")
+
+ if (skip := _skip_if_rate_limited(result)) is not None:
+ return skip
+
+ passed = True
+ artifacts = result.get("artifacts") or {}
+ passed &= _assert(result.get("current_phase") == "done", "phase == done")
+ passed &= _assert(len(artifacts) >= 3, f">=3 artifact files (got {len(artifacts)})")
+
+ tf_content = " ".join(v for k, v in artifacts.items() if k.endswith(".tf")).lower()
+ passed &= _assert("dynamodb" in tf_content, "TF covers DynamoDB")
+ passed &= _assert("lambda" in tf_content or "function" in tf_content, "TF covers Lambda")
+
+ # Check that connections are reflected in the code
+ code_content = " ".join(v for k, v in artifacts.items() if k.endswith(".py")).lower()
+ has_db_ref = any(w in code_content for w in ["dynamodb", "orders_db", "orders", "table"])
+ passed &= _assert(has_db_ref, "Python code references the DynamoDB table or orders")
+
+ meta = result.get("generation_metadata") or {}
+ # DynamoDB has no code tasks (infra-only), so total = 4 (api + processor)
+ passed &= _assert(
+ meta.get("tasks_total", 0) == 4,
+ f"exactly 4 code tasks (api + processor, not dynamodb): got {meta.get('tasks_total')}"
+ )
+ passed &= _assert(
+ meta.get("tasks_done", 0) == meta.get("tasks_total", 0),
+ f"all tasks done: {meta.get('tasks_done')}/{meta.get('tasks_total')}"
+ )
+ _info(f"tasks_done={meta.get('tasks_done')}/{meta.get('tasks_total')}")
+
+ for name in sorted(artifacts):
+ _dump_artifact(name, artifacts[name], max_lines=15)
+
+ return passed
+
+
+def test_yaml_input(graph) -> bool:
+ _hdr("SCENARIO 3 — YAML input format")
+ topology = {
+ "services": [
+ {
+ "id": "worker",
+ "service_type": "ecs",
+ "label": "BackgroundWorker",
+ "config": {"cpu": 256, "memory": 512},
+ }
+ ],
+ "connections": [],
+ }
+ state = _build_state(topology, fmt="yaml")
+ result = _run(graph, state)
+
+ if (skip := _skip_if_rate_limited(result)) is not None:
+ return skip
+
+ _info(f"Phase: {result.get('current_phase')}")
+ _info(f"TF validated: {result.get('tf_validated')}")
+ _info(f"Pipeline errors: {result.get('pipeline_errors') or []}")
+
+ passed = True
+ passed &= _assert(result.get("current_phase") == "done", "YAML input parses and runs to done")
+ passed &= _assert(bool(result.get("artifacts")), "artifacts present from YAML input")
+ _info(f"Artifact keys: {sorted(result.get('artifacts', {}).keys())}")
+ return passed
+
+
+def test_language_override(graph) -> bool:
+ _hdr("SCENARIO 4 — Per-service language override (lambda → TypeScript)")
+ topology = {
+ "services": [
+ {
+ "id": "ts_fn",
+ "service_type": "lambda",
+ "label": "TypeScriptHandler",
+ "config": {"runtime": "nodejs20.x", "memory": 256},
+ }
+ ],
+ "connections": [],
+ }
+ state = _build_state(topology, language_overrides={"ts_fn": "typescript"})
+ result = _run(graph, state)
+
+ if (skip := _skip_if_rate_limited(result)) is not None:
+ return skip
+
+ _info(f"Phase: {result.get('current_phase')}")
+ _info(f"TF validated: {result.get('tf_validated')}")
+ _info(f"Pipeline errors: {result.get('pipeline_errors') or []}")
+
+ passed = True
+ artifacts = result.get("artifacts") or {}
+ ts_files = [k for k in artifacts if k.endswith(".ts")]
+ py_files = [k for k in artifacts if k.endswith(".py") and "handler" in k]
+ passed &= _assert(result.get("current_phase") == "done", "phase == done")
+ passed &= _assert(len(ts_files) >= 1, f"at least 1 .ts file generated (got {ts_files})")
+ passed &= _assert(len(py_files) == 0, f"no .py handler files (TypeScript override respected, got {py_files})")
+ _info(f"TypeScript files: {ts_files}")
+ return passed
+
+
+def test_invalid_json_input(graph) -> bool:
+ _hdr("SCENARIO 5 — Invalid JSON input (error handling)")
+ state = _build_state({})
+ state["raw_input"] = "{ this is not valid json !!!"
+ result = _run(graph, state)
+
+ passed = True
+ passed &= _assert(result.get("current_phase") == "error", "phase == error for bad JSON")
+ errors = result.get("pipeline_errors") or []
+ passed &= _assert(len(errors) > 0, f"pipeline_errors populated: {errors}")
+ _info(f"Errors: {errors}")
+ return passed
+
+
+def test_empty_services(graph) -> bool:
+ _hdr("SCENARIO 6 — Empty services list (validation error)")
+ topology = {"services": [], "connections": []}
+ result = _run(graph, _build_state(topology))
+
+ passed = True
+ passed &= _assert(result.get("current_phase") == "error", "phase == error for empty services")
+ errors = result.get("pipeline_errors") or []
+ passed &= _assert(len(errors) > 0, f"pipeline_errors populated: {errors}")
+ _info(f"Errors: {errors}")
+ return passed
+
+
+def test_unknown_service_type_warning(graph) -> bool:
+ _hdr("SCENARIO 7 — Unknown service type (warning, not error)")
+ topology = {
+ "services": [
+ {
+ "id": "my_svc",
+ "service_type": "totally_unknown_thing",
+ "label": "MyService",
+ "config": {},
+ }
+ ],
+ "connections": [],
+ }
+ result = _run(graph, _build_state(topology))
+
+ if (skip := _skip_if_rate_limited(result)) is not None:
+ return skip
+
+ passed = True
+ # Should still run (unknown types are warned, not rejected)
+ passed &= _assert(
+ result.get("current_phase") in ("done", "error"),
+ f"pipeline runs to terminal phase (got {result.get('current_phase')})"
+ )
+ errors = result.get("pipeline_errors") or []
+ has_warning = any("unknown" in str(e).lower() or "type" in str(e).lower() for e in errors)
+ passed &= _assert(has_warning, f"warning about unknown type in pipeline_errors: {errors}")
+ _info(f"Pipeline errors/warnings: {errors}")
+ return passed
+
+
+def test_human_review_on_zero_retries(graph) -> bool:
+ _hdr("SCENARIO 8 — Human-in-the-loop (tf_max_retries=0)")
+ topology = {
+ "services": [
+ {
+ "id": "fn1",
+ "service_type": "lambda",
+ "label": "TestFunction",
+ "config": {"runtime": "python3.12", "memory": 128},
+ }
+ ],
+ "connections": [],
+ }
+ state = _build_state(topology, tf_max_retries=0)
+ try:
+ result = _run(graph, state)
+ # If the TF validator isn't installed, validation "passes" (tools skip gracefully)
+ # and the run completes normally. Check for either outcome.
+ phase = result.get("current_phase")
+ human_review = result.get("human_review_required", False)
+ if human_review:
+ passed = True
+ _ok(f"human_review_required=True (interrupt triggered as expected, phase={phase})")
+ else:
+ passed = True
+ _warn(
+ f"human_review_required=False (TF tools likely not installed, "
+ f"validation auto-passed — phase={phase})"
+ )
+ _info(f"tf_validated: {result.get('tf_validated')}")
+ _info(f"tf_fix_attempts: {result.get('tf_fix_attempts')}")
+ except Exception as e:
+ # GraphInterrupt surfaces as an exception in non-streaming invoke
+ if "interrupt" in str(e).lower() or "GraphInterrupt" in type(e).__name__:
+ passed = True
+ _ok(f"GraphInterrupt raised as expected: {type(e).__name__}")
+ else:
+ passed = False
+ _err(f"Unexpected exception: {type(e).__name__}: {e}")
+ return passed
+
+
+def test_multiple_services_no_code_gen(graph) -> bool:
+ _hdr("SCENARIO 9 — S3 + RDS only (services without code gen in language map)")
+ topology = {
+ "services": [
+ {
+ "id": "bucket1",
+ "service_type": "s3",
+ "label": "AssetsBucket",
+ "config": {"versioning": True, "encryption": "AES256"},
+ },
+ {
+ "id": "db1",
+ "service_type": "rds",
+ "label": "AppDatabase",
+ "config": {
+ "engine": "postgres",
+ "instance_class": "db.t3.micro",
+ "storage": 20,
+ },
+ },
+ ],
+ "connections": [
+ {"source": "bucket1", "target": "db1", "relationship": "backup_target"}
+ ],
+ }
+ result = _run(graph, _build_state(topology))
+
+ if (skip := _skip_if_rate_limited(result)) is not None:
+ return skip
+
+ passed = True
+ artifacts = result.get("artifacts") or {}
+ passed &= _assert(result.get("current_phase") == "done", "phase == done")
+ tf_files = [k for k in artifacts if k.endswith(".tf")]
+ passed &= _assert(len(tf_files) >= 1, f"TF files generated for infra-only services: {tf_files}")
+ task_list = result.get("task_list") or []
+ passed &= _assert(len(task_list) == 0, f"no code tasks for infra-only services (got {len(task_list)})")
+
+ tf_content = " ".join(artifacts.get(k, "") for k in tf_files).lower()
+ passed &= _assert("s3" in tf_content or "bucket" in tf_content, "TF covers S3")
+ passed &= _assert("rds" in tf_content or "db_instance" in tf_content or "postgres" in tf_content, "TF covers RDS")
+
+ # After the fix, infra-only services have no code tasks at all
+ code_files = [k for k in artifacts if k.endswith(".py") or k.endswith(".ts")]
+ passed &= _assert(len(code_files) == 0, f"no code files for infra-only services (got {code_files})")
+ meta = result.get("generation_metadata") or {}
+ _info(f"Artifact keys: {sorted(artifacts.keys())}")
+ _info(f"tasks_done={meta.get('tasks_done')}/{meta.get('tasks_total')}")
+ return passed
+
+
+def test_parse_only_smoke(graph) -> bool:
+ _hdr("SCENARIO 10 — Parse + task list smoke check")
+ topology = {
+ "services": [
+ {"id": "a", "service_type": "lambda", "label": "A", "config": {}},
+ {"id": "b", "service_type": "lambda", "label": "B", "config": {}},
+ ],
+ "connections": [{"source": "a", "target": "b", "relationship": "calls"}],
+ }
+ result = _run(graph, _build_state(topology))
+
+ if (skip := _skip_if_rate_limited(result)) is not None:
+ return skip
+
+ passed = True
+ task_list = result.get("task_list") or []
+ # 2 services × 2 task types = 4 tasks
+ passed &= _assert(len(task_list) == 4, f"task_list has 4 entries (got {len(task_list)})")
+ task_types = {t["task_type"] for t in task_list}
+ passed &= _assert(task_types == {"code_gen", "test_gen"}, f"both task types present: {task_types}")
+ done_count = sum(1 for t in task_list if t["status"] == "done")
+ passed &= _assert(done_count >= 2, f"at least 2 tasks completed (got {done_count})")
+ _info(f"Task statuses: {[(t['service_id'], t['task_type'], t['status']) for t in task_list]}")
+ return passed
+
+
+# ---------------------------------------------------------------------------
+# Runner
+# ---------------------------------------------------------------------------
+
+
+def main() -> None:
+ print("\n" + "=" * 72)
+ print(" AGENT3 FULL-FLOW INTEGRATION TEST SUITE")
+ print(" Model: llama-3.3-70b-versatile (Groq)")
+ print("=" * 72)
+
+ # Compile graph once — reused across all tests
+ print("\nCompiling graph...")
+ t_compile = time.time()
+ graph = compile_graph()
+ print(f"Graph compiled in {time.time() - t_compile:.2f}s")
+
+ tests = [
+ # --- No LLM calls (always fast, no quota consumed) ---
+ test_invalid_json_input,
+ test_empty_services,
+ # --- TF generation only, no orchestrator (light quota) ---
+ test_unknown_service_type_warning,
+ test_multiple_services_no_code_gen,
+ test_human_review_on_zero_retries,
+ # --- Full pipelines, single service (medium quota) ---
+ test_yaml_input,
+ test_language_override,
+ test_single_lambda,
+ # --- Full pipelines, multiple services (heavier quota) ---
+ test_parse_only_smoke,
+ test_multi_service_with_connections, # heaviest — always last
+ ]
+
+ results: list[tuple[str, bool]] = []
+ for fn in tests:
+ try:
+ ok = fn(graph)
+ except Exception as exc:
+ _err(f"UNHANDLED EXCEPTION in {fn.__name__}: {exc}")
+ import traceback
+ traceback.print_exc()
+ ok = False
+ results.append((fn.__name__, ok))
+
+ # Summary
+ print(f"\n{'=' * 72}")
+ print(" SUMMARY")
+ print("=" * 72)
+ for name, ok in results:
+ status = "[PASS]" if ok else "[FAIL]"
+ print(f" {status} {name}")
+
+ total = len(results)
+ passed_count = sum(1 for _, ok in results if ok)
+ print(f"\n {passed_count}/{total} scenarios passed")
+ print("=" * 72 + "\n")
+
+ sys.exit(0 if passed_count == total else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/uv.lock b/backend/uv.lock
index 891bf4c..f108c61 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -233,6 +233,7 @@ dependencies = [
{ name = "langchain" },
{ name = "langchain-anthropic" },
{ name = "langchain-community" },
+ { name = "langchain-groq" },
{ name = "langgraph" },
{ name = "langgraph-checkpoint-sqlite" },
{ name = "langgraph-cli" },
@@ -261,6 +262,7 @@ requires-dist = [
{ name = "langchain", specifier = ">=1.2.13" },
{ name = "langchain-anthropic", specifier = ">=1.4.0" },
{ name = "langchain-community", specifier = ">=0.4.1" },
+ { name = "langchain-groq", specifier = ">=1.1.2" },
{ name = "langgraph", specifier = ">=1.1.3" },
{ name = "langgraph-checkpoint-sqlite", specifier = ">=2.0.0" },
{ name = "langgraph-cli", specifier = ">=0.4.19" },
@@ -919,6 +921,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]
+[[package]]
+name = "groq"
+version = "0.37.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/78/18948a9056e1509c87e10ab8316a90ecce87035fbd53342dffdf97f4de00/groq-0.37.1.tar.gz", hash = "sha256:7353d6dfb60834fd7aacbb86af106e2dc2aeaff6d0edd65fb2fd0f16bd39314c", size = 145289, upload-time = "2025-12-04T18:08:07.118Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5f/d6/645a081750e43f858b7d09dce5d8e1e76cf11e7e4bdba81252e04f78963d/groq-0.37.1-py3-none-any.whl", hash = "sha256:b49f8c8898c55eaec9f71f1342f3fcacc9560d67a08ce5f35fbfb84e8dacd3da", size = 137494, upload-time = "2025-12-04T18:08:05.801Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -1261,6 +1280,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/06/08c88ddd4d6766de4e6c43111ae8f3025df383d2a4379cb938fc571b49d4/langchain_core-1.2.20-py3-none-any.whl", hash = "sha256:b65ff678f3c3dc1f1b4d03a3af5ee3b8d51f9be5181d74eb53c6c11cd9dd5e68", size = 504215, upload-time = "2026-03-18T17:34:44.087Z" },
]
+[[package]]
+name = "langchain-groq"
+version = "1.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "groq" },
+ { name = "langchain-core" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/d9/bbaa43598fcaffb669c5aea4088d92c77a426c46d25a013c21c979772a54/langchain_groq-1.1.2.tar.gz", hash = "sha256:67d1d752fb6590be517735947ec49b4ab9ed9191c7cca79f105d227775c67ae5", size = 178337, upload-time = "2026-02-02T15:57:29.435Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/11/71a35db3ed8ac2c7129eb69f0d590e4961be67ac84b6c470fc097d0dd7c8/langchain_groq-1.1.2-py3-none-any.whl", hash = "sha256:1f59f12233e8e6280c968bca6c40a7e5434e971e9a5387ad23c4c64ec776de10", size = 19450, upload-time = "2026-02-02T15:57:28.6Z" },
+]
+
[[package]]
name = "langchain-text-splitters"
version = "1.1.1"
From a91d311dc3bfc7e9ad0a8382cb24ba1d821df0fe Mon Sep 17 00:00:00 2001
From: ak-asu
Date: Sat, 21 Mar 2026 15:56:33 -0700
Subject: [PATCH 03/50] groq to ollama
---
backend/app/agents/agent3/config.py | 4 +--
backend/app/agents/agent3/llm.py | 19 +++++--------
backend/pyproject.toml | 2 +-
backend/uv.lock | 44 +++++++++++++----------------
4 files changed, 30 insertions(+), 39 deletions(-)
diff --git a/backend/app/agents/agent3/config.py b/backend/app/agents/agent3/config.py
index 2575be6..a47e166 100644
--- a/backend/app/agents/agent3/config.py
+++ b/backend/app/agents/agent3/config.py
@@ -4,8 +4,8 @@
# LLM
# ---------------------------------------------------------------------------
-DEFAULT_MODEL = "llama-3.1-8b-instant" # Groq: strong model with tool-use support
-FAST_MODEL = "llama-3.1-8b-instant" # Groq: fast model for lighter tasks like code fixing
+DEFAULT_MODEL = "qwen3.5" # Ollama: strong model with tool-use support
+FAST_MODEL = "qwen3.5" # Ollama: fast model for lighter tasks like code fixing
# ---------------------------------------------------------------------------
# Retry / iteration limits
diff --git a/backend/app/agents/agent3/llm.py b/backend/app/agents/agent3/llm.py
index 3cd0fa1..6891aac 100644
--- a/backend/app/agents/agent3/llm.py
+++ b/backend/app/agents/agent3/llm.py
@@ -2,38 +2,33 @@
import threading
-from langchain_groq import ChatGroq
+from langchain_ollama import ChatOllama
from app.agents.agent3.config import DEFAULT_MODEL, FAST_MODEL
_lock = threading.Lock()
-_clients: dict[str, ChatGroq] = {}
+_clients: dict[str, ChatOllama] = {}
-def get_llm(model: str = DEFAULT_MODEL, temperature: float = 0.0) -> ChatGroq:
+def get_llm(model: str = DEFAULT_MODEL, temperature: float = 0.0) -> ChatOllama:
"""
- Return a module-level singleton ChatGroq client for a given (model, temperature) pair.
+ Return a module-level singleton ChatOllama client for a given (model, temperature) pair.
Thread-safe; avoids re-creating clients on every node invocation.
-
- parallel_tool_calls=False prevents models from generating multiple tool calls in a
- single response turn, which causes format errors on models not well-calibrated for
- parallel tool-call JSON output.
"""
key = f"{model}:{temperature}"
if key not in _clients:
with _lock:
if key not in _clients:
- _clients[key] = ChatGroq(
+ _clients[key] = ChatOllama(
model=model,
temperature=temperature,
- model_kwargs={"parallel_tool_calls": False},
)
return _clients[key]
-def get_default_llm() -> ChatGroq:
+def get_default_llm() -> ChatOllama:
return get_llm(DEFAULT_MODEL, 0.0)
-def get_fast_llm() -> ChatGroq:
+def get_fast_llm() -> ChatOllama:
return get_llm(FAST_MODEL, 0.0)
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index aa3c64a..301da18 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -21,7 +21,7 @@ dependencies = [
"checkov>=3.2.0",
"jsonschema>=4.23.0",
"jinja2>=3.1.4",
- "langchain-groq>=1.1.2",
+ "langchain-ollama>=0.3.0",
]
[project.optional-dependencies]
diff --git a/backend/uv.lock b/backend/uv.lock
index f108c61..7d9f20c 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -233,7 +233,7 @@ dependencies = [
{ name = "langchain" },
{ name = "langchain-anthropic" },
{ name = "langchain-community" },
- { name = "langchain-groq" },
+ { name = "langchain-ollama" },
{ name = "langgraph" },
{ name = "langgraph-checkpoint-sqlite" },
{ name = "langgraph-cli" },
@@ -262,7 +262,7 @@ requires-dist = [
{ name = "langchain", specifier = ">=1.2.13" },
{ name = "langchain-anthropic", specifier = ">=1.4.0" },
{ name = "langchain-community", specifier = ">=0.4.1" },
- { name = "langchain-groq", specifier = ">=1.1.2" },
+ { name = "langchain-ollama", specifier = ">=0.3.0" },
{ name = "langgraph", specifier = ">=1.1.3" },
{ name = "langgraph-checkpoint-sqlite", specifier = ">=2.0.0" },
{ name = "langgraph-cli", specifier = ">=0.4.19" },
@@ -921,23 +921,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]
-[[package]]
-name = "groq"
-version = "0.37.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "distro" },
- { name = "httpx" },
- { name = "pydantic" },
- { name = "sniffio" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e9/78/18948a9056e1509c87e10ab8316a90ecce87035fbd53342dffdf97f4de00/groq-0.37.1.tar.gz", hash = "sha256:7353d6dfb60834fd7aacbb86af106e2dc2aeaff6d0edd65fb2fd0f16bd39314c", size = 145289, upload-time = "2025-12-04T18:08:07.118Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5f/d6/645a081750e43f858b7d09dce5d8e1e76cf11e7e4bdba81252e04f78963d/groq-0.37.1-py3-none-any.whl", hash = "sha256:b49f8c8898c55eaec9f71f1342f3fcacc9560d67a08ce5f35fbfb84e8dacd3da", size = 137494, upload-time = "2025-12-04T18:08:05.801Z" },
-]
-
[[package]]
name = "h11"
version = "0.16.0"
@@ -1281,16 +1264,16 @@ wheels = [
]
[[package]]
-name = "langchain-groq"
-version = "1.1.2"
+name = "langchain-ollama"
+version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "groq" },
{ name = "langchain-core" },
+ { name = "ollama" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3d/d9/bbaa43598fcaffb669c5aea4088d92c77a426c46d25a013c21c979772a54/langchain_groq-1.1.2.tar.gz", hash = "sha256:67d1d752fb6590be517735947ec49b4ab9ed9191c7cca79f105d227775c67ae5", size = 178337, upload-time = "2026-02-02T15:57:29.435Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/51/72cd04d74278f3575f921084f34280e2f837211dc008c9671c268c578afe/langchain_ollama-1.0.1.tar.gz", hash = "sha256:e37880c2f41cdb0895e863b1cfd0c2c840a117868b3f32e44fef42569e367443", size = 153850, upload-time = "2025-12-12T21:48:28.68Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e8/11/71a35db3ed8ac2c7129eb69f0d590e4961be67ac84b6c470fc097d0dd7c8/langchain_groq-1.1.2-py3-none-any.whl", hash = "sha256:1f59f12233e8e6280c968bca6c40a7e5434e971e9a5387ad23c4c64ec776de10", size = 19450, upload-time = "2026-02-02T15:57:28.6Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/46/f2907da16dc5a5a6c679f83b7de21176178afad8d2ca635a581429580ef6/langchain_ollama-1.0.1-py3-none-any.whl", hash = "sha256:37eb939a4718a0255fe31e19fbb0def044746c717b01b97d397606ebc3e9b440", size = 29207, upload-time = "2025-12-12T21:48:27.832Z" },
]
[[package]]
@@ -1693,6 +1676,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
]
+[[package]]
+name = "ollama"
+version = "0.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+ { name = "pydantic" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" },
+]
+
[[package]]
name = "orjson"
version = "3.11.7"
From 37d3f48c9c2094bcba533f907d799948871a3ea6 Mon Sep 17 00:00:00 2001
From: ak-asu
Date: Sat, 21 Mar 2026 18:26:50 -0700
Subject: [PATCH 04/50] inconsistency resolves
---
backend/.env.example | 10 +
backend/app/agents/agent3/config.py | 9 +-
backend/app/agents/agent3/llm.py | 20 +-
backend/app/config.py | 10 +
backend/app/main.py | 4 +-
backend/app/routers/agent3.py | 2 +-
backend/app/routers/architecture.py | 331 ++++++++++++++++++++++++++
backend/app/routers/workflows.py | 6 +-
backend/app/schemas/architecture.py | 90 +++++++
backend/app/services/arch_sessions.py | 52 ++++
10 files changed, 516 insertions(+), 18 deletions(-)
create mode 100644 backend/app/routers/architecture.py
create mode 100644 backend/app/schemas/architecture.py
create mode 100644 backend/app/services/arch_sessions.py
diff --git a/backend/.env.example b/backend/.env.example
index 866ee91..276dec0 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -10,3 +10,13 @@ LLM_TIMEOUT_SECONDS=90
ENABLE_WEB_SEARCH=true
MAX_CLARIFICATION_ROUNDS=3
MAX_RESEARCH_ROUNDS=3
+
+# Agent 2 — Architecture Planner
+# Use "anthropic" + set ANTHROPIC_API_KEY for Claude; defaults to local Ollama.
+ARCH_MODEL_TYPE=ollama
+ARCH_MODEL_NAME=llama3.1:8b
+# ANTHROPIC_API_KEY=sk-ant-...
+
+# Agent 3 — Terraform / Code Generator (always Ollama)
+AGENT3_MODEL=qwen3.5
+AGENT3_FAST_MODEL=qwen3.5
diff --git a/backend/app/agents/agent3/config.py b/backend/app/agents/agent3/config.py
index a47e166..06d4a68 100644
--- a/backend/app/agents/agent3/config.py
+++ b/backend/app/agents/agent3/config.py
@@ -1,11 +1,8 @@
from __future__ import annotations
-# ---------------------------------------------------------------------------
-# LLM
-# ---------------------------------------------------------------------------
-
-DEFAULT_MODEL = "qwen3.5" # Ollama: strong model with tool-use support
-FAST_MODEL = "qwen3.5" # Ollama: fast model for lighter tasks like code fixing
+# NOTE: LLM model names are configured via settings.agent3_model /
+# settings.agent3_fast_model (app/config.py) so they can be
+# overridden through the .env file without touching source code.
# ---------------------------------------------------------------------------
# Retry / iteration limits
diff --git a/backend/app/agents/agent3/llm.py b/backend/app/agents/agent3/llm.py
index 6891aac..7b7d6e6 100644
--- a/backend/app/agents/agent3/llm.py
+++ b/backend/app/agents/agent3/llm.py
@@ -4,31 +4,37 @@
from langchain_ollama import ChatOllama
-from app.agents.agent3.config import DEFAULT_MODEL, FAST_MODEL
+from app.config import settings
_lock = threading.Lock()
_clients: dict[str, ChatOllama] = {}
-def get_llm(model: str = DEFAULT_MODEL, temperature: float = 0.0) -> ChatOllama:
+def get_llm(model: str | None = None, temperature: float = 0.0) -> ChatOllama:
"""
Return a module-level singleton ChatOllama client for a given (model, temperature) pair.
- Thread-safe; avoids re-creating clients on every node invocation.
+ Model defaults to settings.agent3_model when not specified.
+ Thread-safe double-checked locking avoids re-creating clients on every node invocation.
"""
- key = f"{model}:{temperature}"
+ resolved_model = model or settings.agent3_model
+ key = f"{resolved_model}:{temperature}"
if key not in _clients:
with _lock:
if key not in _clients:
_clients[key] = ChatOllama(
- model=model,
+ model=resolved_model,
+ base_url=settings.ollama_base_url,
temperature=temperature,
+ request_timeout=settings.llm_timeout_seconds,
)
return _clients[key]
def get_default_llm() -> ChatOllama:
- return get_llm(DEFAULT_MODEL, 0.0)
+ """Return the primary LLM for heavy tasks (model = settings.agent3_model)."""
+ return get_llm(settings.agent3_model, 0.0)
def get_fast_llm() -> ChatOllama:
- return get_llm(FAST_MODEL, 0.0)
+ """Return the LLM for lighter tasks (model = settings.agent3_fast_model)."""
+ return get_llm(settings.agent3_fast_model, 0.0)
diff --git a/backend/app/config.py b/backend/app/config.py
index 0001c49..5cb87cd 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -15,6 +15,16 @@ class Settings(BaseSettings):
max_clarification_rounds: int = 6
max_research_rounds: int = 3
+ # Agent 2 — Architecture Planner LLM
+ # Set arch_model_type="anthropic" and ANTHROPIC_API_KEY in .env for Claude.
+ # Defaults to local Ollama so the server starts without any extra API keys.
+ arch_model_type: str = "ollama"
+ arch_model_name: str = "llama3.1:8b"
+
+ # Agent 3 — Terraform / Code Generator LLM (always Ollama)
+ agent3_model: str = "qwen3.5" # primary model for heavy tasks
+ agent3_fast_model: str = "qwen3.5" # lighter tasks (code fixing, test gen)
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
diff --git a/backend/app/main.py b/backend/app/main.py
index 98df4b4..dbc7393 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,6 +1,7 @@
from fastapi import FastAPI
from app.config import settings
from app.routers import agent3, health, workflows
+from app.routers.architecture import router as architecture_router
app = FastAPI(
title=settings.app_name,
@@ -8,8 +9,9 @@
)
app.include_router(health.router)
-app.include_router(agent3.router)
app.include_router(workflows.router)
+app.include_router(architecture_router)
+app.include_router(agent3.router)
@app.get("/")
diff --git a/backend/app/routers/agent3.py b/backend/app/routers/agent3.py
index 6199692..241fc8c 100644
--- a/backend/app/routers/agent3.py
+++ b/backend/app/routers/agent3.py
@@ -15,7 +15,7 @@
get_graph,
)
-router = APIRouter(prefix="/agent3", tags=["agent3"])
+router = APIRouter(prefix="/workflows/agent3", tags=["agent3"])
# Nodes whose start/end events are surfaced as SSE progress messages
_PROGRESS_NODES = frozenset({
diff --git a/backend/app/routers/architecture.py b/backend/app/routers/architecture.py
new file mode 100644
index 0000000..3df54d9
--- /dev/null
+++ b/backend/app/routers/architecture.py
@@ -0,0 +1,331 @@
+from __future__ import annotations
+
+import logging
+import os
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+from langgraph.types import Command
+
+from app.agents.architecture_planner import create_graph, make_initial_state
+from app.agents.agent1.state import AgentState
+from app.config import settings
+from app.schemas.architecture import (
+ ArchWorkflowResponse,
+ ClarifyingQuestionSchema,
+ RespondArchWorkflowRequest,
+ ReviewArchWorkflowRequest,
+ StartArchWorkflowRequest,
+)
+from app.services.arch_sessions import arch_session_store
+from app.services.workflow_sessions import session_store
+
+router = APIRouter(prefix="/workflows/architecture", tags=["architecture"])
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Data paths — resolved relative to THIS file so they work regardless of CWD.
+# layout: backend/app/routers/architecture.py
+# backend/app/agents/data/graph/{graph,community_summaries}.json
+# ---------------------------------------------------------------------------
+_APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_GRAPH_JSON = os.path.join(_APP_DIR, "agents", "data", "graph", "graph.json")
+_SUMMARIES_JSON = os.path.join(_APP_DIR, "agents", "data", "graph", "community_summaries.json")
+
+# cloud_provider normalisation: Agent 1 stores lowercase ("aws"),
+# Agent 2 prompts expect upper-case ("AWS", "GCP", "Azure").
+_PROVIDER_MAP: dict[str, str] = {"aws": "AWS", "gcp": "GCP", "azure": "Azure"}
+
+# ---------------------------------------------------------------------------
+# Singleton compiled graph — created once per process; state is per thread_id
+# ---------------------------------------------------------------------------
+_arch_graph = None
+
+
+def _get_arch_graph():
+ global _arch_graph
+ if _arch_graph is None:
+ _arch_graph = create_graph(
+ model_type=settings.arch_model_type,
+ model_name=settings.arch_model_name,
+ graph_json_path=_GRAPH_JSON,
+ community_summaries_path=_SUMMARIES_JSON,
+ )
+ return _arch_graph
+
+
+# ---------------------------------------------------------------------------
+# Interrupt / state helpers
+# ---------------------------------------------------------------------------
+
+
+def _detect_interrupt(graph, config: dict) -> tuple[str | None, Any]:
+ """
+ Return (interrupt_type, interrupt_payload) when the graph is paused at an
+ interrupt(), or (None, None) when the graph has completed.
+
+ interrupt_type values:
+ "questions" — info_gathering subgraph waiting for user answers
+ "review" — accept subgraph waiting for architecture approval
+ "unknown" — interrupt with unrecognised payload shape
+ """
+ try:
+ snapshot = graph.get_state(config)
+ except Exception:
+ return None, None
+
+ if not snapshot or not snapshot.next:
+ return None, None # graph completed normally
+
+ interrupts: list = []
+ for task in snapshot.tasks:
+ interrupts.extend(getattr(task, "interrupts", []))
+
+ if not interrupts:
+ return None, None
+
+ payload = interrupts[0].value
+ if isinstance(payload, dict):
+ if "questions" in payload:
+ return "questions", payload
+ if "summary" in payload:
+ return "review", payload
+
+ return "unknown", payload
+
+
+def _current_state_values(graph, config: dict) -> dict[str, Any]:
+ """Return the latest values snapshot from the MemorySaver checkpointer."""
+ try:
+ snapshot = graph.get_state(config)
+ return snapshot.values if snapshot else {}
+ except Exception:
+ return {}
+
+
+def _serialize_diagram(diagram: Any) -> dict[str, Any] | None:
+ if diagram is None:
+ return None
+ if hasattr(diagram, "model_dump"):
+ return diagram.model_dump(by_alias=True)
+ if isinstance(diagram, dict):
+ return diagram
+ return None
+
+
+def _serialize_gaps(gaps: Any) -> list[dict[str, Any]]:
+ result: list[dict[str, Any]] = []
+ for g in gaps or []:
+ if hasattr(g, "model_dump"):
+ result.append(g.model_dump())
+ elif isinstance(g, dict):
+ result.append(g)
+ return result
+
+
+def _build_response(session_id: str, graph, config: dict) -> ArchWorkflowResponse:
+ """Inspect the graph's current checkpoint and return the appropriate response."""
+ interrupt_type, interrupt_payload = _detect_interrupt(graph, config)
+
+ # ── Graph paused for clarifying questions ────────────────────────────────
+ if interrupt_type == "questions":
+ questions = [
+ ClarifyingQuestionSchema(**q)
+ for q in interrupt_payload.get("questions", [])
+ ]
+ arch_session_store.update(
+ session_id,
+ status="needs_clarification",
+ interrupt_type="questions",
+ interrupt_payload=interrupt_payload,
+ )
+ return ArchWorkflowResponse(
+ session_id=session_id,
+ status="needs_clarification",
+ clarifying_questions=questions,
+ )
+
+ # ── Graph paused for architecture review ─────────────────────────────────
+ if interrupt_type == "review":
+ state = _current_state_values(graph, config)
+ arch_session_store.update(
+ session_id,
+ status="review_ready",
+ interrupt_type="review",
+ interrupt_payload=interrupt_payload,
+ )
+ return ArchWorkflowResponse(
+ session_id=session_id,
+ status="review_ready",
+ architecture_diagram=_serialize_diagram(state.get("architecture_diagram")),
+ nfr_document=state.get("nfr_document"),
+ component_responsibilities=state.get("component_responsibilities"),
+ extra_context=state.get("extra_context"),
+ eval_score=state.get("eval_score"),
+ eval_feedback=state.get("eval_feedback"),
+ compliance_gaps=_serialize_gaps(state.get("compliance_gaps")),
+ error_message=state.get("error_message"),
+ )
+
+ # ── Graph completed (or unknown interrupt) ───────────────────────────────
+ state = _current_state_values(graph, config)
+ arch_session_store.update(session_id, status="accepted")
+ return ArchWorkflowResponse(
+ session_id=session_id,
+ status="accepted",
+ architecture_diagram=_serialize_diagram(state.get("architecture_diagram")),
+ nfr_document=state.get("nfr_document"),
+ component_responsibilities=state.get("component_responsibilities"),
+ extra_context=state.get("extra_context"),
+ eval_score=state.get("eval_score"),
+ eval_feedback=state.get("eval_feedback"),
+ compliance_gaps=_serialize_gaps(state.get("compliance_gaps")),
+ error_message=state.get("error_message"),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+
+@router.post("/start", response_model=ArchWorkflowResponse)
+async def start_arch_workflow(payload: StartArchWorkflowRequest) -> ArchWorkflowResponse:
+ """
+ Start the Architecture Planner (Agent 2) using an accepted PRD from Agent 1.
+
+ Prerequisite flow:
+ POST /workflows/prd/start → session_id
+ POST /workflows/prd/respond (repeat until plan_ready)
+ POST /workflows/prd/accept accepted=true → use that session_id here
+ """
+ # ── Validate Agent 1 session ─────────────────────────────────────────────
+ prd_data = session_store.get(payload.prd_session_id)
+ if not prd_data:
+ raise HTTPException(status_code=404, detail="PRD session not found.")
+
+ prd_state = AgentState.model_validate(prd_data)
+ if prd_state.status != "accepted":
+ raise HTTPException(
+ status_code=409,
+ detail=(
+ f"PRD is not accepted yet (current status: '{prd_state.status}'). "
+ "Accept the PRD via POST /workflows/prd/accept before starting "
+ "architecture planning."
+ ),
+ )
+ if not prd_state.plan_markdown:
+ raise HTTPException(
+ status_code=409,
+ detail="The accepted PRD has no plan content. Re-run the PRD workflow.",
+ )
+
+ # ── Normalise cloud_provider casing ──────────────────────────────────────
+ cloud_provider = _PROVIDER_MAP.get(
+ prd_state.cloud_provider.lower(), prd_state.cloud_provider.upper()
+ )
+
+ # ── Create arch session (session_id doubles as the LangGraph thread_id) ──
+ session_id = arch_session_store.create(payload.prd_session_id)
+ config = {"configurable": {"thread_id": session_id}}
+
+ initial_state = make_initial_state(
+ budget=payload.budget,
+ traffic=payload.traffic,
+ availability=payload.availability,
+ prd=prd_state.plan_markdown,
+ cloud_provider=cloud_provider,
+ )
+
+ # ── Run Agent 2 until first interrupt or completion ───────────────────────
+ graph = _get_arch_graph()
+ try:
+ await graph.ainvoke(initial_state, config=config)
+ except Exception as exc:
+ logger.error("Architecture start error [%s]: %s", session_id, exc, exc_info=True)
+ arch_session_store.update(session_id, status="error")
+ return ArchWorkflowResponse(
+ session_id=session_id,
+ status="error",
+ error_message=str(exc),
+ )
+
+ return _build_response(session_id, graph, config)
+
+
+@router.post("/respond", response_model=ArchWorkflowResponse)
+async def respond_arch_workflow(payload: RespondArchWorkflowRequest) -> ArchWorkflowResponse:
+ """
+ Supply answers to the architecture planner's clarifying questions.
+ Only valid when the session status is 'needs_clarification'.
+ """
+ session = arch_session_store.get(payload.session_id)
+ if not session:
+ raise HTTPException(status_code=404, detail="Architecture session not found.")
+
+ if session["status"] != "needs_clarification":
+ raise HTTPException(
+ status_code=409,
+ detail=(
+ f"Session is not awaiting clarification "
+ f"(current status: '{session['status']}')."
+ ),
+ )
+
+ config = {"configurable": {"thread_id": session["thread_id"]}}
+ graph = _get_arch_graph()
+
+ try:
+ await graph.ainvoke(Command(resume=payload.answers), config=config)
+ except Exception as exc:
+ logger.error("Architecture respond error [%s]: %s", payload.session_id, exc, exc_info=True)
+ arch_session_store.update(payload.session_id, status="error")
+ return ArchWorkflowResponse(
+ session_id=payload.session_id,
+ status="error",
+ error_message=str(exc),
+ )
+
+ return _build_response(payload.session_id, graph, config)
+
+
+@router.post("/review", response_model=ArchWorkflowResponse)
+async def review_arch_workflow(payload: ReviewArchWorkflowRequest) -> ArchWorkflowResponse:
+ """
+ Accept or request changes to the architecture diagram.
+ Only valid when the session status is 'review_ready'.
+
+ - accepted=true → finalises the architecture (status → 'accepted')
+ - accepted=false → triggers another architecture iteration with your changes
+ """
+ session = arch_session_store.get(payload.session_id)
+ if not session:
+ raise HTTPException(status_code=404, detail="Architecture session not found.")
+
+ if session["status"] != "review_ready":
+ raise HTTPException(
+ status_code=409,
+ detail=(
+ f"Session is not awaiting review "
+ f"(current status: '{session['status']}')."
+ ),
+ )
+
+ config = {"configurable": {"thread_id": session["thread_id"]}}
+ graph = _get_arch_graph()
+
+ try:
+ await graph.ainvoke(
+ Command(resume={"accepted": payload.accepted, "changes": payload.changes}),
+ config=config,
+ )
+ except Exception as exc:
+ logger.error("Architecture review error [%s]: %s", payload.session_id, exc, exc_info=True)
+ arch_session_store.update(payload.session_id, status="error")
+ return ArchWorkflowResponse(
+ session_id=payload.session_id,
+ status="error",
+ error_message=str(exc),
+ )
+
+ return _build_response(payload.session_id, graph, config)
diff --git a/backend/app/routers/workflows.py b/backend/app/routers/workflows.py
index 9e3ec69..c532edc 100644
--- a/backend/app/routers/workflows.py
+++ b/backend/app/routers/workflows.py
@@ -6,6 +6,8 @@
from app.agents.agent1.state import AgentState
from app.schemas.workflow import (
AcceptWorkflowRequest,
+ QuestionOptionSchema,
+ QuestionWithOptionsSchema,
RespondWorkflowRequest,
StartWorkflowRequest,
WorkflowResponse,
@@ -19,9 +21,7 @@ def _to_response(state: AgentState) -> WorkflowResponse:
status = state.status
if status not in {"needs_input", "plan_ready", "accepted"}:
status = "needs_input"
-
- # Convert questions_with_options to schema
- from app.schemas.workflow import QuestionWithOptionsSchema, QuestionOptionSchema
+
questions_with_options = [
QuestionWithOptionsSchema(
question=q.question,
diff --git a/backend/app/schemas/architecture.py b/backend/app/schemas/architecture.py
new file mode 100644
index 0000000..4776c38
--- /dev/null
+++ b/backend/app/schemas/architecture.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+
+ArchWorkflowStatus = Literal["needs_clarification", "review_ready", "accepted", "error"]
+
+
+# ---------------------------------------------------------------------------
+# Request models
+# ---------------------------------------------------------------------------
+
+
+class StartArchWorkflowRequest(BaseModel):
+ """Start the architecture planner from an accepted PRD session."""
+
+ prd_session_id: str = Field(
+ description="session_id returned by POST /workflows/prd/accept"
+ )
+ budget: str = Field(
+ default="",
+ description="Budget constraint, e.g. '$500/month'. Leave blank to let the agent ask.",
+ )
+ traffic: str = Field(
+ default="",
+ description="Expected traffic, e.g. '10k requests/day'.",
+ )
+ availability: str = Field(
+ default="",
+ description="Availability target, e.g. '99.9% uptime'.",
+ )
+
+
+class RespondArchWorkflowRequest(BaseModel):
+ """Submit answers to the architecture planner's clarifying questions."""
+
+ session_id: str
+ answers: list[str] = Field(
+ default_factory=list,
+ description="One answer string per question, in the same order they were returned.",
+ )
+
+
+class ReviewArchWorkflowRequest(BaseModel):
+ """Accept or request changes to the generated architecture."""
+
+ session_id: str
+ accepted: bool
+ changes: str = Field(
+ default="",
+ description="Requested changes when accepted=False.",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Nested response sub-models
+# ---------------------------------------------------------------------------
+
+
+class ClarifyingQuestionSchema(BaseModel):
+ question: str
+ choices: list[str]
+ context: str
+
+
+# ---------------------------------------------------------------------------
+# Response model
+# ---------------------------------------------------------------------------
+
+
+class ArchWorkflowResponse(BaseModel):
+ session_id: str
+ status: ArchWorkflowStatus
+
+ # ── needs_clarification ──────────────────────────────────────────────────
+ clarifying_questions: list[ClarifyingQuestionSchema] = Field(default_factory=list)
+
+ # ── review_ready | accepted ──────────────────────────────────────────────
+ architecture_diagram: dict[str, Any] | None = None
+ nfr_document: str | None = None
+ component_responsibilities: str | None = None
+ extra_context: str | None = None
+ eval_score: float | None = None
+ eval_feedback: str | None = None
+ compliance_gaps: list[dict[str, Any]] = Field(default_factory=list)
+
+ # ── always present on error ──────────────────────────────────────────────
+ error_message: str | None = None
diff --git a/backend/app/services/arch_sessions.py b/backend/app/services/arch_sessions.py
new file mode 100644
index 0000000..460c4a1
--- /dev/null
+++ b/backend/app/services/arch_sessions.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from threading import Lock
+from typing import Any, Literal
+from uuid import uuid4
+
+
+ArchStatus = Literal["needs_clarification", "review_ready", "accepted", "error"]
+
+
+class ArchSessionStore:
+ """In-memory store that tracks Architecture Planner (Agent 2) sessions.
+
+ Each session record holds:
+ - arch_session_id : unique session identifier (also used as thread_id for
+ the LangGraph MemorySaver so checkpoints are isolated)
+ - prd_session_id : reference to the originating Agent 1 session
+ - status : current lifecycle state
+ - interrupt_type : "questions" | "review" | None (last interrupt kind)
+ - interrupt_payload: raw value passed to interrupt()
+ """
+
+ def __init__(self) -> None:
+ self._items: dict[str, dict[str, Any]] = {}
+ self._lock = Lock()
+
+ def create(self, prd_session_id: str) -> str:
+ """Allocate a new session; returns the arch_session_id (= thread_id)."""
+ arch_session_id = str(uuid4())
+ with self._lock:
+ self._items[arch_session_id] = {
+ "arch_session_id": arch_session_id,
+ "prd_session_id": prd_session_id,
+ "thread_id": arch_session_id,
+ "status": "running",
+ "interrupt_type": None,
+ "interrupt_payload": None,
+ }
+ return arch_session_id
+
+ def get(self, arch_session_id: str) -> dict[str, Any] | None:
+ with self._lock:
+ item = self._items.get(arch_session_id)
+ return dict(item) if item else None
+
+ def update(self, arch_session_id: str, **kwargs: Any) -> None:
+ with self._lock:
+ if arch_session_id in self._items:
+ self._items[arch_session_id].update(kwargs)
+
+
+arch_session_store = ArchSessionStore()
From 417a3b68a6beb5891c3ede30140d067ff535eeff Mon Sep 17 00:00:00 2001
From: Gunbir Singh
Date: Sun, 22 Mar 2026 00:19:35 -0700
Subject: [PATCH 05/50] fix: deduplicate configProps by key to prevent
duplicate React keys
ConfigPanel concatenated service defaults and node.config overrides
without deduplication. If an override used the same key as a default
(e.g. "Runtime"), the same key appeared twice in the list, causing
duplicate React keys and potential incorrect row updates. Now using
a Map so node-level overrides win and each key appears exactly once.
---
frontend/src/components/cloudforge/ArchDiagram.tsx | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx
index 761ccb0..3d27a5c 100644
--- a/frontend/src/components/cloudforge/ArchDiagram.tsx
+++ b/frontend/src/components/cloudforge/ArchDiagram.tsx
@@ -674,11 +674,14 @@ function ConfigPanel({ node, onClose }: ConfigPanelProps) {
const serviceConf = AWS_SERVICE_CONFIG[service];
const configData = SERVICE_CONFIG_DATA[service];
- // Merge node-level config overrides with defaults
- const configProps = [
- ...(configData?.configProps ?? []),
- ...Object.entries(node.config ?? {}).map(([key, value]) => ({ key, value })),
- ];
+ // Merge node-level config overrides with defaults; overrides win on duplicate keys
+ const configPropsMap = new Map(
+ (configData?.configProps ?? []).map(({ key, value }) => [key, value]),
+ );
+ for (const [key, value] of Object.entries(node.config ?? {})) {
+ configPropsMap.set(key, value);
+ }
+ const configProps = Array.from(configPropsMap, ([key, value]) => ({ key, value }));
const tierColors: Record = {
Serverless: '#01A88D',
From 9c0ee7eb89ab6ab05723b6e0eb8ce3da739bd811 Mon Sep 17 00:00:00 2001
From: Gunbir Singh
Date: Sun, 22 Mar 2026 00:19:56 -0700
Subject: [PATCH 06/50] fix: replace O(N*E) nodes.find in DiagramEdge with O(1)
nodeMap.get
DiagramEdge called nodes.find() twice per edge on every render,
making edge resolution O(N*E). The main component already builds
a nodeMap; pass it into DiagramEdge so each endpoint lookup is O(1).
---
frontend/src/components/cloudforge/ArchDiagram.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx
index 3d27a5c..bffb4d3 100644
--- a/frontend/src/components/cloudforge/ArchDiagram.tsx
+++ b/frontend/src/components/cloudforge/ArchDiagram.tsx
@@ -1133,15 +1133,15 @@ function ServiceNode({ node, iconSize, isSelected, onSelect, onHover, onLeave }:
function DiagramEdge({
edge,
- nodes,
+ nodeMap,
iconSize,
}: {
edge: DrawIOEdge;
- nodes: DrawIONode[];
+ nodeMap: Map;
iconSize: number;
}) {
- const fromNode = nodes.find((n) => n.id === edge.from);
- const toNode = nodes.find((n) => n.id === edge.to);
+ const fromNode = nodeMap.get(edge.from);
+ const toNode = nodeMap.get(edge.to);
if (!fromNode || !toNode) return null;
const getCenter = (n: DrawIONode): [number, number] => {
@@ -1355,7 +1355,7 @@ export default function ArchDiagram({
))}
From 4d9162500394955e96755da400dd2dc6c057649e Mon Sep 17 00:00:00 2001
From: Gunbir Singh
Date: Sun, 22 Mar 2026 00:20:22 -0700
Subject: [PATCH 07/50] fix: use terraformResource for accurate AWS service
icon mapping
The coarse ForgeArchNode.type caused mis-renders: cache mapped to
DynamoDB instead of ElastiCache, storage mapped to S3 instead of RDS,
auth mapped to Cognito instead of Secrets Manager. mapForgeTypeToService
is replaced with mapForgeNodeToService which checks terraformResource
first and falls back to the type field only when no tf resource match
exists.
---
.../src/components/cloudforge/ArchDiagram.tsx | 47 ++++++++++++++++---
1 file changed, 41 insertions(+), 6 deletions(-)
diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx
index bffb4d3..ea2c554 100644
--- a/frontend/src/components/cloudforge/ArchDiagram.tsx
+++ b/frontend/src/components/cloudforge/ArchDiagram.tsx
@@ -548,16 +548,51 @@ const DEFAULT_EDGES: DrawIOEdge[] = [
/* ── Backward Compatibility Converters ──────────────────────────────────────── */
-function mapForgeTypeToService(type: ForgeArchNode['type']): AWSServiceId {
- const map: Record = {
+/** Maps a ForgeArchNode to an AWSServiceId, using terraformResource for precision. */
+function mapForgeNodeToService(node: ForgeArchNode): AWSServiceId {
+ // Exact terraform resource type takes priority over the coarse `type` field
+ const tfMap: Partial> = {
+ aws_lambda_function: 'lambda',
+ aws_apigatewayv2_api: 'apigateway',
+ aws_api_gateway_rest_api: 'apigateway',
+ aws_elasticache_cluster: 'generic', // ElastiCache not in AWSServiceId — use generic
+ aws_elasticache_replication_group: 'generic',
+ aws_db_instance: 'rds',
+ aws_rds_cluster: 'rds',
+ aws_s3_bucket: 's3',
+ aws_sqs_queue: 'sqs',
+ aws_sns_topic: 'sns',
+ aws_cloudfront_distribution: 'cloudfront',
+ aws_cognito_user_pool: 'cognito',
+ aws_secretsmanager_secret: 'generic',
+ aws_dynamodb_table: 'dynamodb',
+ aws_ecs_service: 'ecs',
+ aws_ecs_cluster: 'ecs',
+ aws_eks_cluster: 'eks',
+ aws_kinesis_stream: 'kinesis',
+ aws_cloudwatch_event_rule: 'eventbridge',
+ aws_sfn_state_machine: 'stepfunctions',
+ aws_route53_record: 'route53',
+ aws_lb: 'elb',
+ aws_alb: 'elb',
+ aws_instance: 'ec2',
+ aws_bedrock_model_invocation_logging_configuration: 'bedrock',
+ };
+
+ if (node.terraformResource && tfMap[node.terraformResource] !== undefined) {
+ return tfMap[node.terraformResource]!;
+ }
+
+ // Fall back to coarse type mapping
+ const typeMap: Record = {
compute: 'lambda',
storage: 's3',
- cache: 'dynamodb',
+ cache: 'generic',
gateway: 'apigateway',
queue: 'sqs',
- auth: 'cognito',
+ auth: 'generic',
};
- return map[type] ?? 'generic';
+ return typeMap[node.type] ?? 'generic';
}
export function convertForgeNodes(forgeNodes: ForgeArchNode[]): DrawIONode[] {
@@ -568,7 +603,7 @@ export function convertForgeNodes(forgeNodes: ForgeArchNode[]): DrawIONode[] {
y: n.y ?? Math.floor(i / 4) * 160 + 40,
label: n.label,
sublabel: n.sublabel,
- service: mapForgeTypeToService(n.type),
+ service: mapForgeNodeToService(n),
}));
}
From e5f76e4e576a8257d64abbcb0bed5eccff7a36a7 Mon Sep 17 00:00:00 2001
From: Gunbir Singh
Date: Sun, 22 Mar 2026 00:20:41 -0700
Subject: [PATCH 08/50] fix: clear tooltip timer on ArchDiagram unmount
tooltipTimerRef was cleared on hover/leave but not on component
unmount. If the component unmounted while a 250ms tooltip delay was
pending, the scheduled setTooltip would fire on an unmounted component
causing a React warning. Added a useEffect cleanup to cancel the timer.
---
frontend/src/components/cloudforge/ArchDiagram.tsx | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx
index ea2c554..f254cee 100644
--- a/frontend/src/components/cloudforge/ArchDiagram.tsx
+++ b/frontend/src/components/cloudforge/ArchDiagram.tsx
@@ -1256,6 +1256,15 @@ export default function ArchDiagram({
useEffect(() => { setMounted(true); }, []);
+ // Clear pending tooltip timer on unmount to prevent state updates on unmounted component
+ useEffect(() => {
+ return () => {
+ if (tooltipTimerRef.current) {
+ clearTimeout(tooltipTimerRef.current);
+ }
+ };
+ }, []);
+
const ICON_SIZE = 56;
const PAD = 60;
From 46e3d263befd9e73dbb63a1603595e8daa668744 Mon Sep 17 00:00:00 2001
From: Gunbir Singh
Date: Sun, 22 Mar 2026 00:20:57 -0700
Subject: [PATCH 09/50] fix: add role="img" to diagram wrapper so aria-label is
announced
The wrapper div had an aria-label describing the diagram but no ARIA
role, so assistive technology would not treat it as a landmark or
announce the label. Adding role="img" makes the relationship explicit
and ensures screen readers announce the diagram's textual description.
---
frontend/src/components/cloudforge/ArchDiagram.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/frontend/src/components/cloudforge/ArchDiagram.tsx b/frontend/src/components/cloudforge/ArchDiagram.tsx
index f254cee..10dbb22 100644
--- a/frontend/src/components/cloudforge/ArchDiagram.tsx
+++ b/frontend/src/components/cloudforge/ArchDiagram.tsx
@@ -1346,6 +1346,7 @@ export default function ArchDiagram({
borderRadius: '12px',
position: 'relative',
}}
+ role="img"
aria-label={ariaLabel}
>
{/* SVG diagram — shrinks when panel is open */}
From 380a94f73058104500cfdbd52dff5783505261ec Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:05:02 -0700
Subject: [PATCH 10/50] feat(config): extend settings with MongoDB, JWT,
Fernet, GitHub OAuth, Anthropic
---
backend/app/config.py | 26 ++++++++++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/backend/app/config.py b/backend/app/config.py
index 5cb87cd..892971e 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -25,6 +25,32 @@ class Settings(BaseSettings):
agent3_model: str = "qwen3.5" # primary model for heavy tasks
agent3_fast_model: str = "qwen3.5" # lighter tasks (code fixing, test gen)
+ # MongoDB
+ mongodb_url: str = "mongodb://localhost:27017"
+ mongodb_db_name: str = "cloudforge"
+
+ # JWT
+ jwt_secret_key: str = "changeme"
+ jwt_algorithm: str = "HS256"
+ jwt_access_expire_minutes: int = 30
+ jwt_refresh_expire_days: int = 7
+
+ # Encryption
+ fernet_key: str = ""
+
+ # GitHub OAuth
+ github_client_id: str = ""
+ github_client_secret: str = ""
+ github_redirect_uri: str = "http://localhost:8000/auth/github/callback"
+
+ # External services
+ anthropic_api_key: str = ""
+ frontend_url: str = "http://localhost:3000"
+
+ # Agent data paths
+ graph_json_path: str = "app/agents/data/graph/graph.json"
+ kuzu_db_path: str = "./cloudforge_db"
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
From 276a6d1924424f90d16a9f8c4bfb2e572422b433 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:05:08 -0700
Subject: [PATCH 11/50] feat(db): add Motor client singleton and collection
accessors
---
backend/app/db/__init__.py | 0
backend/app/db/mongo.py | 63 ++++++++++++++++++++++++++++++++++++++
2 files changed, 63 insertions(+)
create mode 100644 backend/app/db/__init__.py
create mode 100644 backend/app/db/mongo.py
diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/db/mongo.py b/backend/app/db/mongo.py
new file mode 100644
index 0000000..b401662
--- /dev/null
+++ b/backend/app/db/mongo.py
@@ -0,0 +1,63 @@
+from contextlib import asynccontextmanager
+
+from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
+
+from app.config import settings
+
+_client: AsyncIOMotorClient | None = None
+
+
+def get_client() -> AsyncIOMotorClient:
+ if _client is None:
+ raise RuntimeError("MongoDB client is not initialized. Call connect_mongo() first.")
+ return _client
+
+
+def get_db() -> AsyncIOMotorDatabase:
+ return get_client()[settings.mongodb_db_name]
+
+
+def users_col():
+ return get_db()["users"]
+
+
+def projects_col():
+ return get_db()["projects"]
+
+
+def prd_conversations_col():
+ return get_db()["prd_conversations"]
+
+
+def architectures_col():
+ return get_db()["architectures"]
+
+
+def builds_col():
+ return get_db()["builds"]
+
+
+def deployments_col():
+ return get_db()["deployments"]
+
+
+async def connect_mongo() -> None:
+ global _client
+ _client = AsyncIOMotorClient(settings.mongodb_url)
+ await _client.admin.command("ping")
+
+
+async def disconnect_mongo() -> None:
+ global _client
+ if _client is not None:
+ _client.close()
+ _client = None
+
+
+@asynccontextmanager
+async def mongo_lifespan():
+ await connect_mongo()
+ try:
+ yield
+ finally:
+ await disconnect_mongo()
From d75b0db5743e9afb5b9189b2817cbb79e4684020 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:05:14 -0700
Subject: [PATCH 12/50] feat(db): add Fernet encrypt/decrypt wrapper
---
backend/app/db/encryption.py | 15 +++++++++++++++
1 file changed, 15 insertions(+)
create mode 100644 backend/app/db/encryption.py
diff --git a/backend/app/db/encryption.py b/backend/app/db/encryption.py
new file mode 100644
index 0000000..ff2affa
--- /dev/null
+++ b/backend/app/db/encryption.py
@@ -0,0 +1,15 @@
+from cryptography.fernet import Fernet
+
+from app.config import settings
+
+
+def _get_fernet() -> Fernet:
+ return Fernet(settings.fernet_key.encode())
+
+
+def encrypt(plaintext: str) -> str:
+ return _get_fernet().encrypt(plaintext.encode()).decode()
+
+
+def decrypt(ciphertext: str) -> str:
+ return _get_fernet().decrypt(ciphertext.encode()).decode()
From f302f42079df253eb16232489f70870b0188db9a Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:05:18 -0700
Subject: [PATCH 13/50] feat(core): add JWT create/decode utilities and
password hashing
---
backend/app/core/__init__.py | 0
backend/app/core/security.py | 47 ++++++++++++++++++++++++++++++++++++
2 files changed, 47 insertions(+)
create mode 100644 backend/app/core/__init__.py
create mode 100644 backend/app/core/security.py
diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
new file mode 100644
index 0000000..f7086ee
--- /dev/null
+++ b/backend/app/core/security.py
@@ -0,0 +1,47 @@
+from datetime import datetime, timedelta, timezone
+
+from fastapi import HTTPException, status
+from jose import JWTError, jwt
+from passlib.context import CryptContext
+
+from app.config import settings
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+def create_access_token(data: dict) -> str:
+ payload = data.copy()
+ payload["exp"] = datetime.now(timezone.utc) + timedelta(
+ minutes=settings.jwt_access_expire_minutes
+ )
+ payload["iat"] = datetime.now(timezone.utc)
+ return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
+
+
+def create_refresh_token(data: dict) -> str:
+ payload = data.copy()
+ payload["exp"] = datetime.now(timezone.utc) + timedelta(
+ days=settings.jwt_refresh_expire_days
+ )
+ payload["iat"] = datetime.now(timezone.utc)
+ payload["type"] = "refresh"
+ return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
+
+
+def decode_token(token: str) -> dict:
+ try:
+ return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
+ except JWTError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+
+def hash_password(password: str) -> str:
+ return pwd_context.hash(password)
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ return pwd_context.verify(plain, hashed)
From 5b07791e0755ba815b77241e79589a62a905d42f Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:05:25 -0700
Subject: [PATCH 14/50] feat(core): add get_current_user and
require_project_owner dependencies
---
backend/app/core/dependencies.py | 40 ++++++++++++++++++++++++++++++++
1 file changed, 40 insertions(+)
create mode 100644 backend/app/core/dependencies.py
diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py
new file mode 100644
index 0000000..bcdda7c
--- /dev/null
+++ b/backend/app/core/dependencies.py
@@ -0,0 +1,40 @@
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+
+from bson import ObjectId
+
+from app.core.security import decode_token
+from app.db.mongo import projects_col, users_col
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
+
+
+async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
+ payload = decode_token(token)
+ user_id: str | None = payload.get("sub")
+ if user_id is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Token missing subject",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ user = await users_col().find_one({"_id": ObjectId(user_id)})
+ if user is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="User not found",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ return user
+
+
+async def require_project_owner(project_id: str, user: dict = Depends(get_current_user)) -> dict:
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if project is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
+ if project.get("owner_id") != str(user["_id"]):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="You do not have permission to access this project",
+ )
+ return project
From ce8c868617460c38317ec18898885eccbbf68e17 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:05:30 -0700
Subject: [PATCH 15/50] chore(deps): add motor, pymongo, cryptography,
python-jose, passlib, httpx, boto3
---
backend/pyproject.toml | 7 +
backend/uv.lock | 1300 +++++++++++++++++++++++-----------------
2 files changed, 764 insertions(+), 543 deletions(-)
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index bb7e52c..b4546d2 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -30,6 +30,13 @@ dependencies = [
"jsonschema>=4.23.0",
"jinja2>=3.1.4",
"langchain-ollama>=0.3.0",
+ "motor>=3.3",
+ "pymongo>=4.6",
+ "cryptography",
+ "python-jose[cryptography]",
+ "passlib[bcrypt]",
+ "httpx",
+ "boto3",
]
[project.optional-dependencies]
diff --git a/backend/uv.lock b/backend/uv.lock
index 210ec4b..52f01a5 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -11,9 +11,6 @@ resolution-markers = [
"python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
- "python_full_version >= '3.14'",
- "python_full_version == '3.13.*'",
- "python_full_version < '3.13'",
]
[[package]]
@@ -235,15 +232,17 @@ name = "backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
+ { name = "boto3" },
{ name = "checkov" },
+ { name = "cryptography" },
+ { name = "ddgs" },
+ { name = "duckduckgo-search" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" },
+ { name = "jsonschema" },
{ name = "kuzu" },
{ name = "langchain", extra = ["ollama"] },
- { name = "jinja2" },
- { name = "jsonschema" },
- { name = "langchain" },
{ name = "langchain-anthropic" },
{ name = "langchain-community" },
{ name = "langchain-ollama" },
@@ -252,61 +251,82 @@ dependencies = [
{ name = "langgraph-cli" },
{ name = "langgraph-prebuilt" },
{ name = "langsmith" },
- { name = "pandas" },
+ { name = "motor" },
+ { name = "passlib", extra = ["bcrypt"] },
{ name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pymongo" },
{ name = "python-dotenv" },
+ { name = "python-jose", extra = ["cryptography"] },
+ { name = "pyyaml" },
{ name = "scikit-learn" },
{ name = "sentence-transformers" },
- { name = "pyyaml" },
{ name = "uvicorn", extra = ["standard"] },
]
-[package.dev-dependencies]
+[package.optional-dependencies]
dev = [
+ { name = "httpx" },
+ { name = "mcp" },
+ { name = "pandas" },
{ name = "pytest" },
+ { name = "pytest-asyncio" },
]
-[package.optional-dependencies]
+[package.dev-dependencies]
dev = [
- { name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
+ { name = "boto3" },
{ name = "checkov", specifier = ">=3.2.0" },
+ { name = "cryptography" },
+ { name = "ddgs", specifier = ">=9.9.1" },
+ { name = "duckduckgo-search", specifier = ">=8.1.1" },
{ name = "fastapi", specifier = ">=0.135.1" },
- { name = "httpx", specifier = ">=0.27.0" },
- { name = "jinja2", specifier = ">=3.1.6" },
- { name = "kuzu", specifier = ">=0.7.0" },
- { name = "langchain", extras = ["ollama"], specifier = ">=1.2.13" },
+ { name = "httpx" },
+ { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" },
{ name = "jinja2", specifier = ">=3.1.4" },
+ { name = "jinja2", specifier = ">=3.1.6" },
{ name = "jsonschema", specifier = ">=4.23.0" },
- { name = "langchain", specifier = ">=1.2.13" },
+ { name = "kuzu", specifier = ">=0.7.0" },
+ { name = "langchain", extras = ["ollama"], specifier = ">=1.2.13" },
{ name = "langchain-anthropic", specifier = ">=1.4.0" },
{ name = "langchain-community", specifier = ">=0.4.1" },
{ name = "langchain-ollama", specifier = ">=0.3.0" },
+ { name = "langchain-ollama", specifier = ">=1.0.0" },
{ name = "langgraph", specifier = ">=1.1.3" },
{ name = "langgraph-checkpoint-sqlite", specifier = ">=2.0.0" },
{ name = "langgraph-cli", specifier = ">=0.4.19" },
{ name = "langgraph-prebuilt", specifier = ">=1.0.8" },
{ name = "langsmith", specifier = ">=0.7.22" },
- { name = "pandas", specifier = ">=3.0.1" },
+ { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.0.0" },
+ { name = "motor", specifier = ">=3.3" },
+ { name = "pandas", marker = "extra == 'dev'", specifier = ">=3.0.1" },
+ { name = "passlib", extras = ["bcrypt"] },
{ name = "pydantic", specifier = ">=2.12.5" },
+ { name = "pydantic-settings", specifier = ">=2.13.1" },
+ { name = "pymongo", specifier = ">=4.6" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
+ { name = "python-jose", extras = ["cryptography"] },
+ { name = "pyyaml", specifier = ">=6.0.2" },
{ name = "scikit-learn", specifier = ">=1.5.0" },
{ name = "sentence-transformers", specifier = ">=3.0.0" },
- { name = "pyyaml", specifier = ">=6.0.2" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.42.0" },
]
+provides-extras = ["dev"]
[package.metadata.requires-dev]
-dev = [{ name = "pytest", specifier = ">=9.0.2" }]
-provides-extras = ["dev"]
+dev = [
+ { name = "pytest", specifier = ">=9.0.2" },
+ { name = "pytest-asyncio", specifier = ">=0.23.0" },
+]
[[package]]
name = "bc-detect-secrets"
@@ -347,6 +367,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/f7/e064b2e767c094ac26512ddf5242b63a0cbca11678bf551545cd45d4f500/bc_python_hcl2-0.4.3-py3-none-any.whl", hash = "sha256:b0cce4cea16823f7da7fefa0f8177dfb91f51a1befe64ef59d8fe4d5ac616eec", size = 15015, upload-time = "2025-07-14T11:09:36.289Z" },
]
+[[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" },
+]
+
[[package]]
name = "beartype"
version = "0.22.9"
@@ -670,6 +756,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "configargparse"
+version = "1.7.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/30328302903c55218ffc5199646d0e9d28348ff26c02ba77b2ffc58d294a/configargparse-1.7.5.tar.gz", hash = "sha256:e3f9a7bb6be34d66b2e3c4a2f58e3045f8dfae47b0dc039f87bcfaa0f193fb0f", size = 53548, upload-time = "2026-03-11T02:19:38.144Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/19/3ba5e1b0bcc7b91aeab6c258afd70e4907d220fed3972febe38feb40db30/configargparse-1.7.5-py3-none-any.whl", hash = "sha256:1e63fdffedf94da9cd435fc13a1cd24777e76879dd2343912c1f871d4ac8c592", size = 27692, upload-time = "2026-03-11T02:19:36.442Z" },
+]
+
+[[package]]
+name = "contextlib2"
+version = "21.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/13/37ea7805ae3057992e96ecb1cffa2fa35c2ef4498543b846f90dd2348d8f/contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869", size = 43795, upload-time = "2021-06-27T06:54:40.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/56/6d6872f79d14c0cb02f1646cbb4592eef935857c0951a105874b7b62a0c3/contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f", size = 13277, upload-time = "2021-06-27T06:54:20.972Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "46.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
+ { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
+ { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
+ { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
+ { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
+ { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
+ { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
+ { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
+ { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
+ { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
+ { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
+ { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
+ { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
+ { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
+ { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
+ { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
+ { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
+]
+
[[package]]
name = "cuda-bindings"
version = "12.9.4"
@@ -693,24 +850,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/59/911a1a597264f1fb7ac176995a0f0b6062e37f8c1b6e0f23071a76838507/cuda_pathfinder-1.4.3-py3-none-any.whl", hash = "sha256:4345d8ead1f701c4fb8a99be6bc1843a7348b6ba0ef3b031f5a2d66fb128ae4c", size = 47951, upload-time = "2026-03-16T21:31:25.526Z" },
]
-[[package]]
-name = "configargparse"
-version = "1.7.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/30328302903c55218ffc5199646d0e9d28348ff26c02ba77b2ffc58d294a/configargparse-1.7.5.tar.gz", hash = "sha256:e3f9a7bb6be34d66b2e3c4a2f58e3045f8dfae47b0dc039f87bcfaa0f193fb0f", size = 53548, upload-time = "2026-03-11T02:19:38.144Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fe/19/3ba5e1b0bcc7b91aeab6c258afd70e4907d220fed3972febe38feb40db30/configargparse-1.7.5-py3-none-any.whl", hash = "sha256:1e63fdffedf94da9cd435fc13a1cd24777e76879dd2343912c1f871d4ac8c592", size = 27692, upload-time = "2026-03-11T02:19:36.442Z" },
-]
-
-[[package]]
-name = "contextlib2"
-version = "21.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c7/13/37ea7805ae3057992e96ecb1cffa2fa35c2ef4498543b846f90dd2348d8f/contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869", size = 43795, upload-time = "2021-06-27T06:54:40.841Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/76/56/6d6872f79d14c0cb02f1646cbb4592eef935857c0951a105874b7b62a0c3/contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f", size = 13277, upload-time = "2021-06-27T06:54:20.972Z" },
-]
-
[[package]]
name = "cyclonedx-python-lib"
version = "7.6.2"
@@ -739,6 +878,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" },
]
+[[package]]
+name = "ddgs"
+version = "9.11.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "lxml" },
+ { name = "primp" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d7/3c/0ea1d5685bb0cd3607556d76da30e64c158e5d86bb16a3555f232fe0a33f/ddgs-9.11.4.tar.gz", hash = "sha256:445e22d3ffa16f893bfb0a717593bb0f34d1ceb1df68b6ff4ec27b1a3cdf6941", size = 34798, upload-time = "2026-03-14T18:15:18.086Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/46/f9412fafdebd4eaba366b5336ee4987995ccc3db1bc450e865a34db5fb63/ddgs-9.11.4-py3-none-any.whl", hash = "sha256:62d4d05b25db5d225a727c0a2771ef40258c1d894582c73dad24c88bf90f918b", size = 43681, upload-time = "2026-03-14T18:15:17.063Z" },
+]
+
[[package]]
name = "decorator"
version = "5.2.1"
@@ -766,6 +919,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
+[[package]]
+name = "dnspython"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
+]
+
[[package]]
name = "docker"
version = "7.1.0"
@@ -807,6 +969,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/12/02fdb87afeab1987442164a2db470a995122c800f15265e9b7a5103a3fd9/dpath-2.1.3-py3-none-any.whl", hash = "sha256:d9560e03ccd83b3c6f29988b0162ce9b34fd28b9d8dbda46663b20c68d9cdae3", size = 17232, upload-time = "2022-12-13T07:27:20.023Z" },
]
+[[package]]
+name = "duckduckgo-search"
+version = "8.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "lxml" },
+ { name = "primp" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/10/ef/07791a05751e6cc9de1dd49fb12730259ee109b18e6d097e25e6c32d5617/duckduckgo_search-8.1.1.tar.gz", hash = "sha256:9da91c9eb26a17e016ea1da26235d40404b46b0565ea86d75a9f78cc9441f935", size = 22868, upload-time = "2025-07-06T15:30:59.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/72/c027b3b488b1010cf71670032fcf7e681d44b81829d484bb04e31a949a8d/duckduckgo_search-8.1.1-py3-none-any.whl", hash = "sha256:f48adbb06626ee05918f7e0cef3a45639e9939805c4fc179e68c48a12f1b5062", size = 18932, upload-time = "2025-07-06T15:30:58.339Z" },
+]
+
+[[package]]
+name = "ecdsa"
+version = "0.19.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
+]
+
[[package]]
name = "fastapi"
version = "0.135.1"
@@ -963,6 +1151,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
@@ -971,6 +1160,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
+ { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
@@ -979,6 +1169,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -987,6 +1178,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
@@ -1129,27 +1321,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
-[[package]]
-name = "iniconfig"
-version = "2.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
-]
-
-[[package]]
-name = "jinja2"
-version = "3.1.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markupsafe" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
-]
-
[[package]]
name = "importlib-metadata"
version = "7.2.1"
@@ -1252,21 +1423,21 @@ wheels = [
]
[[package]]
-name = "joblib"
-version = "1.5.3"
+name = "jmespath"
+version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
+ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
-name = "jmespath"
-version = "1.1.0"
+name = "joblib"
+version = "1.5.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
[[package]]
@@ -1290,30 +1461,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/25/cebb241a435cbf4626b5ea096d8385c04416d7ca3082a15299b746e248fa/jsonpointer-3.1.0-py3-none-any.whl", hash = "sha256:f82aa0f745001f169b96473348370b43c3f581446889c41c807bab1db11c8e7b", size = 7651, upload-time = "2026-03-20T21:47:08.792Z" },
]
-[[package]]
-name = "kuzu"
-version = "0.11.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/96/0c/f141a81485729a072dc527b474e7580d5632309c68ad1a5aa6ed9ac45387/kuzu-0.11.3.tar.gz", hash = "sha256:e7bea3ca30c4bb462792eedcaa7f2125c800b243bb4a872e1eedc16917c1967a", size = 19430620, upload-time = "2025-10-10T13:36:54.984Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/64/88/ed193fd0ddfdbdde6c79e96b96df3b760fe48b2626e7151d81a1ed90fd9f/kuzu-0.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d865ca31506867cf1ccf50c094c44de96de94bc77ffb350bfcaca0e4c5e469da", size = 4093637, upload-time = "2025-10-10T13:36:04.206Z" },
- { url = "https://files.pythonhosted.org/packages/d6/6d/06e02828b78297d6d99ff3dfb0ab7b5ec5d075053aae33b53189437bbb66/kuzu-0.11.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:109372bc16ce6724f88e0312bc686e34145e330d69b163b22ba92f4d3d96b48f", size = 4520482, upload-time = "2025-10-10T13:36:06.302Z" },
- { url = "https://files.pythonhosted.org/packages/72/d5/0939a953860a8b373bef7b8a66a4571b27ff9faeb22672d2cd2cf3b6ba15/kuzu-0.11.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6274da6c470c001b7d332ec78a076395b009c2f267914640884fd6fa78bf47d", size = 6795398, upload-time = "2025-10-10T13:36:08Z" },
- { url = "https://files.pythonhosted.org/packages/7d/5d/8e3dfb89aa3f70f63aa283c523f2dd2ac90a1b3ed990643e3a89909236f9/kuzu-0.11.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bb3b833ca2d1d919423cb3e0150592c2587562ab85259277f622e6f06e0b487", size = 7615389, upload-time = "2025-10-10T13:36:09.809Z" },
- { url = "https://files.pythonhosted.org/packages/6c/19/c8e93185d6142f01b2e6daec4ad537dfd32afd1f69894889769b725b08c1/kuzu-0.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:605909f744763775b8647014a03526d7f928a7b5a62a8b8c1d1e7bbdaf9dbb6c", size = 4714355, upload-time = "2025-10-10T13:36:11.527Z" },
- { url = "https://files.pythonhosted.org/packages/c9/e4/2c0e222a9b0605745234fec2774a25dd2e472699931f683f15d28ab8c076/kuzu-0.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e20ab3e3b20ccf75219872feb86582f959e313eeb59f51131adf4c91ebfabe30", size = 4093664, upload-time = "2025-10-10T13:36:13.116Z" },
- { url = "https://files.pythonhosted.org/packages/88/05/3020ed9a0a7b492597f211f805233b77ef37266a23c27efc40bb7cb37402/kuzu-0.11.3-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:054479d3ce71410b8af2f5fa6aa37883db7fea5b25606af8d3bd7cf717aa5395", size = 4520498, upload-time = "2025-10-10T13:36:14.933Z" },
- { url = "https://files.pythonhosted.org/packages/0f/66/1a502700a7f2863f8f60621a412a7074d7eda9e92f18fd1d8d86905aa4d3/kuzu-0.11.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:143a37f1ae38b6b4337ccfcf42fa4f779a897223fff9c6c29f1a5a5a86911300", size = 6795804, upload-time = "2025-10-10T13:36:16.55Z" },
- { url = "https://files.pythonhosted.org/packages/79/c2/1ea8cdc05946cb5906a7ebb451d7268e501ebb51ebecc0437969f8c07450/kuzu-0.11.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:548bfb3045d89bce1fbe89f4a890d636789671abfa80cbde2054c671e6069133", size = 7615668, upload-time = "2025-10-10T13:36:18.882Z" },
- { url = "https://files.pythonhosted.org/packages/d1/c3/336d6181f8f50126cf3d7186b3c5479f9f49d973145f79bed45cf87a9bb7/kuzu-0.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:87bf6c369f182a59e5b8a38b3ca288b90fab2827577d9b0d2170a202c42bc8f5", size = 4714372, upload-time = "2025-10-10T13:36:20.877Z" },
- { url = "https://files.pythonhosted.org/packages/94/db/e7e6cada6dc924eb8939bd35c5f724f5de4fc430a64d6d9e71b75cd0c271/kuzu-0.11.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5c165bc5838059e498e8325939fc6bac075e1941157e8df6ebdd710135d43b", size = 6798556, upload-time = "2025-10-10T13:36:22.472Z" },
- { url = "https://files.pythonhosted.org/packages/cf/68/a0fa02134cb255c80b5ed5bb5f6130fbbc75a8ae8be4fd6ea6eb6bc8014b/kuzu-0.11.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88c73dfd3d6a1fb374031050b725236fa9dd9a95424b09b20086a3d274bed51f", size = 7620378, upload-time = "2025-10-10T13:36:24.453Z" },
- { url = "https://files.pythonhosted.org/packages/d6/b7/2a4569984995f09476dbf1ef2e0a7298aa9fdb8896f2e8195d80e11786f4/kuzu-0.11.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2752a9e37adda6aef3bf041932ae3a1cf74ca7e893bbbacdd5e62b3ac6f8c2", size = 6795649, upload-time = "2025-10-10T13:36:26.15Z" },
- { url = "https://files.pythonhosted.org/packages/77/13/df6e06a7d7506743c3a6cfbe50ee3f9d3fc58228e2a2fcbe7e74e7c17b00/kuzu-0.11.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86be7d113e4e2c6761b1701079af8aeffc04c6981517f2d6aa393e883cc46036", size = 7615882, upload-time = "2025-10-10T13:36:28.215Z" },
- { url = "https://files.pythonhosted.org/packages/32/85/c52c3b167edcc67da3b8788a20a2fb5b4f045060cbe1aed6121ce3ce83d3/kuzu-0.11.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d72ebd88e231b562e7a60ff88d200825d53e78a681bddd7f8d77b78126a5060c", size = 6798657, upload-time = "2025-10-10T13:36:29.935Z" },
- { url = "https://files.pythonhosted.org/packages/06/be/5b4ff168718165c2ff5848ab79e22ecce72ad00522afee6820d390cb0753/kuzu-0.11.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64c7ec822906bdee154eb38d93e64f184d8f94b30bbeaceaa252725f2b9efab3", size = 7620394, upload-time = "2025-10-10T13:36:31.69Z" },
-]
-
[[package]]
name = "jsonschema"
version = "4.26.0"
@@ -1353,6 +1500,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/93/2d896b5fd3d79b4cadd8882c06650e66d003f465c9d12c488d92853dff78/junit_xml-1.9-py2.py3-none-any.whl", hash = "sha256:ec5ca1a55aefdd76d28fcc0b135251d156c7106fa979686a4b48d62b761b4732", size = 7130, upload-time = "2020-02-22T20:41:37.661Z" },
]
+[[package]]
+name = "kuzu"
+version = "0.11.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/0c/f141a81485729a072dc527b474e7580d5632309c68ad1a5aa6ed9ac45387/kuzu-0.11.3.tar.gz", hash = "sha256:e7bea3ca30c4bb462792eedcaa7f2125c800b243bb4a872e1eedc16917c1967a", size = 19430620, upload-time = "2025-10-10T13:36:54.984Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/88/ed193fd0ddfdbdde6c79e96b96df3b760fe48b2626e7151d81a1ed90fd9f/kuzu-0.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d865ca31506867cf1ccf50c094c44de96de94bc77ffb350bfcaca0e4c5e469da", size = 4093637, upload-time = "2025-10-10T13:36:04.206Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/6d/06e02828b78297d6d99ff3dfb0ab7b5ec5d075053aae33b53189437bbb66/kuzu-0.11.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:109372bc16ce6724f88e0312bc686e34145e330d69b163b22ba92f4d3d96b48f", size = 4520482, upload-time = "2025-10-10T13:36:06.302Z" },
+ { url = "https://files.pythonhosted.org/packages/72/d5/0939a953860a8b373bef7b8a66a4571b27ff9faeb22672d2cd2cf3b6ba15/kuzu-0.11.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6274da6c470c001b7d332ec78a076395b009c2f267914640884fd6fa78bf47d", size = 6795398, upload-time = "2025-10-10T13:36:08Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/5d/8e3dfb89aa3f70f63aa283c523f2dd2ac90a1b3ed990643e3a89909236f9/kuzu-0.11.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bb3b833ca2d1d919423cb3e0150592c2587562ab85259277f622e6f06e0b487", size = 7615389, upload-time = "2025-10-10T13:36:09.809Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/19/c8e93185d6142f01b2e6daec4ad537dfd32afd1f69894889769b725b08c1/kuzu-0.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:605909f744763775b8647014a03526d7f928a7b5a62a8b8c1d1e7bbdaf9dbb6c", size = 4714355, upload-time = "2025-10-10T13:36:11.527Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e4/2c0e222a9b0605745234fec2774a25dd2e472699931f683f15d28ab8c076/kuzu-0.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e20ab3e3b20ccf75219872feb86582f959e313eeb59f51131adf4c91ebfabe30", size = 4093664, upload-time = "2025-10-10T13:36:13.116Z" },
+ { url = "https://files.pythonhosted.org/packages/88/05/3020ed9a0a7b492597f211f805233b77ef37266a23c27efc40bb7cb37402/kuzu-0.11.3-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:054479d3ce71410b8af2f5fa6aa37883db7fea5b25606af8d3bd7cf717aa5395", size = 4520498, upload-time = "2025-10-10T13:36:14.933Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/66/1a502700a7f2863f8f60621a412a7074d7eda9e92f18fd1d8d86905aa4d3/kuzu-0.11.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:143a37f1ae38b6b4337ccfcf42fa4f779a897223fff9c6c29f1a5a5a86911300", size = 6795804, upload-time = "2025-10-10T13:36:16.55Z" },
+ { url = "https://files.pythonhosted.org/packages/79/c2/1ea8cdc05946cb5906a7ebb451d7268e501ebb51ebecc0437969f8c07450/kuzu-0.11.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:548bfb3045d89bce1fbe89f4a890d636789671abfa80cbde2054c671e6069133", size = 7615668, upload-time = "2025-10-10T13:36:18.882Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c3/336d6181f8f50126cf3d7186b3c5479f9f49d973145f79bed45cf87a9bb7/kuzu-0.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:87bf6c369f182a59e5b8a38b3ca288b90fab2827577d9b0d2170a202c42bc8f5", size = 4714372, upload-time = "2025-10-10T13:36:20.877Z" },
+ { url = "https://files.pythonhosted.org/packages/94/db/e7e6cada6dc924eb8939bd35c5f724f5de4fc430a64d6d9e71b75cd0c271/kuzu-0.11.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5c165bc5838059e498e8325939fc6bac075e1941157e8df6ebdd710135d43b", size = 6798556, upload-time = "2025-10-10T13:36:22.472Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/68/a0fa02134cb255c80b5ed5bb5f6130fbbc75a8ae8be4fd6ea6eb6bc8014b/kuzu-0.11.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88c73dfd3d6a1fb374031050b725236fa9dd9a95424b09b20086a3d274bed51f", size = 7620378, upload-time = "2025-10-10T13:36:24.453Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/b7/2a4569984995f09476dbf1ef2e0a7298aa9fdb8896f2e8195d80e11786f4/kuzu-0.11.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2752a9e37adda6aef3bf041932ae3a1cf74ca7e893bbbacdd5e62b3ac6f8c2", size = 6795649, upload-time = "2025-10-10T13:36:26.15Z" },
+ { url = "https://files.pythonhosted.org/packages/77/13/df6e06a7d7506743c3a6cfbe50ee3f9d3fc58228e2a2fcbe7e74e7c17b00/kuzu-0.11.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86be7d113e4e2c6761b1701079af8aeffc04c6981517f2d6aa393e883cc46036", size = 7615882, upload-time = "2025-10-10T13:36:28.215Z" },
+ { url = "https://files.pythonhosted.org/packages/32/85/c52c3b167edcc67da3b8788a20a2fb5b4f045060cbe1aed6121ce3ce83d3/kuzu-0.11.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d72ebd88e231b562e7a60ff88d200825d53e78a681bddd7f8d77b78126a5060c", size = 6798657, upload-time = "2025-10-10T13:36:29.935Z" },
+ { url = "https://files.pythonhosted.org/packages/06/be/5b4ff168718165c2ff5848ab79e22ecce72ad00522afee6820d390cb0753/kuzu-0.11.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64c7ec822906bdee154eb38d93e64f184d8f94b30bbeaceaa252725f2b9efab3", size = 7620394, upload-time = "2025-10-10T13:36:31.69Z" },
+]
+
[[package]]
name = "langchain"
version = "1.2.13"
@@ -1459,19 +1630,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/46/f2907da16dc5a5a6c679f83b7de21176178afad8d2ca635a581429580ef6/langchain_ollama-1.0.1-py3-none-any.whl", hash = "sha256:37eb939a4718a0255fe31e19fbb0def044746c717b01b97d397606ebc3e9b440", size = 29207, upload-time = "2025-12-12T21:48:27.832Z" },
]
-[[package]]
-name = "langchain-ollama"
-version = "1.0.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "langchain-core" },
- { name = "ollama" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/73/51/72cd04d74278f3575f921084f34280e2f837211dc008c9671c268c578afe/langchain_ollama-1.0.1.tar.gz", hash = "sha256:e37880c2f41cdb0895e863b1cfd0c2c840a117868b3f32e44fef42569e367443", size = 153850, upload-time = "2025-12-12T21:48:28.68Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e3/46/f2907da16dc5a5a6c679f83b7de21176178afad8d2ca635a581429580ef6/langchain_ollama-1.0.1-py3-none-any.whl", hash = "sha256:37eb939a4718a0255fe31e19fbb0def044746c717b01b97d397606ebc3e9b440", size = 29207, upload-time = "2025-12-12T21:48:27.832Z" },
-]
-
[[package]]
name = "langchain-text-splitters"
version = "1.1.1"
@@ -1589,6 +1747,116 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/94/1f5d72655ab6534129540843776c40eff757387b88e798d8b3bf7e313fd4/langsmith-0.7.22-py3-none-any.whl", hash = "sha256:6e9d5148314d74e86748cb9d3898632cad0320c9323d95f70f969e5bc078eee4", size = 359927, upload-time = "2026-03-19T22:45:21.603Z" },
]
+[[package]]
+name = "lark"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" },
+]
+
+[[package]]
+name = "license-expression"
+version = "30.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "boolean-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
+]
+
+[[package]]
+name = "lxml"
+version = "6.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
+ { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
+ { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
+ { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
+ { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
+ { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
+ { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
+ { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
+ { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
+ { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
+ { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
+ { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
+ { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
+ { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
+ { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
+ { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
+ { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
+ { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
+ { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
+ { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
+ { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
+ { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
+ { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
+]
+
+[[package]]
+name = "markdown"
+version = "3.10.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
+]
+
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -1665,117 +1933,61 @@ wheels = [
]
[[package]]
-name = "lark"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" },
-]
-
-[[package]]
-name = "license-expression"
-version = "30.4.4"
+name = "marshmallow"
+version = "3.26.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "boolean-py" },
+ { name = "packaging" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" },
]
[[package]]
-name = "markdown"
-version = "3.10.2"
+name = "mcp"
+version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
+dependencies = [
+ { name = "anyio" },
+ { name = "httpx" },
+ { name = "httpx-sse" },
+ { name = "jsonschema" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pyjwt", extra = ["crypto"] },
+ { name = "python-multipart" },
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
+ { name = "sse-starlette" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+ { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
[[package]]
-name = "markupsafe"
-version = "3.0.3"
+name = "mdurl"
+version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
- { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
- { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
- { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
- { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
- { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
- { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
- { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
- { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
- { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
- { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
- { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
- { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
- { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
- { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
- { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
- { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
- { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
- { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
- { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
- { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
- { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
- { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
- { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
- { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
- { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
- { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
- { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
- { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
- { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
- { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
- { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
- { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
- { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
- { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
- { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
- { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
- { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
- { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
- { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
- { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
- { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
- { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
- { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
- { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
- { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
- { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
- { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
- { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
- { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
- { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
- { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
- { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
- { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
-name = "marshmallow"
-version = "3.26.2"
+name = "motor"
+version = "3.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "packaging" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" },
+ { name = "pymongo" },
]
-
-[[package]]
-name = "mdurl"
-version = "0.1.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/93/ae/96b88362d6a84cb372f7977750ac2a8aed7b2053eed260615df08d5c84f4/motor-3.7.1.tar.gz", hash = "sha256:27b4d46625c87928f331a6ca9d7c51c2f518ba0e270939d395bc1ddc89d64526", size = 280997, upload-time = "2025-05-14T18:56:33.653Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+ { url = "https://files.pythonhosted.org/packages/01/9a/35e053d4f442addf751ed20e0e922476508ee580786546d699b0567c4c67/motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298", size = 74996, upload-time = "2025-05-14T18:56:31.665Z" },
]
[[package]]
@@ -1895,15 +2107,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
-[[package]]
-name = "networkx"
-version = "3.6.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
-]
-
[[package]]
name = "networkx"
version = "2.6.3"
@@ -2121,19 +2324,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" },
]
-[[package]]
-name = "ollama"
-version = "0.6.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "httpx" },
- { name = "pydantic" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" },
-]
-
[[package]]
name = "orjson"
version = "3.11.7"
@@ -2244,53 +2434,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/1a/610693ac4ee14fcdf2d9bf3c493370e4f2ef7ae2e19217d7a237ff42367d/packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7", size = 53011, upload-time = "2023-10-01T13:50:03.745Z" },
]
-[[package]]
-name = "pluggy"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
-]
-
-[[package]]
-name = "ply"
-version = "3.11"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" },
-]
-
-[[package]]
-name = "policy-sentry"
-version = "0.13.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "beautifulsoup4" },
- { name = "click" },
- { name = "orjson" },
- { name = "pyyaml" },
- { name = "requests" },
- { name = "schema" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a4/05/75e8953eb5fa564e45fc5afc61696d38ce779169309ca270224561926fa8/policy_sentry-0.13.2.tar.gz", hash = "sha256:db2b39f92989077f83fc4dd1d064e3ff20b69cfed82168ebdc060e7dce292e77", size = 1055327, upload-time = "2024-12-01T11:26:53.65Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0c/fa/abd81b7ce825250e3e7e02f2a94315707db28a4426c8c35ca5bde92a75cf/policy_sentry-0.13.2-py3-none-any.whl", hash = "sha256:e82c3bc1783606449399c4221f67d05f6b08d8a184ba2fee87d04541d7282b86", size = 966631, upload-time = "2024-12-01T11:26:42.603Z" },
-]
-
-[[package]]
-name = "prettytable"
-version = "3.17.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "wcwidth" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433, upload-time = "2025-11-14T17:33:19.093Z" },
-]
-
[[package]]
name = "pandas"
version = "3.0.1"
@@ -2343,6 +2486,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" },
]
+[[package]]
+name = "passlib"
+version = "1.7.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
+]
+
+[package.optional-dependencies]
+bcrypt = [
+ { name = "bcrypt" },
+]
+
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -2352,6 +2509,82 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "ply"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" },
+]
+
+[[package]]
+name = "policy-sentry"
+version = "0.13.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beautifulsoup4" },
+ { name = "click" },
+ { name = "orjson" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "schema" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a4/05/75e8953eb5fa564e45fc5afc61696d38ce779169309ca270224561926fa8/policy_sentry-0.13.2.tar.gz", hash = "sha256:db2b39f92989077f83fc4dd1d064e3ff20b69cfed82168ebdc060e7dce292e77", size = 1055327, upload-time = "2024-12-01T11:26:53.65Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/fa/abd81b7ce825250e3e7e02f2a94315707db28a4426c8c35ca5bde92a75cf/policy_sentry-0.13.2-py3-none-any.whl", hash = "sha256:e82c3bc1783606449399c4221f67d05f6b08d8a184ba2fee87d04541d7282b86", size = 966631, upload-time = "2024-12-01T11:26:42.603Z" },
+]
+
+[[package]]
+name = "prettytable"
+version = "3.17.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433, upload-time = "2025-11-14T17:33:19.093Z" },
+]
+
+[[package]]
+name = "primp"
+version = "1.1.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/0e/62ed44af95c66fd6fa8ad49c8bde815f64c7e976772d6979730be2b7cd97/primp-1.1.3.tar.gz", hash = "sha256:56adc3b8a5048cbd5f926b21fdff839195f3a9181512ca33f56ddc66f4c95897", size = 311356, upload-time = "2026-03-11T06:42:51.763Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/6b/36794b5758a0dd1251e67b6ab3ea946e53fa69745e0ecc29facc072ddf5b/primp-1.1.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:24383cfc267f620769be102b7fa4b64c7d47105f86bd21d047f1e07709e83c6e", size = 4000660, upload-time = "2026-03-11T06:42:58.092Z" },
+ { url = "https://files.pythonhosted.org/packages/98/18/ebbe318a926d158c57f9e9cf49bbea70e8f0bd7f87e7675ed68e0d6ab433/primp-1.1.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:61bcb8c53b41e4bac43d04a1374b6ab7d8ded0f3517d32c5cdd5c30562756805", size = 3737318, upload-time = "2026-03-11T06:42:50.19Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/4c/430c9154284b53b771e6713a18dec4ad0159e4a501a20b222d67c730ced9/primp-1.1.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0c6b9388578ee9d903f30549a792c5f391fdeb9d36b508da2ffb8e13c764954", size = 3881005, upload-time = "2026-03-11T06:43:12.894Z" },
+ { url = "https://files.pythonhosted.org/packages/93/34/2466ef66386a1b50e6aaf7832f9f603628407bb33342378faf4b38c4aee8/primp-1.1.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09a8bfa870c92c81d76611846ec53b2520845e3ec5f4139f47604986bcf4bc25", size = 3514480, upload-time = "2026-03-11T06:43:06.058Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/42/ca7a71df6493dd6c1971c0cc3b20b8125e2547eb3bf88b4429715cb6ed81/primp-1.1.3-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac372cb9959fff690b255fad91c5b3bc948c14065da9fc00ad80d139651515af", size = 3734658, upload-time = "2026-03-11T06:43:47.486Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/7c/0fb34db619e9935e11140929713c2c7b5323c1e8ba75cad6f0aade51c89d/primp-1.1.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3034672a007f04e12b8fe7814c97ea172e8b9c5d45bd7b00cf6e7334fdd4222a", size = 4011898, upload-time = "2026-03-11T06:43:41.121Z" },
+ { url = "https://files.pythonhosted.org/packages/da/8b/afd1bd8b14f38d58c5ebd0d45fc6b74914956907aa4e981bb2e5231626d3/primp-1.1.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a07d5b7d7278dc63452a59f3bf851dc4d1f8ddc2aada7844cbdb68002256e2f4", size = 3910728, upload-time = "2026-03-11T06:43:01.819Z" },
+ { url = "https://files.pythonhosted.org/packages/32/9e/1ec3a9678efcbb51e50d7b4886d9195f956c9fd7f4efcff13ccb152248b0/primp-1.1.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08eec2f58abbcc1060032a2af81dabacec87a580a364a75862039f7422ac82e6", size = 4114189, upload-time = "2026-03-11T06:42:47.639Z" },
+ { url = "https://files.pythonhosted.org/packages/28/d9/76de611027c0688be188d5a833be45b1e36d9c0c98baefab27bf6336ab9d/primp-1.1.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9716d4cd36db2c175443fe1bbd54045a944fc9c49d01a385af8ada1fe9c948df", size = 4061973, upload-time = "2026-03-11T06:43:37.301Z" },
+ { url = "https://files.pythonhosted.org/packages/37/3b/a30a5ea366705d0ece265b12ad089793d644bd5730b18201e3a0a7fa7b5f/primp-1.1.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e19daca65dc6df369c33e711fa481ad2afe5d26c5bde926c069b3ab067c4fd45", size = 3747920, upload-time = "2026-03-11T06:43:10.403Z" },
+ { url = "https://files.pythonhosted.org/packages/df/46/e3c323221c371cdfe6c2ed971f7a70e3b69f30b561977715c55230bd5fda/primp-1.1.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ee357537712aa486364b0194cf403c5f9eaaa1354e23e9ac8322a22003f31e6b", size = 3861184, upload-time = "2026-03-11T06:43:49.391Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/7f/babaf00753daad7d80061003d7ae1bdfca64ea94c181cdea8d25c8a7226a/primp-1.1.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:06c53e77ebf6ac00633bc09e7e5a6d1a994592729d399ca8f065451a2574b92e", size = 4364610, upload-time = "2026-03-11T06:42:56.223Z" },
+ { url = "https://files.pythonhosted.org/packages/03/48/c7bca8045c681f5f60972c180d2a20582c7a0857b3b07b12e0a0ee062ac4/primp-1.1.3-cp310-abi3-win32.whl", hash = "sha256:4b1ea3693c118bf04a6e05286f0a73637cf6fe5c9fd77fa1e29a01f190adf512", size = 3265160, upload-time = "2026-03-11T06:43:43.774Z" },
+ { url = "https://files.pythonhosted.org/packages/45/3e/4a4b8a0f6f15734cded91e85439e68912b2bb8eafe7132420c13c2db8340/primp-1.1.3-cp310-abi3-win_amd64.whl", hash = "sha256:5ea386a4c8c4d8c1021d17182f4ee24dbb6f17c107c4e9ee5500b6372cf08f32", size = 3603953, upload-time = "2026-03-11T06:43:33.144Z" },
+ { url = "https://files.pythonhosted.org/packages/70/46/1baf13a7f5fbed6052deb3e4822c69441a8d0fd990fe2a50e4cec802130b/primp-1.1.3-cp310-abi3-win_arm64.whl", hash = "sha256:63c7b1a1ccbcd07213f438375df186f807cdc5214bc2debb055737db9b5078de", size = 3619917, upload-time = "2026-03-11T06:42:44.76Z" },
+ { url = "https://files.pythonhosted.org/packages/be/0c/a73cbe13f075e7ceaa5172b44ebc6f423713c6b4efe168114993a1710b26/primp-1.1.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4b3d52f3233134584ef527e7e52f1b371a964ade1df0461f8187100e41d7fa84", size = 3987141, upload-time = "2026-03-11T06:43:24.904Z" },
+ { url = "https://files.pythonhosted.org/packages/49/56/b70d7991fb1e07af53706b1f69f78a0b440a7b4b2a2999c44ab44afef1e7/primp-1.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b3d947e2c1d15147e8f4736d027b9f3bef518d67da859ead1c54e028ff491bbb", size = 3735665, upload-time = "2026-03-11T06:43:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/31/82/69efc663341c2bab55659ed221903a090e5c80255c2de2acc70f3726a3fc/primp-1.1.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3ed2fee7d4758f6bb873b19a6759f54e0bc453213dad5ba7e52de7582921079", size = 3873695, upload-time = "2026-03-11T06:43:15.396Z" },
+ { url = "https://files.pythonhosted.org/packages/07/7e/6b360742019ef8fb4ea036a420eb21b0a58d380ca09c68b075fc103cc043/primp-1.1.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5aa717f256af9e4391fb1c4dc946d99d04652b4c57dad20c3947e839ab26769", size = 3512644, upload-time = "2026-03-11T06:43:08.368Z" },
+ { url = "https://files.pythonhosted.org/packages/03/46/51d2ada6d5b53b8496eddf2c80392deab13698987412d0234f88e72390c1/primp-1.1.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17f37fcacd97540f68b06f2b468b111ca7f2b142c48370db7344b522274fc0d6", size = 3733114, upload-time = "2026-03-11T06:43:22.838Z" },
+ { url = "https://files.pythonhosted.org/packages/45/f5/5f5f5f4bef7e247ec3543e2fbdb670d8db8753a7693baf9c8b9fcf52cd43/primp-1.1.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5f010d0b8ba111dd9a66f814c2cd56332e047c98f45d7714ffbf2b1cec5b073", size = 4005664, upload-time = "2026-03-11T06:43:20.824Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/bf/99cf4a5f179b3f13b0c2ba4d3ae8f8af19f0084308e76cb79a0cee03c31b/primp-1.1.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e1e431915e4a7094d589213fc14e955243d93751031d889f4b359fa8ed54298", size = 3895746, upload-time = "2026-03-11T06:43:35.376Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/75/4c625e1cab37585365b0856ca44f31ad598e92a847d23561f454b7f36fca/primp-1.1.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaffa22dae2f193d899d9f68cca109ea5d16cdf4c901c20cec186de89e7d5db4", size = 4109815, upload-time = "2026-03-11T06:43:04.059Z" },
+ { url = "https://files.pythonhosted.org/packages/49/72/6197ea78779d359f307be1acc64659896fc960ed91c0bdc6e6e698e423e6/primp-1.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f93bee50990884621ef482e8434e87f9fbb4eca6f4d47973c44c5d6393c35679", size = 4050839, upload-time = "2026-03-11T06:43:18.296Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/b2/cdd565b28bcf7ce555f4decdf89dafd16db8ed3ba8661890d3b9337abe45/primp-1.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:399dfb9ad01c3612c9e510a7034ac925af5524cade0961d8a019dedd90a46474", size = 3748397, upload-time = "2026-03-11T06:43:27.347Z" },
+ { url = "https://files.pythonhosted.org/packages/62/6e/def3a90821b52589dbe1f57477c2c89bde7a5b26a7c166d7751930c06f98/primp-1.1.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:78ce595bbb9f339e83975efa9db2a81128842fad1a2fdafb78d72fcdc59590fc", size = 3861261, upload-time = "2026-03-11T06:43:39.292Z" },
+ { url = "https://files.pythonhosted.org/packages/10/7d/3e610614d6a426502cfc6eccea21ef4557b39177d365df393c994945ca43/primp-1.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d709bdf520aa9401c0592b642730b3477c828629f01d2550977b77135b34e8d", size = 4358608, upload-time = "2026-03-11T06:43:45.606Z" },
+ { url = "https://files.pythonhosted.org/packages/91/50/eb190cefe5eb05896825a5b3365d5650b9327161329cd1df4f7351b66ba9/primp-1.1.3-cp314-cp314t-win32.whl", hash = "sha256:6fe893eb87156dfb146dd666c7c8754670de82e38af0a27d82a47b7461ec2eea", size = 3259903, upload-time = "2026-03-11T06:42:59.922Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/a8/9e8534bc6d729a667f79b249fcdbf2230b0eb41214e277998cd6be900498/primp-1.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:ced76ef6669f31dc4af25e81e87914310645bcfc0892036bde084dafd6d00c3c", size = 3602569, upload-time = "2026-03-11T06:42:53.955Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/92/e18be996a01c7fd0e7dd7d198edefe42813cdfe1637bbbc80370ce656f62/primp-1.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:efadef0dfd10e733a254a949abf9ed05c668c28a68aa6513d811c0c6acd54cdb", size = 3611571, upload-time = "2026-03-11T06:43:31.249Z" },
+]
+
[[package]]
name = "propcache"
version = "0.4.1"
@@ -2448,6 +2681,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/f2/3483060562245668bb07193b65277f0ea619cabf530deb351911eb0453eb/py_serializable-1.1.2-py3-none-any.whl", hash = "sha256:801be61b0a1ba64c3861f7c624f1de5cfbbabf8b458acc9cdda91e8f7e5effa1", size = 22786, upload-time = "2024-10-01T15:55:42.498Z" },
]
+[[package]]
+name = "pyasn1"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
+]
+
[[package]]
name = "pycares"
version = "4.11.0"
@@ -2644,40 +2886,68 @@ wheels = [
]
[[package]]
-name = "pytest"
-version = "9.0.2"
+name = "pyjwt"
+version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
-[[package]]
-name = "python-dateutil"
-version = "2.9.0.post0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "six" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+[package.optional-dependencies]
+crypto = [
+ { name = "cryptography" },
]
[[package]]
-name = "pygments"
-version = "2.19.2"
+name = "pymongo"
+version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+dependencies = [
+ { name = "dnspython" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/65/9c/a4895c4b785fc9865a84a56e14b5bd21ca75aadc3dab79c14187cdca189b/pymongo-4.16.0.tar.gz", hash = "sha256:8ba8405065f6e258a6f872fe62d797a28f383a12178c7153c01ed04e845c600c", size = 2495323, upload-time = "2026-01-07T18:05:48.107Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/03/6dd7c53cbde98de469a3e6fb893af896dca644c476beb0f0c6342bcc368b/pymongo-4.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bd4911c40a43a821dfd93038ac824b756b6e703e26e951718522d29f6eb166a8", size = 917619, upload-time = "2026-01-07T18:04:19.173Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e1/328915f2734ea1f355dc9b0e98505ff670f5fab8be5e951d6ed70971c6aa/pymongo-4.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25a6b03a68f9907ea6ec8bc7cf4c58a1b51a18e23394f962a6402f8e46d41211", size = 917364, upload-time = "2026-01-07T18:04:20.861Z" },
+ { url = "https://files.pythonhosted.org/packages/41/fe/4769874dd9812a1bc2880a9785e61eba5340da966af888dd430392790ae0/pymongo-4.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:91ac0cb0fe2bf17616c2039dac88d7c9a5088f5cb5829b27c9d250e053664d31", size = 1686901, upload-time = "2026-01-07T18:04:22.219Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/8d/15707b9669fdc517bbc552ac60da7124dafe7ac1552819b51e97ed4038b4/pymongo-4.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf0ec79e8ca7077f455d14d915d629385153b6a11abc0b93283ed73a8013e376", size = 1723034, upload-time = "2026-01-07T18:04:24.055Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/af/3d5d16ff11d447d40c1472da1b366a31c7380d7ea2922a449c7f7f495567/pymongo-4.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d0082631a7510318befc2b4fdab140481eb4b9dd62d9245e042157085da2a70", size = 1797161, upload-time = "2026-01-07T18:04:25.964Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/04/725ab8664eeec73ec125b5a873448d80f5d8cf2750aaaf804cbc538a50a5/pymongo-4.16.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85dc2f3444c346ea019a371e321ac868a4fab513b7a55fe368f0cc78de8177cc", size = 1780938, upload-time = "2026-01-07T18:04:28.745Z" },
+ { url = "https://files.pythonhosted.org/packages/22/50/dd7e9095e1ca35f93c3c844c92eb6eb0bc491caeb2c9bff3b32fe3c9b18f/pymongo-4.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dabbf3c14de75a20cc3c30bf0c6527157224a93dfb605838eabb1a2ee3be008d", size = 1714342, upload-time = "2026-01-07T18:04:30.331Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c9/542776987d5c31ae8e93e92680ea2b6e5a2295f398b25756234cabf38a39/pymongo-4.16.0-cp312-cp312-win32.whl", hash = "sha256:60307bb91e0ab44e560fe3a211087748b2b5f3e31f403baf41f5b7b0a70bd104", size = 887868, upload-time = "2026-01-07T18:04:32.124Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/d4/b4045a7ccc5680fb496d01edf749c7a9367cc8762fbdf7516cf807ef679b/pymongo-4.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:f513b2c6c0d5c491f478422f6b5b5c27ac1af06a54c93ef8631806f7231bd92e", size = 907554, upload-time = "2026-01-07T18:04:33.685Z" },
+ { url = "https://files.pythonhosted.org/packages/60/4c/33f75713d50d5247f2258405142c0318ff32c6f8976171c4fcae87a9dbdf/pymongo-4.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:dfc320f08ea9a7ec5b2403dc4e8150636f0d6150f4b9792faaae539c88e7db3b", size = 892971, upload-time = "2026-01-07T18:04:35.594Z" },
+ { url = "https://files.pythonhosted.org/packages/47/84/148d8b5da8260f4679d6665196ae04ab14ffdf06f5fe670b0ab11942951f/pymongo-4.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d15f060bc6d0964a8bb70aba8f0cb6d11ae99715438f640cff11bbcf172eb0e8", size = 972009, upload-time = "2026-01-07T18:04:38.303Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/5e/9f3a8daf583d0adaaa033a3e3e58194d2282737dc164014ff33c7a081103/pymongo-4.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a19ea46a0fe71248965305a020bc076a163311aefbaa1d83e47d06fa30ac747", size = 971784, upload-time = "2026-01-07T18:04:39.669Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/f2/b6c24361fcde24946198573c0176406bfd5f7b8538335f3d939487055322/pymongo-4.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:311d4549d6bf1f8c61d025965aebb5ba29d1481dc6471693ab91610aaffbc0eb", size = 1947174, upload-time = "2026-01-07T18:04:41.368Z" },
+ { url = "https://files.pythonhosted.org/packages/47/1a/8634192f98cf740b3d174e1018dd0350018607d5bd8ac35a666dc49c732b/pymongo-4.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46ffb728d92dd5b09fc034ed91acf5595657c7ca17d4cf3751322cd554153c17", size = 1991727, upload-time = "2026-01-07T18:04:42.965Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/2f/0c47ac84572b28e23028a23a3798a1f725e1c23b0cf1c1424678d16aff42/pymongo-4.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:acda193f440dd88c2023cb00aa8bd7b93a9df59978306d14d87a8b12fe426b05", size = 2082497, upload-time = "2026-01-07T18:04:44.652Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/57/9f46ef9c862b2f0cf5ce798f3541c201c574128d31ded407ba4b3918d7b6/pymongo-4.16.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d9fdb386cf958e6ef6ff537d6149be7edb76c3268cd6833e6c36aa447e4443f", size = 2064947, upload-time = "2026-01-07T18:04:46.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/56/5421c0998f38e32288100a07f6cb2f5f9f352522157c901910cb2927e211/pymongo-4.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91899dd7fb9a8c50f09c3c1cf0cb73bfbe2737f511f641f19b9650deb61c00ca", size = 1980478, upload-time = "2026-01-07T18:04:48.017Z" },
+ { url = "https://files.pythonhosted.org/packages/92/93/bfc448d025e12313a937d6e1e0101b50cc9751636b4b170e600fe3203063/pymongo-4.16.0-cp313-cp313-win32.whl", hash = "sha256:2cd60cd1e05de7f01927f8e25ca26b3ea2c09de8723241e5d3bcfdc70eaff76b", size = 934672, upload-time = "2026-01-07T18:04:49.538Z" },
+ { url = "https://files.pythonhosted.org/packages/96/10/12710a5e01218d50c3dd165fd72c5ed2699285f77348a3b1a119a191d826/pymongo-4.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3ead8a0050c53eaa55935895d6919d393d0328ec24b2b9115bdbe881aa222673", size = 959237, upload-time = "2026-01-07T18:04:51.382Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/56/d288bcd1d05bc17ec69df1d0b1d67bc710c7c5dbef86033a5a4d2e2b08e6/pymongo-4.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:dbbc5b254c36c37d10abb50e899bc3939bbb7ab1e7c659614409af99bd3e7675", size = 940909, upload-time = "2026-01-07T18:04:52.904Z" },
+ { url = "https://files.pythonhosted.org/packages/30/9e/4d343f8d0512002fce17915a89477b9f916bda1205729e042d8f23acf194/pymongo-4.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a254d49a9ffe9d7f888e3c677eed3729b14ce85abb08cd74732cead6ccc3c66", size = 1026634, upload-time = "2026-01-07T18:04:54.359Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/e3/341f88c5535df40c0450fda915f582757bb7d988cdfc92990a5e27c4c324/pymongo-4.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a1bf44e13cf2d44d2ea2e928a8140d5d667304abe1a61c4d55b4906f389fbe64", size = 1026252, upload-time = "2026-01-07T18:04:56.642Z" },
+ { url = "https://files.pythonhosted.org/packages/af/64/9471b22eb98f0a2ca0b8e09393de048502111b2b5b14ab1bd9e39708aab5/pymongo-4.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f1c5f1f818b669875d191323a48912d3fcd2e4906410e8297bb09ac50c4d5ccc", size = 2207399, upload-time = "2026-01-07T18:04:58.255Z" },
+ { url = "https://files.pythonhosted.org/packages/87/ac/47c4d50b25a02f21764f140295a2efaa583ee7f17992a5e5fa542b3a690f/pymongo-4.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77cfd37a43a53b02b7bd930457c7994c924ad8bbe8dff91817904bcbf291b371", size = 2260595, upload-time = "2026-01-07T18:04:59.788Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/1b/0ce1ce9dd036417646b2fe6f63b58127acff3cf96eeb630c34ec9cd675ff/pymongo-4.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:36ef2fee50eee669587d742fb456e349634b4fcf8926208766078b089054b24b", size = 2366958, upload-time = "2026-01-07T18:05:01.942Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/3c/a5a17c0d413aa9d6c17bc35c2b472e9e79cda8068ba8e93433b5f43028e9/pymongo-4.16.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55f8d5a6fe2fa0b823674db2293f92d74cd5f970bc0360f409a1fc21003862d3", size = 2346081, upload-time = "2026-01-07T18:05:03.576Z" },
+ { url = "https://files.pythonhosted.org/packages/65/19/f815533d1a88fb8a3b6c6e895bb085ffdae68ccb1e6ed7102202a307f8e2/pymongo-4.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9caacac0dd105e2555521002e2d17afc08665187017b466b5753e84c016628e6", size = 2246053, upload-time = "2026-01-07T18:05:05.459Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/88/4be3ec78828dc64b212c123114bd6ae8db5b7676085a7b43cc75d0131bd2/pymongo-4.16.0-cp314-cp314-win32.whl", hash = "sha256:c789236366525c3ee3cd6e4e450a9ff629a7d1f4d88b8e18a0aea0615fd7ecf8", size = 989461, upload-time = "2026-01-07T18:05:07.018Z" },
+ { url = "https://files.pythonhosted.org/packages/af/5a/ab8d5af76421b34db483c9c8ebc3a2199fb80ae63dc7e18f4cf1df46306a/pymongo-4.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b0714d7764efb29bf9d3c51c964aed7c4c7237b341f9346f15ceaf8321fdb35", size = 1017803, upload-time = "2026-01-07T18:05:08.499Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/f4/98d68020728ac6423cf02d17cfd8226bf6cce5690b163d30d3f705e8297e/pymongo-4.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:12762e7cc0f8374a8cae3b9f9ed8dabb5d438c7b33329232dd9b7de783454033", size = 997184, upload-time = "2026-01-07T18:05:09.944Z" },
+ { url = "https://files.pythonhosted.org/packages/50/00/dc3a271daf06401825b9c1f4f76f018182c7738281ea54b9762aea0560c1/pymongo-4.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1c01e8a7cd0ea66baf64a118005535ab5bf9f9eb63a1b50ac3935dccf9a54abe", size = 1083303, upload-time = "2026-01-07T18:05:11.702Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/4b/b5375ee21d12eababe46215011ebc63801c0d2c5ffdf203849d0d79f9852/pymongo-4.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4c4872299ebe315a79f7f922051061634a64fda95b6b17677ba57ef00b2ba2a4", size = 1083233, upload-time = "2026-01-07T18:05:13.182Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/e3/52efa3ca900622c7dcb56c5e70f15c906816d98905c22d2ee1f84d9a7b60/pymongo-4.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:78037d02389745e247fe5ab0bcad5d1ab30726eaac3ad79219c7d6bbb07eec53", size = 2527438, upload-time = "2026-01-07T18:05:14.981Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/96/43b1be151c734e7766c725444bcbfa1de6b60cc66bfb406203746839dd25/pymongo-4.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c126fb72be2518395cc0465d4bae03125119136462e1945aea19840e45d89cfc", size = 2600399, upload-time = "2026-01-07T18:05:16.794Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/62/fa64a5045dfe3a1cd9217232c848256e7bc0136cffb7da4735c5e0d30e40/pymongo-4.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3867dc225d9423c245a51eaac2cfcd53dde8e0a8d8090bb6aed6e31bd6c2d4f", size = 2720960, upload-time = "2026-01-07T18:05:18.498Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7b/01577eb97e605502821273a5bc16ce0fb0be5c978fe03acdbff471471202/pymongo-4.16.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f25001a955073b80510c0c3db0e043dbbc36904fd69e511c74e3d8640b8a5111", size = 2699344, upload-time = "2026-01-07T18:05:20.073Z" },
+ { url = "https://files.pythonhosted.org/packages/55/68/6ef6372d516f703479c3b6cbbc45a5afd307173b1cbaccd724e23919bb1a/pymongo-4.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d9885aad05f82fd7ea0c9ca505d60939746b39263fa273d0125170da8f59098", size = 2577133, upload-time = "2026-01-07T18:05:22.052Z" },
+ { url = "https://files.pythonhosted.org/packages/15/c7/b5337093bb01da852f945802328665f85f8109dbe91d81ea2afe5ff059b9/pymongo-4.16.0-cp314-cp314t-win32.whl", hash = "sha256:948152b30eddeae8355495f9943a3bf66b708295c0b9b6f467de1c620f215487", size = 1040560, upload-time = "2026-01-07T18:05:23.888Z" },
+ { url = "https://files.pythonhosted.org/packages/96/8c/5b448cd1b103f3889d5713dda37304c81020ff88e38a826e8a75ddff4610/pymongo-4.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f6e42c1bc985d9beee884780ae6048790eb4cd565c46251932906bdb1630034a", size = 1075081, upload-time = "2026-01-07T18:05:26.874Z" },
+ { url = "https://files.pythonhosted.org/packages/32/cd/ddc794cdc8500f6f28c119c624252fb6dfb19481c6d7ed150f13cf468a6d/pymongo-4.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6b2a20edb5452ac8daa395890eeb076c570790dfce6b7a44d788af74c2f8cf96", size = 1047725, upload-time = "2026-01-07T18:05:28.47Z" },
]
[[package]]
@@ -2739,6 +3009,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
+[[package]]
+name = "python-jose"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ecdsa" },
+ { name = "pyasn1" },
+ { name = "rsa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
+]
+
+[package.optional-dependencies]
+cryptography = [
+ { name = "cryptography" },
+]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
+]
+
[[package]]
name = "pywin32"
version = "311"
@@ -2801,94 +3099,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
-[[package]]
-name = "regex"
-version = "2026.2.28"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" },
- { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" },
- { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" },
- { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" },
- { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" },
- { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" },
- { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" },
- { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" },
- { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" },
- { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" },
- { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" },
- { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" },
- { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" },
- { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" },
- { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" },
- { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" },
- { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311, upload-time = "2026-02-28T02:17:22.591Z" },
- { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285, upload-time = "2026-02-28T02:17:24.355Z" },
- { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051, upload-time = "2026-02-28T02:17:26.722Z" },
- { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842, upload-time = "2026-02-28T02:17:29.064Z" },
- { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083, upload-time = "2026-02-28T02:17:31.363Z" },
- { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412, upload-time = "2026-02-28T02:17:33.248Z" },
- { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101, upload-time = "2026-02-28T02:17:35.053Z" },
- { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260, upload-time = "2026-02-28T02:17:37.692Z" },
- { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311, upload-time = "2026-02-28T02:17:39.855Z" },
- { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876, upload-time = "2026-02-28T02:17:42.317Z" },
- { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632, upload-time = "2026-02-28T02:17:45.073Z" },
- { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320, upload-time = "2026-02-28T02:17:47.192Z" },
- { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152, upload-time = "2026-02-28T02:17:49.067Z" },
- { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398, upload-time = "2026-02-28T02:17:50.744Z" },
- { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282, upload-time = "2026-02-28T02:17:53.074Z" },
- { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382, upload-time = "2026-02-28T02:17:54.888Z" },
- { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541, upload-time = "2026-02-28T02:17:56.813Z" },
- { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984, upload-time = "2026-02-28T02:17:58.538Z" },
- { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509, upload-time = "2026-02-28T02:18:00.208Z" },
- { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429, upload-time = "2026-02-28T02:18:02.328Z" },
- { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422, upload-time = "2026-02-28T02:18:04.23Z" },
- { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175, upload-time = "2026-02-28T02:18:06.791Z" },
- { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044, upload-time = "2026-02-28T02:18:08.736Z" },
- { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056, upload-time = "2026-02-28T02:18:10.777Z" },
- { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743, upload-time = "2026-02-28T02:18:13.025Z" },
- { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633, upload-time = "2026-02-28T02:18:16.84Z" },
- { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862, upload-time = "2026-02-28T02:18:18.892Z" },
- { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788, upload-time = "2026-02-28T02:18:21.475Z" },
- { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184, upload-time = "2026-02-28T02:18:23.492Z" },
- { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137, upload-time = "2026-02-28T02:18:25.375Z" },
- { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682, upload-time = "2026-02-28T02:18:27.205Z" },
- { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735, upload-time = "2026-02-28T02:18:29.015Z" },
- { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" },
- { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" },
- { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" },
- { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" },
- { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" },
- { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" },
- { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" },
- { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" },
- { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" },
- { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" },
- { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" },
- { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" },
- { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" },
- { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825, upload-time = "2026-02-28T02:18:59.202Z" },
- { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548, upload-time = "2026-02-28T02:19:01.049Z" },
- { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444, upload-time = "2026-02-28T02:19:03.255Z" },
- { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" },
- { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" },
- { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" },
- { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" },
- { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" },
- { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" },
- { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" },
- { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" },
- { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" },
- { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" },
- { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" },
- { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" },
- { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" },
- { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936, upload-time = "2026-02-28T02:19:36.313Z" },
- { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779, upload-time = "2026-02-28T02:19:38.625Z" },
- { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010, upload-time = "2026-02-28T02:19:40.65Z" },
-]
-
[[package]]
name = "rdflib"
version = "7.6.0"
@@ -3043,6 +3253,133 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
+[[package]]
+name = "rpds-py"
+version = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
+ { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
+ { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
+ { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
+ { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
+ { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
+ { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
+ { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
+ { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
+ { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
+ { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
+ { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
+ { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
+ { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
+ { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
+ { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
+ { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
+ { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
+ { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
+ { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
+ { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
+]
+
+[[package]]
+name = "rsa"
+version = "4.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
+]
+
+[[package]]
+name = "rustworkx"
+version = "0.17.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e7/b0/66d96f02120f79eeed86b5c5be04029b6821155f31ed4907a4e9f1460671/rustworkx-0.17.1.tar.gz", hash = "sha256:59ea01b4e603daffa4e8827316c1641eef18ae9032f0b1b14aa0181687e3108e", size = 399407, upload-time = "2025-09-15T16:29:46.429Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/24/8972ed631fa05fdec05a7bb7f1fc0f8e78ee761ab37e8a93d1ed396ba060/rustworkx-0.17.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c08fb8db041db052da404839b064ebfb47dcce04ba9a3e2eb79d0c65ab011da4", size = 2257491, upload-time = "2025-08-13T01:43:31.466Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ae/7b6bbae5e0487ee42072dc6a46edf5db9731a0701ed648db22121fb7490c/rustworkx-0.17.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:4ef8e327dadf6500edd76fedb83f6d888b9266c58bcdbffd5a40c33835c9dd26", size = 2040175, upload-time = "2025-08-13T01:43:33.762Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/ea/c17fb9428c8f0dcc605596f9561627a5b9ef629d356204ee5088cfcf52c6/rustworkx-0.17.1-cp39-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b809e0aa2927c68574b196f993233e269980918101b0dd235289c4f3ddb2115", size = 2324771, upload-time = "2025-08-13T01:43:35.553Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/40/ec8b3b8b0f8c0b768690c454b8dcc2781b4f2c767f9f1215539c7909e35b/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7e82c46a92fb0fd478b7372e15ca524c287485fdecaed37b8bb68f4df2720f2", size = 2068584, upload-time = "2025-08-13T01:43:37.261Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/22/713b900d320d06ce8677e71bba0ec5df0037f1d83270bff5db3b271c10d7/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42170075d8a7319e89ff63062c2f1d1116ced37b6f044f3bf36d10b60a107aa4", size = 2380949, upload-time = "2025-08-13T01:52:17.435Z" },
+ { url = "https://files.pythonhosted.org/packages/20/4b/54be84b3b41a19caf0718a2b6bb280dde98c8626c809c969f16aad17458f/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65cba97fa95470239e2d65eb4db1613f78e4396af9f790ff771b0e5476bfd887", size = 2562069, upload-time = "2025-08-13T02:09:27.222Z" },
+ { url = "https://files.pythonhosted.org/packages/39/5b/281bb21d091ab4e36cf377088366d55d0875fa2347b3189c580ec62b44c7/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246cc252053f89e36209535b9c58755960197e6ae08d48d3973760141c62ac95", size = 2221186, upload-time = "2025-08-13T01:43:38.598Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/2d/30a941a21b81e9db50c4c3ef8a64c5ee1c8eea3a90506ca0326ce39d021f/rustworkx-0.17.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c10d25e9f0e87d6a273d1ea390b636b4fb3fede2094bf0cb3fe565d696a91b48", size = 2123510, upload-time = "2025-08-13T01:43:40.288Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/ef/c9199e4b6336ee5a9f1979c11b5779c5cf9ab6f8386e0b9a96c8ffba7009/rustworkx-0.17.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:48784a673cf8d04f3cd246fa6b53fd1ccc4d83304503463bd561c153517bccc1", size = 2302783, upload-time = "2025-08-13T01:43:42.073Z" },
+ { url = "https://files.pythonhosted.org/packages/30/3d/a49ab633e99fca4ccbb9c9f4bd41904186c175ebc25c530435529f71c480/rustworkx-0.17.1-cp39-abi3-win32.whl", hash = "sha256:5dbc567833ff0a8ad4580a4fe4bde92c186d36b4c45fca755fb1792e4fafe9b5", size = 1931541, upload-time = "2025-08-13T01:43:43.415Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/ec/cee878c1879b91ab8dc7d564535d011307839a2fea79d2a650413edf53be/rustworkx-0.17.1-cp39-abi3-win_amd64.whl", hash = "sha256:d0a48fb62adabd549f9f02927c3a159b51bf654c7388a12fc16d45452d5703ea", size = 2055049, upload-time = "2025-08-13T01:43:44.926Z" },
+]
+
+[[package]]
+name = "s3transfer"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/0a/1cdbabf9edd0ea7747efdf6c9ab4e7061b085aa7f9bfc36bb1601563b069/s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7", size = 145287, upload-time = "2024-11-20T21:06:05.981Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/66/05/7957af15543b8c9799209506df4660cba7afc4cf94bfb60513827e96bed6/s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", size = 83175, upload-time = "2024-11-20T21:06:03.961Z" },
+]
+
[[package]]
name = "safetensors"
version = "0.7.0"
@@ -3065,6 +3402,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
]
+[[package]]
+name = "schema"
+version = "0.7.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "contextlib2" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4e/e8/01e1b46d9e04cdaee91c9c736d9117304df53361a191144c8eccda7f0ee9/schema-0.7.5.tar.gz", hash = "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197", size = 48173, upload-time = "2021-12-01T20:49:24.038Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/93/ca8aa5a772efd69043d0a745172d92bee027caa7565c7f774a2f44b91207/schema-0.7.5-py2.py3-none-any.whl", hash = "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c", size = 17603, upload-time = "2021-12-01T20:49:21.252Z" },
+]
+
[[package]]
name = "scikit-learn"
version = "1.8.0"
@@ -3170,6 +3519,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
]
+[[package]]
+name = "semantic-version"
+version = "2.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" },
+]
+
[[package]]
name = "sentence-transformers"
version = "5.3.0"
@@ -3216,151 +3574,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
-[[package]]
-name = "rpds-py"
-version = "0.30.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
- { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
- { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
- { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
- { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
- { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
- { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
- { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
- { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
- { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
- { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
- { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
- { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
- { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
- { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
- { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
- { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
- { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
- { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
- { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
- { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
- { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
- { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
- { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
- { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
- { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
- { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
- { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
- { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
- { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
- { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
- { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
- { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
- { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
- { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
- { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
- { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
- { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
- { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
- { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
- { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
- { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
- { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
- { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
- { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
- { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
- { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
- { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
- { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
- { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
- { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
- { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
- { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
- { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
- { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
- { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
- { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
- { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
- { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
- { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
- { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
- { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
- { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
- { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
- { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
- { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
- { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
- { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
- { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
- { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
- { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
- { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
- { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
-]
-
-[[package]]
-name = "rustworkx"
-version = "0.17.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "numpy" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e7/b0/66d96f02120f79eeed86b5c5be04029b6821155f31ed4907a4e9f1460671/rustworkx-0.17.1.tar.gz", hash = "sha256:59ea01b4e603daffa4e8827316c1641eef18ae9032f0b1b14aa0181687e3108e", size = 399407, upload-time = "2025-09-15T16:29:46.429Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/20/24/8972ed631fa05fdec05a7bb7f1fc0f8e78ee761ab37e8a93d1ed396ba060/rustworkx-0.17.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c08fb8db041db052da404839b064ebfb47dcce04ba9a3e2eb79d0c65ab011da4", size = 2257491, upload-time = "2025-08-13T01:43:31.466Z" },
- { url = "https://files.pythonhosted.org/packages/23/ae/7b6bbae5e0487ee42072dc6a46edf5db9731a0701ed648db22121fb7490c/rustworkx-0.17.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:4ef8e327dadf6500edd76fedb83f6d888b9266c58bcdbffd5a40c33835c9dd26", size = 2040175, upload-time = "2025-08-13T01:43:33.762Z" },
- { url = "https://files.pythonhosted.org/packages/cd/ea/c17fb9428c8f0dcc605596f9561627a5b9ef629d356204ee5088cfcf52c6/rustworkx-0.17.1-cp39-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b809e0aa2927c68574b196f993233e269980918101b0dd235289c4f3ddb2115", size = 2324771, upload-time = "2025-08-13T01:43:35.553Z" },
- { url = "https://files.pythonhosted.org/packages/d7/40/ec8b3b8b0f8c0b768690c454b8dcc2781b4f2c767f9f1215539c7909e35b/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7e82c46a92fb0fd478b7372e15ca524c287485fdecaed37b8bb68f4df2720f2", size = 2068584, upload-time = "2025-08-13T01:43:37.261Z" },
- { url = "https://files.pythonhosted.org/packages/d9/22/713b900d320d06ce8677e71bba0ec5df0037f1d83270bff5db3b271c10d7/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42170075d8a7319e89ff63062c2f1d1116ced37b6f044f3bf36d10b60a107aa4", size = 2380949, upload-time = "2025-08-13T01:52:17.435Z" },
- { url = "https://files.pythonhosted.org/packages/20/4b/54be84b3b41a19caf0718a2b6bb280dde98c8626c809c969f16aad17458f/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65cba97fa95470239e2d65eb4db1613f78e4396af9f790ff771b0e5476bfd887", size = 2562069, upload-time = "2025-08-13T02:09:27.222Z" },
- { url = "https://files.pythonhosted.org/packages/39/5b/281bb21d091ab4e36cf377088366d55d0875fa2347b3189c580ec62b44c7/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246cc252053f89e36209535b9c58755960197e6ae08d48d3973760141c62ac95", size = 2221186, upload-time = "2025-08-13T01:43:38.598Z" },
- { url = "https://files.pythonhosted.org/packages/cc/2d/30a941a21b81e9db50c4c3ef8a64c5ee1c8eea3a90506ca0326ce39d021f/rustworkx-0.17.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c10d25e9f0e87d6a273d1ea390b636b4fb3fede2094bf0cb3fe565d696a91b48", size = 2123510, upload-time = "2025-08-13T01:43:40.288Z" },
- { url = "https://files.pythonhosted.org/packages/4f/ef/c9199e4b6336ee5a9f1979c11b5779c5cf9ab6f8386e0b9a96c8ffba7009/rustworkx-0.17.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:48784a673cf8d04f3cd246fa6b53fd1ccc4d83304503463bd561c153517bccc1", size = 2302783, upload-time = "2025-08-13T01:43:42.073Z" },
- { url = "https://files.pythonhosted.org/packages/30/3d/a49ab633e99fca4ccbb9c9f4bd41904186c175ebc25c530435529f71c480/rustworkx-0.17.1-cp39-abi3-win32.whl", hash = "sha256:5dbc567833ff0a8ad4580a4fe4bde92c186d36b4c45fca755fb1792e4fafe9b5", size = 1931541, upload-time = "2025-08-13T01:43:43.415Z" },
- { url = "https://files.pythonhosted.org/packages/a9/ec/cee878c1879b91ab8dc7d564535d011307839a2fea79d2a650413edf53be/rustworkx-0.17.1-cp39-abi3-win_amd64.whl", hash = "sha256:d0a48fb62adabd549f9f02927c3a159b51bf654c7388a12fc16d45452d5703ea", size = 2055049, upload-time = "2025-08-13T01:43:44.926Z" },
-]
-
-[[package]]
-name = "s3transfer"
-version = "0.10.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "botocore" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c0/0a/1cdbabf9edd0ea7747efdf6c9ab4e7061b085aa7f9bfc36bb1601563b069/s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7", size = 145287, upload-time = "2024-11-20T21:06:05.981Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/66/05/7957af15543b8c9799209506df4660cba7afc4cf94bfb60513827e96bed6/s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", size = 83175, upload-time = "2024-11-20T21:06:03.961Z" },
-]
-
-[[package]]
-name = "schema"
-version = "0.7.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "contextlib2" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/4e/e8/01e1b46d9e04cdaee91c9c736d9117304df53361a191144c8eccda7f0ee9/schema-0.7.5.tar.gz", hash = "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197", size = 48173, upload-time = "2021-12-01T20:49:24.038Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0d/93/ca8aa5a772efd69043d0a745172d92bee027caa7565c7f774a2f44b91207/schema-0.7.5-py2.py3-none-any.whl", hash = "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c", size = 17603, upload-time = "2021-12-01T20:49:21.252Z" },
-]
-
-[[package]]
-name = "semantic-version"
-version = "2.10.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" },
-]
-
-[[package]]
-name = "six"
-version = "1.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
-]
-
[[package]]
name = "smmap"
version = "5.0.3"
@@ -3475,6 +3688,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/27/fb1b6e3f9072854fe405f7aa99c46d4b465e84c9cec2ff7778edf29ecbbd/sqlite_vec-0.1.7-py3-none-win_amd64.whl", hash = "sha256:0c67877a87cb49426237b950237e82dbeb77778ab2ba89bea859f391fd169382", size = 292804, upload-time = "2026-03-17T07:42:54.325Z" },
]
+[[package]]
+name = "sse-starlette"
+version = "3.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "starlette" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" },
+]
+
[[package]]
name = "starlette"
version = "0.52.1"
@@ -3518,6 +3744,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
+[[package]]
+name = "termcolor"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b8/85/147a0529b4e80b6b9d021ca8db3a820fcac53ec7374b87073d004aaf444c/termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a", size = 12163, upload-time = "2023-04-23T19:45:24.004Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/e1/434566ffce04448192369c1a282931cf4ae593e91907558eaecd2e9f2801/termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475", size = 6872, upload-time = "2023-04-23T19:45:22.671Z" },
+]
+
[[package]]
name = "threadpoolctl"
version = "3.6.0"
@@ -3672,27 +3907,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
]
-[[package]]
-name = "termcolor"
-version = "2.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b8/85/147a0529b4e80b6b9d021ca8db3a820fcac53ec7374b87073d004aaf444c/termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a", size = 12163, upload-time = "2023-04-23T19:45:24.004Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/67/e1/434566ffce04448192369c1a282931cf4ae593e91907558eaecd2e9f2801/termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475", size = 6872, upload-time = "2023-04-23T19:45:22.671Z" },
-]
-
-[[package]]
-name = "tqdm"
-version = "4.67.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
-]
-
[[package]]
name = "typing-extensions"
version = "4.15.0"
From 8a5b931bcf2264e2f20eaac0a00dcc935d212ca8 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:05:35 -0700
Subject: [PATCH 16/50] feat(main): add lifespan, CORS middleware, and router
registration
---
backend/app/main.py | 53 ++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 52 insertions(+), 1 deletion(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index dbc7393..6494996 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,17 +1,68 @@
+import logging
+from contextlib import asynccontextmanager
+
from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
from app.config import settings
-from app.routers import agent3, health, workflows
+from app.db.mongo import connect_mongo, disconnect_mongo
+from app.routers import agent3, auth, health, projects, workflows
from app.routers.architecture import router as architecture_router
+from app.routers.architecture_sse import router as architecture_sse_router
+from app.routers.build import router as build_router
+from app.routers.deploy import router as deploy_router
+from app.routers.prd import router as prd_router
+
+logger = logging.getLogger(__name__)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await connect_mongo()
+ logger.info("MongoDB connected")
+
+ try:
+ from app.agents.architecture_planner.kg_traversal_agent import init_kuzu
+
+ app.state.kuzu_conn = init_kuzu(
+ graph_json_path=settings.graph_json_path,
+ db_path=settings.kuzu_db_path,
+ )
+ logger.info("Kuzu loaded")
+ except Exception as exc:
+ logger.warning("Kuzu init failed: %s", exc)
+ app.state.kuzu_conn = None
+
+ yield
+
+ await disconnect_mongo()
+ logger.info("MongoDB disconnected")
+
app = FastAPI(
title=settings.app_name,
debug=settings.debug,
+ lifespan=lifespan,
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[settings.frontend_url],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
)
app.include_router(health.router)
+app.include_router(auth.router)
+app.include_router(projects.router)
app.include_router(workflows.router)
app.include_router(architecture_router)
+app.include_router(architecture_sse_router)
app.include_router(agent3.router)
+app.include_router(prd_router)
+app.include_router(build_router)
+app.include_router(deploy_router)
@app.get("/")
From f8c0bccecc7c3172d1cc47e45e399f478c57279b Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:07:23 -0700
Subject: [PATCH 17/50] =?UTF-8?q?feat(auth):=20add=20auth=20schemas=20?=
=?UTF-8?q?=E2=80=94=20RegisterRequest,=20LoginRequest,=20AuthResponse?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/schemas/auth.py | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
create mode 100644 backend/app/schemas/auth.py
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
new file mode 100644
index 0000000..38b123a
--- /dev/null
+++ b/backend/app/schemas/auth.py
@@ -0,0 +1,31 @@
+from pydantic import BaseModel
+
+
+class RegisterRequest(BaseModel):
+ email: str
+ username: str
+ password: str
+
+
+class LoginRequest(BaseModel):
+ email: str
+ password: str
+
+
+class UserPublic(BaseModel):
+ id: str
+ email: str
+ username: str
+ github_connected: bool
+
+
+class AuthResponse(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+ user: UserPublic
+
+
+class RefreshResponse(BaseModel):
+ access_token: str
+ token_type: str = "bearer"
From 559ebfe8a10de5b24a93d08ef66e36bbf2e5f34d Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:07:34 -0700
Subject: [PATCH 18/50] feat(auth): add register, login, refresh, me, and
GitHub OAuth endpoints
---
backend/app/routers/auth.py | 203 ++++++++++++++++++++++++++++++++++++
1 file changed, 203 insertions(+)
create mode 100644 backend/app/routers/auth.py
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
new file mode 100644
index 0000000..24f7247
--- /dev/null
+++ b/backend/app/routers/auth.py
@@ -0,0 +1,203 @@
+from datetime import datetime, timedelta, timezone
+from secrets import token_urlsafe
+
+import httpx
+from bson import ObjectId
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+
+from app.config import settings
+from app.core.dependencies import get_current_user
+from app.core.security import (
+ create_access_token,
+ create_refresh_token,
+ decode_token,
+ hash_password,
+ verify_password,
+)
+from app.db.encryption import encrypt
+from app.db.mongo import users_col
+from app.schemas.auth import AuthResponse, LoginRequest, RefreshResponse, RegisterRequest, UserPublic
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
+
+# state -> {"user_id": str, "expires_at": datetime}
+_github_states: dict[str, dict] = {}
+
+
+def _user_to_public(user: dict) -> UserPublic:
+ return UserPublic(
+ id=str(user["_id"]),
+ email=user["email"],
+ username=user["username"],
+ github_connected=bool(user.get("github_token_encrypted")),
+ )
+
+
+@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
+async def register(payload: RegisterRequest) -> AuthResponse:
+ col = users_col()
+
+ if await col.find_one({"email": payload.email}):
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
+
+ if await col.find_one({"username": payload.username}):
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken")
+
+ now = datetime.now(timezone.utc)
+ result = await col.insert_one(
+ {
+ "email": payload.email,
+ "username": payload.username,
+ "hashed_password": hash_password(payload.password),
+ "github_token_encrypted": None,
+ "github_login": None,
+ "created_at": now,
+ "updated_at": now,
+ }
+ )
+
+ user_id = str(result.inserted_id)
+ access_token = create_access_token({"sub": user_id, "token_type": "access"})
+ refresh_token = create_refresh_token({"sub": user_id})
+
+ user = await col.find_one({"_id": result.inserted_id})
+ return AuthResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ user=_user_to_public(user),
+ )
+
+
+@router.post("/login", response_model=AuthResponse)
+async def login(payload: LoginRequest) -> AuthResponse:
+ col = users_col()
+ user = await col.find_one({"email": payload.email})
+
+ if not user or not verify_password(payload.password, user["hashed_password"]):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid email or password",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ user_id = str(user["_id"])
+ access_token = create_access_token({"sub": user_id, "token_type": "access"})
+ refresh_token = create_refresh_token({"sub": user_id})
+
+ return AuthResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ user=_user_to_public(user),
+ )
+
+
+@router.post("/refresh", response_model=RefreshResponse)
+async def refresh_token(token: str = Depends(oauth2_scheme)) -> RefreshResponse:
+ payload = decode_token(token)
+
+ if payload.get("type") != "refresh":
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid token type",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ user_id: str | None = payload.get("sub")
+ if not user_id:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Token missing subject",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ user = await users_col().find_one({"_id": ObjectId(user_id)})
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="User not found",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ access_token = create_access_token({"sub": user_id, "token_type": "access"})
+ return RefreshResponse(access_token=access_token)
+
+
+@router.get("/me", response_model=UserPublic)
+async def me(current_user: dict = Depends(get_current_user)) -> UserPublic:
+ return _user_to_public(current_user)
+
+
+@router.get("/github")
+async def github_oauth_init(current_user: dict = Depends(get_current_user)) -> dict:
+ state = token_urlsafe(32)
+ _github_states[state] = {
+ "user_id": str(current_user["_id"]),
+ "expires_at": datetime.now(timezone.utc) + timedelta(minutes=10),
+ }
+ auth_url = (
+ f"https://github.com/login/oauth/authorize"
+ f"?client_id={settings.github_client_id}"
+ f"&redirect_uri={settings.github_redirect_uri}"
+ f"&scope=repo"
+ f"&state={state}"
+ )
+ return {"auth_url": auth_url}
+
+
+@router.get("/github/callback")
+async def github_oauth_callback(code: str, state: str) -> dict:
+ entry = _github_states.get(state)
+ if not entry:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired state")
+
+ if datetime.now(timezone.utc) > entry["expires_at"]:
+ del _github_states[state]
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="State expired")
+
+ user_id = entry["user_id"]
+ del _github_states[state]
+
+ async with httpx.AsyncClient() as client:
+ token_resp = await client.post(
+ "https://github.com/login/oauth/access_token",
+ headers={"Accept": "application/json"},
+ json={
+ "client_id": settings.github_client_id,
+ "client_secret": settings.github_client_secret,
+ "code": code,
+ "redirect_uri": settings.github_redirect_uri,
+ },
+ )
+ token_data = token_resp.json()
+ github_access_token = token_data.get("access_token")
+ if not github_access_token:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="GitHub token exchange failed",
+ )
+
+ user_resp = await client.get(
+ "https://api.github.com/user",
+ headers={"Authorization": f"token {github_access_token}"},
+ )
+ github_user = user_resp.json()
+ login = github_user.get("login")
+
+ encrypted_token = encrypt(github_access_token)
+ now = datetime.now(timezone.utc)
+
+ await users_col().update_one(
+ {"_id": ObjectId(user_id)},
+ {
+ "$set": {
+ "github_token_encrypted": encrypted_token,
+ "github_login": login,
+ "updated_at": now,
+ }
+ },
+ )
+
+ return {"github_connected": True, "github_login": login}
From 50ad05c02e742d77368274224abf018919de8bc3 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:07:39 -0700
Subject: [PATCH 19/50] =?UTF-8?q?feat(projects):=20add=20project=20schemas?=
=?UTF-8?q?=20=E2=80=94=20ProjectCreate,=20ProjectUpdate,=20ProjectRespons?=
=?UTF-8?q?e?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/schemas/project.py | 43 ++++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 backend/app/schemas/project.py
diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py
new file mode 100644
index 0000000..2241a30
--- /dev/null
+++ b/backend/app/schemas/project.py
@@ -0,0 +1,43 @@
+from datetime import datetime
+from typing import Optional
+
+from pydantic import BaseModel
+
+
+class ProjectCreate(BaseModel):
+ name: str
+ description: Optional[str] = None
+ region: Optional[str] = None
+ cloud_provider: Optional[str] = None
+
+
+class ProjectUpdate(BaseModel):
+ name: Optional[str] = None
+ description: Optional[str] = None
+ github_repo: Optional[str] = None
+
+
+class CloudCredentials(BaseModel):
+ provider: str
+ role_arn: str
+ region: str
+
+
+class ProjectResponse(BaseModel):
+ id: str
+ owner_id: str
+ name: str
+ description: Optional[str] = None
+ status: str
+ stage: str
+ region: Optional[str] = None
+ cloud_provider: Optional[str] = None
+ prd_session_id: Optional[str] = None
+ arch_session_id: Optional[str] = None
+ build_id: Optional[str] = None
+ deployment_id: Optional[str] = None
+ github_repo: Optional[str] = None
+ github_connected: bool = False
+ cloud_verified: bool = False
+ created_at: datetime
+ updated_at: datetime
From 99fd2869e6374761bba2753efcd482cdf0a661d0 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:07:54 -0700
Subject: [PATCH 20/50] feat(projects): add CRUD and cloud credentials storage
endpoints
---
backend/app/routers/projects.py | 168 ++++++++++++++++++++++++++++++++
1 file changed, 168 insertions(+)
create mode 100644 backend/app/routers/projects.py
diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py
new file mode 100644
index 0000000..f4f579c
--- /dev/null
+++ b/backend/app/routers/projects.py
@@ -0,0 +1,168 @@
+import json
+from datetime import datetime, timezone
+
+from bson import ObjectId
+from fastapi import APIRouter, Depends, HTTPException, status
+
+from app.core.dependencies import get_current_user
+from app.db.encryption import encrypt
+from app.db.mongo import (
+ architectures_col,
+ builds_col,
+ deployments_col,
+ projects_col,
+ prd_conversations_col,
+)
+from app.schemas.project import CloudCredentials, ProjectCreate, ProjectResponse, ProjectUpdate
+
+router = APIRouter(prefix="/projects", tags=["projects"])
+
+
+def _doc_to_response(doc: dict) -> ProjectResponse:
+ return ProjectResponse(
+ id=str(doc["_id"]),
+ owner_id=str(doc["owner_id"]),
+ name=doc["name"],
+ description=doc.get("description"),
+ status=doc["status"],
+ stage=doc["stage"],
+ region=doc.get("region"),
+ cloud_provider=doc.get("cloud_provider"),
+ prd_session_id=doc.get("prd_session_id"),
+ arch_session_id=doc.get("arch_session_id"),
+ build_id=doc.get("build_id"),
+ deployment_id=doc.get("deployment_id"),
+ github_repo=doc.get("github_repo"),
+ github_connected=bool(doc.get("github_repo")),
+ cloud_verified=bool(doc.get("cloud_credentials_encrypted")),
+ created_at=doc["created_at"],
+ updated_at=doc["updated_at"],
+ )
+
+
+async def _get_owned_project(project_id: str, user: dict) -> dict:
+ try:
+ oid = ObjectId(project_id)
+ except Exception:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
+
+ project = await projects_col().find_one({"_id": oid})
+ if project is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
+ if project.get("owner_id") != ObjectId(str(user["_id"])):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="You do not have permission to access this project",
+ )
+ return project
+
+
+@router.post("/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
+async def create_project(
+ body: ProjectCreate,
+ user: dict = Depends(get_current_user),
+) -> ProjectResponse:
+ now = datetime.now(timezone.utc)
+ doc = {
+ "owner_id": ObjectId(str(user["_id"])),
+ "name": body.name,
+ "description": body.description,
+ "region": body.region,
+ "cloud_provider": body.cloud_provider,
+ "status": "draft",
+ "stage": "prd",
+ "prd_session_id": None,
+ "arch_session_id": None,
+ "build_id": None,
+ "deployment_id": None,
+ "github_repo": None,
+ "cloud_credentials_encrypted": None,
+ "cloud_provider_type": None,
+ "created_at": now,
+ "updated_at": now,
+ }
+ result = await projects_col().insert_one(doc)
+ created = await projects_col().find_one({"_id": result.inserted_id})
+ return _doc_to_response(created)
+
+
+@router.get("/", response_model=list[ProjectResponse])
+async def list_projects(
+ user: dict = Depends(get_current_user),
+) -> list[ProjectResponse]:
+ cursor = projects_col().find({"owner_id": ObjectId(str(user["_id"]))})
+ docs = await cursor.to_list(length=None)
+ return [_doc_to_response(doc) for doc in docs]
+
+
+@router.get("/{project_id}", response_model=ProjectResponse)
+async def get_project(
+ project_id: str,
+ user: dict = Depends(get_current_user),
+) -> ProjectResponse:
+ project = await _get_owned_project(project_id, user)
+ return _doc_to_response(project)
+
+
+@router.patch("/{project_id}", response_model=ProjectResponse)
+async def update_project(
+ project_id: str,
+ body: ProjectUpdate,
+ user: dict = Depends(get_current_user),
+) -> ProjectResponse:
+ await _get_owned_project(project_id, user)
+
+ updates: dict = {k: v for k, v in body.model_dump().items() if v is not None}
+ updates["updated_at"] = datetime.now(timezone.utc)
+
+ await projects_col().update_one(
+ {"_id": ObjectId(project_id)},
+ {"$set": updates},
+ )
+ updated = await projects_col().find_one({"_id": ObjectId(project_id)})
+ return _doc_to_response(updated)
+
+
+@router.delete("/{project_id}")
+async def delete_project(
+ project_id: str,
+ user: dict = Depends(get_current_user),
+) -> dict:
+ await _get_owned_project(project_id, user)
+
+ oid = ObjectId(project_id)
+ await projects_col().delete_one({"_id": oid})
+ await prd_conversations_col().delete_many({"project_id": oid})
+ await architectures_col().delete_many({"project_id": oid})
+ await builds_col().delete_many({"project_id": oid})
+ await deployments_col().delete_many({"project_id": oid})
+
+ return {"deleted": True}
+
+
+@router.post("/{project_id}/credentials")
+async def set_cloud_credentials(
+ project_id: str,
+ body: CloudCredentials,
+ user: dict = Depends(get_current_user),
+) -> dict:
+ await _get_owned_project(project_id, user)
+
+ payload = json.dumps(
+ {"provider": body.provider, "role_arn": body.role_arn, "region": body.region}
+ )
+ encrypted = encrypt(payload)
+ now = datetime.now(timezone.utc)
+
+ await projects_col().update_one(
+ {"_id": ObjectId(project_id)},
+ {
+ "$set": {
+ "cloud_credentials_encrypted": encrypted,
+ "cloud_provider_type": body.provider,
+ "updated_at": now,
+ }
+ },
+ )
+
+ return {"cloud_verified": False}
From 60abae9433eaea91689538963be350835a0f238c Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:07:58 -0700
Subject: [PATCH 21/50] =?UTF-8?q?feat(prd):=20add=20Agent=201=20SSE=20wrap?=
=?UTF-8?q?per=20=E2=80=94=20constraint=20chip=20extraction=20and=20accept?=
=?UTF-8?q?=20endpoint?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/routers/prd.py | 327 +++++++++++++++++++++++++++++++++++++
backend/app/schemas/prd.py | 18 ++
2 files changed, 345 insertions(+)
create mode 100644 backend/app/routers/prd.py
create mode 100644 backend/app/schemas/prd.py
diff --git a/backend/app/routers/prd.py b/backend/app/routers/prd.py
new file mode 100644
index 0000000..bf92b58
--- /dev/null
+++ b/backend/app/routers/prd.py
@@ -0,0 +1,327 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime, timezone
+from uuid import uuid4
+
+from bson import ObjectId
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.responses import StreamingResponse
+
+from app.agents.agent1 import run_until_interrupt
+from app.agents.agent1.state import AgentState
+from app.core.dependencies import get_current_user
+from app.db.mongo import prd_conversations_col, projects_col
+from app.schemas.prd import ConstraintChip, PrdRespondRequest, PrdStartRequest
+from app.services.workflow_sessions import session_store
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/workflows/prd/v2", tags=["prd-v2"])
+
+
+# ---------------------------------------------------------------------------
+# SSE helpers
+# ---------------------------------------------------------------------------
+
+def _sse(data: dict) -> str:
+ return f"data: {json.dumps(data)}\n\n"
+
+
+def _infer_category(label: str) -> str:
+ low = label.lower()
+ if any(k in low for k in ["latency", "throughput", "performance", "p95", "p99", "response"]):
+ return "performance"
+ if any(k in low for k in ["jwt", "tls", "auth", "security", "encrypt", "ssl"]):
+ return "security"
+ if any(k in low for k in ["cost", "price", "budget", "dollar", "$"]):
+ return "cost"
+ return "reliability"
+
+
+def _parse_constraints(state: AgentState) -> list[ConstraintChip]:
+ if state.plan_json is None:
+ return []
+ chips: list[ConstraintChip] = []
+ for item in state.plan_json.non_functional_requirements:
+ if isinstance(item, dict):
+ label = (
+ item.get("requirement")
+ or item.get("description")
+ or str(item)
+ )
+ else:
+ label = str(item)
+ label = label.strip()
+ if not label:
+ continue
+ chips.append(
+ ConstraintChip(
+ id=str(uuid4()),
+ label=label,
+ category=_infer_category(label),
+ )
+ )
+ return chips
+
+
+# ---------------------------------------------------------------------------
+# Async wrapper around the sync agent runner
+# ---------------------------------------------------------------------------
+
+async def _run_agent1(state: AgentState) -> AgentState:
+ loop = asyncio.get_event_loop()
+
+ def _sync_run() -> AgentState:
+ return run_until_interrupt(state.as_graph_state())
+
+ with ThreadPoolExecutor() as executor:
+ result: AgentState = await loop.run_in_executor(executor, _sync_run)
+ return result
+
+
+# ---------------------------------------------------------------------------
+# Persistence helpers
+# ---------------------------------------------------------------------------
+
+async def _persist_prd(
+ project_id: str,
+ session_id: str,
+ result: AgentState,
+ chips: list[ConstraintChip],
+) -> None:
+ now = datetime.now(timezone.utc)
+ session_doc = {
+ "project_id": ObjectId(project_id),
+ "session_id": session_id,
+ "status": "plan_ready",
+ "plan_markdown": result.plan_markdown or "",
+ "plan_json": result.plan_json.model_dump(mode="json") if result.plan_json else {},
+ "messages": [],
+ "created_at": now,
+ "updated_at": now,
+ }
+ await prd_conversations_col().update_one(
+ {"session_id": session_id},
+ {"$set": session_doc},
+ upsert=True,
+ )
+ await projects_col().update_one(
+ {"_id": ObjectId(project_id)},
+ {"$set": {"prd_session_id": session_id, "updated_at": now}},
+ )
+
+
+# ---------------------------------------------------------------------------
+# POST /workflows/prd/v2/start/{project_id}
+# ---------------------------------------------------------------------------
+
+@router.post("/start/{project_id}")
+async def start_prd(
+ project_id: str,
+ payload: PrdStartRequest,
+ user: dict = Depends(get_current_user),
+) -> StreamingResponse:
+ # Gate: validate project
+ try:
+ oid = ObjectId(project_id)
+ except Exception:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
+
+ project = await projects_col().find_one({"_id": oid})
+ if project is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
+ if project.get("owner_id") != ObjectId(str(user["_id"])):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
+ if project.get("stage") != "prd":
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Project stage is '{project.get('stage')}', expected 'prd'",
+ )
+
+ # Resolve or create in-memory session
+ existing_session_id: str | None = project.get("prd_session_id")
+ if existing_session_id:
+ raw = session_store.get(existing_session_id)
+ if raw:
+ initial_state = AgentState.model_validate(raw)
+ else:
+ # Session evicted from memory — rebuild from Mongo
+ conv = await prd_conversations_col().find_one({"session_id": existing_session_id})
+ if conv:
+ initial_state = AgentState(
+ session_id=existing_session_id,
+ prd_text=payload.prd_text,
+ cloud_provider=payload.cloud_provider.lower().strip(),
+ status="running",
+ )
+ session_store.save(initial_state.as_graph_state())
+ else:
+ initial_state = None
+ if initial_state is None:
+ existing_session_id = None
+
+ if not existing_session_id:
+ base_state = AgentState(
+ prd_text=payload.prd_text,
+ cloud_provider=payload.cloud_provider.lower().strip(),
+ status="running",
+ )
+ raw = session_store.create(base_state.as_graph_state())
+ session_id = raw["session_id"]
+ initial_state = AgentState.model_validate(raw)
+ else:
+ session_id = existing_session_id
+
+ async def _stream():
+ try:
+ result = await _run_agent1(initial_state)
+ session_store.save(result.as_graph_state())
+
+ chips = _parse_constraints(result)
+ await _persist_prd(project_id, session_id, result, chips)
+
+ for chip in chips:
+ yield _sse({"type": "constraint", "chip": chip.model_dump()})
+
+ yield _sse({
+ "type": "plan_ready",
+ "session_id": session_id,
+ "plan_markdown": result.plan_markdown or "",
+ })
+
+ except Exception as exc:
+ logger.exception("Error in start_prd SSE stream")
+ yield _sse({"type": "error", "message": str(exc)})
+
+ yield "data: [DONE]\n\n"
+
+ return StreamingResponse(
+ _stream(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+# ---------------------------------------------------------------------------
+# POST /workflows/prd/v2/respond/{project_id}
+# ---------------------------------------------------------------------------
+
+@router.post("/respond/{project_id}")
+async def respond_prd(
+ project_id: str,
+ payload: PrdRespondRequest,
+ user: dict = Depends(get_current_user),
+) -> StreamingResponse:
+ # Gate: validate project
+ try:
+ oid = ObjectId(project_id)
+ except Exception:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
+
+ project = await projects_col().find_one({"_id": oid})
+ if project is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
+ if project.get("owner_id") != ObjectId(str(user["_id"])):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
+
+ session_id: str | None = project.get("prd_session_id")
+ if not session_id:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="No active PRD session; call /start first",
+ )
+
+ raw = session_store.get(session_id)
+ if not raw:
+ # Try to restore from Mongo
+ conv = await prd_conversations_col().find_one({"session_id": session_id})
+ if not conv:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
+ raw = {
+ "session_id": session_id,
+ "prd_text": conv.get("plan_markdown", ""),
+ "cloud_provider": project.get("cloud_provider", "aws"),
+ "status": "needs_input",
+ "plan_markdown": conv.get("plan_markdown"),
+ "plan_json": conv.get("plan_json"),
+ }
+ session_store.save(raw)
+
+ state = AgentState.model_validate(raw)
+ state.user_answers = list(state.user_answers) + [payload.message]
+ state.accepted = None
+ state.status = "running"
+
+ async def _stream():
+ try:
+ result = await _run_agent1(state)
+ session_store.save(result.as_graph_state())
+
+ chips = _parse_constraints(result)
+ await _persist_prd(project_id, session_id, result, chips)
+
+ for chip in chips:
+ yield _sse({"type": "constraint", "chip": chip.model_dump()})
+
+ yield _sse({
+ "type": "plan_ready",
+ "session_id": session_id,
+ "plan_markdown": result.plan_markdown or "",
+ })
+
+ except Exception as exc:
+ logger.exception("Error in respond_prd SSE stream")
+ yield _sse({"type": "error", "message": str(exc)})
+
+ yield "data: [DONE]\n\n"
+
+ return StreamingResponse(
+ _stream(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+# ---------------------------------------------------------------------------
+# POST /workflows/prd/v2/accept/{project_id}
+# ---------------------------------------------------------------------------
+
+@router.post("/accept/{project_id}")
+async def accept_prd(
+ project_id: str,
+ user: dict = Depends(get_current_user),
+) -> dict:
+ # Gate: validate project
+ try:
+ oid = ObjectId(project_id)
+ except Exception:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
+
+ project = await projects_col().find_one({"_id": oid})
+ if project is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
+ if project.get("owner_id") != ObjectId(str(user["_id"])):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
+
+ prd_session_id: str | None = project.get("prd_session_id")
+ if not prd_session_id:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="No PRD session to accept; call /start first",
+ )
+
+ now = datetime.now(timezone.utc)
+ await prd_conversations_col().update_one(
+ {"session_id": prd_session_id},
+ {"$set": {"status": "accepted", "updated_at": now}},
+ )
+ await projects_col().update_one(
+ {"_id": oid},
+ {"$set": {"stage": "arch", "updated_at": now}},
+ )
+
+ return {"session_id": prd_session_id, "status": "accepted"}
diff --git a/backend/app/schemas/prd.py b/backend/app/schemas/prd.py
new file mode 100644
index 0000000..5e80ce1
--- /dev/null
+++ b/backend/app/schemas/prd.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from pydantic import BaseModel
+
+
+class PrdStartRequest(BaseModel):
+ prd_text: str
+ cloud_provider: str = "aws"
+
+
+class PrdRespondRequest(BaseModel):
+ message: str
+
+
+class ConstraintChip(BaseModel):
+ id: str
+ label: str
+ category: str # "performance" | "security" | "cost" | "reliability"
From 2f24236b986f46dc736280ea3ce49711277d6f02 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:08:04 -0700
Subject: [PATCH 22/50] fix(agents): convert compliance and service discovery
nodes to sync
---
.../app/agents/architecture_planner/compliance_agent.py | 7 ++++---
.../architecture_planner/service_discovery_agent.py | 9 +++++----
2 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/backend/app/agents/architecture_planner/compliance_agent.py b/backend/app/agents/architecture_planner/compliance_agent.py
index b7b59f3..d2ca03c 100644
--- a/backend/app/agents/architecture_planner/compliance_agent.py
+++ b/backend/app/agents/architecture_planner/compliance_agent.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import asyncio
import json
import os
import logging
@@ -26,7 +27,7 @@ class ComplianceOutput(BaseModel):
def make_compliance_node(llm):
"""Factory that returns a compliance_node bound to the provided LLM."""
- async def compliance_node(state: ArchitecturePlannerState) -> dict:
+ def compliance_node(state: ArchitecturePlannerState) -> dict:
if state["architecture_diagram"] is None:
return {
"compliance_gaps": [],
@@ -40,11 +41,11 @@ async def compliance_node(state: ArchitecturePlannerState) -> dict:
if state["architecture_diagram"] is not None:
services = [node.service for node in state["architecture_diagram"].nodes]
try:
- cost_data = await fetch_cost_data(
+ cost_data = asyncio.run(fetch_cost_data(
cloud_provider=state["cloud_provider"],
services=services,
region=os.environ.get("AWS_REGION", "us-east-1"),
- )
+ ))
except Exception:
cost_data = None # never block the compliance check
diff --git a/backend/app/agents/architecture_planner/service_discovery_agent.py b/backend/app/agents/architecture_planner/service_discovery_agent.py
index 1b46c70..572ed87 100644
--- a/backend/app/agents/architecture_planner/service_discovery_agent.py
+++ b/backend/app/agents/architecture_planner/service_discovery_agent.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import asyncio
import json
from langchain_core.messages import HumanMessage
@@ -21,7 +22,7 @@ class ServiceDiscoveryOutput(BaseModel):
def make_service_discovery_node(llm, terraform_adapter=None):
"""
- Factory returning an async service_discovery_node bound to the provided LLM.
+ Factory returning a service_discovery_node bound to the provided LLM.
Args:
llm: Any LangChain chat model.
@@ -33,15 +34,15 @@ def make_service_discovery_node(llm, terraform_adapter=None):
behaviour identical to the original implementation.
"""
- async def service_discovery_node(state: ArchitecturePlannerState) -> dict:
+ def service_discovery_node(state: ArchitecturePlannerState) -> dict:
# ------------------------------------------------------------------
# Step 1: Fetch Terraform context via MCP (async, cached, never raises)
# ------------------------------------------------------------------
terraform_context: str | None = None
if terraform_adapter is not None:
- terraform_context = await terraform_adapter.format_for_prompt(
+ terraform_context = asyncio.run(terraform_adapter.format_for_prompt(
cloud_provider=state["cloud_provider"],
- )
+ ))
# ------------------------------------------------------------------
# Step 2: Render prompt — terraform_context may be None (Jinja handles it)
From f2c509a937004ca06d7c580c927c4516754429cb Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:08:14 -0700
Subject: [PATCH 23/50] =?UTF-8?q?feat(arch):=20add=20architecture=20SSE=20?=
=?UTF-8?q?schemas=20=E2=80=94=20ArchSSEStartRequest,=20RespondRequest,=20?=
=?UTF-8?q?ReviewRequest?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/schemas/arch_sse.py | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
create mode 100644 backend/app/schemas/arch_sse.py
diff --git a/backend/app/schemas/arch_sse.py b/backend/app/schemas/arch_sse.py
new file mode 100644
index 0000000..a704932
--- /dev/null
+++ b/backend/app/schemas/arch_sse.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from typing import Optional
+
+from pydantic import BaseModel
+
+
+class ArchSSEStartRequest(BaseModel):
+ budget: Optional[str] = None
+ traffic: Optional[str] = None
+ availability: Optional[str] = None
+
+
+class ArchSSERespondRequest(BaseModel):
+ answers: list[str]
+
+
+class ArchSSEReviewRequest(BaseModel):
+ accepted: bool
+ changes: Optional[str] = None
From d4da03ab6fcd4cdc474b944713b838137464c7b5 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:08:22 -0700
Subject: [PATCH 24/50] feat(arch): add Agent 2 astream SSE router v2 with
interrupt detection and node events
---
backend/app/routers/architecture_sse.py | 413 ++++++++++++++++++++++++
1 file changed, 413 insertions(+)
create mode 100644 backend/app/routers/architecture_sse.py
diff --git a/backend/app/routers/architecture_sse.py b/backend/app/routers/architecture_sse.py
new file mode 100644
index 0000000..bf5dc2d
--- /dev/null
+++ b/backend/app/routers/architecture_sse.py
@@ -0,0 +1,413 @@
+from __future__ import annotations
+
+import json
+import logging
+import os
+from datetime import datetime, timezone
+from typing import AsyncGenerator
+from uuid import uuid4
+
+from bson import ObjectId
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import StreamingResponse
+from langgraph.types import Command
+
+from app.agents.architecture_planner import create_graph, make_initial_state
+from app.config import settings
+from app.core.dependencies import get_current_user
+from app.db.mongo import architectures_col, prd_conversations_col, projects_col
+from app.schemas.arch_sse import ArchSSERespondRequest, ArchSSEReviewRequest, ArchSSEStartRequest
+
+router = APIRouter(prefix="/workflows/architecture/v2", tags=["architecture-v2"])
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Data paths — same constants as architecture.py
+# ---------------------------------------------------------------------------
+
+_APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_GRAPH_JSON = os.path.join(_APP_DIR, "agents", "data", "graph", "graph.json")
+_SUMMARIES_JSON = os.path.join(_APP_DIR, "agents", "data", "graph", "community_summaries.json")
+
+_PROVIDER_MAP: dict[str, str] = {"aws": "AWS", "gcp": "GCP", "azure": "Azure"}
+
+# Node → SSE step number
+_NODE_STEPS = {
+ "info_gathering": 1,
+ "query": 2,
+ "kg_traversal": 3,
+ "service_discovery": 4,
+ "architecture": 5,
+ "compliance": 6,
+ "eval": 7,
+}
+
+# ---------------------------------------------------------------------------
+# Graph singleton
+# ---------------------------------------------------------------------------
+
+_arch_graph_v2 = None
+
+
+def _get_arch_graph(kuzu_conn=None): # noqa: ARG001 — kuzu_conn reserved for future use
+ global _arch_graph_v2
+ if _arch_graph_v2 is None:
+ _arch_graph_v2 = create_graph(
+ model_type=settings.arch_model_type,
+ model_name=settings.arch_model_name,
+ graph_json_path=_GRAPH_JSON,
+ community_summaries_path=_SUMMARIES_JSON,
+ )
+ return _arch_graph_v2
+
+
+# ---------------------------------------------------------------------------
+# SSE helpers
+# ---------------------------------------------------------------------------
+
+
+def _sse(data: dict) -> str:
+ return f"data: {json.dumps(data)}\n\n"
+
+
+def _serialize_diagram(diagram) -> dict | None:
+ if diagram is None:
+ return None
+ if hasattr(diagram, "model_dump"):
+ return diagram.model_dump(by_alias=True)
+ if isinstance(diagram, dict):
+ return diagram
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Streaming generator — start
+# ---------------------------------------------------------------------------
+
+
+async def _stream_arch_start(
+ project_id: str,
+ project: dict,
+ payload: ArchSSEStartRequest,
+ user: dict,
+ kuzu_conn,
+) -> AsyncGenerator[str, None]:
+ # 1. Fetch PRD and gate on accepted status
+ prd_conv = await prd_conversations_col().find_one({"project_id": ObjectId(project_id)})
+ if not prd_conv or prd_conv.get("status") != "accepted":
+ yield _sse({"type": "error", "message": "PRD not accepted"})
+ return
+
+ # 2. Create session
+ session_id = str(uuid4())
+ config = {"configurable": {"thread_id": session_id}}
+
+ # 3. Build graph and initial state
+ graph = _get_arch_graph(kuzu_conn)
+ cloud_provider = _PROVIDER_MAP.get(
+ project.get("cloud_provider", "aws").lower(), "AWS"
+ )
+ initial_state = make_initial_state(
+ budget=payload.budget or "",
+ traffic=payload.traffic or "",
+ availability=payload.availability or "",
+ prd=prd_conv.get("plan_markdown", ""),
+ cloud_provider=cloud_provider,
+ )
+
+ # 4. Persist session
+ now = datetime.now(timezone.utc)
+ await architectures_col().insert_one(
+ {
+ "project_id": ObjectId(project_id),
+ "session_id": session_id,
+ "status": "in_progress",
+ "created_at": now,
+ "updated_at": now,
+ }
+ )
+ await projects_col().update_one(
+ {"_id": ObjectId(project_id)},
+ {"$set": {"arch_session_id": session_id, "updated_at": now}},
+ )
+
+ # 5. Stream
+ seen_nodes: set[str] = set()
+ try:
+ async for state_snapshot in graph.astream(initial_state, config, stream_mode="values"):
+ current_node = state_snapshot.get("current_node", "")
+
+ # Check for interrupt
+ try:
+ graph_state = graph.get_state(config)
+ except Exception:
+ graph_state = None
+
+ if graph_state and graph_state.next:
+ interrupts = []
+ for task in graph_state.tasks:
+ interrupts.extend(getattr(task, "interrupts", []))
+
+ if interrupts:
+ payload_val = interrupts[0].value
+ if isinstance(payload_val, dict):
+ if "questions" in payload_val:
+ yield _sse({"node": "interrupt", "type": "questions", **payload_val})
+ elif "summary" in payload_val:
+ yield _sse({"node": "interrupt", "type": "review", **payload_val})
+ else:
+ yield _sse({"node": "interrupt", "type": "unknown", "payload": payload_val})
+ yield "data: [DONE]\n\n"
+ return
+
+ # Emit node event once per node
+ if current_node and current_node in _NODE_STEPS and current_node not in seen_nodes:
+ seen_nodes.add(current_node)
+ step = _NODE_STEPS[current_node]
+ event: dict = {"node": current_node, "status": "done", "step": step}
+ if current_node == "kg_traversal":
+ event["active_nodes_count"] = len(state_snapshot.get("active_nodes") or [])
+ elif current_node == "compliance":
+ event["gaps_count"] = len(state_snapshot.get("compliance_gaps") or [])
+ elif current_node == "eval":
+ event["score"] = state_snapshot.get("eval_score")
+ yield _sse(event)
+
+ except Exception as exc:
+ logger.error("arch_sse start error [%s]: %s", session_id, exc, exc_info=True)
+ yield _sse({"type": "error", "message": str(exc)})
+ return
+
+ # 6. Graph completed — finalise
+ try:
+ final_state = graph.get_state(config).values or {}
+ except Exception:
+ final_state = {}
+
+ arch_diagram = _serialize_diagram(final_state.get("architecture_diagram"))
+
+ await architectures_col().update_one(
+ {"session_id": session_id},
+ {
+ "$set": {
+ "status": "review_ready",
+ "architecture_diagram": arch_diagram,
+ "nfr_document": final_state.get("nfr_document"),
+ "eval_score": final_state.get("eval_score"),
+ "updated_at": datetime.now(timezone.utc),
+ }
+ },
+ )
+
+ yield _sse(
+ {
+ "node": "complete",
+ "session_id": session_id,
+ "architecture_diagram": arch_diagram,
+ "nfr_document": final_state.get("nfr_document"),
+ "eval_score": final_state.get("eval_score"),
+ }
+ )
+ yield "data: [DONE]\n\n"
+
+
+# ---------------------------------------------------------------------------
+# Streaming generator — respond (resume after questions interrupt)
+# ---------------------------------------------------------------------------
+
+
+async def _stream_arch_resume(
+ session_id: str,
+ resume_value,
+ kuzu_conn,
+) -> AsyncGenerator[str, None]:
+ config = {"configurable": {"thread_id": session_id}}
+ graph = _get_arch_graph(kuzu_conn)
+
+ seen_nodes: set[str] = set()
+ try:
+ async for state_snapshot in graph.astream(
+ Command(resume=resume_value), config, stream_mode="values"
+ ):
+ current_node = state_snapshot.get("current_node", "")
+
+ try:
+ graph_state = graph.get_state(config)
+ except Exception:
+ graph_state = None
+
+ if graph_state and graph_state.next:
+ interrupts = []
+ for task in graph_state.tasks:
+ interrupts.extend(getattr(task, "interrupts", []))
+
+ if interrupts:
+ payload_val = interrupts[0].value
+ if isinstance(payload_val, dict):
+ if "questions" in payload_val:
+ yield _sse({"node": "interrupt", "type": "questions", **payload_val})
+ elif "summary" in payload_val:
+ yield _sse({"node": "interrupt", "type": "review", **payload_val})
+ else:
+ yield _sse({"node": "interrupt", "type": "unknown", "payload": payload_val})
+ yield "data: [DONE]\n\n"
+ return
+
+ if current_node and current_node in _NODE_STEPS and current_node not in seen_nodes:
+ seen_nodes.add(current_node)
+ step = _NODE_STEPS[current_node]
+ event: dict = {"node": current_node, "status": "done", "step": step}
+ if current_node == "kg_traversal":
+ event["active_nodes_count"] = len(state_snapshot.get("active_nodes") or [])
+ elif current_node == "compliance":
+ event["gaps_count"] = len(state_snapshot.get("compliance_gaps") or [])
+ elif current_node == "eval":
+ event["score"] = state_snapshot.get("eval_score")
+ yield _sse(event)
+
+ except Exception as exc:
+ logger.error("arch_sse resume error [%s]: %s", session_id, exc, exc_info=True)
+ yield _sse({"type": "error", "message": str(exc)})
+ return
+
+ try:
+ final_state = graph.get_state(config).values or {}
+ except Exception:
+ final_state = {}
+
+ arch_diagram = _serialize_diagram(final_state.get("architecture_diagram"))
+
+ await architectures_col().update_one(
+ {"session_id": session_id},
+ {
+ "$set": {
+ "status": "review_ready",
+ "architecture_diagram": arch_diagram,
+ "nfr_document": final_state.get("nfr_document"),
+ "eval_score": final_state.get("eval_score"),
+ "updated_at": datetime.now(timezone.utc),
+ }
+ },
+ )
+
+ yield _sse(
+ {
+ "node": "complete",
+ "session_id": session_id,
+ "architecture_diagram": arch_diagram,
+ "nfr_document": final_state.get("nfr_document"),
+ "eval_score": final_state.get("eval_score"),
+ }
+ )
+ yield "data: [DONE]\n\n"
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+
+@router.post("/start/{project_id}")
+async def start_arch_v2(
+ project_id: str,
+ payload: ArchSSEStartRequest,
+ request: Request,
+ user: dict = Depends(get_current_user),
+):
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+ if str(project["owner_id"]) != str(user["_id"]):
+ raise HTTPException(status_code=403, detail="Forbidden")
+
+ kuzu_conn = getattr(request.app.state, "kuzu_conn", None)
+
+ return StreamingResponse(
+ _stream_arch_start(project_id, project, payload, user, kuzu_conn),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+@router.post("/respond/{project_id}")
+async def respond_arch_v2(
+ project_id: str,
+ payload: ArchSSERespondRequest,
+ request: Request,
+ user: dict = Depends(get_current_user),
+):
+ arch_doc = await architectures_col().find_one(
+ {"project_id": ObjectId(project_id)},
+ sort=[("created_at", -1)],
+ )
+ if not arch_doc:
+ raise HTTPException(status_code=404, detail="Architecture session not found")
+
+ session_id = arch_doc["session_id"]
+ kuzu_conn = getattr(request.app.state, "kuzu_conn", None)
+
+ return StreamingResponse(
+ _stream_arch_resume(session_id, payload.answers, kuzu_conn),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+@router.post("/review/{project_id}")
+async def review_arch_v2(
+ project_id: str,
+ payload: ArchSSEReviewRequest,
+ request: Request,
+ user: dict = Depends(get_current_user),
+):
+ arch_doc = await architectures_col().find_one(
+ {"project_id": ObjectId(project_id)},
+ sort=[("created_at", -1)],
+ )
+ if not arch_doc:
+ raise HTTPException(status_code=404, detail="Architecture session not found")
+
+ session_id = arch_doc["session_id"]
+ kuzu_conn = getattr(request.app.state, "kuzu_conn", None)
+
+ resume_value = {"accepted": payload.accepted, "changes": payload.changes}
+
+ return StreamingResponse(
+ _stream_arch_resume(session_id, resume_value, kuzu_conn),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+@router.post("/accept/{project_id}")
+async def accept_arch_v2(
+ project_id: str,
+ request: Request,
+ user: dict = Depends(get_current_user),
+):
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+ if str(project["owner_id"]) != str(user["_id"]):
+ raise HTTPException(status_code=403, detail="Forbidden")
+
+ arch_doc = await architectures_col().find_one(
+ {"project_id": ObjectId(project_id)},
+ sort=[("created_at", -1)],
+ )
+ if not arch_doc:
+ raise HTTPException(status_code=404, detail="Architecture session not found")
+
+ session_id = arch_doc["session_id"]
+ now = datetime.now(timezone.utc)
+
+ await architectures_col().update_one(
+ {"session_id": session_id},
+ {"$set": {"status": "accepted", "updated_at": now}},
+ )
+ await projects_col().update_one(
+ {"_id": ObjectId(project_id)},
+ {"$set": {"stage": "build", "updated_at": now}},
+ )
+
+ return {"session_id": session_id, "status": "accepted"}
From 32a438fb856749b58314879d6b8494ba467a9df4 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:08:34 -0700
Subject: [PATCH 25/50] feat(build): add GitHub tree-based commit service via
Git Data API
---
backend/app/services/github.py | 158 +++++++++++++++++++++++++++++++++
1 file changed, 158 insertions(+)
create mode 100644 backend/app/services/github.py
diff --git a/backend/app/services/github.py b/backend/app/services/github.py
new file mode 100644
index 0000000..31c5c2e
--- /dev/null
+++ b/backend/app/services/github.py
@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+import base64
+from typing import Optional
+
+import httpx
+
+GITHUB_API = "https://api.github.com"
+
+
+async def list_repos(token: str) -> list[dict]:
+ """Returns list of {full_name, default_branch, private}"""
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ f"{GITHUB_API}/user/repos",
+ headers={
+ "Authorization": f"token {token}",
+ "Accept": "application/vnd.github.v3+json",
+ },
+ params={"per_page": 100, "sort": "updated"},
+ )
+ resp.raise_for_status()
+ return [
+ {
+ "full_name": r["full_name"],
+ "default_branch": r["default_branch"],
+ "private": r["private"],
+ }
+ for r in resp.json()
+ ]
+
+
+async def get_default_branch(token: str, owner: str, repo: str) -> str:
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ f"{GITHUB_API}/repos/{owner}/{repo}",
+ headers={"Authorization": f"token {token}"},
+ )
+ resp.raise_for_status()
+ return resp.json()["default_branch"]
+
+
+async def _get_ref_sha(
+ client: httpx.AsyncClient, token: str, owner: str, repo: str, branch: str
+) -> str:
+ resp = await client.get(
+ f"{GITHUB_API}/repos/{owner}/{repo}/git/ref/heads/{branch}",
+ headers={"Authorization": f"token {token}"},
+ )
+ resp.raise_for_status()
+ return resp.json()["object"]["sha"]
+
+
+async def _create_blob(
+ client: httpx.AsyncClient, token: str, owner: str, repo: str, content: str
+) -> str:
+ resp = await client.post(
+ f"{GITHUB_API}/repos/{owner}/{repo}/git/blobs",
+ json={
+ "content": base64.b64encode(content.encode()).decode(),
+ "encoding": "base64",
+ },
+ headers={"Authorization": f"token {token}"},
+ )
+ resp.raise_for_status()
+ return resp.json()["sha"]
+
+
+async def _create_tree(
+ client: httpx.AsyncClient,
+ token: str,
+ owner: str,
+ repo: str,
+ base_tree_sha: str,
+ files: list[dict],
+) -> str:
+ """files: list of {path: str, content: str}"""
+ tree_items = []
+ for f in files:
+ blob_sha = await _create_blob(client, token, owner, repo, f["content"])
+ tree_items.append(
+ {"path": f["path"], "mode": "100644", "type": "blob", "sha": blob_sha}
+ )
+
+ resp = await client.post(
+ f"{GITHUB_API}/repos/{owner}/{repo}/git/trees",
+ json={"base_tree": base_tree_sha, "tree": tree_items},
+ headers={"Authorization": f"token {token}"},
+ )
+ resp.raise_for_status()
+ return resp.json()["sha"]
+
+
+async def _create_commit(
+ client: httpx.AsyncClient,
+ token: str,
+ owner: str,
+ repo: str,
+ tree_sha: str,
+ parent_sha: str,
+ message: str,
+) -> str:
+ resp = await client.post(
+ f"{GITHUB_API}/repos/{owner}/{repo}/git/commits",
+ json={"message": message, "tree": tree_sha, "parents": [parent_sha]},
+ headers={"Authorization": f"token {token}"},
+ )
+ resp.raise_for_status()
+ return resp.json()["sha"]
+
+
+async def _update_ref(
+ client: httpx.AsyncClient,
+ token: str,
+ owner: str,
+ repo: str,
+ branch: str,
+ commit_sha: str,
+) -> None:
+ resp = await client.patch(
+ f"{GITHUB_API}/repos/{owner}/{repo}/git/refs/heads/{branch}",
+ json={"sha": commit_sha, "force": False},
+ headers={"Authorization": f"token {token}"},
+ )
+ resp.raise_for_status()
+
+
+async def commit_files(
+ token: str,
+ owner: str,
+ repo: str,
+ files: list[dict],
+ message: str,
+ branch: Optional[str] = None,
+) -> str:
+ """
+ files: list of {path: str, content: str}
+ Returns commit_sha.
+ """
+ async with httpx.AsyncClient() as client:
+ if branch is None:
+ branch = await get_default_branch(token, owner, repo)
+
+ parent_sha = await _get_ref_sha(client, token, owner, repo, branch)
+
+ commit_resp = await client.get(
+ f"{GITHUB_API}/repos/{owner}/{repo}/git/commits/{parent_sha}",
+ headers={"Authorization": f"token {token}"},
+ )
+ commit_resp.raise_for_status()
+ base_tree_sha = commit_resp.json()["tree"]["sha"]
+
+ tree_sha = await _create_tree(client, token, owner, repo, base_tree_sha, files)
+ commit_sha = await _create_commit(
+ client, token, owner, repo, tree_sha, parent_sha, message
+ )
+ await _update_ref(client, token, owner, repo, branch, commit_sha)
+ return commit_sha
From bdbcce0f6855b5f8216b270ef6be0f490c22f686 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:08:41 -0700
Subject: [PATCH 26/50] feat(build): add Agent 3 build SSE, status, GitHub
commit, and repo list endpoints
---
backend/app/routers/build.py | 389 +++++++++++++++++++++++++++++++++++
backend/app/schemas/build.py | 12 ++
2 files changed, 401 insertions(+)
create mode 100644 backend/app/routers/build.py
create mode 100644 backend/app/schemas/build.py
diff --git a/backend/app/routers/build.py b/backend/app/routers/build.py
new file mode 100644
index 0000000..2881dc3
--- /dev/null
+++ b/backend/app/routers/build.py
@@ -0,0 +1,389 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from typing import Any
+from uuid import uuid4
+
+from bson import ObjectId
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import StreamingResponse
+
+from app.agents.agent3 import GenerateRequest, get_graph
+from app.core.dependencies import get_current_user
+from app.db.encryption import decrypt
+from app.db.mongo import architectures_col, builds_col, projects_col
+from app.routers.agent3 import _build_initial_state, _stream_events
+from app.schemas.build import BuildCommitRequest
+from app.services.github import commit_files, list_repos
+
+router = APIRouter(prefix="/workflows/build", tags=["build"])
+
+# ---------------------------------------------------------------------------
+# Service type normalisation
+# ---------------------------------------------------------------------------
+
+_SERVICE_TYPE_MAP = {
+ "api gateway": "apigateway",
+ "gateway": "apigateway",
+ "apigw": "apigateway",
+ "lambda": "lambda",
+ "function": "lambda",
+ "ec2": "ec2",
+ "server": "ec2",
+ "rds": "rds",
+ "postgres": "rds",
+ "mysql": "rds",
+ "database": "rds",
+ "dynamodb": "dynamodb",
+ "nosql": "dynamodb",
+ "s3": "s3",
+ "storage": "s3",
+ "bucket": "s3",
+ "elasticache": "elasticache",
+ "redis": "elasticache",
+ "cache": "elasticache",
+ "ecs": "ecs",
+ "container": "ecs",
+ "eks": "eks",
+ "kubernetes": "eks",
+ "sqs": "sqs",
+ "queue": "sqs",
+ "sns": "sns",
+ "cloudfront": "cloudfront",
+ "cdn": "cloudfront",
+ "alb": "alb",
+ "load balancer": "alb",
+ "secrets manager": "secretsmanager",
+ "cognito": "cognito",
+ "auth": "cognito",
+}
+
+
+def _normalize_service_type(service: str) -> str:
+ low = service.lower().strip()
+ for key, val in _SERVICE_TYPE_MAP.items():
+ if key in low:
+ return val
+ return low.replace(" ", "_")
+
+
+def _arch_diagram_to_topology(arch_diagram: dict) -> dict:
+ """Convert architecture_diagram from Agent 2 to topology format for Agent 3."""
+ nodes = arch_diagram.get("nodes", [])
+ connections = arch_diagram.get("connections", [])
+
+ services = []
+ for node in nodes:
+ services.append(
+ {
+ "id": node.get("id", ""),
+ "service_type": _normalize_service_type(
+ node.get("service", node.get("type", ""))
+ ),
+ "label": node.get(
+ "description", node.get("label", node.get("id", ""))
+ ),
+ "config": node.get("config", {}),
+ }
+ )
+
+ conns = []
+ for conn in connections:
+ conns.append(
+ {
+ "source": conn.get(
+ "from", conn.get("from_", conn.get("source", ""))
+ ),
+ "target": conn.get("to", conn.get("target", "")),
+ }
+ )
+
+ return {"services": services, "connections": conns}
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _artifacts_to_files(artifacts: dict) -> list[dict]:
+ files = []
+ for key, content in artifacts.items():
+ if isinstance(content, str):
+ ext = key.rsplit(".", 1)[-1] if "." in key else "text"
+ lang_map = {
+ "tf": "hcl",
+ "py": "python",
+ "ts": "typescript",
+ "js": "javascript",
+ "yaml": "yaml",
+ "yml": "yaml",
+ "json": "json",
+ "sh": "bash",
+ }
+ lang = lang_map.get(ext, ext)
+ files.append(
+ {
+ "id": str(uuid4()),
+ "name": key.rsplit("/", 1)[-1],
+ "path": key,
+ "lang": lang,
+ "content": content,
+ "status": "new",
+ }
+ )
+ return files
+
+
+def _serialize_doc(doc: dict) -> dict:
+ """Recursively convert ObjectId / datetime values to strings."""
+ out: dict[str, Any] = {}
+ for k, v in doc.items():
+ if isinstance(v, ObjectId):
+ out[k] = str(v)
+ elif isinstance(v, datetime):
+ out[k] = v.isoformat()
+ elif isinstance(v, dict):
+ out[k] = _serialize_doc(v)
+ elif isinstance(v, list):
+ out[k] = [
+ _serialize_doc(i) if isinstance(i, dict) else i for i in v
+ ]
+ else:
+ out[k] = v
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Routes
+# ---------------------------------------------------------------------------
+
+
+@router.post("/start/{project_id}")
+async def start_build(
+ project_id: str,
+ request: Request,
+ user=Depends(get_current_user),
+) -> StreamingResponse:
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+ if str(project["owner_id"]) != str(user["_id"]):
+ raise HTTPException(status_code=403, detail="Forbidden")
+
+ arch_doc = await architectures_col().find_one(
+ {"project_id": ObjectId(project_id)}
+ )
+ if not arch_doc or arch_doc.get("status") != "accepted":
+ raise HTTPException(
+ status_code=409, detail="Architecture not accepted"
+ )
+
+ arch_diagram = arch_doc.get("architecture_diagram") or {}
+ topology = _arch_diagram_to_topology(arch_diagram)
+
+ thread_id = str(uuid4())
+
+ generate_request = GenerateRequest(
+ topology=json.dumps(topology),
+ input_format="json",
+ )
+
+ config = {"configurable": {"thread_id": thread_id}}
+ initial_state = _build_initial_state(generate_request, thread_id)
+
+ build_doc = {
+ "project_id": ObjectId(project_id),
+ "thread_id": thread_id,
+ "status": "in_progress",
+ "artifacts": {},
+ "generated_files": [],
+ "github_commit_sha": None,
+ "github_repo": None,
+ "created_at": datetime.now(timezone.utc),
+ "updated_at": datetime.now(timezone.utc),
+ }
+ result = await builds_col().insert_one(build_doc)
+ build_id = str(result.inserted_id)
+
+ async def _stream():
+ async for sse_event in _stream_events(initial_state, config):
+ try:
+ raw = sse_event
+ if raw.startswith("data: "):
+ raw = raw[len("data: "):]
+ raw = raw.strip()
+ parsed = json.loads(raw)
+
+ if parsed.get("phase") == "complete":
+ artifacts = parsed.get("artifacts", {})
+ generated_files = _artifacts_to_files(artifacts)
+ await builds_col().update_one(
+ {"_id": result.inserted_id},
+ {
+ "$set": {
+ "status": "complete",
+ "artifacts": artifacts,
+ "generated_files": generated_files,
+ "updated_at": datetime.now(timezone.utc),
+ }
+ },
+ )
+ await projects_col().update_one(
+ {"_id": ObjectId(project_id)},
+ {
+ "$set": {
+ "build_id": build_id,
+ "stage": "deploy",
+ "updated_at": datetime.now(timezone.utc),
+ }
+ },
+ )
+ parsed["build_id"] = build_id
+ yield f"data: {json.dumps(parsed)}\n\n"
+ continue
+
+ elif parsed.get("phase") == "human_review_required":
+ await builds_col().update_one(
+ {"_id": result.inserted_id},
+ {
+ "$set": {
+ "status": "human_review",
+ "updated_at": datetime.now(timezone.utc),
+ }
+ },
+ )
+
+ elif parsed.get("phase") == "error":
+ await builds_col().update_one(
+ {"_id": result.inserted_id},
+ {
+ "$set": {
+ "status": "error",
+ "updated_at": datetime.now(timezone.utc),
+ }
+ },
+ )
+
+ except Exception:
+ pass
+
+ yield sse_event
+
+ return StreamingResponse(
+ _stream(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "X-Accel-Buffering": "no",
+ "X-Build-ID": build_id,
+ },
+ )
+
+
+@router.get("/status/{project_id}")
+async def get_build_status(
+ project_id: str,
+ user=Depends(get_current_user),
+) -> dict:
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+ if str(project["owner_id"]) != str(user["_id"]):
+ raise HTTPException(status_code=403, detail="Forbidden")
+
+ build_doc = await builds_col().find_one(
+ {"project_id": ObjectId(project_id)},
+ sort=[("created_at", -1)],
+ )
+ if not build_doc:
+ raise HTTPException(status_code=404, detail="No build found for project")
+
+ return _serialize_doc(build_doc)
+
+
+@router.post("/{project_id}/commit")
+async def commit_build(
+ project_id: str,
+ body: BuildCommitRequest,
+ user=Depends(get_current_user),
+) -> dict:
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+ if str(project["owner_id"]) != str(user["_id"]):
+ raise HTTPException(status_code=403, detail="Forbidden")
+
+ if not user.get("github_token_encrypted"):
+ raise HTTPException(
+ status_code=400, detail="GitHub account not connected"
+ )
+
+ token = decrypt(user["github_token_encrypted"])
+
+ build_doc = await builds_col().find_one({"_id": ObjectId(body.build_id)})
+ if not build_doc:
+ raise HTTPException(status_code=404, detail="Build not found")
+
+ if "/" not in body.repo:
+ raise HTTPException(status_code=400, detail="repo must be 'owner/repo'")
+ owner, repo = body.repo.split("/", 1)
+
+ generated_files = build_doc.get("generated_files", [])
+ files = [
+ {"path": f["path"], "content": f["content"]}
+ for f in generated_files
+ if f.get("content")
+ ]
+
+ if not files:
+ raise HTTPException(
+ status_code=400, detail="No generated files to commit"
+ )
+
+ commit_sha = await commit_files(
+ token,
+ owner,
+ repo,
+ files,
+ body.commit_message or "feat: add CloudForge scaffold",
+ body.branch,
+ )
+
+ await builds_col().update_one(
+ {"_id": ObjectId(body.build_id)},
+ {
+ "$set": {
+ "github_commit_sha": commit_sha,
+ "github_repo": body.repo,
+ "updated_at": datetime.now(timezone.utc),
+ }
+ },
+ )
+
+ return {
+ "commit_sha": commit_sha,
+ "repo_url": f"https://github.com/{body.repo}",
+ "files_committed": len(files),
+ }
+
+
+@router.get("/{project_id}/repos")
+async def get_repos(
+ project_id: str,
+ user=Depends(get_current_user),
+) -> list[dict]:
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+ if str(project["owner_id"]) != str(user["_id"]):
+ raise HTTPException(status_code=403, detail="Forbidden")
+
+ if not user.get("github_token_encrypted"):
+ raise HTTPException(
+ status_code=400, detail="GitHub account not connected"
+ )
+
+ token = decrypt(user["github_token_encrypted"])
+ return await list_repos(token)
diff --git a/backend/app/schemas/build.py b/backend/app/schemas/build.py
new file mode 100644
index 0000000..54ff81e
--- /dev/null
+++ b/backend/app/schemas/build.py
@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+from typing import Optional
+
+from pydantic import BaseModel
+
+
+class BuildCommitRequest(BaseModel):
+ build_id: str
+ repo: str # "owner/repo"
+ branch: Optional[str] = None
+ commit_message: Optional[str] = "feat: add CloudForge scaffold"
From d9a9e7ad1ff6ab8f138c53b90c527f178139c322 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:08:47 -0700
Subject: [PATCH 27/50] feat(deploy): add CloudProvider base class, AWSProvider
with STS+CloudFormation, and factory
---
backend/app/providers/__init__.py | 0
backend/app/providers/aws.py | 163 ++++++++++++++++++++++++++++++
backend/app/providers/base.py | 32 ++++++
backend/app/providers/factory.py | 13 +++
4 files changed, 208 insertions(+)
create mode 100644 backend/app/providers/__init__.py
create mode 100644 backend/app/providers/aws.py
create mode 100644 backend/app/providers/base.py
create mode 100644 backend/app/providers/factory.py
diff --git a/backend/app/providers/__init__.py b/backend/app/providers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/providers/aws.py b/backend/app/providers/aws.py
new file mode 100644
index 0000000..709e1f7
--- /dev/null
+++ b/backend/app/providers/aws.py
@@ -0,0 +1,163 @@
+from __future__ import annotations
+import asyncio
+import json
+import time
+from typing import Any, Callable, Awaitable
+from app.providers.base import CloudProvider
+
+
+class AWSProvider(CloudProvider):
+ def __init__(self, role_arn: str, region: str):
+ self.role_arn = role_arn
+ self.region = region
+ self._session_creds: dict | None = None # cached STS temp creds
+
+ @classmethod
+ def from_credentials(cls, credentials: dict) -> "AWSProvider":
+ return cls(role_arn=credentials["role_arn"], region=credentials["region"])
+
+ def _get_sts_creds(self) -> dict:
+ """AssumeRole via STS and return temporary credentials."""
+ import boto3
+ sts = boto3.client("sts", region_name=self.region)
+ resp = sts.assume_role(
+ RoleArn=self.role_arn,
+ RoleSessionName="cloudforge-deploy",
+ )
+ return resp["Credentials"]
+
+ def _boto3_client(self, service: str):
+ import boto3
+ creds = self._session_creds or {}
+ return boto3.client(
+ service,
+ region_name=self.region,
+ aws_access_key_id=creds.get("AccessKeyId"),
+ aws_secret_access_key=creds.get("SecretAccessKey"),
+ aws_session_token=creds.get("SessionToken"),
+ )
+
+ async def verify_credentials(self) -> bool:
+ loop = asyncio.get_event_loop()
+
+ def _verify():
+ import boto3
+ creds = self._get_sts_creds()
+ self._session_creds = creds
+ sts = boto3.client(
+ "sts",
+ region_name=self.region,
+ aws_access_key_id=creds["AccessKeyId"],
+ aws_secret_access_key=creds["SecretAccessKey"],
+ aws_session_token=creds["SessionToken"],
+ )
+ sts.get_caller_identity()
+ return True
+
+ return await loop.run_in_executor(None, _verify)
+
+ async def deploy(
+ self,
+ stack_name: str,
+ template_body: str,
+ parameters: dict[str, str],
+ on_event: Callable[[dict], Awaitable[None]],
+ ) -> dict[str, Any]:
+ loop = asyncio.get_event_loop()
+
+ cf_params = [{"ParameterKey": k, "ParameterValue": v} for k, v in parameters.items()]
+
+ def _create_or_update():
+ cf = self._boto3_client("cloudformation")
+ try:
+ cf.describe_stacks(StackName=stack_name)
+ cf.update_stack(
+ StackName=stack_name,
+ TemplateBody=template_body,
+ Parameters=cf_params,
+ Capabilities=["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"],
+ )
+ return "UPDATE_IN_PROGRESS"
+ except cf.exceptions.ClientError as e:
+ if "does not exist" in str(e):
+ cf.create_stack(
+ StackName=stack_name,
+ TemplateBody=template_body,
+ Parameters=cf_params,
+ Capabilities=["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"],
+ )
+ return "CREATE_IN_PROGRESS"
+ raise
+
+ status = await loop.run_in_executor(None, _create_or_update)
+ await on_event({"type": "log", "line": f"⟳ Stack {status.lower().replace('_', ' ')}…"})
+
+ def _poll_events(seen_event_ids: set) -> tuple[list[dict], str]:
+ cf = self._boto3_client("cloudformation")
+ stacks = cf.describe_stacks(StackName=stack_name)["Stacks"]
+ stack_status = stacks[0]["StackStatus"] if stacks else "UNKNOWN"
+
+ events_resp = cf.describe_stack_events(StackName=stack_name)
+ new_events = []
+ for evt in reversed(events_resp["StackEvents"]):
+ if evt["EventId"] not in seen_event_ids:
+ seen_event_ids.add(evt["EventId"])
+ new_events.append(evt)
+ return new_events, stack_status
+
+ seen_ids: set[str] = set()
+ terminal_statuses = {
+ "CREATE_COMPLETE", "UPDATE_COMPLETE",
+ "CREATE_FAILED", "UPDATE_FAILED", "ROLLBACK_COMPLETE",
+ "ROLLBACK_FAILED", "UPDATE_ROLLBACK_COMPLETE",
+ }
+
+ while True:
+ events, stack_status = await loop.run_in_executor(None, _poll_events, seen_ids)
+
+ for evt in events:
+ resource_id = evt.get("LogicalResourceId", "")
+ res_status = evt.get("ResourceStatus", "")
+ reason = evt.get("ResourceStatusReason", "")
+
+ log_line = f"{'✓' if 'COMPLETE' in res_status else '⟳'} {resource_id}: {res_status}"
+ if reason and "User Initiated" not in reason:
+ log_line += f" — {reason}"
+
+ await on_event({"type": "log", "line": log_line})
+
+ node_status = None
+ if "COMPLETE" in res_status and "FAILED" not in res_status:
+ node_status = "live"
+ elif "IN_PROGRESS" in res_status:
+ node_status = "provisioning"
+
+ if node_status and resource_id:
+ await on_event({"type": "node_status", "nodeId": resource_id.lower(), "status": node_status})
+
+ if stack_status in terminal_statuses:
+ break
+
+ await asyncio.sleep(5)
+
+ def _get_outputs():
+ cf = self._boto3_client("cloudformation")
+ stacks = cf.describe_stacks(StackName=stack_name)["Stacks"]
+ return stacks[0].get("Outputs", []) if stacks else []
+
+ outputs_raw = await loop.run_in_executor(None, _get_outputs)
+ outputs = {o["OutputKey"]: o["OutputValue"] for o in outputs_raw}
+
+ if "FAILED" in stack_status or "ROLLBACK" in stack_status:
+ raise ValueError(f"Stack deployment failed with status: {stack_status}")
+
+ return outputs
+
+ async def rollback(self, stack_name: str) -> None:
+ loop = asyncio.get_event_loop()
+
+ def _delete():
+ cf = self._boto3_client("cloudformation")
+ cf.delete_stack(StackName=stack_name)
+
+ await loop.run_in_executor(None, _delete)
diff --git a/backend/app/providers/base.py b/backend/app/providers/base.py
new file mode 100644
index 0000000..3734874
--- /dev/null
+++ b/backend/app/providers/base.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+from abc import ABC, abstractmethod
+from typing import Callable, Awaitable, Any
+
+
+class CloudProvider(ABC):
+ @abstractmethod
+ async def verify_credentials(self) -> bool:
+ """Return True if credentials are valid, raise ValueError on failure."""
+ ...
+
+ @abstractmethod
+ async def deploy(
+ self,
+ stack_name: str,
+ template_body: str,
+ parameters: dict[str, str],
+ on_event: Callable[[dict], Awaitable[None]],
+ ) -> dict[str, Any]:
+ """Deploy resources. Call on_event for each status update. Returns stack outputs."""
+ ...
+
+ @abstractmethod
+ async def rollback(self, stack_name: str) -> None:
+ """Delete/rollback the named stack."""
+ ...
+
+ @classmethod
+ @abstractmethod
+ def from_credentials(cls, credentials: dict) -> "CloudProvider":
+ """Construct from a credentials dict (decrypted from DB)."""
+ ...
diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py
new file mode 100644
index 0000000..e8d6cc2
--- /dev/null
+++ b/backend/app/providers/factory.py
@@ -0,0 +1,13 @@
+from app.providers.base import CloudProvider
+from app.providers.aws import AWSProvider
+
+_PROVIDERS = {
+ "aws": AWSProvider,
+}
+
+
+def get_provider(provider_type: str, credentials: dict) -> CloudProvider:
+ cls = _PROVIDERS.get(provider_type.lower())
+ if cls is None:
+ raise ValueError(f"Unsupported cloud provider: {provider_type}")
+ return cls.from_credentials(credentials)
From 096ae53a273d39015e9384f62ae064992e7002c9 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:08:51 -0700
Subject: [PATCH 28/50] feat(deploy): add deploy SSE, rollback, and status
endpoints
---
backend/app/routers/deploy.py | 227 ++++++++++++++++++++++++++++++++++
backend/app/schemas/deploy.py | 5 +
2 files changed, 232 insertions(+)
create mode 100644 backend/app/routers/deploy.py
create mode 100644 backend/app/schemas/deploy.py
diff --git a/backend/app/routers/deploy.py b/backend/app/routers/deploy.py
new file mode 100644
index 0000000..07a1699
--- /dev/null
+++ b/backend/app/routers/deploy.py
@@ -0,0 +1,227 @@
+import asyncio
+import json
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
+from bson import ObjectId
+
+from app.core.dependencies import get_current_user
+from app.db.mongo import projects_col, builds_col, deployments_col
+from app.db.encryption import decrypt
+from app.providers.factory import get_provider
+from app.schemas.deploy import DeployRollbackRequest
+
+router = APIRouter(prefix="/workflows/deploy", tags=["deploy"])
+
+
+def _sse(data: dict) -> str:
+ return f"data: {json.dumps(data)}\n\n"
+
+
+def _find_cf_template(artifacts: dict) -> str:
+ """Find a CloudFormation template in artifacts, or return a minimal placeholder."""
+ for key, content in artifacts.items():
+ if isinstance(content, str) and (
+ key.endswith(".yaml") or key.endswith(".yml") or key.endswith(".json")
+ ):
+ if "AWSTemplateFormatVersion" in content or "Resources" in content:
+ return content
+ return json.dumps({
+ "AWSTemplateFormatVersion": "2010-09-09",
+ "Description": "CloudForge generated stack",
+ "Resources": {
+ "Placeholder": {
+ "Type": "AWS::CloudFormation::WaitConditionHandle"
+ }
+ }
+ })
+
+
+@router.post("/start/{project_id}")
+async def start_deploy(project_id: str, user=Depends(get_current_user)):
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(404, "Project not found")
+ if str(project["owner_id"]) != str(user["_id"]):
+ raise HTTPException(403, "Forbidden")
+
+ build_doc = await builds_col().find_one(
+ {"project_id": ObjectId(project_id), "status": "complete"}
+ )
+ if not build_doc:
+ raise HTTPException(409, "No completed build for this project")
+
+ if not project.get("cloud_credentials_encrypted"):
+ raise HTTPException(409, "Cloud credentials not configured")
+
+ creds_json = decrypt(project["cloud_credentials_encrypted"])
+ credentials = json.loads(creds_json)
+ provider_type = project.get("cloud_provider_type") or credentials.get("provider", "aws")
+
+ deployment_id = str(ObjectId())
+
+ deploy_doc = {
+ "_id": ObjectId(deployment_id),
+ "project_id": ObjectId(project_id),
+ "build_id": build_doc["_id"],
+ "status": "running",
+ "provider": provider_type,
+ "region": credentials.get("region", "us-east-1"),
+ "log_lines": [],
+ "resource_statuses": {},
+ "stack_outputs": {},
+ "external_id": None,
+ "role_arn": credentials.get("role_arn"),
+ "created_at": datetime.now(timezone.utc),
+ "updated_at": datetime.now(timezone.utc),
+ }
+ await deployments_col().insert_one(deploy_doc)
+
+ async def _stream():
+ log_lines = []
+ resource_statuses = {}
+
+ async def on_event(event: dict):
+ if event.get("type") == "log":
+ log_lines.append(event["line"])
+ elif event.get("type") == "node_status":
+ resource_statuses[event["nodeId"]] = event["status"]
+
+ try:
+ provider = get_provider(provider_type, credentials)
+
+ yield _sse({"type": "log", "line": "⟳ Verifying cloud credentials…"})
+ await provider.verify_credentials()
+ yield _sse({"type": "log", "line": "✓ Credentials verified"})
+
+ stack_name = f"cloudforge-{project_id[:8]}"
+ artifacts = build_doc.get("artifacts", {})
+ template_body = _find_cf_template(artifacts)
+
+ event_queue: asyncio.Queue = asyncio.Queue()
+
+ async def queuing_on_event(event: dict):
+ await on_event(event)
+ await event_queue.put(event)
+
+ deploy_task = asyncio.create_task(
+ provider.deploy(stack_name, template_body, {}, queuing_on_event)
+ )
+
+ while not deploy_task.done():
+ try:
+ event = await asyncio.wait_for(event_queue.get(), timeout=1.0)
+ yield _sse(event)
+ except asyncio.TimeoutError:
+ continue
+
+ while not event_queue.empty():
+ event = event_queue.get_nowait()
+ yield _sse(event)
+
+ stack_outputs = await deploy_task
+
+ await deployments_col().update_one(
+ {"_id": ObjectId(deployment_id)},
+ {"$set": {
+ "status": "complete",
+ "log_lines": log_lines,
+ "resource_statuses": resource_statuses,
+ "stack_outputs": stack_outputs,
+ "updated_at": datetime.now(timezone.utc),
+ }}
+ )
+ await projects_col().update_one(
+ {"_id": ObjectId(project_id)},
+ {"$set": {
+ "deployment_id": deployment_id,
+ "stage": "done",
+ "status": "deployed",
+ "updated_at": datetime.now(timezone.utc),
+ }}
+ )
+
+ yield _sse({"type": "log", "line": "✓ Deployment complete"})
+
+ except Exception as e:
+ await deployments_col().update_one(
+ {"_id": ObjectId(deployment_id)},
+ {"$set": {"status": "failed", "updated_at": datetime.now(timezone.utc)}}
+ )
+ yield _sse({"type": "error", "message": str(e)})
+
+ yield "data: [DONE]\n\n"
+
+ return StreamingResponse(
+ _stream(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+@router.post("/rollback/{project_id}")
+async def rollback_deploy(
+ project_id: str,
+ body: DeployRollbackRequest,
+ user=Depends(get_current_user),
+):
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(404, "Project not found")
+ if str(project["owner_id"]) != str(user["_id"]):
+ raise HTTPException(403, "Forbidden")
+
+ deployment = await deployments_col().find_one({"_id": ObjectId(body.deployment_id)})
+ if not deployment:
+ raise HTTPException(404, "Deployment not found")
+
+ if not project.get("cloud_credentials_encrypted"):
+ raise HTTPException(409, "Cloud credentials not configured")
+
+ creds_json = decrypt(project["cloud_credentials_encrypted"])
+ credentials = json.loads(creds_json)
+ provider_type = project.get("cloud_provider_type") or credentials.get("provider", "aws")
+
+ stack_name = f"cloudforge-{project_id[:8]}"
+
+ provider = get_provider(provider_type, credentials)
+ await provider.rollback(stack_name)
+
+ await deployments_col().update_one(
+ {"_id": ObjectId(body.deployment_id)},
+ {"$set": {"status": "failed", "updated_at": datetime.now(timezone.utc)}}
+ )
+
+ return {"rolled_back": True}
+
+
+@router.get("/status/{project_id}")
+async def deploy_status(project_id: str, user=Depends(get_current_user)):
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(404, "Project not found")
+ if str(project["owner_id"]) != str(user["_id"]):
+ raise HTTPException(403, "Forbidden")
+
+ deployment = await deployments_col().find_one(
+ {"project_id": ObjectId(project_id)},
+ sort=[("created_at", -1)],
+ )
+ if not deployment:
+ raise HTTPException(404, "No deployment found for this project")
+
+ def _serialize(doc: dict) -> dict:
+ result = {}
+ for k, v in doc.items():
+ if isinstance(v, ObjectId):
+ result[k] = str(v)
+ elif isinstance(v, datetime):
+ result[k] = v.isoformat()
+ elif isinstance(v, dict):
+ result[k] = _serialize(v)
+ else:
+ result[k] = v
+ return result
+
+ return _serialize(deployment)
diff --git a/backend/app/schemas/deploy.py b/backend/app/schemas/deploy.py
new file mode 100644
index 0000000..0bda30f
--- /dev/null
+++ b/backend/app/schemas/deploy.py
@@ -0,0 +1,5 @@
+from pydantic import BaseModel
+
+
+class DeployRollbackRequest(BaseModel):
+ deployment_id: str
From 675599a9cda47e7db5047d8a1d8202823497b6d9 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:08:57 -0700
Subject: [PATCH 29/50] feat(frontend): add auth store with JWT persistence,
update forgeStore and projectStore with real API
---
frontend/src/store/authStore.ts | 41 +++++++++++++++++++
frontend/src/store/forgeStore.ts | 5 +++
frontend/src/store/projectStore.ts | 66 ++++++++++++++++++++++++++++++
3 files changed, 112 insertions(+)
create mode 100644 frontend/src/store/authStore.ts
diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts
new file mode 100644
index 0000000..2b2ef5b
--- /dev/null
+++ b/frontend/src/store/authStore.ts
@@ -0,0 +1,41 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+
+interface User {
+ id: string
+ email: string
+ username: string
+ github_connected: boolean
+}
+
+interface AuthState {
+ accessToken: string | null
+ refreshToken: string | null
+ user: User | null
+ setAuth: (accessToken: string, refreshToken: string, user: User) => void
+ clearAuth: () => void
+}
+
+export const useAuthStore = create()(
+ persist(
+ (set) => ({
+ accessToken: null,
+ refreshToken: null,
+ user: null,
+ setAuth: (accessToken, refreshToken, user) => set({ accessToken, refreshToken, user }),
+ clearAuth: () => set({ accessToken: null, refreshToken: null, user: null }),
+ }),
+ { name: 'cloudforge-auth' }
+ )
+)
+
+export function getAccessToken(): string | null {
+ try {
+ const stored = localStorage.getItem('cloudforge-auth')
+ if (!stored) return null
+ const parsed = JSON.parse(stored)
+ return parsed?.state?.accessToken ?? null
+ } catch {
+ return null
+ }
+}
diff --git a/frontend/src/store/forgeStore.ts b/frontend/src/store/forgeStore.ts
index 5f470b6..9477682 100644
--- a/frontend/src/store/forgeStore.ts
+++ b/frontend/src/store/forgeStore.ts
@@ -73,6 +73,7 @@ interface ForgeState {
buildTotal: number;
deployLog: string[];
deployModalOpen: boolean;
+ currentProjectId: string | null;
// Actions
setStageStatus: (stage: ForgeStage, status: StageStatus) => void;
@@ -91,6 +92,7 @@ interface ForgeState {
updateNodeDeployStatus: (nodeId: string, status: ForgeArchNode['deployStatus']) => void;
setProjectName: (name: string) => void;
setDeployModalOpen: (open: boolean) => void;
+ setCurrentProjectId: (id: string | null) => void;
}
// ── Stage order ───────────────────────────────────────────────────────────────
@@ -153,6 +155,7 @@ export const useForgeStore = create((set, get) => ({
buildTotal: 5,
deployLog: [],
deployModalOpen: false,
+ currentProjectId: null,
setStageStatus: (stage, status) =>
set((state) => ({
@@ -253,4 +256,6 @@ export const useForgeStore = create((set, get) => ({
setProjectName: (name) => set({ projectName: name }),
setDeployModalOpen: (open) => set({ deployModalOpen: open }),
+
+ setCurrentProjectId: (id) => set({ currentProjectId: id }),
}));
diff --git a/frontend/src/store/projectStore.ts b/frontend/src/store/projectStore.ts
index 1ddd16e..cb27657 100644
--- a/frontend/src/store/projectStore.ts
+++ b/frontend/src/store/projectStore.ts
@@ -1,15 +1,47 @@
import { create } from 'zustand';
import { MOCK_PROJECTS, type Project, type ProjectStage } from '@/lib/mock-data';
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
+
const STAGE_ORDER: ProjectStage[] = ['prd', 'arch', 'build', 'live'];
+// API-backed project shape (returned from backend)
+interface ApiProject {
+ id: string;
+ name: string;
+ description?: string;
+ stage: string;
+ status: string;
+ region?: string;
+ cloud_provider?: string;
+ prd_session_id?: string;
+ arch_session_id?: string;
+ build_id?: string;
+ deployment_id?: string;
+ github_repo?: string;
+ github_connected: boolean;
+ cloud_verified: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
interface ProjectStoreState {
+ // Legacy mock-backed state (used by existing dashboard UI)
projects: Project[];
advanceStage: (id: string) => void;
createProject: (name: string) => string;
+
+ // API-backed state
+ apiProjects: ApiProject[];
+ currentProjectId: string | null;
+ isLoading: boolean;
+ loadProjects: (token: string) => Promise;
+ createApiProject: (name: string, token: string) => Promise;
+ setCurrentProjectId: (id: string | null) => void;
}
export const useProjectStore = create((set) => ({
+ // ── Legacy mock state ──────────────────────────────────────────────────────
projects: [...MOCK_PROJECTS],
advanceStage: (id) =>
@@ -36,6 +68,40 @@ export const useProjectStore = create((set) => ({
set((state) => ({ projects: [...state.projects, newProject] }));
return id;
},
+
+ // ── API-backed state ───────────────────────────────────────────────────────
+ apiProjects: [],
+ currentProjectId: null,
+ isLoading: false,
+
+ loadProjects: async (token: string) => {
+ set({ isLoading: true });
+ try {
+ const resp = await fetch(`${API_URL}/projects`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (resp.ok) {
+ const data = await resp.json();
+ set({ apiProjects: data });
+ }
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ createApiProject: async (name: string, token: string) => {
+ const resp = await fetch(`${API_URL}/projects`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name }),
+ });
+ if (!resp.ok) throw new Error('Failed to create project');
+ const project: ApiProject = await resp.json();
+ set((state) => ({ apiProjects: [...state.apiProjects, project] }));
+ return project;
+ },
+
+ setCurrentProjectId: (id) => set({ currentProjectId: id }),
}));
export { STAGE_ORDER };
From 87d24f0b3dcd226878b09d89d4bce3edb985b5f3 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:10:13 -0700
Subject: [PATCH 30/50] feat(frontend): wire login/signup to auth API, replace
agent mocks with real SSE calls, remove stub deploy route
---
frontend/src/app/(auth)/login/page.tsx | 75 +++---
frontend/src/app/(auth)/signup/page.tsx | 74 +++---
frontend/src/app/api/deploy/route.ts | 33 ---
frontend/src/lib/forge-agents.ts | 301 ++++++++++++++++++++----
4 files changed, 341 insertions(+), 142 deletions(-)
delete mode 100644 frontend/src/app/api/deploy/route.ts
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
index 96fb7ab..94182a2 100644
--- a/frontend/src/app/(auth)/login/page.tsx
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -4,7 +4,10 @@ import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
-import { Github, Mail, Eye, EyeOff, ArrowRight } from 'lucide-react';
+import { Github, Eye, EyeOff, ArrowRight } from 'lucide-react';
+import { useAuthStore } from '@/store/authStore';
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
// ── Sub-components ────────────────────────────────────────────────────────────
@@ -160,18 +163,36 @@ function FormInput({
export default function LoginPage() {
const router = useRouter();
+ const setAuth = useAuthStore((s) => s.setAuth);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
- function handleSubmit(e: React.FormEvent) {
+ async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
- // Mock: redirect to dashboard after short delay
- setTimeout(() => {
+ setError(null);
+ try {
+ const resp = await fetch(`${API_URL}/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password }),
+ });
+ if (!resp.ok) {
+ const body = await resp.json().catch(() => ({}));
+ setError((body as { detail?: string }).detail || 'Invalid email or password');
+ return;
+ }
+ const data = await resp.json();
+ setAuth(data.access_token, data.refresh_token, data.user);
router.push('/dashboard');
- }, 800);
+ } catch {
+ setError('Unable to reach the server. Please try again.');
+ } finally {
+ setLoading(false);
+ }
}
function handleOAuth() {
@@ -277,6 +298,25 @@ export default function LoginPage() {
+ {/* Error message */}
+ {error && (
+
+ {error}
+
+ )}
+
{/* Email form */}
- {/* Mock notice */}
-
-
-
- Mock UI — submitting will redirect to the dashboard.
-
-
);
}
diff --git a/frontend/src/app/api/deploy/route.ts b/frontend/src/app/api/deploy/route.ts
deleted file mode 100644
index 2659096..0000000
--- a/frontend/src/app/api/deploy/route.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-// ============================================================
-// STUB: This route is a placeholder for the Claude backend agent.
-//
-// REAL IMPLEMENTATION (to be built separately):
-// 1. Receive CloudForgeTopology JSON
-// 2. Pass to Claude Sonnet via Anthropic SDK
-// 3. Claude generates Terraform HCL for each resource
-// 4. Execute Terraform via AWS Cloud Control API MCP server
-// 5. Stream deployment progress back via SSE or WebSocket
-// 6. Return live resource ARNs and endpoints on completion
-//
-// See: docs/backend-architecture.md
-// ============================================================
-
-import { NextRequest, NextResponse } from 'next/server';
-import type { CloudForgeTopology } from '@cloudforge/types';
-
-export async function POST(request: NextRequest): Promise {
- const topology = (await request.json()) as CloudForgeTopology;
-
- if (process.env.NODE_ENV === 'development') {
- // eslint-disable-next-line no-console
- console.log('[CloudForge] Topology received:', JSON.stringify(topology, null, 2));
- }
-
- // Simulate processing delay
- await new Promise((resolve) => setTimeout(resolve, 500));
-
- return NextResponse.json({
- deploymentId: `dep_mock_${Date.now()}`,
- status: 'accepted',
- });
-}
diff --git a/frontend/src/lib/forge-agents.ts b/frontend/src/lib/forge-agents.ts
index 501131c..ea68a35 100644
--- a/frontend/src/lib/forge-agents.ts
+++ b/frontend/src/lib/forge-agents.ts
@@ -1,7 +1,6 @@
/**
* Forge agent service layer.
* All agent calls are centralised here so the UI never calls external services directly.
- * Swap the MOCK_* constants and the implementation bodies to wire real API calls.
*/
import type {
@@ -11,12 +10,65 @@ import type {
GeneratedFile,
} from '@/store/forgeStore';
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
+
// ── Utility ───────────────────────────────────────────────────────────────────
function delay(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+async function streamSSE(
+ url: string,
+ fetchInit: RequestInit,
+ onEvent: (data: string) => void
+): Promise {
+ const resp = await fetch(url, {
+ ...fetchInit,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...fetchInit.headers,
+ },
+ });
+
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ if (!resp.body) throw new Error('No response body');
+
+ const reader = resp.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const data = line.slice(6).trim();
+ if (data && data !== '[DONE]') {
+ onEvent(data);
+ }
+ }
+ }
+ }
+}
+
+function authHeaders(): Record {
+ try {
+ const stored = localStorage.getItem('cloudforge-auth');
+ if (!stored) return {};
+ const token = JSON.parse(stored)?.state?.accessToken;
+ if (!token) return {};
+ return { Authorization: `Bearer ${token}` };
+ } catch {
+ return {};
+ }
+}
+
// ── Agent 1 — Requirements → Constraint extraction ────────────────────────────
const MOCK_CONSTRAINTS: ConstraintChip[] = [
@@ -26,21 +78,41 @@ const MOCK_CONSTRAINTS: ConstraintChip[] = [
{ id: 'c4', label: '99.9% uptime SLA', category: 'reliability' },
];
-/**
- * BACKEND HOOK: POST /api/agent1
- * Body: { prdText: string }
- * Response stream: Server-Sent Events, each event is a ConstraintChip JSON object.
- */
export async function runAgent1(
- _prdText: string,
- onChip?: (chip: ConstraintChip, index: number) => void
+ prdText: string,
+ onChip?: (chip: ConstraintChip, index: number) => void,
+ projectId?: string,
): Promise {
- await delay(2000);
- for (let i = 0; i < MOCK_CONSTRAINTS.length; i++) {
- onChip?.(MOCK_CONSTRAINTS[i], i);
- await delay(350);
+ const chips: ConstraintChip[] = [];
+
+ if (!projectId) {
+ await delay(2000);
+ for (let i = 0; i < MOCK_CONSTRAINTS.length; i++) {
+ onChip?.(MOCK_CONSTRAINTS[i], i);
+ await delay(350);
+ }
+ return MOCK_CONSTRAINTS;
}
- return MOCK_CONSTRAINTS;
+
+ await streamSSE(
+ `${API_URL}/workflows/prd/v2/start/${projectId}`,
+ {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify({ prd_text: prdText, cloud_provider: 'aws' }),
+ },
+ (data) => {
+ try {
+ const event = JSON.parse(data);
+ if (event.type === 'constraint' && event.chip) {
+ chips.push(event.chip);
+ onChip?.(event.chip, chips.length - 1);
+ }
+ } catch { /* ignore parse errors */ }
+ }
+ );
+
+ return chips;
}
// ── Agent 2 — Constraints → Architecture ──────────────────────────────────────
@@ -175,21 +247,73 @@ export const AGENT2_STEPS = [
'Generating explanation',
] as const;
-/**
- * BACKEND HOOK: POST /api/agent2
- * Body: { constraints: ConstraintChip[] }
- * Response stream: SSE with { step: number } events, then final { nodes, edges } payload.
- */
+function _mapNodeType(service: string): ForgeArchNode['type'] {
+ const s = service.toLowerCase();
+ if (s.includes('gateway') || s.includes('apigw')) return 'gateway';
+ if (s.includes('lambda') || s.includes('function') || s.includes('compute') || s.includes('ec2')) return 'compute';
+ if (s.includes('cache') || s.includes('redis') || s.includes('elasticache')) return 'cache';
+ if (s.includes('rds') || s.includes('database') || s.includes('postgres') || s.includes('mysql') || s.includes('s3')) return 'storage';
+ if (s.includes('auth') || s.includes('cognito') || s.includes('secret')) return 'auth';
+ return 'compute';
+}
+
export async function runAgent2(
_constraints: ConstraintChip[],
- onStep?: Agent2StepCallback
+ onStep?: Agent2StepCallback,
+ projectId?: string,
): Promise<{ nodes: ForgeArchNode[]; edges: ForgeArchEdge[] }> {
- for (let i = 0; i < AGENT2_STEPS.length; i++) {
- onStep?.(i, AGENT2_STEPS.length);
- await delay(1300 + Math.random() * 400);
+ if (!projectId) {
+ for (let i = 0; i < AGENT2_STEPS.length; i++) {
+ onStep?.(i, AGENT2_STEPS.length);
+ await delay(1300 + Math.random() * 400);
+ }
+ await delay(500);
+ return { nodes: MOCK_ARCH_NODES, edges: MOCK_ARCH_EDGES };
}
- await delay(500);
- return { nodes: MOCK_ARCH_NODES, edges: MOCK_ARCH_EDGES };
+
+ let nodes: ForgeArchNode[] = [];
+ let edges: ForgeArchEdge[] = [];
+
+ await streamSSE(
+ `${API_URL}/workflows/architecture/v2/start/${projectId}`,
+ {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify({}),
+ },
+ (data) => {
+ try {
+ const event = JSON.parse(data);
+ if (event.step !== undefined) {
+ onStep?.(event.step - 1, 7);
+ }
+ if (event.node === 'complete' && event.architecture_diagram) {
+ const diagram = event.architecture_diagram;
+ nodes = (diagram.nodes || []).map((n: Record) => ({
+ id: String(n.id || ''),
+ label: String(n.service || n.label || n.id || ''),
+ sublabel: String(n.description || ''),
+ type: _mapNodeType(String(n.service || n.type || '')),
+ x: Math.random() * 500,
+ y: Math.random() * 400,
+ terraformResource: String(n.terraform_resource || ''),
+ estimatedCost: String(n.estimated_cost || ''),
+ config: (n.config as Record) || {},
+ whyChosen: String(n.why_chosen || ''),
+ validates: (n.validates as string[]) || [],
+ blocks: (n.blocks as string[]) || [],
+ deployStatus: 'queued' as const,
+ }));
+ edges = (diagram.connections || []).map((c: Record) => ({
+ from: String(c.from || c.from_ || c.source || ''),
+ to: String(c.to || c.target || ''),
+ }));
+ }
+ } catch { /* ignore */ }
+ }
+ );
+
+ return nodes.length > 0 ? { nodes, edges } : { nodes: MOCK_ARCH_NODES, edges: MOCK_ARCH_EDGES };
}
export { MOCK_ARCH_NODES, MOCK_ARCH_EDGES };
@@ -339,25 +463,81 @@ const MOCK_FILES: GeneratedFile[] = [
export { MOCK_FILES };
-/**
- * BACKEND HOOK: POST /api/agent3
- * Body: { architectureData: { nodes, edges } }
- * Response stream: SSE with { file: GeneratedFile } events, then { done: true }.
- */
export async function runAgent3(
- _architectureData: { nodes: ForgeArchNode[]; edges: ForgeArchEdge[] },
- callbacks?: Agent3Callbacks
+ architectureData: { nodes: ForgeArchNode[]; edges: ForgeArchEdge[] },
+ callbacks?: Agent3Callbacks,
+ projectId?: string,
): Promise {
- const total = MOCK_FILES.length;
- callbacks?.onProgress(0, total);
-
- for (let i = 0; i < MOCK_FILES.length; i++) {
- await delay(1400 + Math.random() * 900);
- callbacks?.onFileReady(MOCK_FILES[i]);
- callbacks?.onProgress(i + 1, total);
+ if (!projectId) {
+ const total = MOCK_FILES.length;
+ callbacks?.onProgress(0, total);
+ for (let i = 0; i < MOCK_FILES.length; i++) {
+ await delay(1400 + Math.random() * 900);
+ callbacks?.onFileReady(MOCK_FILES[i]);
+ callbacks?.onProgress(i + 1, total);
+ }
+ return MOCK_FILES;
}
- return MOCK_FILES;
+ const files: GeneratedFile[] = [];
+
+ await streamSSE(
+ `${API_URL}/workflows/build/start/${projectId}`,
+ {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify({}),
+ },
+ (data) => {
+ try {
+ const event = JSON.parse(data) as Record;
+ if (event.phase === 'complete' && event.artifacts) {
+ const artifacts = event.artifacts as Record;
+ let idx = 0;
+ for (const [path, content] of Object.entries(artifacts)) {
+ if (typeof content !== 'string') continue;
+ const name = path.split('/').pop() || path;
+ const ext = name.includes('.') ? name.split('.').pop()! : 'text';
+ const langMap: Record = {
+ tf: 'hcl',
+ py: 'python',
+ ts: 'typescript',
+ js: 'javascript',
+ yaml: 'yaml',
+ yml: 'yaml',
+ json: 'json',
+ sh: 'bash',
+ };
+ const file: GeneratedFile = {
+ id: `f${idx++}`,
+ name,
+ path,
+ lang: langMap[ext] || ext,
+ status: 'new',
+ lines: content.split('\n').map((l) => ({ content: l, highlight: false })),
+ };
+ files.push(file);
+ callbacks?.onFileReady(file);
+ callbacks?.onProgress(files.length, Object.keys(artifacts).length);
+ }
+ } else if (
+ event.phase === 'orchestrator' ||
+ event.phase === 'tf_generator' ||
+ event.phase === 'assembler'
+ ) {
+ const done = (event.tasks_done as number) || 0;
+ const total = (event.tasks_total as number) || files.length || 1;
+ callbacks?.onProgress(done, total);
+ }
+ } catch { /* ignore */ }
+ }
+ );
+
+ // architectureData is intentionally unused when a real projectId is provided;
+ // the backend derives the build from its own stored architecture session.
+ void architectureData;
+
+ return files.length > 0 ? files : MOCK_FILES;
}
// ── Deploy agent ──────────────────────────────────────────────────────────────
@@ -396,21 +576,42 @@ const DEPLOY_SEQUENCE: Array<{
{ ms: 300, line: '✓ Deployment complete — 5 resources provisioned, est. $32.70/mo' },
];
-/**
- * BACKEND HOOK: POST /api/deploy
- * Body: { files: GeneratedFile[], architectureData: { nodes, edges } }
- * Response stream: SSE with { log: string, nodeId?: string, status?: string } events.
- */
export async function runDeploy(
_files: GeneratedFile[],
_architectureData: { nodes: ForgeArchNode[]; edges: ForgeArchEdge[] },
- callbacks: DeployCallbacks
+ callbacks: DeployCallbacks,
+ projectId?: string,
): Promise {
- for (const event of DEPLOY_SEQUENCE) {
- await delay(event.ms);
- callbacks.onLog(event.line);
- if (event.nodeId && event.status) {
- callbacks.onNodeStatus(event.nodeId, event.status);
+ if (!projectId) {
+ for (const event of DEPLOY_SEQUENCE) {
+ await delay(event.ms);
+ callbacks.onLog(event.line);
+ if (event.nodeId && event.status) {
+ callbacks.onNodeStatus(event.nodeId, event.status);
+ }
}
+ return;
}
+
+ await streamSSE(
+ `${API_URL}/workflows/deploy/start/${projectId}`,
+ {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify({}),
+ },
+ (data) => {
+ try {
+ const event = JSON.parse(data) as Record;
+ if (event.type === 'log') {
+ callbacks.onLog(event.line as string);
+ } else if (event.type === 'node_status') {
+ callbacks.onNodeStatus(
+ event.nodeId as string,
+ event.status as 'provisioning' | 'live'
+ );
+ }
+ } catch { /* ignore */ }
+ }
+ );
}
From bd910c4a695e24d010ba304aa51fe4ba2c0d5e6e Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:10:23 -0700
Subject: [PATCH 31/50] docs: add env.sample and startup guides for backend and
frontend
---
backend/.env.sample | 59 ++++++++++++++++++
backend/STARTUP.md | 149 ++++++++++++++++++++++++++++++++++++++++++++
frontend/STARTUP.md | 114 +++++++++++++++++++++++++++++++++
3 files changed, 322 insertions(+)
create mode 100644 backend/.env.sample
create mode 100644 backend/STARTUP.md
create mode 100644 frontend/STARTUP.md
diff --git a/backend/.env.sample b/backend/.env.sample
new file mode 100644
index 0000000..e9b6b94
--- /dev/null
+++ b/backend/.env.sample
@@ -0,0 +1,59 @@
+# ── App ───────────────────────────────────────────────────────────────────────
+APP_NAME=CloudForge API
+APP_ENV=development
+DEBUG=true
+HOST=0.0.0.0
+PORT=8000
+
+# ── MongoDB ───────────────────────────────────────────────────────────────────
+MONGODB_URL=mongodb://localhost:27017
+MONGODB_DB_NAME=cloudforge
+
+# ── JWT ───────────────────────────────────────────────────────────────────────
+# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
+JWT_SECRET_KEY=replace_with_64_char_hex
+JWT_ALGORITHM=HS256
+JWT_ACCESS_EXPIRE_MINUTES=30
+JWT_REFRESH_EXPIRE_DAYS=7
+
+# ── Encryption (Fernet) ───────────────────────────────────────────────────────
+# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+FERNET_KEY=replace_with_fernet_key
+
+# ── GitHub OAuth ──────────────────────────────────────────────────────────────
+# Create an OAuth App at https://github.com/settings/developers
+# Homepage URL: http://localhost:3000
+# Callback URL: http://localhost:8000/auth/github/callback
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+GITHUB_REDIRECT_URI=http://localhost:8000/auth/github/callback
+
+# ── Anthropic ─────────────────────────────────────────────────────────────────
+# Required when ARCH_MODEL_TYPE=anthropic (Agent 2 uses Claude)
+ANTHROPIC_API_KEY=sk-ant-...
+
+# ── Frontend ──────────────────────────────────────────────────────────────────
+FRONTEND_URL=http://localhost:3000
+
+# ── Agent 1 (PRD) — Ollama ────────────────────────────────────────────────────
+OLLAMA_BASE_URL=http://localhost:11434
+QWEN_MODEL=qwen3.5:latest
+LLM_TEMPERATURE=0.2
+LLM_TIMEOUT_SECONDS=90
+ENABLE_WEB_SEARCH=true
+MAX_CLARIFICATION_ROUNDS=6
+MAX_RESEARCH_ROUNDS=3
+
+# ── Agent 2 (Architecture Planner) ───────────────────────────────────────────
+# Set to "anthropic" + fill ANTHROPIC_API_KEY to use Claude.
+# Set to "ollama" + set ARCH_MODEL_NAME for a local model.
+ARCH_MODEL_TYPE=ollama
+ARCH_MODEL_NAME=llama3.1:8b
+
+# ── Agent 3 (Code / Terraform Generator) — Ollama ────────────────────────────
+AGENT3_MODEL=qwen3.5
+AGENT3_FAST_MODEL=qwen3.5
+
+# ── Agent data paths ─────────────────────────────────────────────────────────
+GRAPH_JSON_PATH=app/agents/data/graph/graph.json
+KUZU_DB_PATH=./cloudforge_db
diff --git a/backend/STARTUP.md b/backend/STARTUP.md
new file mode 100644
index 0000000..643c27a
--- /dev/null
+++ b/backend/STARTUP.md
@@ -0,0 +1,149 @@
+# Backend — Startup Guide
+
+## Prerequisites
+
+| Tool | Version | Install |
+|------|---------|---------|
+| Python | 3.12+ | [python.org](https://python.org) |
+| uv | latest | `pip install uv` |
+| MongoDB | 7.0+ | [mongodb.com](https://www.mongodb.com/try/download/community) |
+| Ollama | latest | [ollama.com](https://ollama.com) — only if using local LLMs |
+
+---
+
+## 1. Install dependencies
+
+```bash
+cd backend
+uv sync
+```
+
+---
+
+## 2. Configure environment
+
+```bash
+cp .env.sample .env
+```
+
+Open `.env` and fill in the required values:
+
+**Required (server will not start without these):**
+
+```bash
+# Generate JWT secret
+python -c "import secrets; print(secrets.token_hex(32))"
+# → paste as JWT_SECRET_KEY
+
+# Generate Fernet key
+python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+# → paste as FERNET_KEY
+```
+
+**Optional but recommended:**
+
+- `ANTHROPIC_API_KEY` — set `ARCH_MODEL_TYPE=anthropic` to use Claude for Agent 2 instead of Ollama
+- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — required for GitHub OAuth (connect repos, commit scaffold)
+
+**GitHub OAuth setup:**
+1. Go to [github.com/settings/developers](https://github.com/settings/developers) → New OAuth App
+2. Homepage URL: `http://localhost:3000`
+3. Callback URL: `http://localhost:8000/auth/github/callback`
+4. Copy Client ID and Client Secret into `.env`
+
+---
+
+## 3. Start MongoDB
+
+```bash
+# macOS (Homebrew)
+brew services start mongodb-community
+
+# Linux (systemd)
+sudo systemctl start mongod
+
+# Windows
+net start MongoDB
+
+# Or run directly
+mongod --dbpath ./data/db
+```
+
+---
+
+## 4. Pull Ollama models (if using local LLMs)
+
+```bash
+ollama pull qwen3.5 # Agent 1 and Agent 3
+ollama pull llama3.1:8b # Agent 2 (or set ARCH_MODEL_TYPE=anthropic)
+```
+
+---
+
+## 5. Start the server
+
+```bash
+cd backend
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+```
+
+On startup you should see:
+```
+INFO MongoDB connected
+INFO Kuzu loaded
+INFO Uvicorn running on http://0.0.0.0:8000
+```
+
+---
+
+## 6. Verify
+
+```bash
+curl http://localhost:8000/health
+# → {"status": "ok"}
+
+curl http://localhost:8000/docs
+# → Open in browser for interactive API docs (Swagger UI)
+```
+
+---
+
+## API Overview
+
+| Prefix | Description |
+|--------|-------------|
+| `POST /auth/register` | Create account |
+| `POST /auth/login` | Get JWT tokens |
+| `GET /auth/github` | Start GitHub OAuth |
+| `GET /auth/github/callback` | GitHub OAuth callback |
+| `GET /auth/me` | Current user |
+| `POST /projects` | Create project |
+| `GET /projects` | List projects |
+| `POST /workflows/prd/v2/start/{id}` | Run Agent 1 (SSE) |
+| `POST /workflows/prd/v2/accept/{id}` | Accept PRD |
+| `POST /workflows/architecture/v2/start/{id}` | Run Agent 2 (SSE) |
+| `POST /workflows/architecture/v2/accept/{id}` | Accept architecture |
+| `POST /workflows/build/start/{id}` | Run Agent 3 (SSE) |
+| `POST /workflows/build/{id}/commit` | Commit to GitHub |
+| `POST /workflows/deploy/start/{id}` | Deploy to cloud (SSE) |
+
+Full docs at `http://localhost:8000/docs` when running.
+
+---
+
+## Troubleshooting
+
+**`RuntimeError: MongoDB client is not initialized`**
+→ MongoDB is not running. Start `mongod` first.
+
+**`Kuzu init failed`**
+→ `graph.json` is missing at `GRAPH_JSON_PATH`. Agent 2 will still work but without knowledge graph enrichment.
+
+**`FERNET_KEY` is empty**
+→ Encryption will fail on credential storage. Generate and set the key (see step 2).
+
+**Agent 2 timeout / slow**
+→ Switch to Claude: set `ARCH_MODEL_TYPE=anthropic` and add `ANTHROPIC_API_KEY`.
+
+**GitHub commit fails with 401**
+→ User has not connected GitHub. Hit `GET /auth/github` to get the OAuth URL, complete the flow.
diff --git a/frontend/STARTUP.md b/frontend/STARTUP.md
new file mode 100644
index 0000000..d9075b0
--- /dev/null
+++ b/frontend/STARTUP.md
@@ -0,0 +1,114 @@
+# Frontend — Startup Guide
+
+## Prerequisites
+
+| Tool | Version | Install |
+|------|---------|---------|
+| Node.js | 20+ | [nodejs.org](https://nodejs.org) |
+| npm | 10+ | bundled with Node |
+
+---
+
+## 1. Install dependencies
+
+```bash
+cd frontend
+npm install
+```
+
+---
+
+## 2. Configure environment
+
+Create `frontend/.env.local` (already gitignored):
+
+```bash
+cp .env.local.sample .env.local # if sample exists
+# or create manually:
+echo "NEXT_PUBLIC_API_URL=http://localhost:8000" > .env.local
+```
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `NEXT_PUBLIC_API_URL` | `http://localhost:8000` | FastAPI backend URL |
+
+---
+
+## 3. Start the dev server
+
+Make sure the backend is running first (see `backend/STARTUP.md`), then:
+
+```bash
+cd frontend
+npm run dev
+```
+
+App runs at `http://localhost:3000`.
+
+---
+
+## 4. Verify
+
+Open `http://localhost:3000` — you should see the CloudForge landing page.
+
+Navigate to `/signup` to create an account. The form POSTs to the backend; on success you are redirected to `/dashboard`.
+
+---
+
+## Available scripts
+
+| Command | Description |
+|---------|-------------|
+| `npm run dev` | Start development server with hot reload |
+| `npm run build` | Production build |
+| `npm run start` | Serve production build |
+| `npm run lint` | Run ESLint |
+
+---
+
+## Project structure
+
+```
+src/
+ app/ # Next.js App Router pages
+ (auth)/ # login, signup
+ dashboard/ # project list
+ app/ # forge screens (requirements → architecture → build → deploy)
+ lib/
+ forge-agents.ts # SSE client for all 4 agents — pass projectId for real calls
+ store/
+ authStore.ts # JWT tokens + user (persisted to localStorage)
+ forgeStore.ts # active forge session state
+ projectStore.ts # project list + API actions
+```
+
+---
+
+## Auth flow
+
+1. Register or log in → tokens saved to `localStorage` via `authStore`
+2. All API calls in `forge-agents.ts` read the token from `authStore.getAccessToken()`
+3. To use real backend endpoints, pass `projectId` to each agent function:
+ ```ts
+ await runAgent1(prdText, onChip, project.id)
+ await runAgent2(chips, onStep, project.id)
+ await runAgent3(archData, callbacks, project.id)
+ await runDeploy(files, archData, callbacks, project.id)
+ ```
+4. Omit `projectId` to fall back to mock data (useful when backend is unavailable)
+
+---
+
+## Troubleshooting
+
+**`fetch` fails with CORS error**
+→ Ensure `FRONTEND_URL=http://localhost:3000` is set in the backend `.env` and the backend is running.
+
+**`401 Unauthorized` on all requests**
+→ Token expired or missing. Log out and log back in. Check `localStorage` key `cloudforge-auth`.
+
+**SSE stream hangs or never completes**
+→ The backend agent is still running. Open `http://localhost:8000/docs` to check endpoint health. For Agent 2/3 with Ollama, first inference can take 30–60 s on first model load.
+
+**`NEXT_PUBLIC_API_URL` is undefined**
+→ `.env.local` is missing or was created after `npm run dev` was started. Restart the dev server after creating the file.
From 7b9d00ed65e674cfc8d4693742cbe787ccec104d Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:15:21 -0700
Subject: [PATCH 32/50] fix(auth): correct ObjectId/string comparison in
require_project_owner and add missing ownership checks to arch SSE
respond/review endpoints
---
backend/app/core/dependencies.py | 2 +-
backend/app/routers/architecture_sse.py | 36 ++++++++++++++++++++-----
2 files changed, 30 insertions(+), 8 deletions(-)
diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py
index bcdda7c..ad21a35 100644
--- a/backend/app/core/dependencies.py
+++ b/backend/app/core/dependencies.py
@@ -32,7 +32,7 @@ async def require_project_owner(project_id: str, user: dict = Depends(get_curren
project = await projects_col().find_one({"_id": ObjectId(project_id)})
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
- if project.get("owner_id") != str(user["_id"]):
+ if str(project.get("owner_id")) != str(user["_id"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to access this project",
diff --git a/backend/app/routers/architecture_sse.py b/backend/app/routers/architecture_sse.py
index bf5dc2d..581fe3c 100644
--- a/backend/app/routers/architecture_sse.py
+++ b/backend/app/routers/architecture_sse.py
@@ -91,6 +91,7 @@ async def _stream_arch_start(
payload: ArchSSEStartRequest,
user: dict,
kuzu_conn,
+ request: Request,
) -> AsyncGenerator[str, None]:
# 1. Fetch PRD and gate on accepted status
prd_conv = await prd_conversations_col().find_one({"project_id": ObjectId(project_id)})
@@ -135,6 +136,10 @@ async def _stream_arch_start(
seen_nodes: set[str] = set()
try:
async for state_snapshot in graph.astream(initial_state, config, stream_mode="values"):
+ if await request.is_disconnected():
+ logger.info("Client disconnected, stopping architecture stream")
+ return
+
current_node = state_snapshot.get("current_node", "")
# Check for interrupt
@@ -174,8 +179,8 @@ async def _stream_arch_start(
yield _sse(event)
except Exception as exc:
- logger.error("arch_sse start error [%s]: %s", session_id, exc, exc_info=True)
- yield _sse({"type": "error", "message": str(exc)})
+ logger.exception("Workflow error in arch stream")
+ yield _sse({"type": "error", "message": "An internal error occurred. Please try again."})
return
# 6. Graph completed — finalise
@@ -220,6 +225,7 @@ async def _stream_arch_resume(
session_id: str,
resume_value,
kuzu_conn,
+ request: Request,
) -> AsyncGenerator[str, None]:
config = {"configurable": {"thread_id": session_id}}
graph = _get_arch_graph(kuzu_conn)
@@ -229,6 +235,10 @@ async def _stream_arch_resume(
async for state_snapshot in graph.astream(
Command(resume=resume_value), config, stream_mode="values"
):
+ if await request.is_disconnected():
+ logger.info("Client disconnected, stopping architecture stream")
+ return
+
current_node = state_snapshot.get("current_node", "")
try:
@@ -266,8 +276,8 @@ async def _stream_arch_resume(
yield _sse(event)
except Exception as exc:
- logger.error("arch_sse resume error [%s]: %s", session_id, exc, exc_info=True)
- yield _sse({"type": "error", "message": str(exc)})
+ logger.exception("Workflow error in arch stream")
+ yield _sse({"type": "error", "message": "An internal error occurred. Please try again."})
return
try:
@@ -323,7 +333,7 @@ async def start_arch_v2(
kuzu_conn = getattr(request.app.state, "kuzu_conn", None)
return StreamingResponse(
- _stream_arch_start(project_id, project, payload, user, kuzu_conn),
+ _stream_arch_start(project_id, project, payload, user, kuzu_conn, request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@@ -336,6 +346,12 @@ async def respond_arch_v2(
request: Request,
user: dict = Depends(get_current_user),
):
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+ if str(project.get("owner_id")) != str(user["_id"]):
+ raise HTTPException(status_code=403, detail="Forbidden")
+
arch_doc = await architectures_col().find_one(
{"project_id": ObjectId(project_id)},
sort=[("created_at", -1)],
@@ -347,7 +363,7 @@ async def respond_arch_v2(
kuzu_conn = getattr(request.app.state, "kuzu_conn", None)
return StreamingResponse(
- _stream_arch_resume(session_id, payload.answers, kuzu_conn),
+ _stream_arch_resume(session_id, payload.answers, kuzu_conn, request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@@ -360,6 +376,12 @@ async def review_arch_v2(
request: Request,
user: dict = Depends(get_current_user),
):
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+ if str(project.get("owner_id")) != str(user["_id"]):
+ raise HTTPException(status_code=403, detail="Forbidden")
+
arch_doc = await architectures_col().find_one(
{"project_id": ObjectId(project_id)},
sort=[("created_at", -1)],
@@ -373,7 +395,7 @@ async def review_arch_v2(
resume_value = {"accepted": payload.accepted, "changes": payload.changes}
return StreamingResponse(
- _stream_arch_resume(session_id, resume_value, kuzu_conn),
+ _stream_arch_resume(session_id, resume_value, kuzu_conn, request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
From c28d4d036310002d87096733e40f1bb05334bcac Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:15:36 -0700
Subject: [PATCH 33/50] fix(security): migrate from python-jose
(CVE-2024-33664/33663) to PyJWT
---
backend/app/core/security.py | 5 +++--
backend/pyproject.toml | 3 ++-
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
index f7086ee..377ca0e 100644
--- a/backend/app/core/security.py
+++ b/backend/app/core/security.py
@@ -1,7 +1,8 @@
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, status
-from jose import JWTError, jwt
+import jwt
+from jwt.exceptions import InvalidTokenError
from passlib.context import CryptContext
from app.config import settings
@@ -31,7 +32,7 @@ def create_refresh_token(data: dict) -> str:
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
- except JWTError:
+ except InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index b4546d2..f612668 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -33,10 +33,11 @@ dependencies = [
"motor>=3.3",
"pymongo>=4.6",
"cryptography",
- "python-jose[cryptography]",
+ "PyJWT>=2.8.0",
"passlib[bcrypt]",
"httpx",
"boto3",
+ "slowapi>=0.1.9",
]
[project.optional-dependencies]
From d042a6979e27a498a18a0718aa53b2e37b8f3110 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:15:50 -0700
Subject: [PATCH 34/50] fix(startup): fail fast on missing JWT_SECRET_KEY or
FERNET_KEY, wrap all decrypt calls with HTTP 400
---
backend/app/main.py | 27 +++++++++++++++++++++
backend/app/routers/build.py | 44 ++++++++++++++++-------------------
backend/app/routers/deploy.py | 41 ++++++++++++++++----------------
3 files changed, 68 insertions(+), 44 deletions(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index 6494996..2b7014f 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -3,6 +3,10 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
+from slowapi import Limiter, _rate_limit_exceeded_handler
+from slowapi.util import get_remote_address
+from slowapi.errors import RateLimitExceeded
+from slowapi.middleware import SlowAPIMiddleware
from app.config import settings
from app.db.mongo import connect_mongo, disconnect_mongo
@@ -15,12 +19,31 @@
logger = logging.getLogger(__name__)
+limiter = Limiter(key_func=get_remote_address)
+
@asynccontextmanager
async def lifespan(app: FastAPI):
await connect_mongo()
logger.info("MongoDB connected")
+ # Validate critical secrets — fail fast rather than serve broken crypto
+ if not settings.jwt_secret_key or settings.jwt_secret_key == "changeme":
+ raise RuntimeError(
+ "JWT_SECRET_KEY is not set or is using the insecure default. "
+ "Set a strong secret in your .env file."
+ )
+ if not settings.fernet_key:
+ raise RuntimeError(
+ "FERNET_KEY is not set. "
+ "Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
+ )
+ try:
+ from cryptography.fernet import Fernet
+ Fernet(settings.fernet_key.encode())
+ except Exception as exc:
+ raise RuntimeError(f"FERNET_KEY is invalid: {exc}") from exc
+
try:
from app.agents.architecture_planner.kg_traversal_agent import init_kuzu
@@ -45,6 +68,10 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)
+app.state.limiter = limiter
+app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
+app.add_middleware(SlowAPIMiddleware)
+
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_url],
diff --git a/backend/app/routers/build.py b/backend/app/routers/build.py
index 2881dc3..2af5f26 100644
--- a/backend/app/routers/build.py
+++ b/backend/app/routers/build.py
@@ -1,8 +1,8 @@
from __future__ import annotations
import json
+import logging
from datetime import datetime, timezone
-from typing import Any
from uuid import uuid4
from bson import ObjectId
@@ -16,8 +16,14 @@
from app.routers.agent3 import _build_initial_state, _stream_events
from app.schemas.build import BuildCommitRequest
from app.services.github import commit_files, list_repos
+from app.utils.serialization import serialize_doc
router = APIRouter(prefix="/workflows/build", tags=["build"])
+logger = logging.getLogger(__name__)
+
+
+def _sse(data: dict) -> str:
+ return f"data: {json.dumps(data)}\n\n"
# ---------------------------------------------------------------------------
# Service type normalisation
@@ -136,24 +142,6 @@ def _artifacts_to_files(artifacts: dict) -> list[dict]:
return files
-def _serialize_doc(doc: dict) -> dict:
- """Recursively convert ObjectId / datetime values to strings."""
- out: dict[str, Any] = {}
- for k, v in doc.items():
- if isinstance(v, ObjectId):
- out[k] = str(v)
- elif isinstance(v, datetime):
- out[k] = v.isoformat()
- elif isinstance(v, dict):
- out[k] = _serialize_doc(v)
- elif isinstance(v, list):
- out[k] = [
- _serialize_doc(i) if isinstance(i, dict) else i for i in v
- ]
- else:
- out[k] = v
- return out
-
# ---------------------------------------------------------------------------
# Routes
@@ -266,8 +254,10 @@ async def _stream():
},
)
- except Exception:
- pass
+ except Exception as e:
+ logger.exception("Error processing build SSE event")
+ yield _sse({"phase": "error", "message": "Internal error during build processing"})
+ return
yield sse_event
@@ -300,7 +290,7 @@ async def get_build_status(
if not build_doc:
raise HTTPException(status_code=404, detail="No build found for project")
- return _serialize_doc(build_doc)
+ return serialize_doc(build_doc)
@router.post("/{project_id}/commit")
@@ -320,7 +310,10 @@ async def commit_build(
status_code=400, detail="GitHub account not connected"
)
- token = decrypt(user["github_token_encrypted"])
+ try:
+ token = decrypt(user["github_token_encrypted"])
+ except Exception:
+ raise HTTPException(status_code=400, detail="Failed to decrypt GitHub token. Re-connect your GitHub account.")
build_doc = await builds_col().find_one({"_id": ObjectId(body.build_id)})
if not build_doc:
@@ -385,5 +378,8 @@ async def get_repos(
status_code=400, detail="GitHub account not connected"
)
- token = decrypt(user["github_token_encrypted"])
+ try:
+ token = decrypt(user["github_token_encrypted"])
+ except Exception:
+ raise HTTPException(status_code=400, detail="Failed to decrypt GitHub token. Re-connect your GitHub account.")
return await list_repos(token)
diff --git a/backend/app/routers/deploy.py b/backend/app/routers/deploy.py
index 07a1699..82f394d 100644
--- a/backend/app/routers/deploy.py
+++ b/backend/app/routers/deploy.py
@@ -1,8 +1,9 @@
import asyncio
import json
+import logging
from datetime import datetime, timezone
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from bson import ObjectId
@@ -11,8 +12,10 @@
from app.db.encryption import decrypt
from app.providers.factory import get_provider
from app.schemas.deploy import DeployRollbackRequest
+from app.utils.serialization import serialize_doc
router = APIRouter(prefix="/workflows/deploy", tags=["deploy"])
+logger = logging.getLogger(__name__)
def _sse(data: dict) -> str:
@@ -39,7 +42,7 @@ def _find_cf_template(artifacts: dict) -> str:
@router.post("/start/{project_id}")
-async def start_deploy(project_id: str, user=Depends(get_current_user)):
+async def start_deploy(project_id: str, request: Request, user=Depends(get_current_user)):
project = await projects_col().find_one({"_id": ObjectId(project_id)})
if not project:
raise HTTPException(404, "Project not found")
@@ -55,7 +58,10 @@ async def start_deploy(project_id: str, user=Depends(get_current_user)):
if not project.get("cloud_credentials_encrypted"):
raise HTTPException(409, "Cloud credentials not configured")
- creds_json = decrypt(project["cloud_credentials_encrypted"])
+ try:
+ creds_json = decrypt(project["cloud_credentials_encrypted"])
+ except Exception:
+ raise HTTPException(status_code=400, detail="Failed to decrypt cloud credentials. Re-enter your credentials.")
credentials = json.loads(creds_json)
provider_type = project.get("cloud_provider_type") or credentials.get("provider", "aws")
@@ -110,6 +116,10 @@ async def queuing_on_event(event: dict):
)
while not deploy_task.done():
+ if await request.is_disconnected():
+ deploy_task.cancel()
+ logger.info("Client disconnected, cancelled deploy task")
+ return
try:
event = await asyncio.wait_for(event_queue.get(), timeout=1.0)
yield _sse(event)
@@ -144,12 +154,13 @@ async def queuing_on_event(event: dict):
yield _sse({"type": "log", "line": "✓ Deployment complete"})
- except Exception as e:
+ except Exception:
+ logger.exception("Workflow error in deploy stream")
await deployments_col().update_one(
{"_id": ObjectId(deployment_id)},
{"$set": {"status": "failed", "updated_at": datetime.now(timezone.utc)}}
)
- yield _sse({"type": "error", "message": str(e)})
+ yield _sse({"type": "error", "message": "An internal error occurred. Please try again."})
yield "data: [DONE]\n\n"
@@ -179,7 +190,10 @@ async def rollback_deploy(
if not project.get("cloud_credentials_encrypted"):
raise HTTPException(409, "Cloud credentials not configured")
- creds_json = decrypt(project["cloud_credentials_encrypted"])
+ try:
+ creds_json = decrypt(project["cloud_credentials_encrypted"])
+ except Exception:
+ raise HTTPException(status_code=400, detail="Failed to decrypt cloud credentials. Re-enter your credentials.")
credentials = json.loads(creds_json)
provider_type = project.get("cloud_provider_type") or credentials.get("provider", "aws")
@@ -211,17 +225,4 @@ async def deploy_status(project_id: str, user=Depends(get_current_user)):
if not deployment:
raise HTTPException(404, "No deployment found for this project")
- def _serialize(doc: dict) -> dict:
- result = {}
- for k, v in doc.items():
- if isinstance(v, ObjectId):
- result[k] = str(v)
- elif isinstance(v, datetime):
- result[k] = v.isoformat()
- elif isinstance(v, dict):
- result[k] = _serialize(v)
- else:
- result[k] = v
- return result
-
- return _serialize(deployment)
+ return serialize_doc(deployment)
From 16c8d023e955d56d4a40a0b300ec9f71bc700cab Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:16:12 -0700
Subject: [PATCH 35/50] fix(sse): fix silent exception swallowing in build
stream, add client disconnect detection, sanitize error messages
---
backend/app/routers/prd.py | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/backend/app/routers/prd.py b/backend/app/routers/prd.py
index bf92b58..56df9fa 100644
--- a/backend/app/routers/prd.py
+++ b/backend/app/routers/prd.py
@@ -8,7 +8,7 @@
from uuid import uuid4
from bson import ObjectId
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from app.agents.agent1 import run_until_interrupt
@@ -123,6 +123,7 @@ async def _persist_prd(
async def start_prd(
project_id: str,
payload: PrdStartRequest,
+ request: Request,
user: dict = Depends(get_current_user),
) -> StreamingResponse:
# Gate: validate project
@@ -178,7 +179,11 @@ async def start_prd(
async def _stream():
try:
+ if await request.is_disconnected():
+ return
result = await _run_agent1(initial_state)
+ if await request.is_disconnected():
+ return
session_store.save(result.as_graph_state())
chips = _parse_constraints(result)
@@ -195,7 +200,7 @@ async def _stream():
except Exception as exc:
logger.exception("Error in start_prd SSE stream")
- yield _sse({"type": "error", "message": str(exc)})
+ yield _sse({"type": "error", "message": "An internal error occurred. Please try again."})
yield "data: [DONE]\n\n"
@@ -214,6 +219,7 @@ async def _stream():
async def respond_prd(
project_id: str,
payload: PrdRespondRequest,
+ request: Request,
user: dict = Depends(get_current_user),
) -> StreamingResponse:
# Gate: validate project
@@ -258,7 +264,11 @@ async def respond_prd(
async def _stream():
try:
+ if await request.is_disconnected():
+ return
result = await _run_agent1(state)
+ if await request.is_disconnected():
+ return
session_store.save(result.as_graph_state())
chips = _parse_constraints(result)
@@ -275,7 +285,7 @@ async def _stream():
except Exception as exc:
logger.exception("Error in respond_prd SSE stream")
- yield _sse({"type": "error", "message": str(exc)})
+ yield _sse({"type": "error", "message": "An internal error occurred. Please try again."})
yield "data: [DONE]\n\n"
From 8f1697cbbaef847c6e9e4089aa572ca1091efefa Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:17:06 -0700
Subject: [PATCH 36/50] =?UTF-8?q?perf(db):=20add=20MongoDB=20indexes=20for?=
=?UTF-8?q?=20all=20query=20patterns=20=E2=80=94=20users,=20projects,=20pr?=
=?UTF-8?q?d,=20arch,=20builds,=20deployments?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/db/mongo.py | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/backend/app/db/mongo.py b/backend/app/db/mongo.py
index b401662..967a763 100644
--- a/backend/app/db/mongo.py
+++ b/backend/app/db/mongo.py
@@ -41,10 +41,33 @@ def deployments_col():
return get_db()["deployments"]
+async def ensure_indexes() -> None:
+ """Create all required indexes. Safe to call on every startup (no-op if already exist)."""
+ await users_col().create_index("email", unique=True, background=True)
+ await users_col().create_index("username", unique=True, background=True)
+ await projects_col().create_index("owner_id", background=True)
+ await prd_conversations_col().create_index("session_id", unique=True, background=True)
+ await prd_conversations_col().create_index("project_id", background=True)
+ await architectures_col().create_index(
+ [("project_id", 1), ("created_at", -1)], background=True
+ )
+ await architectures_col().create_index("session_id", unique=True, background=True)
+ await builds_col().create_index(
+ [("project_id", 1), ("created_at", -1)], background=True
+ )
+ await builds_col().create_index(
+ [("project_id", 1), ("status", 1)], background=True
+ )
+ await deployments_col().create_index(
+ [("project_id", 1), ("created_at", -1)], background=True
+ )
+
+
async def connect_mongo() -> None:
global _client
_client = AsyncIOMotorClient(settings.mongodb_url)
await _client.admin.command("ping")
+ await ensure_indexes()
async def disconnect_mongo() -> None:
From 3da07e14ba61c485e5a1db142c5100d30abc0bcf Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:17:29 -0700
Subject: [PATCH 37/50] feat(encryption): replace single Fernet key with
MultiFernet to support key rotation
---
backend/app/config.py | 1 +
backend/app/db/encryption.py | 14 +++++++++++---
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/backend/app/config.py b/backend/app/config.py
index 892971e..174bc4e 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -37,6 +37,7 @@ class Settings(BaseSettings):
# Encryption
fernet_key: str = ""
+ fernet_keys: str = "" # Comma-separated list of Fernet keys, newest first. Overrides fernet_key if set.
# GitHub OAuth
github_client_id: str = ""
diff --git a/backend/app/db/encryption.py b/backend/app/db/encryption.py
index ff2affa..2592707 100644
--- a/backend/app/db/encryption.py
+++ b/backend/app/db/encryption.py
@@ -1,10 +1,18 @@
-from cryptography.fernet import Fernet
+from cryptography.fernet import Fernet, MultiFernet
from app.config import settings
-def _get_fernet() -> Fernet:
- return Fernet(settings.fernet_key.encode())
+def _get_fernet() -> MultiFernet:
+ """
+ Build a MultiFernet from FERNET_KEYS (comma-separated, newest first).
+ Falls back to FERNET_KEY for backward compatibility.
+ """
+ raw = settings.fernet_keys if settings.fernet_keys else settings.fernet_key
+ keys = [k.strip() for k in raw.split(",") if k.strip()]
+ if not keys:
+ raise ValueError("No Fernet keys configured")
+ return MultiFernet([Fernet(k.encode()) for k in keys])
def encrypt(plaintext: str) -> str:
From 620e76baae60b9cae7714559d1357803de0fecd0 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:17:51 -0700
Subject: [PATCH 38/50] refactor(utils): extract shared serialize_doc utility,
remove duplicated serializers from build and deploy routers
---
backend/app/utils/__init__.py | 0
backend/app/utils/serialization.py | 29 +++++++++++++++++++++++++++++
2 files changed, 29 insertions(+)
create mode 100644 backend/app/utils/__init__.py
create mode 100644 backend/app/utils/serialization.py
diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/utils/serialization.py b/backend/app/utils/serialization.py
new file mode 100644
index 0000000..88d8631
--- /dev/null
+++ b/backend/app/utils/serialization.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from bson import ObjectId
+
+
+def serialize_doc(doc: dict[str, Any]) -> dict[str, Any]:
+ """Recursively convert ObjectId and datetime values to JSON-serializable types."""
+ result = {}
+ for key, value in doc.items():
+ if isinstance(value, ObjectId):
+ result[key] = str(value)
+ elif isinstance(value, datetime):
+ result[key] = value.isoformat()
+ elif isinstance(value, dict):
+ result[key] = serialize_doc(value)
+ elif isinstance(value, list):
+ result[key] = [
+ serialize_doc(item) if isinstance(item, dict) else
+ str(item) if isinstance(item, ObjectId) else
+ item.isoformat() if isinstance(item, datetime) else
+ item
+ for item in value
+ ]
+ else:
+ result[key] = value
+ return result
From e9b7f6b15b71f1e2cb44046fd4d639fe2dc4e50b Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:18:15 -0700
Subject: [PATCH 39/50] fix(validation): add min/max length constraints to
prd_text, project name, description, and cloud fields
---
backend/app/schemas/prd.py | 6 +++---
backend/app/schemas/project.py | 16 ++++++++--------
2 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/backend/app/schemas/prd.py b/backend/app/schemas/prd.py
index 5e80ce1..694e69c 100644
--- a/backend/app/schemas/prd.py
+++ b/backend/app/schemas/prd.py
@@ -1,11 +1,11 @@
from __future__ import annotations
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
class PrdStartRequest(BaseModel):
- prd_text: str
- cloud_provider: str = "aws"
+ prd_text: str = Field(..., min_length=10, max_length=50_000)
+ cloud_provider: str = Field("aws", max_length=20)
class PrdRespondRequest(BaseModel):
diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py
index 2241a30..4ce0dd4 100644
--- a/backend/app/schemas/project.py
+++ b/backend/app/schemas/project.py
@@ -1,20 +1,20 @@
from datetime import datetime
from typing import Optional
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
class ProjectCreate(BaseModel):
- name: str
- description: Optional[str] = None
- region: Optional[str] = None
- cloud_provider: Optional[str] = None
+ name: str = Field(..., min_length=1, max_length=255)
+ description: Optional[str] = Field(None, max_length=2000)
+ region: Optional[str] = Field(None, max_length=50)
+ cloud_provider: Optional[str] = Field(None, max_length=20)
class ProjectUpdate(BaseModel):
- name: Optional[str] = None
- description: Optional[str] = None
- github_repo: Optional[str] = None
+ name: Optional[str] = Field(None, min_length=1, max_length=255)
+ description: Optional[str] = Field(None, max_length=2000)
+ github_repo: Optional[str] = Field(None, max_length=200)
class CloudCredentials(BaseModel):
From 0aec530c62bf14c9e56a5d2c0571530713e2d96a Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:18:50 -0700
Subject: [PATCH 40/50] =?UTF-8?q?feat(ratelimit):=20add=20SlowAPI=20rate?=
=?UTF-8?q?=20limiting=20to=20auth=20endpoints=20=E2=80=94=205/min=20regis?=
=?UTF-8?q?ter,=2010/min=20login?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/routers/auth.py | 21 +++++++++++++++------
1 file changed, 15 insertions(+), 6 deletions(-)
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 24f7247..13431de 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -3,8 +3,10 @@
import httpx
from bson import ObjectId
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
+from slowapi import Limiter
+from slowapi.util import get_remote_address
from app.config import settings
from app.core.dependencies import get_current_user
@@ -21,6 +23,8 @@
router = APIRouter(prefix="/auth", tags=["auth"])
+limiter = Limiter(key_func=get_remote_address)
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
# state -> {"user_id": str, "expires_at": datetime}
@@ -37,7 +41,8 @@ def _user_to_public(user: dict) -> UserPublic:
@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
-async def register(payload: RegisterRequest) -> AuthResponse:
+@limiter.limit("5/minute")
+async def register(request: Request, payload: RegisterRequest) -> AuthResponse:
col = users_col()
if await col.find_one({"email": payload.email}):
@@ -72,7 +77,8 @@ async def register(payload: RegisterRequest) -> AuthResponse:
@router.post("/login", response_model=AuthResponse)
-async def login(payload: LoginRequest) -> AuthResponse:
+@limiter.limit("10/minute")
+async def login(request: Request, payload: LoginRequest) -> AuthResponse:
col = users_col()
user = await col.find_one({"email": payload.email})
@@ -95,7 +101,8 @@ async def login(payload: LoginRequest) -> AuthResponse:
@router.post("/refresh", response_model=RefreshResponse)
-async def refresh_token(token: str = Depends(oauth2_scheme)) -> RefreshResponse:
+@limiter.limit("20/minute")
+async def refresh_token(request: Request, token: str = Depends(oauth2_scheme)) -> RefreshResponse:
payload = decode_token(token)
if payload.get("type") != "refresh":
@@ -131,7 +138,8 @@ async def me(current_user: dict = Depends(get_current_user)) -> UserPublic:
@router.get("/github")
-async def github_oauth_init(current_user: dict = Depends(get_current_user)) -> dict:
+@limiter.limit("10/minute")
+async def github_oauth_init(request: Request, current_user: dict = Depends(get_current_user)) -> dict:
state = token_urlsafe(32)
_github_states[state] = {
"user_id": str(current_user["_id"]),
@@ -148,7 +156,8 @@ async def github_oauth_init(current_user: dict = Depends(get_current_user)) -> d
@router.get("/github/callback")
-async def github_oauth_callback(code: str, state: str) -> dict:
+@limiter.limit("10/minute")
+async def github_oauth_callback(request: Request, code: str, state: str) -> dict:
entry = _github_states.get(state)
if not entry:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired state")
From 6483a742c96b0dd305cf083d885b4627e5af4a8e Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 02:19:24 -0700
Subject: [PATCH 41/50] fix(frontend): SSR-safe authHeaders, token refresh on
401, AbortController for SSE streams, deprecate mock createProject
---
frontend/src/lib/forge-agents.ts | 121 ++++++++++++++++++++---------
frontend/src/store/projectStore.ts | 6 +-
2 files changed, 88 insertions(+), 39 deletions(-)
diff --git a/frontend/src/lib/forge-agents.ts b/frontend/src/lib/forge-agents.ts
index ea68a35..3b6d16d 100644
--- a/frontend/src/lib/forge-agents.ts
+++ b/frontend/src/lib/forge-agents.ts
@@ -18,18 +18,65 @@ function delay(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+function authHeaders(): Record {
+ if (typeof window === 'undefined') return {};
+ try {
+ const stored = localStorage.getItem('cloudforge-auth');
+ if (!stored) return {};
+ const token = JSON.parse(stored)?.state?.accessToken;
+ if (!token) return {};
+ return { Authorization: `Bearer ${token}` };
+ } catch {
+ return {};
+ }
+}
+
+async function refreshAccessToken(): Promise {
+ if (typeof window === 'undefined') return null;
+ try {
+ const stored = localStorage.getItem('cloudforge-auth');
+ if (!stored) return null;
+ const refreshToken = JSON.parse(stored)?.state?.refreshToken;
+ if (!refreshToken) return null;
+
+ const resp = await fetch(`${API_URL}/auth/refresh`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${refreshToken}` },
+ });
+ if (!resp.ok) return null;
+
+ const data = await resp.json();
+ const parsed = JSON.parse(stored);
+ parsed.state.accessToken = data.access_token;
+ localStorage.setItem('cloudforge-auth', JSON.stringify(parsed));
+ return data.access_token;
+ } catch {
+ return null;
+ }
+}
+
async function streamSSE(
url: string,
fetchInit: RequestInit,
- onEvent: (data: string) => void
+ onEvent: (data: string) => void,
+ signal?: AbortSignal,
): Promise {
- const resp = await fetch(url, {
- ...fetchInit,
- headers: {
- 'Content-Type': 'application/json',
- ...fetchInit.headers,
- },
- });
+ const doFetch = async (headers: Record) => {
+ return fetch(url, {
+ ...fetchInit,
+ headers: { 'Content-Type': 'application/json', ...fetchInit.headers, ...headers },
+ signal,
+ });
+ };
+
+ let resp = await doFetch({});
+
+ if (resp.status === 401) {
+ const newToken = await refreshAccessToken();
+ if (newToken) {
+ resp = await doFetch({ Authorization: `Bearer ${newToken}` });
+ }
+ }
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (!resp.body) throw new Error('No response body');
@@ -38,34 +85,26 @@ async function streamSSE(
const decoder = new TextDecoder();
let buffer = '';
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split('\n');
- buffer = lines.pop() ?? '';
-
- for (const line of lines) {
- if (line.startsWith('data: ')) {
- const data = line.slice(6).trim();
- if (data && data !== '[DONE]') {
- onEvent(data);
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const data = line.slice(6).trim();
+ if (data && data !== '[DONE]') {
+ onEvent(data);
+ }
}
}
}
- }
-}
-
-function authHeaders(): Record {
- try {
- const stored = localStorage.getItem('cloudforge-auth');
- if (!stored) return {};
- const token = JSON.parse(stored)?.state?.accessToken;
- if (!token) return {};
- return { Authorization: `Bearer ${token}` };
- } catch {
- return {};
+ } finally {
+ reader.cancel();
}
}
@@ -82,6 +121,7 @@ export async function runAgent1(
prdText: string,
onChip?: (chip: ConstraintChip, index: number) => void,
projectId?: string,
+ signal?: AbortSignal,
): Promise {
const chips: ConstraintChip[] = [];
@@ -109,7 +149,8 @@ export async function runAgent1(
onChip?.(event.chip, chips.length - 1);
}
} catch { /* ignore parse errors */ }
- }
+ },
+ signal,
);
return chips;
@@ -261,6 +302,7 @@ export async function runAgent2(
_constraints: ConstraintChip[],
onStep?: Agent2StepCallback,
projectId?: string,
+ signal?: AbortSignal,
): Promise<{ nodes: ForgeArchNode[]; edges: ForgeArchEdge[] }> {
if (!projectId) {
for (let i = 0; i < AGENT2_STEPS.length; i++) {
@@ -310,7 +352,8 @@ export async function runAgent2(
}));
}
} catch { /* ignore */ }
- }
+ },
+ signal,
);
return nodes.length > 0 ? { nodes, edges } : { nodes: MOCK_ARCH_NODES, edges: MOCK_ARCH_EDGES };
@@ -467,6 +510,7 @@ export async function runAgent3(
architectureData: { nodes: ForgeArchNode[]; edges: ForgeArchEdge[] },
callbacks?: Agent3Callbacks,
projectId?: string,
+ signal?: AbortSignal,
): Promise {
if (!projectId) {
const total = MOCK_FILES.length;
@@ -530,7 +574,8 @@ export async function runAgent3(
callbacks?.onProgress(done, total);
}
} catch { /* ignore */ }
- }
+ },
+ signal,
);
// architectureData is intentionally unused when a real projectId is provided;
@@ -581,6 +626,7 @@ export async function runDeploy(
_architectureData: { nodes: ForgeArchNode[]; edges: ForgeArchEdge[] },
callbacks: DeployCallbacks,
projectId?: string,
+ signal?: AbortSignal,
): Promise {
if (!projectId) {
for (const event of DEPLOY_SEQUENCE) {
@@ -612,6 +658,7 @@ export async function runDeploy(
);
}
} catch { /* ignore */ }
- }
+ },
+ signal,
);
}
diff --git a/frontend/src/store/projectStore.ts b/frontend/src/store/projectStore.ts
index cb27657..1cd914d 100644
--- a/frontend/src/store/projectStore.ts
+++ b/frontend/src/store/projectStore.ts
@@ -29,7 +29,8 @@ interface ProjectStoreState {
// Legacy mock-backed state (used by existing dashboard UI)
projects: Project[];
advanceStage: (id: string) => void;
- createProject: (name: string) => string;
+ /** @deprecated Use createApiProject instead */
+ createMockProject: (name: string) => string;
// API-backed state
apiProjects: ApiProject[];
@@ -54,7 +55,8 @@ export const useProjectStore = create((set) => ({
}),
})),
- createProject: (name) => {
+ /** @deprecated Use createApiProject instead */
+ createMockProject: (name) => {
const id = `proj-${Date.now()}`;
const newProject: Project = {
id,
From 430bee17f95356388296a9f6396dda1c7bb383a9 Mon Sep 17 00:00:00 2001
From: Bhavya Shah
Date: Sun, 22 Mar 2026 02:38:40 -0700
Subject: [PATCH 42/50] Fixed agent 1
---
backend/.env.example | 3 +
backend/README.md | 4 +-
backend/app/agents/agent1/__init__.py | 4 +-
backend/app/agents/agent1/graph.py | 41 +++-
backend/app/agents/agent1/nodes.py | 190 +++++++++++++++++-
backend/app/agents/agent1/prompts.py | 36 +++-
.../agents/agent1/standalone_smoke_test.py | 17 +-
backend/app/agents/agent1/state.py | 4 +
backend/app/agents/agent1/tools.py | 127 +++++++++++-
backend/app/config.py | 4 +-
backend/app/routers/workflows.py | 8 +-
backend/app/schemas/workflow.py | 2 +
backend/pyproject.toml | 1 +
backend/requirements.txt | 1 +
14 files changed, 419 insertions(+), 23 deletions(-)
diff --git a/backend/.env.example b/backend/.env.example
index 866ee91..25cc2ed 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -8,5 +8,8 @@ QWEN_MODEL=qwen3.5:latest
LLM_TEMPERATURE=0.2
LLM_TIMEOUT_SECONDS=90
ENABLE_WEB_SEARCH=true
+ENABLE_TINYFISH_SEARCH=false
+TINYFISH_TIMEOUT_SECONDS=25
MAX_CLARIFICATION_ROUNDS=3
MAX_RESEARCH_ROUNDS=3
+TINYFISH_API_KEY=sk-tinyfish****
\ No newline at end of file
diff --git a/backend/README.md b/backend/README.md
index f2b1637..d1e2641 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -5,7 +5,7 @@ FastAPI backend with a LangGraph-based multi-agent workflow in `app/agents/agent
## What Was Added
- PRD refinement workflow powered by local Ollama + Qwen model.
-- Optional web research via DuckDuckGo (`langchain-community`).
+- Optional web research via TinyFish (primary) with DuckDuckGo fallback.
- **Multi-choice options** for user clarifications (like GitHub Copilot planning mode):
- Agent generates 2-4 predefined options per question
- Last option always allows custom user input
@@ -25,6 +25,8 @@ Important settings:
- `OLLAMA_BASE_URL` (default: `http://localhost:11434`)
- `QWEN_MODEL` (default: `qwen3.5:latest`)
- `ENABLE_WEB_SEARCH` (`true`/`false`)
+- `ENABLE_TINYFISH_SEARCH` (`true`/`false`)
+- `TINYFISH_TIMEOUT_SECONDS` (default: `25`)
- `MAX_CLARIFICATION_ROUNDS`
## Run
diff --git a/backend/app/agents/agent1/__init__.py b/backend/app/agents/agent1/__init__.py
index 4014088..3e2aaf0 100644
--- a/backend/app/agents/agent1/__init__.py
+++ b/backend/app/agents/agent1/__init__.py
@@ -2,7 +2,6 @@
import logging
import time
-from app.agents.agent1.graph import build_graph
from app.agents.agent1.state import AgentState, AgentStatus
@@ -11,6 +10,9 @@
def run_until_interrupt(state: AgentState | dict[str, Any]) -> AgentState:
"""Run the graph until it reaches a user-interruptible status."""
+ # Lazy import avoids package-level graph module initialization side effects.
+ from app.agents.agent1.graph import build_graph
+
graph = build_graph()
initial_state = AgentState.model_validate(state)
logger.info("Starting agent run")
diff --git a/backend/app/agents/agent1/graph.py b/backend/app/agents/agent1/graph.py
index 9ef2ca3..822f640 100644
--- a/backend/app/agents/agent1/graph.py
+++ b/backend/app/agents/agent1/graph.py
@@ -4,16 +4,17 @@
from langgraph.graph import END, START, StateGraph
-from app.agents.agent1.nodes import acceptance_node, acceptance_route, information_gate_node, information_route, plan_node, research_route, research_node, user_input_node, web_search_node
+from app.agents.agent1.nodes import acceptance_node, acceptance_route, await_user_node, information_gate_node, information_route, plan_node, research_route, research_node, user_input_node, web_search_node
+from app.agents.agent1.state import AgentState
@lru_cache(maxsize=1)
def build_graph():
- # Graph keeps a plain dict state; node functions perform pydantic validation.
- graph = StateGraph(dict)
+ graph = StateGraph(AgentState)
graph.add_node("user_input", user_input_node)
graph.add_node("research", research_node)
graph.add_node("web_search", web_search_node)
graph.add_node("information_gate", information_gate_node)
+ graph.add_node("await_user", await_user_node)
graph.add_node("plan", plan_node)
graph.add_node("acceptance", acceptance_node)
@@ -32,18 +33,48 @@ def build_graph():
"information_gate",
information_route,
{
- "await_user": END,
+ "await_user": "await_user",
"plan": "plan",
},
)
+ graph.add_edge("await_user", END)
graph.add_edge("plan", "acceptance")
graph.add_conditional_edges(
"acceptance",
acceptance_route,
{
- "user_input": "user_input",
+ "await_user": "await_user",
"end": END,
},
)
return graph.compile()
+
+
+def _fallback_mermaid() -> str:
+ return """flowchart TD
+ S[START] --> A[Initial idea from USER]
+ A --Cloud provider, Idea--> B[Research Agent]
+ B --Search Queries--> C[Optional web search Tool]
+ B --Draft PRD--> E{Is some more information/clarification about usecase or an aspect required from USER?}
+ E --YES--> F[Get additional information / clarification from USER]
+ E --NO--> G[Plan]
+ G --Semifinal PRD--> H{Accept?}
+ C --Web Results--> B
+ F --Additional Information / Clarification--> B
+ H --NO--> F
+ H --YES-->I[END]
+"""
+
+
+def mermaid_graph() -> str:
+ compiled = build_graph()
+ try:
+ return compiled.get_graph().draw_mermaid()
+ except Exception:
+ return _fallback_mermaid()
+
+
+if __name__ == '__main__':
+ mermaid = mermaid_graph()
+ print(mermaid)
diff --git a/backend/app/agents/agent1/nodes.py b/backend/app/agents/agent1/nodes.py
index e5b9521..1ccbafc 100644
--- a/backend/app/agents/agent1/nodes.py
+++ b/backend/app/agents/agent1/nodes.py
@@ -7,7 +7,7 @@
from app.agents.agent1.llm import get_llm
from app.agents.agent1.prompts import CLARIFIER_PROMPT, PLANNER_PROMPT
-from app.agents.agent1.state import AgentState, ClarifierOutput, FinalPRDJson, PlannerOutput, ResearchResult
+from app.agents.agent1.state import AgentState, ClarifierOutput, FinalPRDJson, PlannerOutput, QuestionOption, QuestionWithOptions, ResearchResult
from app.agents.agent1.tools import web_search
from app.config import settings
@@ -79,6 +79,154 @@ def _augment_research_queries(state: AgentState, queries: list[str]) -> list[str
return unique[: max(settings.max_research_rounds, len(REQUIRED_RESEARCH_AREAS))]
+def _normalize_options_metadata(payload: ClarifierOutput) -> None:
+ for question in payload.questions_with_options:
+ for option in question.options:
+ if not option.description.strip():
+ if option.is_custom:
+ option.description = "Provide your own requirement when presets do not fit."
+ else:
+ option.description = f"Choose this when '{option.label}' best matches your use case."
+ if not option.impact.strip():
+ if option.is_custom:
+ option.impact = "Planner will adapt architecture using your custom guidance."
+ else:
+ option.impact = "This choice directly influences service sizing, architecture complexity, and cost."
+
+
+def _fallback_options_for_question(question: str) -> list[QuestionOption]:
+ q = question.lower()
+
+ if any(key in q for key in ["persona", "user", "workflow", "audience"]):
+ return [
+ QuestionOption(
+ label="Internal security and compliance teams",
+ value="internal_security_compliance_teams",
+ description="Focused on governance, policy checks, and enterprise controls.",
+ impact="Prioritizes auditability, RBAC, and policy automation.",
+ ),
+ QuestionOption(
+ label="IT operations and platform engineering",
+ value="it_ops_platform_engineering",
+ description="Focused on operational visibility and lifecycle workflows.",
+ impact="Drives integration with automation pipelines and incident tooling.",
+ ),
+ QuestionOption(
+ label="Mixed stakeholders (security + ops + leadership)",
+ value="mixed_enterprise_stakeholders",
+ description="Supports cross-functional reporting and decision-making.",
+ impact="Requires role-specific views and stronger data model flexibility.",
+ ),
+ QuestionOption(
+ label="Custom",
+ value="custom",
+ description="Provide your own persona/workflow details.",
+ impact="Planner will adapt features and user journeys to your custom needs.",
+ is_custom=True,
+ ),
+ ]
+
+ if any(key in q for key in ["scale", "latency", "availability", "sla", "slo", "rps", "throughput"]):
+ return [
+ QuestionOption(
+ label="Moderate scale, standard latency",
+ value="moderate_scale_standard_latency",
+ description="Good for initial enterprise rollout with predictable load.",
+ impact="Lower baseline cost and simpler architecture at launch.",
+ ),
+ QuestionOption(
+ label="High scale, near-real-time response",
+ value="high_scale_near_realtime",
+ description="Designed for larger tenant activity and frequent analysis runs.",
+ impact="Needs autoscaling, partitioning, and tighter observability.",
+ ),
+ QuestionOption(
+ label="Mission-critical, strict SLO/HA",
+ value="mission_critical_strict_slo_ha",
+ description="For regulated workloads with strict uptime/latency targets.",
+ impact="Requires multi-region resilience and higher operational cost.",
+ ),
+ QuestionOption(
+ label="Custom",
+ value="custom",
+ description="Provide custom scale and SLO targets.",
+ impact="Planner will tune architecture and capacity to your explicit targets.",
+ is_custom=True,
+ ),
+ ]
+
+ if any(key in q for key in ["compliance", "regulation", "audit", "security", "gdpr", "hipaa", "soc2", "pii"]):
+ return [
+ QuestionOption(
+ label="Baseline enterprise controls (SOC2-aligned)",
+ value="baseline_soc2_aligned",
+ description="Standard enterprise baseline with IAM and auditability.",
+ impact="Enforces centralized logging and retention policies.",
+ ),
+ QuestionOption(
+ label="Regulated controls (GDPR/PII)",
+ value="regulated_gdpr_pii",
+ description="Adds data locality and privacy governance constraints.",
+ impact="Introduces stronger encryption and lifecycle requirements.",
+ ),
+ QuestionOption(
+ label="Highly regulated (HIPAA/financial-grade)",
+ value="highly_regulated_hipaa_finance",
+ description="For strict compliance and formal control evidence.",
+ impact="Needs immutable audit trails and stronger segmentation.",
+ ),
+ QuestionOption(
+ label="Custom",
+ value="custom",
+ description="Provide your own compliance requirements.",
+ impact="Planner will align architecture and controls to your obligations.",
+ is_custom=True,
+ ),
+ ]
+
+ return [
+ QuestionOption(
+ label="Lean baseline approach",
+ value="lean_baseline_approach",
+ description="Start with minimal capabilities and iterate quickly.",
+ impact="Faster delivery and lower initial complexity.",
+ ),
+ QuestionOption(
+ label="Balanced enterprise approach",
+ value="balanced_enterprise_approach",
+ description="Balance reliability, governance, and implementation effort.",
+ impact="Moderate cost with good scalability and compliance readiness.",
+ ),
+ QuestionOption(
+ label="Robust future-proof approach",
+ value="robust_future_proof_approach",
+ description="Design for high scale and strict governance from day one.",
+ impact="Higher initial cost but fewer redesigns later.",
+ ),
+ QuestionOption(
+ label="Custom",
+ value="custom",
+ description="Provide your own preferred direction.",
+ impact="Planner will tailor architecture to your priorities.",
+ is_custom=True,
+ ),
+ ]
+
+
+def _ensure_questions_have_options(payload: ClarifierOutput) -> None:
+ if payload.questions_with_options:
+ return
+
+ payload.questions_with_options = [
+ QuestionWithOptions(
+ question=question,
+ original_question=question,
+ options=_fallback_options_for_question(question),
+ )
+ for question in payload.follow_up_questions
+ ]
+
+
def user_input_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
model_state = _as_state(state)
logger.info("Processing user input")
@@ -101,11 +249,21 @@ def research_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
model_state.clarification_rounds = rounds
llm = get_llm()
- # Include all prior answers to support deep, iterative clarification for natural-language input.
+
+ # Build Q&A history from answered pairs for clarity.
+ qa_history = ""
+ if model_state.answered_qa_pairs:
+ qa_history = "Previously Answered Questions:\n"
+ for q, a in model_state.answered_qa_pairs:
+ qa_history += f"Q: {q}\nA: {a}\n\n"
+ qa_history += "\n"
+
+ # Include all prior answers and structured Q&A history to support deep, iterative clarification.
prompt = (
f"{CLARIFIER_PROMPT}\n\n"
f"Cloud provider: {model_state.cloud_provider}\n"
f"Initial Product Description:\n{model_state.prd_text}\n\n"
+ f"{qa_history}"
f"User Clarifications:\n{model_state.user_answers}\n"
)
@@ -115,6 +273,8 @@ def research_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
elapsed = time.perf_counter() - start
logger.info("Research analysis completed in %.2f seconds", elapsed)
payload = ClarifierOutput.model_validate(_extract_json(str(response.content)))
+ _ensure_questions_have_options(payload)
+ _normalize_options_metadata(payload)
except Exception as exc: # noqa: BLE001
_safe_errors(model_state, f"clarifier_failed: {exc}")
model_state.status = "needs_input"
@@ -123,6 +283,14 @@ def research_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
"What user personas and core workflows should this product support?",
"What are target scale, latency, availability, and compliance requirements?",
]
+ fallback_payload = ClarifierOutput(
+ is_information_enough=False,
+ follow_up_questions=model_state.follow_up_questions,
+ research_queries=[],
+ )
+ _ensure_questions_have_options(fallback_payload)
+ _normalize_options_metadata(fallback_payload)
+ model_state.questions_with_options = fallback_payload.questions_with_options
return _dump_state(model_state)
# CoT is not logged; show a safe reasoning summary from structured model output.
@@ -192,8 +360,9 @@ def web_search_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
def information_gate_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
model_state = _as_state(state)
logger.info("Checking if product requirements are clear enough")
-
- # If user provided selected option answers, convert them to natural language user answers.
+
+ # If user provided selected option answers, convert them to natural language user answers
+ # and track Q&A pairs for clarity in subsequent clarification rounds.
if model_state.selected_option_answers:
for q_idx, selected_value in model_state.selected_option_answers.items():
if q_idx < len(model_state.questions_with_options):
@@ -210,11 +379,15 @@ def information_gate_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
response = matched_option.label if not matched_option.is_custom else selected_value
logger.info("User selected option for '%s': %s", question_obj.original_question, response)
model_state.user_answers.append(response)
+ # Track Q&A pair for next round's clarification
+ model_state.answered_qa_pairs.append((question_obj.original_question, response))
else:
# No matching option found - treat as custom freeform input
# (user bypassed option selection and provided direct text)
logger.info("User provided custom input for '%s': %s", question_obj.original_question, selected_value)
model_state.user_answers.append(selected_value)
+ # Track Q&A pair for next round's clarification
+ model_state.answered_qa_pairs.append((question_obj.original_question, selected_value))
model_state.selected_option_answers = {} # Clear after processing
if not model_state.is_information_enough:
@@ -325,6 +498,13 @@ def acceptance_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
return _dump_state(model_state)
+def await_user_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
+ model_state = _as_state(state)
+ model_state.status = "needs_input"
+ logger.info("Waiting for user clarification input")
+ return _dump_state(model_state)
+
+
def information_route(state: dict[str, Any] | AgentState) -> str:
model_state = _as_state(state)
return "plan" if model_state.is_information_enough else "await_user"
@@ -341,5 +521,5 @@ def acceptance_route(state: dict[str, Any] | AgentState) -> str:
if accepted is True:
return "end"
if accepted is False:
- return "user_input"
+ return "await_user"
return "end"
diff --git a/backend/app/agents/agent1/prompts.py b/backend/app/agents/agent1/prompts.py
index 3532e55..34c7aa8 100644
--- a/backend/app/agents/agent1/prompts.py
+++ b/backend/app/agents/agent1/prompts.py
@@ -4,6 +4,11 @@
For each question, provide 2-4 concrete predefined options based on common architectural patterns.
The user can select one of these options or provide a custom value.
+CRITICAL RULE: NEVER ask the same question twice.
+- Review the "Previously Answered Questions" section below.
+- Only ask NEW clarifying questions about aspects NOT yet discussed.
+- Build upon previous answers to explore deeper or related requirements.
+
Return ONLY valid JSON with this exact schema:
{
"is_information_enough": boolean,
@@ -13,10 +18,31 @@
"question": "What is the expected peak traffic?",
"original_question": "What is the expected peak traffic?",
"options": [
- {"label": "Light (1K-10K req/min)", "value": "light_traffic_1k_10k_req_min"},
- {"label": "Medium (10K-100K req/min)", "value": "medium_traffic_10k_100k_req_min"},
- {"label": "Heavy (>100K req/min)", "value": "heavy_traffic_100k_plus_req_min"},
- {"label": "Custom", "value": "custom", "is_custom": true}
+ {
+ "label": "Light (1K-10K req/min)",
+ "value": "light_traffic_1k_10k_req_min",
+ "description": "Best for early-stage products with predictable load.",
+ "impact": "Lower infrastructure cost, simpler operations, less resilience overhead."
+ },
+ {
+ "label": "Medium (10K-100K req/min)",
+ "value": "medium_traffic_10k_100k_req_min",
+ "description": "Suitable for growth-stage systems with periodic spikes.",
+ "impact": "Requires autoscaling, better observability, and performance tuning."
+ },
+ {
+ "label": "Heavy (>100K req/min)",
+ "value": "heavy_traffic_100k_plus_req_min",
+ "description": "Enterprise/high-volume workload with sustained high throughput.",
+ "impact": "Needs partitioned architecture, strict SLOs, and higher baseline cost."
+ },
+ {
+ "label": "Custom",
+ "value": "custom",
+ "description": "Provide a custom answer if none of the presets fit.",
+ "impact": "Planner will adapt architecture to your custom constraints.",
+ "is_custom": true
+ }
]
}
],
@@ -27,6 +53,8 @@
- Keep asking until requirements are implementation-ready.
- Cover these dimensions: product goals, users, scale, latency/SLO, availability, security, compliance, data model, integrations, observability, disaster recovery, cost constraints, rollout strategy.
- For each follow_up_question, generate a corresponding entry in questions_with_options with 2-4 relevant predefined options.
+- Every option MUST include `description` and `impact` so users understand trade-offs before selecting.
+- Keep `description` and `impact` concise and practical (1 sentence each).
- The last option MUST always be a "Custom" option (is_custom: true) where users can provide their own value.
- Predefined options should be concrete, not vague (e.g., "light_traffic_1k_10k_req_min" not "low").
- research_queries must target official cloud documentation for the chosen provider.
diff --git a/backend/app/agents/agent1/standalone_smoke_test.py b/backend/app/agents/agent1/standalone_smoke_test.py
index 2a4d455..1988238 100644
--- a/backend/app/agents/agent1/standalone_smoke_test.py
+++ b/backend/app/agents/agent1/standalone_smoke_test.py
@@ -81,8 +81,16 @@ def display_questions_with_options(state: AgentState) -> None:
for opt_idx, option in enumerate(question.options):
if option.is_custom:
print_white(f" - Custom input: (type your own answer)")
+ if option.description:
+ print_white(f" Description: {option.description}")
+ if option.impact:
+ print_white(f" Impact: {option.impact}")
else:
print_white(f" - {option.label}")
+ if option.description:
+ print_white(f" Description: {option.description}")
+ if option.impact:
+ print_white(f" Impact: {option.impact}")
def _normalize(value: str) -> str:
@@ -95,7 +103,14 @@ def process_option_selection_by_text(state: AgentState, question_idx: int, user_
normalized_input = _normalize(user_input)
for option in question.options:
- if _normalize(option.label) == normalized_input or _normalize(option.value) == normalized_input:
+ option_label = _normalize(option.label)
+ option_value = _normalize(option.value)
+ if (
+ option_label == normalized_input
+ or option_value == normalized_input
+ or normalized_input in option_label
+ or option_label in normalized_input
+ ):
state.selected_option_answers[question_idx] = option.value
logger.info("User matched option text for question %s: %s", question_idx + 1, option.label)
return
diff --git a/backend/app/agents/agent1/state.py b/backend/app/agents/agent1/state.py
index 94741d4..044c240 100644
--- a/backend/app/agents/agent1/state.py
+++ b/backend/app/agents/agent1/state.py
@@ -29,6 +29,8 @@ class QuestionOption(BaseModel):
label: str = ""
value: str = ""
+ description: str = ""
+ impact: str = ""
is_custom: bool = False
@@ -91,6 +93,8 @@ class AgentState(BaseModel):
questions_with_options: list[QuestionWithOptions] = Field(default_factory=list)
# User-selected answers mapped by question index or question text.
selected_option_answers: dict[int, str] = Field(default_factory=dict)
+ # Structured Q&A pairs of (question, answer) to track clarifications with context.
+ answered_qa_pairs: list[tuple[str, str]] = Field(default_factory=list)
# Query plan used by research node to fetch official cloud docs.
research_queries: list[str] = Field(default_factory=list)
# Evidence snippets retained for grounded planning.
diff --git a/backend/app/agents/agent1/tools.py b/backend/app/agents/agent1/tools.py
index 39a51ff..f6d372f 100644
--- a/backend/app/agents/agent1/tools.py
+++ b/backend/app/agents/agent1/tools.py
@@ -1,9 +1,18 @@
from __future__ import annotations
+import os
+from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
+from typing import Any
from urllib.parse import urlparse
from langchain_community.utilities import DuckDuckGoSearchAPIWrapper
+from app.config import settings
+
+
+def _normalize(value: str) -> str:
+ return " ".join(value.strip().lower().split())
+
def trusted_doc_domains(cloud_provider: str) -> list[str]:
provider = cloud_provider.lower().strip()
@@ -18,12 +27,95 @@ def _is_trusted_domain(domain: str, trusted_domains: list[str]) -> bool:
return any(domain == item or domain.endswith(f".{item}") for item in trusted_domains)
-def web_search(query: str, cloud_provider: str, max_results: int = 5) -> list[dict[str, str]]:
+def _extract_items_from_tinyfish_result(result: Any) -> list[dict[str, Any]]:
+ if isinstance(result, list):
+ return [item for item in result if isinstance(item, dict)]
+
+ if isinstance(result, dict):
+ # Common wrappers for list payloads.
+ for key in ("results", "items", "links", "data", "documents"):
+ value = result.get(key)
+ if isinstance(value, list):
+ return [item for item in value if isinstance(item, dict)]
+
+ # Single-item payload.
+ if any(k in result for k in ("url", "link", "href")):
+ return [result]
+
+ return []
+
+
+def _tinyfish_search(query: str, trusted_domains: list[str], max_results: int) -> list[dict[str, Any]]:
+ try:
+ from tinyfish import TinyFish
+ except Exception as exc: # noqa: BLE001
+ raise RuntimeError(f"tinyfish_import_failed: {exc}") from exc
+
+ client = TinyFish()
+ collected: list[dict[str, Any]] = []
+ seen_links: set[str] = set()
+
+ for domain in trusted_domains:
+ url = f"https://{domain}"
+ goal = (
+ "Find official cloud documentation pages relevant to this query and return strictly JSON. "
+ f"Query: {query}. "
+ f"Return up to {max_results} items as an array with keys: title, url, posted, snippet. "
+ f"Only include links whose domain is exactly {domain} or a subdomain of {domain}."
+ )
+
+ complete_event: dict[str, Any] | None = None
+ with client.agent.stream(url=url, goal=goal) as stream:
+ for event in stream:
+ if isinstance(event, dict) and _normalize(str(event.get("type", ""))) == "complete":
+ complete_event = event
+
+ if not complete_event:
+ continue
+
+ status = _normalize(str(complete_event.get("status", "")))
+ if status and status != "completed":
+ continue
+
+ items = _extract_items_from_tinyfish_result(complete_event.get("result"))
+ for item in items:
+ link = str(item.get("url") or item.get("link") or item.get("href") or "").strip()
+ if not link or link in seen_links:
+ continue
+
+ source_domain = (urlparse(link).netloc or "").lower()
+ if not _is_trusted_domain(source_domain, trusted_domains):
+ continue
+
+ seen_links.add(link)
+ collected.append(
+ {
+ "title": str(item.get("title") or item.get("name") or "").strip(),
+ "snippet": str(item.get("snippet") or item.get("summary") or item.get("posted") or "").strip(),
+ "link": link,
+ "source_domain": source_domain,
+ "is_official_source": True,
+ }
+ )
+ if len(collected) >= max_results:
+ return collected[:max_results]
+
+ return collected[:max_results]
+
+
+def _tinyfish_search_with_timeout(query: str, trusted_domains: list[str], max_results: int) -> list[dict[str, Any]]:
+ with ThreadPoolExecutor(max_workers=1) as executor:
+ future = executor.submit(_tinyfish_search, query, trusted_domains, max_results)
+ try:
+ return future.result(timeout=settings.tinyfish_timeout_seconds)
+ except FuturesTimeoutError:
+ return []
+
+
+def _duckduckgo_search(query: str, trusted: list[str], max_results: int) -> list[dict[str, Any]]:
# Search is intentionally constrained to official cloud docs to reduce hallucinated guidance.
search = DuckDuckGoSearchAPIWrapper(max_results=max_results)
- trusted = trusted_doc_domains(cloud_provider)
-
- results: list[dict[str, str]] = []
+ results: list[dict[str, Any]] = []
seen_links: set[str] = set()
for domain in trusted:
scoped_query = f"{query} site:{domain}"
@@ -47,3 +139,30 @@ def web_search(query: str, cloud_provider: str, max_results: int = 5) -> list[di
}
)
return results[:max_results]
+
+
+def web_search(query: str, cloud_provider: str, max_results: int = 5) -> list[dict[str, Any]]:
+ trusted = trusted_doc_domains(cloud_provider)
+
+ # Development workflow prefers the faster DDG path for tighter iteration loops.
+ if settings.app_env.strip().lower() == "development":
+ return _duckduckgo_search(query=query, trusted=trusted, max_results=max_results)
+
+ # TinyFish is preferred for richer browser-driven extraction, but failures should never block results.
+ if settings.enable_tinyfish_search:
+ api_key = os.getenv("TINYFISH_API_KEY", "").strip()
+ # Skip TinyFish when key is absent or obviously placeholder-like.
+ if api_key and "*" not in api_key:
+ try:
+ tinyfish_results = _tinyfish_search_with_timeout(
+ query=query,
+ trusted_domains=trusted,
+ max_results=max_results,
+ )
+ if tinyfish_results:
+ return tinyfish_results
+ except Exception:
+ # Fall back silently to the existing DuckDuckGo behavior on auth/credit/runtime issues.
+ pass
+
+ return _duckduckgo_search(query=query, trusted=trusted, max_results=max_results)
diff --git a/backend/app/config.py b/backend/app/config.py
index 0001c49..0daa4f6 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -8,10 +8,12 @@ class Settings(BaseSettings):
host: str = "0.0.0.0"
port: int = 8000
ollama_base_url: str = "http://localhost:11434"
- qwen_model: str = "qwen3.5:latest"
+ qwen_model: str = "codeqwen:latest"
llm_temperature: float = 0.2
llm_timeout_seconds: int = 90
enable_web_search: bool = True
+ enable_tinyfish_search: bool = False
+ tinyfish_timeout_seconds: int = 25
max_clarification_rounds: int = 6
max_research_rounds: int = 3
diff --git a/backend/app/routers/workflows.py b/backend/app/routers/workflows.py
index 9e3ec69..80c8cea 100644
--- a/backend/app/routers/workflows.py
+++ b/backend/app/routers/workflows.py
@@ -27,7 +27,13 @@ def _to_response(state: AgentState) -> WorkflowResponse:
question=q.question,
original_question=q.original_question,
options=[
- QuestionOptionSchema(label=opt.label, value=opt.value, is_custom=opt.is_custom)
+ QuestionOptionSchema(
+ label=opt.label,
+ value=opt.value,
+ description=opt.description,
+ impact=opt.impact,
+ is_custom=opt.is_custom,
+ )
for opt in q.options
]
)
diff --git a/backend/app/schemas/workflow.py b/backend/app/schemas/workflow.py
index 52dc3f8..7aaf6e6 100644
--- a/backend/app/schemas/workflow.py
+++ b/backend/app/schemas/workflow.py
@@ -12,6 +12,8 @@ class QuestionOptionSchema(BaseModel):
"""Schema for a single option in a clarifying question."""
label: str
value: str
+ description: str = ""
+ impact: str = ""
is_custom: bool = False
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index b165fef..fc0d490 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -18,5 +18,6 @@ dependencies = [
"pydantic>=2.12.5",
"pydantic-settings>=2.13.1",
"python-dotenv>=1.2.2",
+ "tinyfish>=0.1.0",
"uvicorn[standard]>=0.42.0",
]
diff --git a/backend/requirements.txt b/backend/requirements.txt
index c0adcfa..6ef0937 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -9,3 +9,4 @@ langchain-community==0.4.1
langchain-ollama>=1.0.0
duckduckgo-search==8.1.1
ddgs>=9.9.1
+tinyfish>=0.1.0
From 9dc1731057063890836f0c6e8bcee83cc7bf458e Mon Sep 17 00:00:00 2001
From: RETR0-OS
Date: Sun, 22 Mar 2026 02:46:35 -0700
Subject: [PATCH 43/50] delete redundant files
---
backend/IMPLEMENTATION_SUMMARY.md | 357 --------------------------
backend/MULTI_CHOICE_OPTIONS_GUIDE.md | 261 -------------------
2 files changed, 618 deletions(-)
delete mode 100644 backend/IMPLEMENTATION_SUMMARY.md
delete mode 100644 backend/MULTI_CHOICE_OPTIONS_GUIDE.md
diff --git a/backend/IMPLEMENTATION_SUMMARY.md b/backend/IMPLEMENTATION_SUMMARY.md
deleted file mode 100644
index 58e27f1..0000000
--- a/backend/IMPLEMENTATION_SUMMARY.md
+++ /dev/null
@@ -1,357 +0,0 @@
-## Multi-Choice Options Feature - Implementation Summary
-
-### Feature Overview
-
-Added GitHub Copilot Chat-style multi-choice options to the CloudForge agent. When asking clarifying questions, the agent now provides:
-
-1. **2-4 predefined options** based on common architectural patterns
-2. **Custom option** (always last) for users to express unique requirements
-3. **Intelligent routing** to handle both selected options and custom input
-
-### User Experience Flow
-
-```
-User provides initial PRD
- ↓
-Agent asks clarifying question + provides options
- ↓
-User selects option OR inputs custom answer
- ↓
-Agent converts selection to natural language
- ↓
-Agent re-evaluates if information is sufficient
- ↓
-Repeat until PRD is ready
-```
-
-### Architecture Changes
-
-#### 1. **State Model** (`app/agents/agent1/state.py`)
-
-**New Classes:**
-```python
-class QuestionOption(BaseModel):
- label: str # Display text: "Light (1K-10K req/min)"
- value: str # Internal value: "light_traffic_1k_10k"
- is_custom: bool # True only for custom option
-
-class QuestionWithOptions(BaseModel):
- question: str # Standalone question text
- original_question: str # Source requirement being asked
- options: list[QuestionOption]
-
-class ClarifierOutput(BaseModel):
- is_information_enough: bool
- follow_up_questions: list[str]
- questions_with_options: list[QuestionWithOptions] # NEW
- research_queries: list[str]
-```
-
-**State Fields Added:**
-```python
-questions_with_options: list[QuestionWithOptions] # Questions with options
-selected_option_answers: dict[int, str] # User's selections
-```
-
-#### 2. **LLM Prompt** (`app/agents/agent1/prompts.py`)
-
-**CLARIFIER_PROMPT** now requests:
-- For each follow-up question, generate 2-4 contextual options
-- Last option must be Custom (is_custom=true) for freeform input
-- Options should be concrete values, not vague labels
-
-**Example LLM Output:**
-```json
-{
- "is_information_enough": false,
- "follow_up_questions": ["What is expected traffic?"],
- "questions_with_options": [
- {
- "question": "What is the expected traffic pattern?",
- "original_question": "What is the expected traffic pattern?",
- "options": [
- {"label": "Light (1K-10K req/min)", "value": "light_1k_10k", "is_custom": false},
- {"label": "Medium (10K-100K req/min)", "value": "medium_10k_100k", "is_custom": false},
- {"label": "Heavy (>100K req/min)", "value": "heavy_100k_plus", "is_custom": false},
- {"label": "Custom", "value": "custom", "is_custom": true}
- ]
- }
- ],
- "research_queries": [...]
-}
-```
-
-#### 3. **Research Node** (`app/agents/agent1/nodes.py`)
-
-**Changes to `research_node()`:**
-```python
-# Extract questions_with_options from LLM response
-model_state.questions_with_options = payload.questions_with_options[:8]
-```
-
-#### 4. **Information Gate** (`app/agents/agent1/nodes.py`)
-
-**New Logic in `information_gate_node()`:**
-
-```python
-# Process selected_option_answers before evaluating sufficiency
-if model_state.selected_option_answers:
- for q_idx, selected_value in model_state.selected_option_answers.items():
- question_obj = model_state.questions_with_options[q_idx]
-
- # Find matching option
- matched_option = None
- for opt in question_obj.options:
- if opt.value == selected_value:
- matched_option = opt
- break
-
- if matched_option:
- # Predefined option:use label
- response = matched_option.label if not matched_option.is_custom else selected_value
- model_state.user_answers.append(response)
-
- model_state.selected_option_answers = {} # Clear after processing
-```
-
-**Option Selection Rules:**
-- ✅ Selecting by value (e.g., "light_traffic_1k_10k") → Uses option label
-- ✅ Custom input → Uses freeform text as-is
-- ❌ Invalid/irrelevant → Logs warning, continues to next clarification round
-
-#### 5. **API Schemas** (`app/schemas/workflow.py`)
-
-**New Classes:**
-```python
-class QuestionOptionSchema(BaseModel):
- label: str
- value: str
- is_custom: bool = False
-
-class QuestionWithOptionsSchema(BaseModel):
- question: str
- options: list[QuestionOptionSchema]
- original_question: str
-
-class RespondWorkflowRequest(BaseModel):
- session_id: str
- answers: list[str] = []
- selected_option_answers: dict[int, str] = {} # NEW: Maps question idx to selection
-```
-
-**Updated Response:**
-```python
-class WorkflowResponse(BaseModel):
- # ... existing fields ...
- questions_with_options: list[QuestionWithOptionsSchema] = [] # NEW
-```
-
-#### 6. **FastAPI Router** (`app/routers/workflows.py`)
-
-**Updated `_to_response()`:**
-```python
-# Convert state.questions_with_options to schema
-questions_with_options = [
- QuestionWithOptionsSchema(
- question=q.question,
- original_question=q.original_question,
- options=[
- QuestionOptionSchema(label=opt.label, value=opt.value, is_custom=opt.is_custom)
- for opt in q.options
- ]
- )
- for q in state.questions_with_options
-]
-```
-
-**Updated `/respond` Endpoint:**
-```python
-@router.post("/respond", response_model=WorkflowResponse)
-def respond_workflow(payload: RespondWorkflowRequest) -> WorkflowResponse:
- state = AgentState.model_validate(state_data)
- state.user_answers = list(state.user_answers) + payload.answers
- state.selected_option_answers = payload.selected_option_answers # NEW
- state.status = "running"
- # ... continue ...
-```
-
-#### 7. **Standalone Test** (`app/agents/agent1/standalone_smoke_test.py`)
-
-**New Functions:**
-```python
-def display_questions_with_options(state: AgentState) -> None:
- """Show options in interactive format"""
- for idx, question in enumerate(state.questions_with_options):
- print(f"\n{idx + 1}. {question.original_question}")
- for opt_idx, option in enumerate(question.options):
- label = f"Custom input" if option.is_custom else option.label
- print(f" [{opt_idx}] - {label}")
-
-def process_option_selection(state: AgentState, q_idx: int, selection: str) -> bool:
- """Process user selection (option index or custom input)"""
- question = state.questions_with_options[q_idx]
- try:
- opt_idx = int(selection)
- if 0 <= opt_idx < len(question.options):
- state.selected_option_answers[q_idx] = question.options[opt_idx].value
- return True
- except ValueError:
- # Treat as custom input if last option is custom
- if question.options and question.options[-1].is_custom:
- state.selected_option_answers[q_idx] = selection
- return True
- return False
-```
-
-**Updated `run_case()`:**
-- Displays options when `status == "needs_input"`
-- Processes option selections via `process_option_selection()`
-- Maps question index to user answers across iterations
-- Increased max rounds from 3 to 12 for option scenarios
-
-### API Usage Examples
-
-#### Example 1: Select Predefined Option
-```bash
-POST /workflows/prd/respond
-{
- "session_id": "abc-123",
- "selected_option_answers": {
- "0": "medium_traffic_10k_100k"
- }
-}
-```
-
-#### Example 2: Custom Input
-```bash
-POST /workflows/prd/respond
-{
- "session_id": "abc-123",
- "selected_option_answers": {
- "0": "Highly variable: 50k avg, 300k spike during campaigns"
- }
-}
-```
-
-#### Example 3: Mixed Selection
-```bash
-POST /workflows/prd/respond
-{
- "session_id": "abc-123",
- "selected_option_answers": {
- "0": "light_traffic_1k_10k",
- "1": "HIPAA + SOC2 Type II explicit requirement"
- },
- "answers": ["Additional context in natural language"]
-}
-```
-
-### Validation & Error Handling
-
-**Valid Responses:**
-- Option value match (case-sensitive): "light_traffic_1k_10k" → Uses "Light (1K-10K req/min)"
-- Custom input (any text): "Variable pattern 300k peak" → Uses as-is
-- Out-of-range index: Shows error, re-asks question
-
-**Invalid Responses:**
-- Irrelevant text when options provided → Agent logs and asks clearer questions
-- Invalid option index → User gets validation error
-
-### Data Flow Summary
-
-```
-┌─────────────────────────────────────────────────────────────┐
-│ User submits PRD via /start endpoint │
-└────────────────────┬────────────────────────────────────────┘
- │
- ▼
- ┌────────────────────────────┐
- │ research_node │
- │ (clarifier LLM) │
- └────────────────────────────┘
- │
- ┌───────────┴────────────────────────────┐
- │ │
- Does LLM return Does LLM return
- questions_with_options? questions_with_options?
- │ │
- │ YES │ NO
- ┌────┴────────────────────┐ ┌──────────┴──────────┐
- │ Store in state │ │ Fallback to plain │
- │ Proceed normally │ │ follow_up_questions│
- └────┬────────────────────┘ └──────────┬──────────┘
- │ │
- └───────────────┬───────────────────────┘
- │
- ▼
- ┌────────────────────────────┐
- │ information_gate_node │
- │ │
- │ Check is_information_enough│
- └────────────────────────────┘
- │
- ┌───────────────┴────────────────────────┐
- │ │
- Enough info? Not enough?
- │ YES │
- ▼ ▼
- ┌──────────────┐ ┌──────────────────────────────┐
- │ Plan Node │ │ information_gate_node cont'd │
- │ (Generate │ │ │
- │ PRD) │ │ Convert selected_option_answers
- └──────────────┘ │ to user_answers │
- │ │
- │ If options present: │
- │ Display in response │
- │ │
- │ Set status="needs_input" │
- └──────────┬───────────────────┘
- │
- ▼
- User responds via /respond
- with selected_option_answers
- │
- ▼
- Process converts to user_answers
- and loops back
-```
-
-### Testing
-
-**Unit Tests:**
-- ✅ `test_options_logic.py`: Validates Pydantic models, state serialization, option selection logic
-- All tests pass without LLM inference
-
-**Integration Tests:**
-- `standalone_smoke_test.py`: Full workflow with 3 cloud provider scenarios
-- Tests option generation, selection, and multi-round clarification
-- Run: `python3 -m app.agents.agent1.standalone_smoke_test`
-
-**Manual Testing:**
-- `example_api_workflow.sh`: Bash script with curl examples
-- Demonstrates real API interactions with option selection
-
-### Files Modified
-
-1. ✅ `state.py` - New models + state fields
-2. ✅ `prompts.py` - Updated CLARIFIER_PROMPT
-3. ✅ `nodes.py` - research_node + information_gate_node logic
-4. ✅ `schemas/workflow.py` - New schemas
-5. ✅ `routers/workflows.py` - Response conversion + endpoint updates
-6. ✅ `standalone_smoke_test.py` - Option display + selection logic
-
-### Files Created
-
-1. ✅ `MULTI_CHOICE_OPTIONS_GUIDE.md` - User documentation
-2. ✅ `test_options_logic.py` - Validation tests
-3. ✅ `example_api_workflow.sh` - API examples
-4. ✅ `IMPLEMENTATION_SUMMARY.md` - This file
-
-### Future Enhancements
-
-- **Smart Filtering**: Hide options irrelevant to selected cloud provider
-- **Cascading Options**: "If traffic is Heavy, then auto-scaling is required: Yes/No?"
-- **Option Explanations**: Hover text explaining each option
-- **Analytics**: Track which options users select for A/B testing
-- **Confidence Scoring**: LLM rates confidence in recommended options
-- **Option Categories**: Group related options (e.g., "Traffic Patterns", "Compliance")
diff --git a/backend/MULTI_CHOICE_OPTIONS_GUIDE.md b/backend/MULTI_CHOICE_OPTIONS_GUIDE.md
deleted file mode 100644
index c4320c4..0000000
--- a/backend/MULTI_CHOICE_OPTIONS_GUIDE.md
+++ /dev/null
@@ -1,261 +0,0 @@
-# Multi-Choice Options Feature Guide
-
-## Overview
-
-The agent now provides **multiple-choice options** (similar to GitHub Copilot Chat in planning mode) when asking clarifying questions about your product requirements. Users can either:
-
-1. **Select from predefined options** (e.g., "Light traffic", "Medium traffic", "Heavy traffic")
-2. **Provide custom input** as the final option (e.g., "Bursty pattern with 300k req/min peaks")
-
-If a user provides an answer irrelevant to the question, the agent will ask more clarifying questions in the next iteration.
-
----
-
-## How It Works
-
-### 1. Agent Generates Options
-
-When the agent needs clarification, it generates questions with concrete predefined options:
-
-```
-Question 1: What is the expected peak traffic pattern?
- [0] - Light (1K-10K req/min)
- [1] - Medium (10K-100K req/min)
- [2] - Heavy (>100K req/min)
- [3] - Custom input: (you can type your own answer)
-```
-
-The **Custom** option (last one) always allows freeform text input for users to express unique requirements.
-
-### 2. User Selection Options
-
-Users can respond in several ways:
-
-- **Select by index**: `"0"` → Use "Light (1K-10K req/min)"
-- **Select by index**: `"2"` → Use "Heavy (>100K req/min)"
-- **Custom input**: `"Highly variable: 50k baseline, 500k spikes during sales"` → Use freeform answer
-
-### 3. Agent Processes Response
-
-- **Valid option selected**: Option label is appended to user_answers
-- **Custom input**: Freeform text is appended as-is
-- **Irrelevant response**: Agent detects mismatch and asks clarifying questions again
-
----
-
-## API Usage
-
-### Starting a Workflow
-
-```bash
-POST /workflows/prd/start
-{
- "prd_text": "Build a multi-tenant SaaS analytics platform",
- "cloud_provider": "aws"
-}
-
-Response:
-{
- "session_id": "abc-123",
- "status": "needs_input",
- "questions_with_options": [
- {
- "question": "What is the expected peak traffic?",
- "original_question": "What is the expected peak traffic?",
- "options": [
- {
- "label": "Light (1K-10K req/min)",
- "value": "light_traffic_1k_10k",
- "is_custom": false
- },
- {
- "label": "Custom",
- "value": "custom",
- "is_custom": true
- }
- ]
- }
- ],
- "follow_up_questions": ["What is the expected peak traffic?"],
- "plan_markdown": null,
- "errors": []
-}
-```
-
-### Responding with Option Selection
-
-#### Option 1: Select Predefined Option (by index)
-
-```bash
-POST /workflows/prd/respond
-{
- "session_id": "abc-123",
- "selected_option_answers": {
- 0: "light_traffic_1k_10k"
- }
-}
-```
-
-#### Option 2: Provide Custom Input
-
-```bash
-POST /workflows/prd/respond
-{
- "session_id": "abc-123",
- "selected_option_answers": {
- 0: "Highly variable pattern: baseline 50k req/min, peaks to 300k during campaigns"
- }
-}
-```
-
-#### Option 3: Mix of Predefined and Custom
-
-```bash
-POST /workflows/prd/respond
-{
- "session_id": "abc-123",
- "selected_option_answers": {
- 0: "medium_traffic_10k_100k", # Predefined option
- 1: "HIPAA and SOC2 compliance required, PII encryption mandatory" # Custom answer
- },
- "answers": [
- "Additional clarification in natural language if needed"
- ]
-}
-```
-
----
-
-## Standalone Test Example
-
-Run the test to see options in action:
-
-```bash
-cd backend
-python3 -m app.agents.agent1.standalone_smoke_test
-```
-
-The test demonstrates three cloud provider scenarios with multi-choice option handling:
-- **AWS Streaming Commerce**: Traffic scaling options
-- **Azure Claims Processing**: Availability & compliance options
-- **GCP IoT Fleet**: Ingestion pattern & security options
-
----
-
-## Example Q&A Flow
-
-### Round 1:
-
-**Agent:** "What is the expected peak traffic pattern?"
-
-```
- [0] - Light (1K-10K req/min)
- [1] - Medium (10K-100K req/min)
- [2] - Heavy (>100K req/min)
- [3] - Custom input
-```
-
-**User:** `"1"` → Selects "Medium"
-
-**Agent adds to clarifications:** "Medium (10K-100K req/min)"
-
-### Round 2:
-
-**Agent:** "What compliance requirements apply?"
-
-```
- [0] - SOC2 Type II
- [1] - HIPAA with encryption
- [2] - GDPR only
- [3] - Custom input
-```
-
-**User:** `"HIPAA, SOC2, and ISO 27001 certification required"` → Custom input
-
-**Agent adds to clarifications:** "HIPAA, SOC2, and ISO 27001 certification required"
-
-### Round 3:
-
-**Agent:** "What is the geographic distribution strategy?"
-
-```
- [0] - Single region (US East)
- [1] - Multi-region active-active
- [2] - Primary + DR standby
- [3] - Custom input
-```
-
-**User:** `"0"` → Selects single region
-
-**Agent continues refinement...**
-
----
-
-## Validation & Handling
-
-### Valid Responses:
-- ✅ Selecting by index (0, 1, 2, 3)
-- ✅ Custom freeform text for any question
-- ✅ Selecting option that matches to natural language
-
-### Invalid/Irrelevant Responses:
-- ❌ "Hello" when asked about traffic patterns → Agent re-asks with clarifications
-- ❌ Out-of-range index (e.g., "5" when only 4 options exist) → Error message + re-ask
-- ❌ Complete non-sequiturs → Agent detects and loops back
-
----
-
-## Development Notes
-
-### Key Files:
-
-1. **state.py**: Defines `QuestionOption`, `QuestionWithOptions`, `ClarifierOutput`
-2. **prompts.py**: CLARIFIER_PROMPT requests LLM to generate options
-3. **nodes.py**: `information_gate_node` converts selections to user answers
-4. **standalone_smoke_test.py**: UI for displaying options and gathering answers
-5. **schemas/workflow.py**: API request/response schemas with options
-6. **routers/workflows.py**: FastAPI endpoints handling option selection
-
-### LLM Prompt Structure:
-
-The clarifier prompt instructs the LLM to return:
-
-```json
-{
- "is_information_enough": boolean,
- "follow_up_questions": ["q1", "q2"],
- "questions_with_options": [
- {
- "question": "...",
- "original_question": "...",
- "options": [
- {"label": "option 1", "value": "value1", "is_custom": false},
- {"label": "Custom", "value": "custom", "is_custom": true}
- ]
- }
- ],
- "research_queries": ["query1", "query2"]
-}
-```
-
----
-
-## Benefits
-
-| Feature | Benefit |
-|---------|---------|
-| **Predefined Options** | Guides users toward standard architectural patterns |
-| **Custom Option** | Allows expression of unique/atypical requirements |
-| **Multiple Rounds** | Iteratively refines understanding without batch input |
-| **Option Validation** | Reduces invalid/off-topic responses |
-| **LLM Grounding** | Agent generates contextually relevant options |
-
----
-
-## Future Enhancements
-
-- [ ] Smart filtering: Hide irrelevant options based on cloud provider
-- [ ] Dependent options: "If traffic is Heavy, then do you need auto-scaling?"
-- [ ] Option analytics: Track which options users select (A/B testing)
-- [ ] Confidence scoring: LLM rates confidence in recommended options
-- [ ] Option explanations: Hover-text explaining why each option is offered
From be93501fae0c9e540351c0b14772de87e092a399 Mon Sep 17 00:00:00 2001
From: Gunbir
Date: Sun, 22 Mar 2026 07:58:29 -0700
Subject: [PATCH 44/50] major ui
---
.claude/settings.local.json | 12 +
backend/.env.example | 22 -
backend/app/agents/agent1/llm.py | 14 +-
backend/app/agents/agent1/nodes.py | 55 +-
backend/app/agents/agent1/prompts.py | 14 +-
backend/app/agents/agent1/state.py | 4 +
backend/app/agents/agent3/config.py | 5 +-
backend/app/agents/agent3/llm.py | 30 +-
.../architecture_agent.py | 2 +-
.../architecture_planner/compliance_agent.py | 2 +-
.../agents/architecture_planner/console.py | 7 +-
.../agents/architecture_planner/eval_agent.py | 2 +-
.../app/agents/architecture_planner/graph.py | 33 +-
.../agents/architecture_planner/llm_utils.py | 35 +-
.../architecture_planner/query_agent.py | 2 +-
.../service_discovery_agent.py | 4 +-
backend/app/config.py | 22 +-
backend/app/core/security.py | 8 +-
backend/app/main.py | 2 +
backend/app/routers/architecture.py | 2 -
backend/app/routers/architecture_sse.py | 29 +-
backend/app/routers/history.py | 144 ++++
backend/app/routers/prd.py | 160 +++-
backend/cloudforge_db | Bin 0 -> 23404544 bytes
backend/cloudforge_db.wal | Bin 0 -> 11746563 bytes
backend/pyproject.toml | 7 +-
backend/uv.lock | 227 +++---
frontend/src/app/(app)/billing/page.tsx | 9 +-
frontend/src/app/(app)/dashboard/page.tsx | 46 +-
frontend/src/app/(app)/history/page.tsx | 370 ++++++---
.../app/(app)/project/[id]/ProjectShell.tsx | 74 +-
frontend/src/app/(auth)/login/page.tsx | 101 +--
frontend/src/app/(auth)/signup/page.tsx | 101 +--
frontend/src/app/app/layout.tsx | 9 +-
.../components/forge/ArchitecturePanel.tsx | 11 +-
frontend/src/components/forge/BuildPanel.tsx | 11 +-
frontend/src/components/forge/DeployPanel.tsx | 15 +-
.../src/components/forge/ForgeChatPanel.tsx | 749 ++++++++++++++++--
.../components/forge/RequirementsPanel.tsx | 82 +-
frontend/src/components/landing/CTA.tsx | 204 +----
frontend/src/lib/forge-agents.ts | 340 +-------
frontend/src/lib/mock-data.ts | 235 ------
frontend/src/lib/mockDeploy.ts | 42 -
frontend/src/store/canvasStore.ts | 62 +-
frontend/src/store/forgeStore.ts | 135 +++-
frontend/src/store/projectStore.ts | 55 +-
46 files changed, 1871 insertions(+), 1624 deletions(-)
create mode 100644 .claude/settings.local.json
delete mode 100644 backend/.env.example
create mode 100644 backend/app/routers/history.py
create mode 100644 backend/cloudforge_db
create mode 100644 backend/cloudforge_db.wal
delete mode 100644 frontend/src/lib/mockDeploy.ts
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..3bc87b3
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,12 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(python3:*)",
+ "Bash(ls -la \"/c/Users/gunbi/OneDrive/Desktop/cloudforge/frontend/src/app/\\(auth\\)/\")",
+ "Bash(find /c/Users/gunbi/OneDrive/Desktop/cloudforge/backend -name .env* -o -name *.env)",
+ "Bash(cat:*)",
+ "Bash(npx tsc:*)",
+ "Bash(xargs grep:*)"
+ ]
+ }
+}
diff --git a/backend/.env.example b/backend/.env.example
deleted file mode 100644
index 276dec0..0000000
--- a/backend/.env.example
+++ /dev/null
@@ -1,22 +0,0 @@
-APP_NAME=CloudForge API
-APP_ENV=development
-DEBUG=true
-HOST=0.0.0.0
-PORT=8000
-OLLAMA_BASE_URL=http://localhost:11434
-QWEN_MODEL=qwen3.5:latest
-LLM_TEMPERATURE=0.2
-LLM_TIMEOUT_SECONDS=90
-ENABLE_WEB_SEARCH=true
-MAX_CLARIFICATION_ROUNDS=3
-MAX_RESEARCH_ROUNDS=3
-
-# Agent 2 — Architecture Planner
-# Use "anthropic" + set ANTHROPIC_API_KEY for Claude; defaults to local Ollama.
-ARCH_MODEL_TYPE=ollama
-ARCH_MODEL_NAME=llama3.1:8b
-# ANTHROPIC_API_KEY=sk-ant-...
-
-# Agent 3 — Terraform / Code Generator (always Ollama)
-AGENT3_MODEL=qwen3.5
-AGENT3_FAST_MODEL=qwen3.5
diff --git a/backend/app/agents/agent1/llm.py b/backend/app/agents/agent1/llm.py
index 82971b1..90ade55 100644
--- a/backend/app/agents/agent1/llm.py
+++ b/backend/app/agents/agent1/llm.py
@@ -1,9 +1,15 @@
from __future__ import annotations
-from langchain_ollama import ChatOllama
+from langchain_anthropic import ChatAnthropic
from app.config import settings
-def get_llm() -> ChatOllama:
- # Centralized model factory keeps all nodes consistent and easy to tune.
- return ChatOllama(model=settings.qwen_model, base_url=settings.ollama_base_url, temperature=settings.llm_temperature, request_timeout=settings.llm_timeout_seconds)
+
+def get_llm() -> ChatAnthropic:
+ return ChatAnthropic(
+ model=settings.llm_model,
+ api_key=settings.anthropic_api_key,
+ temperature=settings.llm_temperature,
+ timeout=settings.llm_timeout_seconds,
+ max_tokens=16384,
+ )
diff --git a/backend/app/agents/agent1/nodes.py b/backend/app/agents/agent1/nodes.py
index e5b9521..0c6c83c 100644
--- a/backend/app/agents/agent1/nodes.py
+++ b/backend/app/agents/agent1/nodes.py
@@ -35,7 +35,18 @@ def _extract_json(text: str) -> dict[str, Any]:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
- return json.loads(text[start : end + 1])
+ try:
+ return json.loads(text[start : end + 1])
+ except json.JSONDecodeError:
+ pass
+ # Last resort: repair truncated/malformed JSON (e.g. from hitting max_tokens)
+ try:
+ from json_repair import repair_json
+ repaired = repair_json(text[start:] if start >= 0 else text, return_objects=True)
+ if isinstance(repaired, dict):
+ return repaired
+ except Exception:
+ pass
raise
@@ -101,12 +112,26 @@ def research_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
model_state.clarification_rounds = rounds
llm = get_llm()
- # Include all prior answers to support deep, iterative clarification for natural-language input.
+ answers_text = (
+ "\n".join(f"- {a}" for a in model_state.user_answers)
+ if model_state.user_answers
+ else "None provided yet."
+ )
+ has_answers = bool(model_state.user_answers)
prompt = (
f"{CLARIFIER_PROMPT}\n\n"
f"Cloud provider: {model_state.cloud_provider}\n"
f"Initial Product Description:\n{model_state.prd_text}\n\n"
- f"User Clarifications:\n{model_state.user_answers}\n"
+ f"User Clarifications (already answered by the user):\n{answers_text}\n\n"
+ + (
+ "The user has answered the above questions. "
+ "Carefully evaluate whether the answers, combined with the initial description, "
+ "provide enough detail to design a deployable cloud architecture. "
+ "Set is_information_enough to true if sufficient. "
+ "Only ask NEW follow-up questions if genuinely critical information is still missing — "
+ "do NOT repeat questions the user has already addressed.\n"
+ if has_answers else ""
+ )
)
try:
@@ -160,23 +185,25 @@ def web_search_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
# Fetch only trusted official docs, preserving query/source provenance.
for query in model_state.research_queries:
try:
+ # Trim overly long queries — DuckDuckGo site: searches work better under ~8 words
+ trimmed_query = ' '.join(query.split()[:8])
query_start = time.perf_counter()
items = web_search(
- query=query,
+ query=trimmed_query,
cloud_provider=model_state.cloud_provider,
max_results=4,
)
logger.info(
- "Fetched %s result(s) for %s in %.2f seconds",
+ "Fetched %s result(s) for '%s' in %.2f seconds",
len(items),
- query,
+ trimmed_query,
time.perf_counter() - query_start,
)
for item in items:
- item["query"] = query
+ item["query"] = trimmed_query
results.append(ResearchResult.model_validate(item))
except Exception as exc: # noqa: BLE001
- _safe_errors(model_state, f"search_failed[{query}]: {exc}")
+ _safe_errors(model_state, f"search_failed[{trimmed_query}]: {exc}")
model_state.research_results = results
logger.info(
@@ -266,7 +293,17 @@ def plan_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
"PRD generation completed in %.2f seconds",
time.perf_counter() - start,
)
- payload = PlannerOutput.model_validate(_extract_json(str(response.content)))
+ raw = _extract_json(str(response.content))
+ # 7B models often return a flat object with plan_markdown at the top level
+ # and omit the plan_json wrapper entirely. Normalise before validation.
+ if "plan_json" not in raw:
+ # Try to promote top-level fields into plan_json, then strip them.
+ non_markdown = {k: v for k, v in raw.items() if k != "plan_markdown"}
+ raw = {
+ "plan_markdown": raw.get("plan_markdown", ""),
+ "plan_json": non_markdown if non_markdown else {},
+ }
+ payload = PlannerOutput.model_validate(raw)
# CoT is not logged; show a safe reasoning summary from structured model output.
logger.info(
diff --git a/backend/app/agents/agent1/prompts.py b/backend/app/agents/agent1/prompts.py
index 3532e55..ef2989e 100644
--- a/backend/app/agents/agent1/prompts.py
+++ b/backend/app/agents/agent1/prompts.py
@@ -1,8 +1,6 @@
CLARIFIER_PROMPT = """
You are a cloud solution clarifier with expertise in eliciting architectural requirements.
-Given a natural-language product description, identify every missing detail needed to design a deployable cloud architecture.
-For each question, provide 2-4 concrete predefined options based on common architectural patterns.
-The user can select one of these options or provide a custom value.
+Given a natural-language product description, determine whether there is enough information to design a deployable cloud architecture.
Return ONLY valid JSON with this exact schema:
{
@@ -24,13 +22,17 @@
}
Rules:
-- Keep asking until requirements are implementation-ready.
-- Cover these dimensions: product goals, users, scale, latency/SLO, availability, security, compliance, data model, integrations, observability, disaster recovery, cost constraints, rollout strategy.
+- For each question, provide 2-4 concrete predefined options based on common architectural patterns. The user can select one of these options or provide a custom value.
+- If the user has provided the core use case, scale, and any key constraints, set is_information_enough=true immediately — do NOT ask for more.
+- Only ask questions when information is genuinely missing and cannot be reasonably inferred.
+- Dimensions to check: core use case, expected traffic/scale, latency/SLO, availability, cloud provider, rough cost budget. All others can be inferred or defaulted.
+- Do NOT ask about things already stated or clearly implied by the description.
+- Do NOT ask about nice-to-have details like rollout strategy, observability tooling, or compliance unless they are explicitly relevant to the use case.
- For each follow_up_question, generate a corresponding entry in questions_with_options with 2-4 relevant predefined options.
- The last option MUST always be a "Custom" option (is_custom: true) where users can provide their own value.
- Predefined options should be concrete, not vague (e.g., "light_traffic_1k_10k_req_min" not "low").
- research_queries must target official cloud documentation for the chosen provider.
-- If and only if the information is enough, return empty arrays for all fields.
+- If is_information_enough is true, return empty arrays for follow_up_questions and questions_with_options.
""".strip()
diff --git a/backend/app/agents/agent1/state.py b/backend/app/agents/agent1/state.py
index 94741d4..4182d65 100644
--- a/backend/app/agents/agent1/state.py
+++ b/backend/app/agents/agent1/state.py
@@ -52,6 +52,8 @@ class ClarifierOutput(BaseModel):
class FinalPRDJson(BaseModel):
"""Final PRD-like structure consumed by downstream service selection logic."""
+ model_config = ConfigDict(extra="ignore")
+
scope: str = ""
product_summary: str = ""
functional_requirements: list[str] = Field(default_factory=list)
@@ -68,6 +70,8 @@ class FinalPRDJson(BaseModel):
class PlannerOutput(BaseModel):
"""Top-level planner output with markdown plus machine-readable PRD JSON."""
+ model_config = ConfigDict(extra="ignore")
+
plan_markdown: str
plan_json: FinalPRDJson
diff --git a/backend/app/agents/agent3/config.py b/backend/app/agents/agent3/config.py
index 06d4a68..26019fd 100644
--- a/backend/app/agents/agent3/config.py
+++ b/backend/app/agents/agent3/config.py
@@ -1,8 +1,7 @@
from __future__ import annotations
-# NOTE: LLM model names are configured via settings.agent3_model /
-# settings.agent3_fast_model (app/config.py) so they can be
-# overridden through the .env file without touching source code.
+# NOTE: The LLM model is configured via settings.llm_model (app/config.py)
+# and can be overridden through the .env file without touching source code.
# ---------------------------------------------------------------------------
# Retry / iteration limits
diff --git a/backend/app/agents/agent3/llm.py b/backend/app/agents/agent3/llm.py
index 7b7d6e6..12f8751 100644
--- a/backend/app/agents/agent3/llm.py
+++ b/backend/app/agents/agent3/llm.py
@@ -2,39 +2,39 @@
import threading
-from langchain_ollama import ChatOllama
+from langchain_anthropic import ChatAnthropic
from app.config import settings
_lock = threading.Lock()
-_clients: dict[str, ChatOllama] = {}
+_clients: dict[str, ChatAnthropic] = {}
-def get_llm(model: str | None = None, temperature: float = 0.0) -> ChatOllama:
+def get_llm(model: str | None = None, temperature: float = 0.0) -> ChatAnthropic:
"""
- Return a module-level singleton ChatOllama client for a given (model, temperature) pair.
- Model defaults to settings.agent3_model when not specified.
+ Return a module-level singleton ChatAnthropic client for a given (model, temperature) pair.
Thread-safe double-checked locking avoids re-creating clients on every node invocation.
"""
- resolved_model = model or settings.agent3_model
+ resolved_model = model or settings.llm_model
key = f"{resolved_model}:{temperature}"
if key not in _clients:
with _lock:
if key not in _clients:
- _clients[key] = ChatOllama(
+ _clients[key] = ChatAnthropic(
model=resolved_model,
- base_url=settings.ollama_base_url,
+ api_key=settings.anthropic_api_key,
temperature=temperature,
- request_timeout=settings.llm_timeout_seconds,
+ timeout=settings.llm_timeout_seconds,
+ max_tokens=16384,
)
return _clients[key]
-def get_default_llm() -> ChatOllama:
- """Return the primary LLM for heavy tasks (model = settings.agent3_model)."""
- return get_llm(settings.agent3_model, 0.0)
+def get_default_llm() -> ChatAnthropic:
+ """Return the primary LLM for heavy tasks."""
+ return get_llm(settings.llm_model, 0.0)
-def get_fast_llm() -> ChatOllama:
- """Return the LLM for lighter tasks (model = settings.agent3_fast_model)."""
- return get_llm(settings.agent3_fast_model, 0.0)
+def get_fast_llm() -> ChatAnthropic:
+ """Return the LLM for lighter tasks (same model, lower temperature)."""
+ return get_llm(settings.llm_model, 0.0)
diff --git a/backend/app/agents/architecture_planner/architecture_agent.py b/backend/app/agents/architecture_planner/architecture_agent.py
index ecc167e..3358297 100644
--- a/backend/app/agents/architecture_planner/architecture_agent.py
+++ b/backend/app/agents/architecture_planner/architecture_agent.py
@@ -68,7 +68,7 @@ def architecture_node(state: ArchitecturePlannerState) -> dict:
"error_message": f"LLM API error ({type(exc).__name__}): {exc}",
}
except Exception:
- # Ollama / plain-text LLM fallback: attempt raw JSON parse
+ # Structured output fallback: attempt raw JSON parse
try:
raw = llm.invoke(messages).content
raw = raw.strip()
diff --git a/backend/app/agents/architecture_planner/compliance_agent.py b/backend/app/agents/architecture_planner/compliance_agent.py
index d2ca03c..28f5b35 100644
--- a/backend/app/agents/architecture_planner/compliance_agent.py
+++ b/backend/app/agents/architecture_planner/compliance_agent.py
@@ -74,7 +74,7 @@ def compliance_node(state: ArchitecturePlannerState) -> dict:
"error_message": f"LLM API error ({type(exc).__name__}): {exc}",
}
except Exception:
- # Ollama / plain-text LLM fallback: attempt raw JSON parse
+ # Structured output fallback: attempt raw JSON parse
try:
raw = llm.invoke(messages).content
raw = raw.strip()
diff --git a/backend/app/agents/architecture_planner/console.py b/backend/app/agents/architecture_planner/console.py
index 0096a36..0120643 100644
--- a/backend/app/agents/architecture_planner/console.py
+++ b/backend/app/agents/architecture_planner/console.py
@@ -21,8 +21,7 @@
--prd TEXT PRD inline text
--prd-file PATH Path to PRD file (alternative to --prd)
--cloud AWS|GCP|Azure Cloud provider
- --model anthropic|ollama Model backend (default: anthropic)
- --model-name NAME Override default model name
+ --model-name NAME Override default model name (default: claude-haiku-4-5-20251001)
"""
from __future__ import annotations
@@ -52,7 +51,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--prd", type=str, help="PRD text (inline)")
parser.add_argument("--prd-file", type=str, help="Path to PRD file")
parser.add_argument("--cloud", type=str, choices=["AWS", "GCP", "Azure"], help="Cloud provider")
- parser.add_argument("--model", type=str, default="anthropic", choices=["anthropic", "ollama"])
+ parser.add_argument("--model-name", type=str, default=None, help="Override default model name")
parser.add_argument("--model-name", type=str, default=None, help="Override default model name")
parser.add_argument(
"--terraform-mcp-cmd",
@@ -285,7 +284,7 @@ def main() -> None:
if args.model == "anthropic" and not os.environ.get("ANTHROPIC_API_KEY"):
print(
"Error: ANTHROPIC_API_KEY environment variable is not set.\n"
- "Set it before running, or use --model ollama for local inference.",
+ "Set ANTHROPIC_API_KEY before running.",
file=sys.stderr,
)
sys.exit(1)
diff --git a/backend/app/agents/architecture_planner/eval_agent.py b/backend/app/agents/architecture_planner/eval_agent.py
index 8143644..59cf48e 100644
--- a/backend/app/agents/architecture_planner/eval_agent.py
+++ b/backend/app/agents/architecture_planner/eval_agent.py
@@ -70,7 +70,7 @@ def eval_node(state: ArchitecturePlannerState) -> Command:
goto=END,
)
except Exception:
- # Ollama / plain-text LLM fallback: attempt raw JSON parse
+ # Structured output fallback: attempt raw JSON parse
try:
raw = llm.invoke(messages).content
raw = raw.strip()
diff --git a/backend/app/agents/architecture_planner/graph.py b/backend/app/agents/architecture_planner/graph.py
index 449f855..3f36bf8 100644
--- a/backend/app/agents/architecture_planner/graph.py
+++ b/backend/app/agents/architecture_planner/graph.py
@@ -127,24 +127,15 @@ def build_arch_review_subgraph(llm):
def _build_llm(model_type: str, model_name: str | None):
- """Instantiate the chat model based on model_type."""
- if model_type == "anthropic":
- from langchain_anthropic import ChatAnthropic
- return ChatAnthropic(
- model=model_name or "claude-opus-4-6",
- temperature=0,
- max_tokens=8096,
- )
- elif model_type == "ollama":
- from langchain_ollama import ChatOllama
- return ChatOllama(
- model=model_name or "llama3.1:8b",
- temperature=0,
- )
- else:
- raise ValueError(
- f"Unknown model_type '{model_type}'. Use 'anthropic' or 'ollama'."
- )
+ """Instantiate the chat model. All agents use Anthropic Claude."""
+ from langchain_anthropic import ChatAnthropic
+ from app.config import settings
+ return ChatAnthropic(
+ model=model_name or settings.llm_model,
+ api_key=settings.anthropic_api_key,
+ temperature=0,
+ max_tokens=16384,
+ )
# ---------------------------------------------------------------------------
@@ -170,10 +161,8 @@ def create_graph(
Build and compile the full architecture planner graph.
Args:
- model_type: "anthropic" (default) or "ollama"
- model_name: Override the default model name.
- Anthropic default: "claude-opus-4-6"
- Ollama default: "llama3.1:8b"
+ model_type: Ignored — kept for call-site compatibility. All agents use Anthropic.
+ model_name: Override the model name. Defaults to settings.llm_model (Haiku).
graph_json_path: Path to graph.json for KG traversal.
Defaults to CLOUDFORGE_GRAPH_JSON env var or "graph.json".
KG traversal is silently skipped if the file does not exist.
diff --git a/backend/app/agents/architecture_planner/llm_utils.py b/backend/app/agents/architecture_planner/llm_utils.py
index 43964a5..541b07d 100644
--- a/backend/app/agents/architecture_planner/llm_utils.py
+++ b/backend/app/agents/architecture_planner/llm_utils.py
@@ -8,48 +8,35 @@
def _get_api_error_types() -> tuple[type[BaseException], ...]:
- """Return a tuple of API-level exception types for configured LLM backends.
-
- Checked at import time so the tuple is stable. An empty tuple is returned
- if neither backend is installed (never matches in except clauses).
- """
+ """Return a tuple of Anthropic + httpx exception types for use in except clauses."""
types: list[type[BaseException]] = []
- # Anthropic SDK errors
try:
import anthropic
types.extend([
- anthropic.APITimeoutError, # Request timed out
- anthropic.RateLimitError, # HTTP 429
- anthropic.AuthenticationError, # HTTP 401 — bad API key
- anthropic.PermissionDeniedError, # HTTP 403 — key lacks permission
- anthropic.APIConnectionError, # Network-level failure
- anthropic.BadRequestError, # HTTP 400 — bad request / context too long
- anthropic.InternalServerError, # HTTP 500+ and 529 overloaded_error
- anthropic.APIStatusError, # Base for all other HTTP error responses
+ anthropic.APITimeoutError,
+ anthropic.RateLimitError,
+ anthropic.AuthenticationError,
+ anthropic.PermissionDeniedError,
+ anthropic.APIConnectionError,
+ anthropic.BadRequestError,
+ anthropic.InternalServerError,
+ anthropic.APIStatusError,
])
except ImportError:
pass
- # httpx errors (used internally by both the Anthropic client and Ollama)
try:
import httpx
types.extend([
- httpx.ConnectError, # Ollama server not running / unreachable
- httpx.TimeoutException, # Base timeout class
+ httpx.ConnectError,
+ httpx.TimeoutException,
httpx.ConnectTimeout,
httpx.ReadTimeout,
])
except ImportError:
pass
- # Ollama-specific errors (model not found, etc.)
- try:
- import ollama
- types.append(ollama.ResponseError)
- except ImportError:
- pass
-
return tuple(types)
diff --git a/backend/app/agents/architecture_planner/query_agent.py b/backend/app/agents/architecture_planner/query_agent.py
index e75e0bd..7bfa1ec 100644
--- a/backend/app/agents/architecture_planner/query_agent.py
+++ b/backend/app/agents/architecture_planner/query_agent.py
@@ -13,7 +13,7 @@
# ---------------------------------------------------------------------------
-# Module-level Ollama fallback helper
+# Module-level LLM fallback helper
# ---------------------------------------------------------------------------
diff --git a/backend/app/agents/architecture_planner/service_discovery_agent.py b/backend/app/agents/architecture_planner/service_discovery_agent.py
index 572ed87..e23d9f2 100644
--- a/backend/app/agents/architecture_planner/service_discovery_agent.py
+++ b/backend/app/agents/architecture_planner/service_discovery_agent.py
@@ -59,7 +59,7 @@ def service_discovery_node(state: ArchitecturePlannerState) -> dict:
messages = [HumanMessage(content=prompt)]
# ------------------------------------------------------------------
- # Step 3: LLM invocation with structured output + Ollama fallback
+ # Step 3: LLM invocation with structured output + JSON parse fallback
# ------------------------------------------------------------------
try:
result = llm.with_structured_output(ServiceDiscoveryOutput).invoke(messages)
@@ -71,7 +71,7 @@ def service_discovery_node(state: ArchitecturePlannerState) -> dict:
"error_message": f"LLM API error ({type(exc).__name__}): {exc}",
}
except Exception:
- # Ollama / plain-text LLM fallback: attempt raw JSON parse
+ # Structured output fallback: attempt raw JSON parse
try:
raw = llm.invoke(messages).content
raw = raw.strip()
diff --git a/backend/app/config.py b/backend/app/config.py
index 174bc4e..6b6f647 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -1,5 +1,7 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
+HAIKU_MODEL = "claude-haiku-4-5-20251001"
+
class Settings(BaseSettings):
app_name: str = "CloudForge API"
@@ -7,24 +9,16 @@ class Settings(BaseSettings):
debug: bool = True
host: str = "0.0.0.0"
port: int = 8000
- ollama_base_url: str = "http://localhost:11434"
- qwen_model: str = "qwen3.5:latest"
+
+ # LLM — all agents use Claude Haiku via Anthropic
+ anthropic_api_key: str = ""
+ llm_model: str = HAIKU_MODEL
llm_temperature: float = 0.2
llm_timeout_seconds: int = 90
enable_web_search: bool = True
max_clarification_rounds: int = 6
max_research_rounds: int = 3
- # Agent 2 — Architecture Planner LLM
- # Set arch_model_type="anthropic" and ANTHROPIC_API_KEY in .env for Claude.
- # Defaults to local Ollama so the server starts without any extra API keys.
- arch_model_type: str = "ollama"
- arch_model_name: str = "llama3.1:8b"
-
- # Agent 3 — Terraform / Code Generator LLM (always Ollama)
- agent3_model: str = "qwen3.5" # primary model for heavy tasks
- agent3_fast_model: str = "qwen3.5" # lighter tasks (code fixing, test gen)
-
# MongoDB
mongodb_url: str = "mongodb://localhost:27017"
mongodb_db_name: str = "cloudforge"
@@ -44,15 +38,13 @@ class Settings(BaseSettings):
github_client_secret: str = ""
github_redirect_uri: str = "http://localhost:8000/auth/github/callback"
- # External services
- anthropic_api_key: str = ""
frontend_url: str = "http://localhost:3000"
# Agent data paths
graph_json_path: str = "app/agents/data/graph/graph.json"
kuzu_db_path: str = "./cloudforge_db"
- model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
settings = Settings()
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
index 377ca0e..de66929 100644
--- a/backend/app/core/security.py
+++ b/backend/app/core/security.py
@@ -1,14 +1,12 @@
from datetime import datetime, timedelta, timezone
+import bcrypt
from fastapi import HTTPException, status
import jwt
from jwt.exceptions import InvalidTokenError
-from passlib.context import CryptContext
from app.config import settings
-pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
-
def create_access_token(data: dict) -> str:
payload = data.copy()
@@ -41,8 +39,8 @@ def decode_token(token: str) -> dict:
def hash_password(password: str) -> str:
- return pwd_context.hash(password)
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
- return pwd_context.verify(plain, hashed)
+ return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
diff --git a/backend/app/main.py b/backend/app/main.py
index 2b7014f..8084637 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -15,6 +15,7 @@
from app.routers.architecture_sse import router as architecture_sse_router
from app.routers.build import router as build_router
from app.routers.deploy import router as deploy_router
+from app.routers.history import router as history_router
from app.routers.prd import router as prd_router
logger = logging.getLogger(__name__)
@@ -90,6 +91,7 @@ async def lifespan(app: FastAPI):
app.include_router(prd_router)
app.include_router(build_router)
app.include_router(deploy_router)
+app.include_router(history_router)
@app.get("/")
diff --git a/backend/app/routers/architecture.py b/backend/app/routers/architecture.py
index 3df54d9..a45d714 100644
--- a/backend/app/routers/architecture.py
+++ b/backend/app/routers/architecture.py
@@ -46,8 +46,6 @@ def _get_arch_graph():
global _arch_graph
if _arch_graph is None:
_arch_graph = create_graph(
- model_type=settings.arch_model_type,
- model_name=settings.arch_model_name,
graph_json_path=_GRAPH_JSON,
community_summaries_path=_SUMMARIES_JSON,
)
diff --git a/backend/app/routers/architecture_sse.py b/backend/app/routers/architecture_sse.py
index 581fe3c..cec1d48 100644
--- a/backend/app/routers/architecture_sse.py
+++ b/backend/app/routers/architecture_sse.py
@@ -53,8 +53,6 @@ def _get_arch_graph(kuzu_conn=None): # noqa: ARG001 — kuzu_conn reserved for
global _arch_graph_v2
if _arch_graph_v2 is None:
_arch_graph_v2 = create_graph(
- model_type=settings.arch_model_type,
- model_name=settings.arch_model_name,
graph_json_path=_GRAPH_JSON,
community_summaries_path=_SUMMARIES_JSON,
)
@@ -401,6 +399,33 @@ async def review_arch_v2(
)
+@router.get("/{project_id}")
+async def get_architecture(
+ project_id: str,
+ user: dict = Depends(get_current_user),
+) -> dict:
+ project = await projects_col().find_one({"_id": ObjectId(project_id)})
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+ if str(project["owner_id"]) != str(user["_id"]):
+ raise HTTPException(status_code=403, detail="Forbidden")
+
+ arch_doc = await architectures_col().find_one(
+ {"project_id": ObjectId(project_id)},
+ sort=[("created_at", -1)],
+ )
+ if not arch_doc:
+ raise HTTPException(status_code=404, detail="No architecture session for this project")
+
+ return {
+ "session_id": arch_doc["session_id"],
+ "status": arch_doc.get("status"),
+ "architecture_diagram": arch_doc.get("architecture_diagram") or {},
+ "nfr_document": arch_doc.get("nfr_document") or "",
+ "eval_score": arch_doc.get("eval_score"),
+ }
+
+
@router.post("/accept/{project_id}")
async def accept_arch_v2(
project_id: str,
diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py
new file mode 100644
index 0000000..09b3a65
--- /dev/null
+++ b/backend/app/routers/history.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+import logging
+
+from bson import ObjectId
+from fastapi import APIRouter, Depends, Query
+
+from app.core.dependencies import get_current_user
+from app.db.mongo import builds_col, deployments_col, prd_conversations_col
+from app.utils.serialization import serialize_doc
+
+router = APIRouter(prefix="/history", tags=["history"])
+logger = logging.getLogger(__name__)
+
+
+@router.get("/builds")
+async def list_user_builds(
+ limit: int = Query(default=20, ge=1, le=100),
+ user=Depends(get_current_user),
+) -> list[dict]:
+ pipeline = [
+ {
+ "$lookup": {
+ "from": "projects",
+ "localField": "project_id",
+ "foreignField": "_id",
+ "as": "project",
+ }
+ },
+ {"$unwind": "$project"},
+ {
+ "$match": {
+ "project.owner_id": user["_id"],
+ }
+ },
+ {"$sort": {"created_at": -1}},
+ {"$limit": limit},
+ {
+ "$project": {
+ "_id": 0,
+ "id": {"$toString": "$_id"},
+ "project_id": {"$toString": "$project_id"},
+ "project_name": "$project.name",
+ "status": 1,
+ "created_at": 1,
+ "artifacts_count": {
+ "$cond": {
+ "if": {"$isArray": {"$objectToArray": {"$ifNull": ["$artifacts", {}]}}},
+ "then": {"$size": {"$objectToArray": {"$ifNull": ["$artifacts", {}]}}},
+ "else": 0,
+ }
+ },
+ "generated_files_count": {
+ "$cond": {
+ "if": {"$isArray": "$generated_files"},
+ "then": {"$size": "$generated_files"},
+ "else": 0,
+ }
+ },
+ }
+ },
+ ]
+
+ docs = await builds_col().aggregate(pipeline).to_list(length=limit)
+ return [serialize_doc(doc) for doc in docs]
+
+
+@router.get("/deployments")
+async def list_user_deployments(
+ limit: int = Query(default=20, ge=1, le=100),
+ user=Depends(get_current_user),
+) -> list[dict]:
+ pipeline = [
+ {
+ "$lookup": {
+ "from": "projects",
+ "localField": "project_id",
+ "foreignField": "_id",
+ "as": "project",
+ }
+ },
+ {"$unwind": "$project"},
+ {
+ "$match": {
+ "project.owner_id": user["_id"],
+ }
+ },
+ {"$sort": {"created_at": -1}},
+ {"$limit": limit},
+ {
+ "$project": {
+ "_id": 0,
+ "id": {"$toString": "$_id"},
+ "project_id": {"$toString": "$project_id"},
+ "project_name": "$project.name",
+ "status": 1,
+ "provider": 1,
+ "region": 1,
+ "created_at": 1,
+ }
+ },
+ ]
+
+ docs = await deployments_col().aggregate(pipeline).to_list(length=limit)
+ return [serialize_doc(doc) for doc in docs]
+
+
+@router.get("/prd")
+async def list_user_prd_sessions(
+ limit: int = Query(default=20, ge=1, le=100),
+ user=Depends(get_current_user),
+) -> list[dict]:
+ pipeline = [
+ {
+ "$lookup": {
+ "from": "projects",
+ "localField": "project_id",
+ "foreignField": "_id",
+ "as": "project",
+ }
+ },
+ {"$unwind": "$project"},
+ {
+ "$match": {
+ "project.owner_id": user["_id"],
+ }
+ },
+ {"$sort": {"created_at": -1}},
+ {"$limit": limit},
+ {
+ "$project": {
+ "_id": 0,
+ "id": {"$toString": "$_id"},
+ "project_id": {"$toString": "$project_id"},
+ "project_name": "$project.name",
+ "session_id": 1,
+ "status": 1,
+ "created_at": 1,
+ }
+ },
+ ]
+
+ docs = await prd_conversations_col().aggregate(pipeline).to_list(length=limit)
+ return [serialize_doc(doc) for doc in docs]
diff --git a/backend/app/routers/prd.py b/backend/app/routers/prd.py
index 56df9fa..af269b1 100644
--- a/backend/app/routers/prd.py
+++ b/backend/app/routers/prd.py
@@ -42,6 +42,21 @@ def _infer_category(label: str) -> str:
return "reliability"
+def _build_questions_with_options(state: AgentState) -> list[dict]:
+ """Return questions_with_options, generating a Custom-only fallback when the LLM omits them."""
+ if state.questions_with_options:
+ return [q.model_dump() for q in state.questions_with_options]
+ # LLM didn't populate questions_with_options — synthesise from follow_up_questions
+ return [
+ {
+ "question": q,
+ "original_question": q,
+ "options": [{"label": "Custom…", "value": "", "is_custom": True}],
+ }
+ for q in (state.follow_up_questions or [])
+ ]
+
+
def _parse_constraints(state: AgentState) -> list[ConstraintChip]:
if state.plan_json is None:
return []
@@ -87,6 +102,30 @@ def _sync_run() -> AgentState:
# Persistence helpers
# ---------------------------------------------------------------------------
+def _build_messages(result: AgentState) -> list[dict]:
+ """Reconstruct the conversation message list from agent state."""
+ messages: list[dict] = []
+
+ if result.prd_text:
+ messages.append({"role": "user", "type": "prd_text", "content": result.prd_text})
+
+ # Interleave clarification questions and user answers by round index.
+ questions = result.follow_up_questions or []
+ answers = result.user_answers or []
+ for i, question in enumerate(questions):
+ messages.append({"role": "agent", "type": "question", "content": question})
+ if i < len(answers):
+ messages.append({"role": "user", "type": "answer", "content": answers[i]})
+ # Any remaining answers not paired with a question
+ for answer in answers[len(questions):]:
+ messages.append({"role": "user", "type": "answer", "content": answer})
+
+ if result.plan_markdown:
+ messages.append({"role": "agent", "type": "plan_ready", "content": result.plan_markdown})
+
+ return messages
+
+
async def _persist_prd(
project_id: str,
session_id: str,
@@ -100,7 +139,7 @@ async def _persist_prd(
"status": "plan_ready",
"plan_markdown": result.plan_markdown or "",
"plan_json": result.plan_json.model_dump(mode="json") if result.plan_json else {},
- "messages": [],
+ "messages": _build_messages(result),
"created_at": now,
"updated_at": now,
}
@@ -115,6 +154,55 @@ async def _persist_prd(
)
+# ---------------------------------------------------------------------------
+# GET /workflows/prd/v2/{project_id} — restore saved PRD state
+# ---------------------------------------------------------------------------
+
+def _parse_constraints_from_dict(plan_json: dict) -> list[ConstraintChip]:
+ chips: list[ConstraintChip] = []
+ for item in plan_json.get("non_functional_requirements", []):
+ label = (
+ item.get("requirement") or item.get("description") or str(item)
+ if isinstance(item, dict) else str(item)
+ ).strip()
+ if label:
+ chips.append(ConstraintChip(id=str(uuid4()), label=label, category=_infer_category(label)))
+ return chips
+
+
+@router.get("/{project_id}")
+async def get_prd(
+ project_id: str,
+ user: dict = Depends(get_current_user),
+) -> dict:
+ try:
+ oid = ObjectId(project_id)
+ except Exception:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
+
+ project = await projects_col().find_one({"_id": oid})
+ if project is None or project.get("owner_id") != ObjectId(str(user["_id"])):
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
+
+ session_id: str | None = project.get("prd_session_id")
+ if not session_id:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No PRD session for this project")
+
+ conv = await prd_conversations_col().find_one({"session_id": session_id})
+ if not conv:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="PRD conversation not found")
+
+ constraints = _parse_constraints_from_dict(conv.get("plan_json") or {})
+
+ return {
+ "session_id": session_id,
+ "status": conv.get("status"),
+ "plan_markdown": conv.get("plan_markdown", ""),
+ "messages": conv.get("messages", []),
+ "constraints": [c.model_dump() for c in constraints],
+ }
+
+
# ---------------------------------------------------------------------------
# POST /workflows/prd/v2/start/{project_id}
# ---------------------------------------------------------------------------
@@ -186,17 +274,32 @@ async def _stream():
return
session_store.save(result.as_graph_state())
- chips = _parse_constraints(result)
- await _persist_prd(project_id, session_id, result, chips)
-
- for chip in chips:
- yield _sse({"type": "constraint", "chip": chip.model_dump()})
-
- yield _sse({
- "type": "plan_ready",
- "session_id": session_id,
- "plan_markdown": result.plan_markdown or "",
- })
+ if result.status == "needs_input":
+ # Persist session_id so /respond can find the session
+ now = datetime.now(timezone.utc)
+ await projects_col().update_one(
+ {"_id": ObjectId(project_id)},
+ {"$set": {"prd_session_id": session_id, "updated_at": now}},
+ )
+ questions = result.follow_up_questions or []
+ questions_with_options = _build_questions_with_options(result)
+ yield _sse({
+ "type": "clarification_needed",
+ "questions": questions,
+ "questions_with_options": questions_with_options,
+ })
+ elif result.status == "plan_ready":
+ chips = _parse_constraints(result)
+ await _persist_prd(project_id, session_id, result, chips)
+ for chip in chips:
+ yield _sse({"type": "constraint", "chip": chip.model_dump()})
+ yield _sse({
+ "type": "plan_ready",
+ "session_id": session_id,
+ "plan_markdown": result.plan_markdown or "",
+ })
+ else:
+ yield _sse({"type": "error", "message": f"Unexpected agent status: {result.status}"})
except Exception as exc:
logger.exception("Error in start_prd SSE stream")
@@ -250,7 +353,7 @@ async def respond_prd(
raw = {
"session_id": session_id,
"prd_text": conv.get("plan_markdown", ""),
- "cloud_provider": project.get("cloud_provider", "aws"),
+ "cloud_provider": project.get("cloud_provider") or "aws",
"status": "needs_input",
"plan_markdown": conv.get("plan_markdown"),
"plan_json": conv.get("plan_json"),
@@ -271,17 +374,26 @@ async def _stream():
return
session_store.save(result.as_graph_state())
- chips = _parse_constraints(result)
- await _persist_prd(project_id, session_id, result, chips)
-
- for chip in chips:
- yield _sse({"type": "constraint", "chip": chip.model_dump()})
-
- yield _sse({
- "type": "plan_ready",
- "session_id": session_id,
- "plan_markdown": result.plan_markdown or "",
- })
+ if result.status == "needs_input":
+ questions = result.follow_up_questions or []
+ questions_with_options = _build_questions_with_options(result)
+ yield _sse({
+ "type": "clarification_needed",
+ "questions": questions,
+ "questions_with_options": questions_with_options,
+ })
+ elif result.status == "plan_ready":
+ chips = _parse_constraints(result)
+ await _persist_prd(project_id, session_id, result, chips)
+ for chip in chips:
+ yield _sse({"type": "constraint", "chip": chip.model_dump()})
+ yield _sse({
+ "type": "plan_ready",
+ "session_id": session_id,
+ "plan_markdown": result.plan_markdown or "",
+ })
+ else:
+ yield _sse({"type": "error", "message": f"Unexpected agent status: {result.status}"})
except Exception as exc:
logger.exception("Error in respond_prd SSE stream")
diff --git a/backend/cloudforge_db b/backend/cloudforge_db
new file mode 100644
index 0000000000000000000000000000000000000000..25ac0f4a9c2dec5e74a00b1db898a47e0c32d480
GIT binary patch
literal 23404544
zcmeEvdt8)N-Z)kkV3yWeUa~+$W(E-uQ7*E9h|COGTDf`2#sLOqxXs8g%7AR3qA-Yp
zigFPJ6@@{(WG2@F5t$jXw6eTp>F(>hfZJxf+q+!k-QV|n&Uv1BaJSEAfB(II)MvND
zJePC6=X<}Mr>GUHS9mzkfByFw2Zu56=Nr#DIE*Ei@YnN?(W@`8aS9t>V&f}poWVwS
zHj3Ej$;LTs^kw5bHZEXe5F10-7|ObY%E~oPBs>^aW5P9v$33wm29kH;}JF<
zV`Ci~Pq6V68$V#2OEdjIKoDU$0^@E&c-L%=)}gS+4w9QpJU@BHcn>aG&a7>
zMprh@WTOWgC2aI!qYoSX*f^h!foxpJ##h<6h>a0!l(I33jjywDIUC<*<2!6z`2^kX
zwx=8%00j;!;X011*!6eWxR#Au;W?DgEo{tZ<6btdhUf78uR)qaVSH>HeLe^7va83;rpfi{Yw8|>5mxrCI0wN`Ro7sbHB8|U-|!6{{NN#f0h4V(hvXF_1b?5
zpM#5oWA>O(2j}cD0S=BXV?^0w+a0E6Kc4O|!R0Y&wo~XBN%q)AhsiFFtFy0E=Y<2dy^f5jzPg=64gg%~-J-%^FfXh?8+0)w}
z&&YmuU`(isqhpS1=o6;wi2;vEU7SQY?(I*MXFs3**fJMqX^uE_tT}s9<6|pb#;J3>
z+Q&9$zc}#NI+v&Oa(qLdv}I2TcwFr=zBb3d{mGlz)6yT;xjbXZ2@HLzKYM!P@f|v^k>PacYiR`V&Pi&q+7SLY*Wz?u}2B
zx;(GmytLgZD92;qi7J=hMYL@2aK(CnN+)ZMZ5Fr9MANzr(IsKY+e;QE+NOO
zacrZ@i@lrIwvWrm@g5l4?lReNi!$_SQ;u)IlNOgLqAjZSr^|Ev(x1HIGF7@o6FT0U
zoUD|i=q9Qn>oShPd#vX*|NnL
z`fPtrNaIrjF0b@%$!dRgDCgCIryN{e9Jl6#PH^5F7U1aU>MGir+dg6H=J0gK39d7w
zTk}IFN;XF{I!<hn`Hq`B3Jj?ttIWxt==4!?&RY-
z%d)jB^!bF%OB{V^Ga7wb#6oZq~^`<3^=cIot>9^CiDf{=2ZdX)UIB&x#!wnxVd>v
z`Z%5I97}Ff=!^ZE*EWtzclGYgZEJsVXtQEqT(+x^%6>eS3{@Dw`c;!*Sh-GZoAe#
zb@i6S^zo-%=UcYj2%VO&CAo2Yqw9j+ZMWN}Wo*$7jBj@haNK@3^d-}l^#RXVTmwbh
z@3p^FzD1w@%oW!l>GrE-Et%=hK5+f5W&2?0EB#xt8lN3-eYJP{Nc$^8Tizdd)?r4dW8RoB7w4@x
z0TUc&go*OTcDPL4x+Q(Wgc*yZc}`)jlC8Oo6DH3HSLcoIa1GkJePDvijKz6*6T@c6
zx8?^-6wQdJ&70IQWA)a8^oc$*mRRzpgt;YbEo__^Fe9=zZ+eGY#@1Z}6GLZ69rIno
zW}3DZ2RtX8Ars}hcg!r`x+nd)Wi#Z`d~uk&d231Ib1P>=sq?)$+?%)VA9!xvjMwt=
zeZywiww47vub#2AHs8Nv*3GRI>CfwCyl%-44D;yUTG{w~`iwVv^Fum3hPECWcs_f^
zGDlNbn8-P|I^Z{XGnR`?5gnqbxku7}Q#9jEsYw?pU${PFjk+MY!>c*>
z{J;x$X1tqMus&>#Ew?G)#oiffYYPk=b8hCgq`&xJ#(S0mW0-e;Zd>Dv12Yu81z8>5
zL%E*}yy)Pz&T&Uhn2+dXW3B}=9jSTa^sW$xA@*2
zl^uQ=+ddzd66&UQEUXTjXWDi(V5-zjBPy)vm{-2-%k-(s+!CaPbz%PIZPyy7u5?RO
z7dCYGH*fo9VCp)zq`bm2Ve@Ugka
zX?qLXIu;CV`+i_rwwumzXGd6o^Y*&|FXg$d7wzoo2$;J4kLfQJxur^XTEhY*+wV2L
zRO+Tz@4Va*7_|LQ120v%rRD9s8Wtqq-WM>v*3D46^IAvH>h1T_r=ND)VA**iEI48N
z4~^3s-8S~_yxkF;vHiz^>FsXmj=S!LEi`TaDd1&`n^CmuUdO`n?SD&u`HEYHbXQ+k
zhtjZ`V&@zqM^240xs2Evt6d&mF(LxqUeOl?QIW
zx9l1Wd$oW2NaHI5ZtwT*8tHg-X!}10UU8V2?N~HsQK)m?m_QfDnK`1Ou@^$8<~?q7
znJ{y+w8&{um?Up(lgs3pThvA4FN6i&69Z>RXXcBF-7hRI
z&wJiDW7$lTv{<|-!kjm$X~xQ#1?pn23lYtEFAmOFH*-f`vG1ZKw!A5UZt9tZwZ;Ay
zmfXynW^~id+-WHeTol=#H@(R%edey-;*blGLwT{SCf0=%>BK)*ItOq$oC#}Z=YG}xJS9@HB-KC;4I6`
zGSME@h1bgS{fx7&%q*Ag(JWeO&i8Mcb$w=qdQb9&rOo*Z24~%wc_44k`bDqX@&f}s
zdS_PF?lD|={bqi!(c{6)gO)wUMQ`-yhctN%%skY)C+or+L;0@`dN{aOIquC_w9MHQ
z7ASIbuNLjiy|8SmDcmTU;C@)TH-FJ`i7BE(wO<7gjWz-Wimvb3c(+a%R!*
zY^GI#o@)1o+LCh@es|Nf#^|YYKWQmxTJ%o8X>F5dy8Ef#lC}%)44D*zp4slF9rtxC
zTIpP%44j?kenzyf>%z*Z1u@3iMeZL+_gNRMk`$<#W|z93RqwleVO3B;{NU^=_Yd>-
zU0t+VUZ4r|s&zkCyYJeC)vF5ZeYY>H$tchcdbPVZ
zI_|%_=v`C6`oK9B_a@Q)dl%j3)vux5bak$F7qoir>%2_??GOtcm
z^N#(4bJxxKG_TAzJkGYGEYMFq>vCH$P4QN{cC4kt1VyAnXtOB-spdN*4LKuRpE&Vg$+&qjkCV#
zEnnN2m{E9Y(7%1wb;k;2c#^5`OyGRWtQ(>VRcBIp;aTJSE3UKB=E8GL^RLgk
zrLIWsOl~ebKREx+tZ(xw)`zFq3Y!8K^v=3nTVd!-xmnm^T<~Dlca{ocxVFErt!cr)
ztnYg(vO2Xxg`W&AaPYX}cpxWS=e)BcFu>8{uINB+r*7)bPGi6Xk3UEcYY8tKp&6)wj3x6*C*_}
z+!Pq#@u%Jcm7V&Gou3Z|hI;fmR#u0nnRZ?c43c{Ei7IP4)5>>#X$)HC(J!s63pbc|
zUTX?k>2Y6O+0bcd-uca7&^nJl=T)8w-(cH$BQRL)@k4Fpxy}tYciu7v>pcEqscZ`0
z*uV33Q*gS+kG+*`og0UCem@wT?eW0zU`Kem^RByr3-dgF5*_U7OrN^zkH&>X9)Fb{
zw1yicyY4kDEcN)C`rzeGW6-WY4KA$m_LtKk{)U44NewH`m$9=z6>v3l2iW5{Wb
z|FImr5uTZ_>xZV0Mvs5=9=zR|nX&7~!H{;30mnmk!#A0B{S^3Hi^rho(7n!0<-7i7
z{OuKwA?cyM@GSGLpPPPr-D6mN=!ed%=3W07{Oui&hk1v73je)r*I?kQy&faAhkow-
z{mos#bMB{IBKEVEFs}yGEK`9q{;P@1c>-_lI`pBXol3FLPj~ziQ&*&GI6rpfHhW
zY+co)i&$jmrr05j|X3`uhl*Yq3eOfza5@~eIM2D*
zHE403XrlOV#KpX+#cmmki$u@K4$BtjON!l_7nh2j*BoAYF+Zr-V`y=e=r{R?moGNS
zi^V|^wW3LNhgV!QtuFS=h&U~J!FqVr;(~-?ujYtG(TjbD*Iq2hDE1zTXctX(I-*>>
z!&K}Ww8SEsB0i$JxTCz-FJs9S(Nx(H&Ei6Hv48WD>!N9zBgq#Fn~N6=Ex99lDgVg&
z#XD`qfkBbIqUm)<3>SCaEDp|yd?0$+dc?SRSATIxbL4>NmA)fc7k3R6zd97@Aa-#&
znzOjbd3RWl)KTmzKAL;6XzK3p4Cw^%4B65A#l@1{5zW%cVmHmv!i&X0yCa9BF5;Q_
zM~fHlmhYAY$wXrJx}zl*cdy_tIurfY_t&XywH{8N1&Yl7)&z
zPBqnw_nLMu50Xp8VsTB)#l7Xb-^!3L6H8<@b&E^PyH_;JSBgC~H4PU_ns>i5Bwr_<
zonLci@jlz`RY6f|u~%Kqxr_U5?p~7-r4!Gw)-)~N-@kipb5y$6yRWA0;{KuCilL}%
zv5(WSj>VZc4Kix!k*;jrH$eReaCKJtjO4-
z9a`Eh4sbetckuz!p7lYmTf~9lRhvNOrt$aiw{Wq4{-(>*8R|@gFW$
zHt*Ru^!gp~!u;buEk0=5V+?wuR~%Az{O5}YZ|=#=c;kWix7Oo>ix2hh$!dOMK>TXo
z@sW#%hW5Na^oD~Z)Twq%M3wX2oSRLli4lk8d-H>qizE?swUfFIuijgbvD`0L)M_U;;59x9PK)wxC-HSH}9dQ&QqiR;|Ej+XD;lkw&-f;#Lql(6OO`pA!y;;(OR9t3&XX(`nvXhZUn7SdnVU4oa_4N=Du4Q
zYjmC|)`q5t^Zom7H?K+e)b=&Bb)6sD_x;eCY)_rj$&QFd=lyqs-p%t|FFx7T)i`zk
zA2Z%9@=TSTv_>>Z_TOuMx71UwIeEFODQN$nhTg66Ov^ucHKJL*zb|NQt*4>x&ZT35
z*E!D45uYA=seNkcIf=*YIvQ??5+8yCoZ`l
zFLer5ie~55ot|{*!s^m-nM$A8+pMRjEa^-r9p9o1n7zI4^z=)e8Kut-D??}JIh}D`
za?w;eF*sT}J70Xp{nEwq(&saym(4cG&WM+EnM)_NM6aA(pgH4psjIp4#o_36vv=g5
z@m+GsRyrj(Mm@W*?u`GXOE*iWWya`c@3fu?T+-cNI=v+(efF-tGa;9{he}@=j>(=~
z|*f;5tl4e%iJc)d9|n-XYcR(VC|)zj56pPI^igX1i-%fx3@mp&~o^UI98GP_)MReIWnr`X!&)$^wJqduLbHoi$wg>}FYTX8eQM2d!s~OFr)}3u%cTn0=`4Y}TdE
zhss_Zj(6~?a{4f5$rb1Fuwb>LSGD-V+)G!cmWOAmCwLu}eVD)Gs-!%kMLpT;h~~q>
zOIL%+BZt*4UPtpkEMD@3yj&Kn5qZ_reOPkoi`C^(nHnFjW7ZGLmVB8|zO+RX;B~z3
z!^%rvW|Y4%tO@n1bvjqQm#=L}
zO!qp~cdqTyH$&x$;lymO(@r0CEV=Gnp$tyS^ExB`sO!@8sTDDqNkv{C$Ud?zxgn`g
zwIr2#oz;AF`O=M`iumEADz6XoKf1c)ro2KEoLuX5uI{62mu{}ENX$$=?e&rMqZ>X4(0^
zCAZBLhL)7;UM-sQKU})qT(NOD<&IZt{`sGld}pgL25Wo0+Um~#eCfNJ6`7gZ2VNgr
z&krv7zP}=?MLXd2N#FUAOWzMwyg#gUnA7gmI41Ir^MRaTo#UJiapTzTJ5vvA$<$4l
zb3xYV6nR&2Ah$&~c}}OMaeVjPpaa{7buM!*<~L4^{Db^Je(-wHoUXdYN!@=~eV`z7
zz0aIW*2XE3e@r+~*s?xgPIq79^zJ`q9N0CyK6H-7smV3+zf1>;gHxq*tl}p3?*A%3
zuqQKh*&Lg!NgR34e4wNyb>*BMO_Nvmz2*b^hf~+h`82=DH}b!22g-u=>N%I|n*6)}
z`{sd)OucT-XV#{`$UpTTsBF=v&-uKsDWv;PLkA8G>$B%vacT~W>~*fJ4o=IPb5-0N
z(cL?>@wJ0P1Y^ppHyumW(
zhPXx5{pa$^vzZ&N%(*FR(M0}Wt~}SW;rg6gnwI45ADS!A4{x|L=iB_2^^t$ERW=20
z?45JFuEo&(mz$L>nHwL>`Oey6jQp{`vaMy~z?|>^|e-Z|1IO-=flQO?_Q_2
z>d3#F4qgq;kb3ut+iJT1UViY)%#3B;{j#>Y$e+yzueD^X^uDiYYv}&D`QSIh8SA|N
zoZogP@_%dxZv}{JJs`H%jCw_7sPy?^X$YwP~U(82GA
zGqb%PIDOm^IpBQgZt$i&@1MjUcXba;J@m)SO-0^+m3?fD9F!co*RrY9`)`_$FLw_H
z9s1MorYi5h=YM=Pa!7utFF32#`{%lkuXPWtK6F1b>$La(SU&~v&$Im};|_-{%yaa0
zm0ZlVOqg~!d{f>8-x>0Y`O=A=ha+0^Ci}W2Tr9Lq3_cwBFwezzrs-m_^tq_RvW58~
zUw8Ax63cUI4o7Xu_wk)&yI3ZDKJoC<*8BipkN%65mgh4Mzwt0X)K}!(RW1EZ!Qtf#
zO;TU6q^rjAn~KA4Z89zMmB_p5q?76ouV^)`^z}^WYOqXdIsDE;(>mYTrmi#67kUn_
zT3Dd=^)h#zv%GNY@S05pI^Q|At|sY=_YbdaElBtE?(b@|yf}PV@vtD<*T?x%hjj9|
zBg%z4@_cqyds&z-HuV2EY%a$p@N8%susPdg>x^z`K
zHR_0FVPUPWzxmQN%hWYT5;ql|_MLCLbVE8V@knxOVWaPY{!6zl(=v}}9~QRz1~_-$
zmA+JPWc|XO7T-Wg_dUx?6-V@&c3$xfl6Ut>r`I1bwC=p_8=TPngJpWlk&O>`-tk>%
z>i$Xka?cUt!d<<-A?EI%Eid0XlDTQu1K;1;x(B7N+&_}lx@*Aq)&A}g%PYf2-ha5u
zVQ#3iWsJ;a+|itcMUHdBB$lyOmuW}0Y$}>CcahxUBy;sVn%i15d2V=uWxUlj_~`bB
zMJ{s}n=BJ$Gop^>FDw?#jWAm#S!b*{TCl0uXYLZ4Ws1x#@n~UdalqWje#>;LTjtSS
z4~s+RN}a8)vY7=(ix=*e&Xq~5?$((VNB3;ny=<;rZWYVi>yMVS?p`@JD#7Yyb#FPk
z|KaX+b6+!AePy$Hj+QOlqn^9eZ1uO!x^=W-(;nU2*KO87naBO3m92Zy=f2Tz4Y7I*
zA3gMNPxjnp&bBa_Xk1P8!o7KOmrHCBR?)PYBb)XX&3#jDlgY%MH8rh!OXt3oU|VVx
z2iF{bxVLKV+a}v`nIx*FZedC7+!bcq3aeyIP5q{l({q1kv#pYOCe}2xmNd?Nr{A{L
z>X}({>S0Oy+?CEfO4;m!nllUcS>~>i^r)<}D{9Ve+IMB{YI%=F=2c&Fu65t_xoZ-7
zlC54XHRm7hyEFG)Q_p(YoSvGdh5LKwt~K`fy0Bd8r;%K)vCgYF_T{GXWqt|r%XKpU`eWBx
z%UAj(CR}c?`nMeW=3)6dza-P;GqU+T$8IdFQ2QmDFQ2o{zjf@^rV5>3itTcfY{C6w
zw_7XH{j~j;+pG(QkA45JBHK^r{8@)AVBGP$3lHS^t(Sb(Weu2i{EwRs6#1pfKeNgL
zJ&)gOJy7bWPx$PzH8A-2pB^5l@=G&)c2yP>b-Zt3Wv!pV{Mj{Y(3<1-H&r^E_S<0l
z?1n5j@%Rs|m5qKI`#-yF4bD9NEZ`SYKxzrA&Q
zc+;T=e!sVUJ}7(j{_&C4Lj!*A_kTWOeRcTwKOY`)n3wH*WsE#@T)7+tvX=d_Wmo=ZQ+@<&yG}w&dYPY>MCDcP&+Z?uykI&
zRdyP
z=FKaXd=X)jPOEdvI$AVuxBLs4T;^Hl-gdNf-kyXnmfB>&bsi%}tLE)BeX(3FkE#=g
z)YQ%^F@LebCSOzMnN@Ro-agwGtK?CMbzW^Xjq~>Rf3emUm09OKQqw-K)cH%L{I!BQ
z-;iULd1aC>RkqhE>in{fU71%d|577gT3_eicI^7Rii9tdZA)9~7K|LbGw*=u%k}ct
zd+Gv1j`z;1G=FKZy?(1MIP3U>c?WG@8s%@?uM25AJ}~c4|Cd>|H-_t89XamcU*-H&
zj(piTb67~NqkpyJt6bZ%Y3A^(+6n%L{;I_G<{EQUR-KRkG22&V^0yMrOWW!K{Ezp4RcU)G)BMIrU8sMp
z^R;UE+Xd$3A!ez6o#a}L?d=NlTUq91{$}~LI{Auv^NKd}O8@$VYYny)E#`Md%c7@zduQ0J7^%oE=zRI&+)pnxP|7^n7mu;(p>*GgG
zRQZ2s`ueJTbyU43q@mXTocZf(w$*Fu6SEpl`+sEn`i6W>VtsO3L!+uplXpP6;)f&a(0ZwBRx`}J9Erw05#>HlWL
zrWme&f8>B)nqoSiu0%%yuI%D*pb7
z%C@uV^FQyu5z-SseB#i^+3fjOoNtCjsmC=`hkTef|ElC>M2~t}!;!2Hi{^hJzbT8-
zcsA6ueONmG%Y>Utdo;lf$45S_n*WvQ=JKe7sD`?bbG7rYnQyM>Nm$cRpLOo^{I6{{
zS4AZzHZ-)IYn=a0|IM{MiJ1+jM$Wa*zwUfX8I@Gfa3DryogbM0egCbj9_?_$CnM(_7Tj_EHYZ9q?qo+uqvL|Rl5cZ+bkk0D
zW;IS&@CW&~`BCdVPjUUbdiLe!DKpP=E4TTl2~V_Y-b6^cY%Belya%Zo!{Tx6edv=s9^Kq(!~p
z2lMT7JsWPFyp`3WTksd#?WU-W_fOt#Ye`@5WB={8o{hsNzaMGIUhu&AyN;;zai{Kv
zwB{}NN%CD+Px`b|f6QtvTJTr-ch)GQ=c#*bt)&b8mhj!>9%JySKaI3jE%>|XyQ@(d
zQK$Ms+G-d4Z2sLk6Hoon*4DV-AN}9m?#awN_2Woe`+@=I
z@9#!!Dme8^J$S!f{}ltjV&GQ{{EC5JG4Lw}e#OAA82JCkK&&!NsnDdPsZ#Ws4Jw6F
zr%TerDAP3Blo)NY&XA@O+|(QOX{uzsCM7;erASuBs5L1ng+5K2s*G2~YV>KTnrL_-
zmcIm3M=SNJ7O_Bz#sNiOdAvG0ml6RFG
z;1Ojs+)XoTbt;$ycBo8J&@Cz9Ph%SFo4cToNzxi%BdXL58iY}m6sJ&YVKKNSL_&%-
z7BE1rj6wjpl9sB3yV|%o1(_d!O*SN@X>@=*hBR)nxKu4tAU0YVlL*5`RkR{HRl5Z`G#%W~YvGYJeKG(ASVx=~C^|GLaj8nctRW^1P@zu)G$p}9LW+{*6$Y4=
z;x-xJidQA8Qqq#NxOak91{!W1p3`e!3xF?%X%&Nz$4s&&CRJ-k6TXA1MjL>vxH@uH
zQj#VfX~JPy_Jt@I(rbUzwVwiPOYr00iOlz$CGXjj0;oL4=I_)TSgE$u9ycJ{?bK
zNqz&yQ}l7#)MP?lReG8#6^V>H26%+Ov`J|?Z4z+17OqpyQpU%ps^S4{^kQt>EUJZ(ZVoe~#RQ_;hh*PAG8pKtbO2mwA8ZX9c;+4_HG?iWf^r3$dGAd&b
zB76dM2t_`qq-0epVHCkLN!oZary&J!L@1?9VT?nmj#G`+ZHX$Q0;i2tBx$vY20%E{
zQITu_SYcH=Doh6awAfUGCN>7_SfW}BvM(USMr~?h
zl2#c@bYPq^2Jncu#yQB68$rvEcT{Ofs-!f4np_5}qSdR@lsdsw90dPP(!k~!KyMjA
zC;@94C5WLOY+$ldq1Ax^a$7
za~Z2jgQbzbIk-7k*;Ke;_9x{FP@#+pXxc#2Q?Ay=DfGZd>`_e$YDg_8KM;|aM4%by
z96*XLNtuG?LvKK*1*WpwGZmm+YgB=zi%r&~pld>yh;E2DA;3tnXq1GQWjqdqOID(}
zNI}9Wv|!WJuv-c|AtO&9V0a{}z&(ipFCj&hhIq1TUiCP2-+%Esn^G75`hxL*GlEz(t~12Bw!^e6R88K)96%5u&i=Jsuu17H>PQ$6F{Je
zx5_E7I8eeG>PI81D^zjlxuKUtW`os0Rzctt0Dr9A52S3zuBIwCQj`e2@@Nf@1|CVg
z+}P&^*PdFG7{E$Ok}eI{2YoDjLX(X6z`uAuI2MGRcL)`B>EWy|dlP*DoiR-feiT`X
zlMUqnJGa`alnH6sTJ}{qzQ4X<#2sN(=@ixDTyP
zACLNuXg;{JK^bGT+pzdpU205>PDx;-?$ao22}KA(5aK1Kg2#~x<^}Z2qi#QCY&~Et
zR)y*s0}*28^=WbGiH2xZ988#;g#SkW3|v2S2hqHMpKbu*LX%*JPJjdO86gTGupz8q
z)0lqb=t{g`J^B)ldWHxNA?D~M71Uhf>Qo%)M8d@?_1T>~Hqq>(91
zyh{9MPy}EW2>e_SO5R`)k-vf4L2rZFh%qVwa&Xnr`vuP#NeDZ)Lq^EJR)kmRPr*j1
z7AJcqa8lDIfR>MhaVz>!>X^mc^cA~$VT{alBfZP%KmrNTT_FE4ptiw;GqPaT0sIOQ
z6mz$zdPl{nQ$oN8JJX~Se=E5H|_+Xa6Jc1Y&4
zXAD4Nqm|Ua|3SGxpaJn3=u$0cd1PJim;l@KFUE^#`XDR<&ShT7<%tV-I9K~&8gC>>eQ6{7`{{glJ$S=r#
zP!G$V7>QP4tRrN@IMBVQX84Ru4C)gj1Unc-kpKZuLM$uILg*ozp*KVW=V=IgQt3-J
zf@%fjm0whyJ|s~*XaI_7eGELIOa_5U)abwz5`U0FrAdL95*<&wZH0-H$;u3%x89FJ6P=`u
z=Jh?Mq5yE@gt7&`hVccM&ry1Z87$`8aAF~}mjw417BLMa*fTO8%Pw+O&MpC5P7?h`
zo+bkN>4AX!b)HsWogX0yAYdYaxl}p`Qh-iL
zKzz^<9NsAW^bugVm_)F{B;Dx;^Pq!4=p$61EUtqa1cmff5Sdse0xF``Y2ak!7@gAv
z7^`sd&(Ri)IB^{j*jSJl>R56FR)A1(;VK_e^WhRVM?BLk68U07ZmS4K>8vCgQVl}1g(M`Xa$aA|`w$pD&%?nlUBOzst
zxHljh2OZfw%mUFUWCTgJo?0tTjp%_2KxK{=qa>6|dn;7135ZcuafT#e(8cXfm_7iX
zo>+j<*Fa*xXU5D0=zhrbPzL7bVnB@6EisTsLN&`LFgTSVR8`4f(h+``5G+wV_+wg@
z)#inHL$W>&bu_xoEMi4h9=tRb?!kN7)OckI%lLr&CxQ(GPao2~RELp#J4+uy!WlF<
zCgHin3JLIp87t1bL2^$E29=jdyUGVcjqwbuOjtP8Kr%E$WrB&OD8e|NEf0Y$I2fr$
zNLH$0QwU9#sl(B)H+Q&CxXvV3L$LO+2U(qPpyrEJ1t$^T2e;
zAim1@6o|cG52VDSNY;#Anzu)o&&5htJVzyJQ|%BkXM-{<_Yl(zkQ)&isWBC)1ycu#0Q_zaIiyGha+ZRzFxYy~&vA+bZ8T_XPWN+j
z@;oC{K0N;ji%6Vp#)u0E6s?H^H+fS^nUiN#n*mBOWX
zYONL>Plyn}&fB>f>yn(N1QnVLr4dRuK?zVmexghT#$Aa;J;)p~N5>vj($_gAx5vQ9
z?r1;AL^LI)fGq9CG&NP=03zr|yF1BlvLKb4F9}lPP>Mu10RknG*#tEb4VWeQdN8@P
z#0**>UZn+j1!;)kigXk-$PA#4utSD>0Otz$ART8na#-Q)B5|isUr^!+UPK+nm$_N|
z0u!JQNu&+|<@wJZr^Z4c0aa1X(*Yq(9OZ-Z@*pry1Z`Yyf*CL#DXd7!T~;MS+Jn3R
zi=(n9^+_JJ0>lXM1zv?}BLN#@miWP6{Z)#%15K|VC7Ohup=`l(9!@UHDEFUl(-V_XK{OG
zI-QeQse(kg5EaDbp_+oT5}?z7ZCO2oi4}H^
zhCvY<{fPbmieMfWLOKkkxs(!!5eON~Lc{|TyUdV}cGaksP=M7(_#tO6NV$ya4;Xc4
zBlHEp0<_G{;gA)hIE;h&wC{~B4(%A6vjdQFDvE@cxK-wG@jGW2P(Y;&Z86EA0QN@d
zH(~k$!z#EB=@b&rqxQmtX&OW`Lc7672skOYfJqRk0C&Zx!C{8vJyw+(z6(x!;yUOe0~;DhV+jqUnD@zXHApo{>hd*isw4RlBIN{*n6a3&
zB?)eTm24LbFi5sq1CxMGq^U5@UuUQm_Nx#Pg)PKrp*Rov33teATs{sYBuD`~>sci8
zNMtO?cynwaECun`XyP0eGe95*SvycPP_PA(0zH30K8V>S)qn86m>AeC2oI{AV$iAp
zm7RD58MFuHRN1e8#~~1G31yB03@sNIQ==xO2{syaAre6&(}0Wpcmy5xG!OXZD)bw%
zt%M8y9$|C4V^7T^^_TdK3B4THeM~FzGjFi%Znufmv#axKz
zxaWWdF^Qxmj1-{}9h4fu0)pL3HIjrOrm8{lFb4z)Pq4BriKM>9WRh7
zgF*=*2b#cmHRk6O(WIOduLYfk;+a*ZY7I=P_i}*03Vn@Pth{U{RX~(XVkO?
zMO+G+1mY|TtS5XPN)f+sM>ec_?Py7QfmwIV?kl+B5kY#K7EBwY+~d_+
zXi`I~g%U%AfVKhJyUplty-t&;f)o$*F~D2&bx=XPzXa4&=o3{NK?UFg3DE3E8<@zP
zq%vina&&W%k>BrG-y{om1k=+Pjiu@c
z+KDrj25t{sjzUam1gT@1FoH+GzgJ@t8PQEwBa>2cuw@8d8NnFS#()lpCLK}0otS`u
zhMEmV$ZjCPaawv6wr)_t)OsZ<7#R6R51L&`Qf3$x1RPj{^)f=g7vEJ0DvP8X1mz^s
z+J{z$kb>eC>=za<>?KulPk=oFJpU$mAV^x=IxSA=AoSM24_3Bd?o5cVK;L9q1~QoOXf>e$xjqP~m0*
zca&z`kj4fTX$eHDLv$@|sOBGKB(=j!P{rRS^#r$Bdr%8RD12ZsyWqe0+h=w-kr6%vp$
z9{|)3snMZE!7gi1^)Z^%7z6a~povR_LZ%j)!xhmG%t12+fP{tUSnN5%#{tbyMkS6q
z5g`Z@Ao;6FQzw&0A&G*?T>zKAPAndYa^O``AEq5nc6@+lQ$h`qW@T6g2LdCy07`9q^f9O%psL?bg|i--T_|3Zf10G5BAVcc_`fs2}An5^jK|Bej2%8Hxlni_Ugh5vWpA>qEoaKl%
zU=J}s!CfHW5@G_n0eR@A3vLr&!S{q7Fa91;BYIK}fDjq;+faZW#f%D?x*}&FxP=58
z1?UL!p`^l+Bw$Aq;S%&vg0BE&1oX=`UC38h$bc(s&`;^Mm+?nE9{0Ng+u}GVgwIr
zi*xAEx?_SO1~M_wun0RDH3@JfX(8o#izq6=`ob~@an+*1M~egDa(5|?A^N9hZXUrMreJwKR*Z5R
zI7uRQ0pm4b-z58CBJNBMxk^M)cmLMF-|
z@GLar5bS_Q3|?Xy52_s){OeI51e66%f0S!D8jsSIplKjiNV*aPZ`-RQU@g#PRM3{4
zQBw*;NuQ#GO$sg`E+Owp6op`3D2|Yd0l99snR<0>s)8J2LG22mBb;mkzXO73`_))5
z%wRlXNZtT7A5B<*iO{DBS0K_45cAV1xKkXUv+VT*bjxBOpJCrAc%yoCv=+KxP`z*p
zQm_w3oY4m;pg)Xr95Kh1ss%4z@VZ@HBFOgT8SwTCqcLlTikLz!MGl4&c7}CZa=r?T
zExW_JSmbf8#z9i&yzp`yNIeBrI>)Q$_DUQ%Sf(Sv6FdI^wLo-V
zfVRY~gvC(}7<51b2(j#$<}?Zu_8>yY4jV>zL5b;wo{0i{D)0(vTRLkadjxA7@o?Yh
zn{fhY&|%R4+f*RG2%!@stBAtnCL_}b7Ux%?Q1Cya4e^k>R&K<+BK2~hbuETe1vvz>
z>-K{d+yz1<)GL_dw(}@X#8rL3am15rkOv{DetUl`Z|Wdd1V$ZfE23W!i*3Bz!f+Z-
zL0OD!gcu)4-y=q2s8>(!g0q9sEg=v@yMQQg1hEeYg&O@^=$_*)GeLv=fdP1cVcMZvH_6}WC!sWBH#j}AHmG%V%ysYX)#c6+n!CQ
zIc=eY2&96q)fu2bfC)fAA$J>RBc42qOgM%VupozR)I2~D>?Kf)-tr@rYLsH|+a8r-
z`$jK>y7C`peKGA<3s2qX+TAq9wda!m3sNW&LUBJJ?eM~DJr
z+J&lTwqbfL1Kd_#Trs0Oy6t5Yp{Oq4PzWk@w0YY;^Q=_$=KqFe{;wHM^+7i7wT`0f%yx>X7Rs>I;PF2_IEf&<Nq!Cxg#01N534h{ks33wDV*#bQakc{MPm!NWrEJ>ms)F13VA`W;+kS+kr
zhG{ks>1dT&xk1CXoAGrnPE-Ah76id#{k?pQ3{5R)C=q?o_{aB4LDXZ{bBq+QxftLi
zGyq&DoZuN=8OFhJ9<(RGqtNiCHo){$1CEkx8#JmFQVL7<)7UEwr)*Hc}s*1cz<#OgY#wNSvV;lgx1uAeNo?$5bq)y;(||a@ixz
zZ%}Q(j0l7++&L>~tb!bl4nuRAmIY@P6j>T%TR;FYPr-HyMQ|PHbrL$$y&E9(##U19
zY8t7x5Way+PFH_jbBWUyCw!o<#IxDw0tI>cGvm<(RX`00uf^#X4>=ujp2ncZ|
zfW*-12JM&FFk;V_(%A)yiSHkzrwrg|Hi{b|0kmw<39+6CID#obqm!yLLlVa#OvK?i
zXuT%E1E2FIVK-l(A)1+q0xZKCF|Zm*a1R^=T!tdSXu)|9$kmu}5RebhtxvO`8?*QR
zfl47~#zFL;9IgW&Nh>Tdv)m#E4xE~zUK7BcrX{5<_zQgo!9>(Q1ItBnGsL0TKy(D
zzz9-W0K%Zh4UWTd6F-VMdI4)S4|lgn-Zu4BldLJ|PO86z<0*c%6k
zBOOk7ndnfO#3d;rk{pJ(2-1M)Wuh5|t&?DsIv9M_60JJdI*VZ{cj|yF20RHfb4N3<
zWs~6FZY1IJ3+Se!b77wcO^G%cWSw-XqhC!b%j`jt!J!Rm1TLz=xeOKolNuv(SDA_@
z;efQT4VZRR%|);z-#1IZhL8lTHy98iE8twy{1!QW0chZ2Ts)EpG6G>JG_OQMl@{DO
zsCf&g4-z6CEcxdP_J4RH<+7#WtS1o5W_e`2v)5KTU&jkzC!iQs*xv(nr@PtQ^5
zBB*uzEAC)Vpa%dm2rf)NszN`i+lTAR!HlA?KZsq2A`VFj32$)*8$PFirxvhbg`8$!
z15Be*aVZ;eoY;e4B#6s
zq#(osIDSo-7fcL@q7bpdd=}Rl$dg4Gn*a_CnjeNhXl()uhha0s4cL^K))gX!|wa=d*YYlo+4X-z9ufNQrUA!)Dyzf!CdZ^Q3xUnI|~ptVjvR<
z;l7&yIOQitZxf*8TGOx?z`R;c3-QO3?Cm|=IWg{8=(gujL|aH$lRvJCNkx7&rVtfm
zRR%LhHRg2LPjLx*vPYeU5cDw%$g}CEk3>HlI(W{5DpMi)wnbN4V=z==eA2V(^i|E!qK828>OF({RXx+i}pE1Cq&J@<@UMzJ}u`7{l
zFeSbPgPPT{~IIXFsz%9S{=LVS}Z4M?Prv>+F62|g79VF1Z!fony(dZ60?RU3sF%?Ly-
zLf+=)z`74Y0ma=!S{$LLp0GRtNWh8-ap*yDU`Y@D$N98m6MXK12Nu52Bt!`Q;$Hc;
zDMNx(0khy)AL?7!rzBW~95rD3QKYi-BOq@CXZlfX%8=avMB}HESb~{rE#-C($cOL^
zI*tSYKt3MwOHjAx(k=weJbE92%Fb`tEe1tvILrcC&Jc?pEPB?6G_c^nIrc}vnIvJ&
z=!^FAu~a+oOb>-2XicK|JUl5tP6aaUMGxVzViK|?ureg*Ax%ijBtSPz{DZxXw|`0l
zzeE9w0*WjgX}F3Hxnl=Zh!%9%7-!qPY32jND)hS>=(aLf9UW((*BxyD%7WO^c6eGH
z2YT3_VRPA62OYwldn1}1bQzH>X2gI%1U{fK*&SB>DHE`#?IAhoD1GDrD!IlB8=#ex
zv(y^&$`A(9+d#2Iuo8=1w(Lv_6J5NH?hT&dpcz9d`GAEaJUmZa8?-Y5Wd(#}$)HDW
zfRJ*2I_K8Yq%hb>PV2$9NC@b|F=C8x0ua;usq|xJpwQ?8EHa^`p>%uDNV*jHE+PAM4hZh30r9Fp
z?1`;_Wd*vCeo7|+GS(=D;MgF*B!&lVju`^j_#+BKfL~$w1W$2EB;JxxKp1;*kqTgk
z0Y;e#Vy>(RkEDnNz_KOno1qtk&PHl%bTWVHnh-|~-y|a<
zfe|vf!oK-{G{@ith|T-Wa2R!c638F?8{|7ijrU1oE;U9l1AOfX+8W?{Sx}0{c4@4o
zu&;gMM2y8Lqfuo@nI4*eq2NT1GcW)^wE%0u#sxG(O5&Gepw$LCb};!*mZwYtyza
z!QKWhg%}3h6-X4ICt3?yEfqBe>MC@aaNn#Rii^$jO@doWJSOxBp8#yWg@nlg94Gn@X78F
zP7vH4^^rIDTo*ld0(k*QU$Ji=+RY18l|Y#3N3J*|a+oDR^No=l(2#J19zM`mKqnqH
zp>h!tk2hZYocJbl0N9KS7@}qXD;#zSfhn5{5Y2-F91kft1^|z5j)Ax@9ud9*Wr4g&
zd6=UNp(H^a`3pz~_5oVDk-(#sC6#}&9^-n-I`(juw8H?WVM1V(km8caLcmFQnvo+C
zauWA9&=Yu`bWhNVB)P#6AK7LULQsrB`gnGOTrHMn=+~a&G%D2gKxa5bfuAy9vd+HF
z3QJK_NTO8Q_sVw~>($0s>ZFrPyj8_52+bUdBV`PBl$m@s3={aY<(##tL%HeE`}$ZY
z;t;o;bQ|LdF-m#}9Z52o^h=UGfaHTCM*Amek`DHaTem0efL7r6uyiC?0ibJ|ntg}~
zEdxCm$@rb=GNwlCM-L#3R3<}Y08O!Yx>Ktm3X83a?h;5c$q^FX5xAHTV1wt5>PZvJ
zDlO122QQ#$$A@X!+U_yf(`aNWq0?v-P;fNo5khlC5hbV!Of>=(DW*5#Nnm1J+4fSj
zqkcj}i{Ch6PYZg16x^5jKx_ni#5f4hz_S)ZCUi+7f1$yJuT){I3ba9SVpED{2diHK`r$uHy&
zE)~d`75ljgKp=#qnAF9LD8UXBEnp}3d<*v`ffqOh3n_XvIy77ZAD7DK5=i#!DP?EE
zLANb)1;P-%d`40}~5gAxVFxy|gH3x+X79n-Te7?gpk1r{d~kJwJ8ge0qQ
z++}A+=-C$h5a@K4oaNiS2pQIC&%e8$?s^Qcjnq4iYi@;F|EisC}eARrt+M
z`jI5|0Hoo-SRm*e>Gh`zi*RZcQkjgJiM#@CMWmxVWbxn=m=h0$H0L!FEJvg*B
z>bKAe3u5!pT{gT2OUX(lRuBLYB*M`HLoy^fkc&C=Pp5*Iq<~m;u2YG<=dx
zrApL;kpnBo#iQua@jp`OsT-=W`K$PGG-6&kWdw;P(vHX!J_VB`grIR}14x$8>7r0@
zEo&6^IH*yfQDWcT1LESR`M_4gDH@PSG9ih1Aem=?$+V+kruAZo^+@WEhm&4Lf1XRT
zVpcVsuvI!Xwh^^KdQerV_&2!*_JsF(FmfXLJB?}>Bbdu{Jg}!Hf6?y*@aqv(N**M<
z!1NKhKv|hO5a3MluYd#sIl)eI>W$Oe=-px8lR!xdZt?G{jc{%bzIyd68R1iUc
zN$$?xc0)ogD5*msm;`wg1acK?wh#hC_K?P9$WY{bBmL5%0(TRQ58;3Kf+&0pnp=~LGO`B
z_h{4gC|Yq5ZVDbQ9#r5q=vzW*rP3sTk_px*8%xv=;Y|t&BPQ^%eb02e=nFucg>7FD
zPmbDgG&GQb;T?62-;x~^5F-B5GSt`-4&nZL9s;9**~COWJPaSDU`MI}e<_#}0KbhA
z2dyhG2Yw7*J1+rP5Dtu!L=a?4$rJ2@lPM})ngMzz;2aQq*%X^oXlfF?EZY7@g&DLh
z$H?F{7$E0}2~+Z&Bna&(?{f*Kcn}|5{Q9I(krh6I;?Ji7DE{eho;-9W`wC=YIyRS)ZBGu)gY{R&vo$#+r^d!{z3>WfNf`YSz%7P53eakF
zAMjNj_!<$kA3`S`QP1j+Xamcj
zpdvqkKa~h44B)G=@I5lRfh0VQZRcn^+Gr@qu@pi`@XxW7f(Sd(fK>$G2BaxR^8s)V
z_a#Cik>uOkLX7XQ?Uh`0!=!jaZm6Ed`kr!
zK?rJqa8L*(84basWKZO`!1NQ6QntYFQ9yxJ031$7IT6DcmbNCiT%SZ7NVXqhVE|ro
zjFmN_3S0`f0~FI~xT&~Jfl0nQp0
z+8|ixGV_2lR;#s#XLL~n0eW9ZJi-6BYFPY^p$7?t*;iyo`+(fG$QM~rp8p?vZyx38
zb=G%wOWoFDS(apZ#RgekFtP*k8euy`7B&$c19AXk2OnCsbyam&b(L!A?rI39CCe)g
zh=W6H#>~hbW+H;gF$2RAJU)_fh!Z>+Wgr>Hkcn;cT>uizYU1D)v+tfyAUr6%^FN&9DHqAJhj+g*UaRGIp
z#W?e4dFdYYAjElZRSGQ2@{zLsN$R$HWEMLdgT3_4uEB@O?*Ga~q>?Au#Oy3GMNvGg
z2lzs3DJC4O5SV+Jz@5lLwudoBF%zO$6G?e+bN*O34{W85(D5>0N97(9g
z`CT@vnRTw&MNFz#LGY9zvaY;*(JX>eDRn}ZSZV|lRPhf!MSwSyyA~@0WzkH)M>7~y
zxPWN;o-eo9|3M7v4+|p9tg%zy(&L6CTxnzg@q{mHRZyr*e;GUM)tvyAD>R0j+zsL_5Xm7#ng?}!67
z&u#PUEkd&x8BGVzS36yJ5a(X8X?O~WFr*pLt>%2GA_j+c!&-<<0z3QsbNWe`dBYr>
zOzyPu3hq@(Ps?D*wxeql+9jWlm-WpCf`qXrBM?OXo=1yQ_s)znjb3zVhJUlL{4W;L{dvkup;JZc!zg`aH5PNIa1?7grt~aVFvvF
z^zQQF#wJpu9UYiw*6A5-w*-Ekjc7u%GZhQp*%tYuRh__gaqQ!5#jBc~>#Mmz6id~Y
zYl~G-n|WQRTG`ZNL4Gfgc-3{$=Ykpd_p9Ba>CW3C(0iruwJCCqQi10YIIDriyGFh&B^fqN4*iv$3g-ZNBB+_#BOLLM2jL6G;Mz%or8Y;=zx}R1$EFY1-&lRxvo@rOKqNvPXDYAlA;C|
z<{Up!stzP<5N(0>8C43TAh76@QcStx$&pF#FID2ca!p#ia(V~0C@q&HcX?8fK+DM1
zlj^R>k$=;Ek+Kux09eb+Y`PUsrVTkcu~5JS#B7|jC8$_uJyDo}P{7P~o?D7vo%fe3
z6K2Y~l9rI!*l7u^VV9$bo!QFYQqWG@_my$y>B4{uxsK{Lc%p#;$UC*BT?hp+It%2m
zo+v4FD)$i1HU7lD^Z7UfIZ2n*;ZQkqx^^Xe%>*ZKl
zyr#9WJ5Zi*#-L-S5f1jReo|Pz!6z#>Q7&}gBD!q(R?eAMcAbVrt%gY2J$6AS1)@wT
zh9-nyIVB<%(N@MOa~W3G?CV4U#abMTEbPBy?L(I^86-m)xI5Hf_J6`8#MgWHVyz
z7BY@kTUaYh)eO_L{bV(*Yvx>K}xDnYT_H)FO#Af}1Ij~vDknq^V
zbX7-P!j`a(=XxkqAMK|@Fvp#H)e_Cy{FnPR)s=V*T9MIJ-IXNs$@-q|8k7rBi1&$qPXLK;co-FwLL5
zv(O>VvcYorG24dEtF^xp9wF7(uWVSlJQEw(?3XP6!l{r~lRPDmjgk~9LG<_{KkJbXa3v%gzBGYDl
zYfzaivh;;9l|@rtWci`speP=Yi9lA!6TEJTgVo@Q+tfWT%C&(0F>$#jF6$0Xnf7is
zbthxn={01o#DIWE6HYnV{TtypJ>&)PORCJaZEyaNK8Ro$!%okrDYj=fI5&zmv2hYx
zd;*uLPXEnQ)=ofQXGma6dyP6@1+o~ZWzD}Ae
z6LH|QHk4INJ350PTO`9p740ff5<1b=A!1?KdR_Jgw+Kglow>+G#rsy3*|ULsY!+91
zBs{!94>bMH#Lf1{5d9lHXFwAHfxkiG0$*@M)K>P98OshF<_fXymtV%v1^=FAEbv!q15=xs4WWzBCD#(Qrivqa*er^3EqW=shLV?U2JSzL1dw
zWD&C|c7v`6IuA}0_DyM0v>o6)wbgjj17;Tsu?EOGT?V$el0PQ1jUa^eYvC?sYGK(W
zA!HUBTOY2z;-#c0!GN%kIoxVvVVMYXuqt!H(GCe&YWB|lW`c1yDWSDlIthR#n8h&U
zBFmqB7iZ0E4)xP9<#rFrGK+i(oLt#^Ay{q9LM0Y6Jo?KW7nNlm(CswMv@M3%4S#p_B
z<+D-fZ@4^7MnSL6V_2K_iO86;Gg1Dd?KdBe^1_01Y2^yD=-X&m5?B4i8IC}ZGfbL&
zxp%`i?}kcPCf0WCmk4@kBxZDalPSRrp$3(b;{{?d!xjdVH><8^hzhpG^Y=l+yFX0fp>s^(f!o#!GKHv0{pt=lOmNc^>@BYA+koCnjLW5o1!O2gCV+8jdP7ExL
zw=)bp>|y1hD-!_`KopC>uumFG?5`Ujh5)(+6No2=!KLVB)>U9{=!eSR)X|ITVb6*%
zuA0}ghOtwyKaOs2x
z%{+|)SVqv02&b18+3rQ2Gz-M!Wuo-Rhz|AjyziySQ+-uPdW#LTMokl8H-D>X$-l1*
z@DmEr!^D&^#nx&|oITKTIO2sg0#TZ#WWN6B}p$Rysd_!M}hScJ2LC}_}5=V>PXnQ^9U&gdzhT14WF
za!4yk3E1U$%_)$QNNb10S(Gg%eWYSmX>?HqC>TKc>?xk|<=yOzft&ZBR65D#07`ym
zGBeO4ax(`Am2)z-J#Gj~rq_pDk2W>*S&Yvqd=l%tbI33mR2I_m=AiBpf0l)V6|0D(
zO4-99gZ1uoulyAtSB8b%oq|Zhzpz>XmVkm4M<*Y}y8TAYQFKkKA3kdO7hkBQJ
zw|?^pl3=nk7P}fVj&;unhGFqbyJu_ap34*kBnb!fB<0Id;T)S&o+kV7pI0dwzRx%f
z0+KPL2tYxrk>AD9x5nC7Ym6jzvakICp&JSv`J(Y3t
zA<1|cC4*)pb0s$*o*R_mtnu%|1`>86H9kZbBx9Q_q2wS0F1Lq4(v3P;hNUa
zplHEDu<1DXK{dv7mpO+g0bj$yrhDH@n{G}}_>I7}NSI#AhBPlys!^Y+Ct=5Wsp}K$
zDy|CK4)=|?G?t3~(GceB0|VLL(J2I?dI=*R9zSKHEi7tH6uk#pI#@ZiX!V@LAS!F|
zjytN3^mpWs3D0ervQS~U$HVG7k7WO8*GcUpcE6_qlCrR57-BzYm`-D*s@-(ohn+O3$#3u692O
zvmhg4vt}euSp|wZxQ5ZCkAp*lL;^-G`4HmdHZVrOrPgq0%ZM{^FgmyK3Rl;P2*6Cg
zML@Bfpwoa}7(r&%Hq%2>MjgWB3+V}m5Z9|8UEjnX*b%|U$<8#WT;xy}QnA@6AESaJ
zR=tY{BfA7cs{eo&u2dKKwYImnZ8!Dyp0aKb=!p<^e$<1>avQ;lHRe4FCOalF!crcL
z!#Bk3#Si6J0&CKDF%1Se<1+3?+GQnSHu(TsT;AXuR+RoRUd*byzvOXmw1kiv79L7f
z8*4d;TE1$AH=>AlO`29Q)OuMhnm|`i7oh;3%JYV%eUJNfMN@<|4aJr1(xBR&1Kh|Z
zkW}@7J8~c|f$2C}v@S_FTrSu^oA)TqiSD5Hkf99Axre@P*oe#21uZXRZ9)cu7cRK(
zRuV309$7;aVP(3Uqev{)vU;c;8sp$yrU0!;TGSIr4R(+vrxO}>T(>G>8gU8IN$kr?
ze*)B}mVx+zm-VL|R!NfL>eC&K5hW*JLrNDCxJRlFe^(dn3#U&n;Niu|3=T_`IzcX8
zz0CCO+p88zs00+5bn@y9(M4M2vvYz?qd~V%V)NwJpY$(k0o9;kCY|bC>Pd;~soNGI
z*kl*&$TpG!(=IfeXcQQID7xy@ay-iKn>1=&&xk3pDZ~yQ4XsAZjJMAzI`*Tc
zU}BSGI}DSkWn`Ada3_$Q*cO^Uf!EN9BmC8K;)|#(lyo
zq?xp?C5Qp)Q9P@N@sil!$~rH|jYF1Q(-!h_VS^4AYPw;WN6Go}&LZmC(rI@!;B4Ua
zWukaNr36+vBXd*nRl))d(wR~nzayx~)IvZd?51F8^IO>%kvn5iTEuC
z3)AtHZV6a`uC|kRDcy>b*ij~`PepUAz+~~c(D22T{a720a
zD2|v#=O9b+3q-dHy
zfH|&8J;V_>T#o=7QM9l@tdwZF1kmmH?H*?DVy2wfqa9pQJ{{>9PKBi{HVJHze<{4)
zt$c%)x)VUb8C>`pwu#biPU7-Z0`&RTmn&15Yhih^h|2d;Y*L^eVI2v;QA4qq;uF-O
zfm>LbJKF(ppqVx$Qc2+AF@
zk%Z))D4sXWkYh_4!P46(c!-2|#py@VmiZ{o&Nj&L6zKq7(&Mr7A{I^>jeO(4!=%P`
zci8t`9oVW5u&noN3~DWVO|NG|b6AGsV1N7B{l*Hi{!ikw8X0G@458o5TNE|gni
zH_-ZiP#C&ktq<92hh0KZ3TVl&k;63@@_e|EkA?dxuDWbX`cZ?f3U>5Bu8hRX3eQ4_5^Dk+4~$C3e(
zfmBvqNZC@^Dw;5j59i4~MFFsTlu49k#n<=@|F%MN^h%n9IL4wy5)169jBFY
z9S(OYND;@fr^7S#v(oBzl?D|{V@g#D-3H9CT$~vP;~@V+?nN<8j1H766ey@BNMt}>
zb^w<`Zm1u$<2uN7Pz=x^Bkw`Ra44L9I@42?agz^3Uh*D7u6Z5`e4;x(xVc@eb1`s;
zJ?cN|M&Rt?tmULG!r3
z28mr_^0vns)H*yv6Hc~gUggo1qm&Xo;U%Y}+U*0g-cEP|!$XrEraS@aaD6oMYGagY
zOcE5frbdLLktr_is_l@LaUxnhON2PNAUZjP6=1xxX_7+*z$iCDs)>W6oH(Rnc2qpS
z)>({uiSu(H)Xs3)40MtO(bvf30d4i1vm{)cfqsx5`~L>h;}%kb*0K%@4~_t4qT})th_cg
z8v-Z{kf*&!k_cc{n}Z61JPG)-6MZFZ(tT4f94nbexosoB_;&Y*ldVaRgTB9qgtQ6+
zah@tlVx!*2p_r6nwaQkgtS6vBX8YFa0V7G#h=1ae4_79^uRQ}Z=s%Qa*X{%;fT74z
z#5R0ENw0$+%~o4QN)RcSZ)m~U9%qe5Jyt}-`hX~6ksdiavNG~9gRRdE=GM$tVt)Cl
z>Ux(Yf)G0$+eK54`;kHeHXuteV!$HH#tlk42tD6*1r}JL$IJkxoLBM@)fW_+7^6G&
zjHrS`ml&Z~Q=ap!eRV?$uUKqM`a?umq_hqgLp=yd>|C;UTpj0Ybh4gkKPpG5`iHc1
zkTxmEpV-w1`g*e<0MV%pf`3kAU_H?0+2@~$kjVohalN1W=2+|~DVh_76_olJ+t4;J
zN33s7H!03{B4ng;6M}~7j*_-ix~_}u4yOYp2tvvY2mG;E4-=mlRrtCig+xV(-ULCM
zF(5HXF|=s9Zqxn^4WlNa~_j`AAwdru!k|fbXt`O
z@&@o7E7gHQ%($kh6Xe5tV2(>;7@4EoZcC`|pGN3~dMD7zRK8L#pRX$`Z!}L%;t5v*I>3SIm
z767=Ez8LlvwG}xp4A^8Z#B*t2OeafpcKvI6R)^aioKk~ii91UT5`<|gd}37%YWl9n
z#2M4gPG&rF+IFOAdU+lFD>K({E7M}!uUUCo+Cw3M+6YZiazcNp?G~oizr90F5J{?=
z`q;opQb?w)7#g)TH=4zj2u;ZUnxM-f^F;hmHla`hzH>1l+O{bD6Vy>R3uB?&PQ6|*dCJghc>pQE1`daLBf@Pt)uHwo28;VBxRb~f
zWFuPTgFwb*9F9@}Ty>jfRO#Ak3{K%fJ07>z$S(6|xMIk%6%967oy1u(o#*5{f)WUq
zaXKUE(1Vu4KFj5QNNepd^z{2r(>&X`B$j6hJ^+AAe3on#;%hA}Xye>D_x6Zcw%CTG
zNoE}!x?@_%WwjTyCy@d=^SnnF@ef1B&lpF4_m}lc|8wN76MF(htEwdw!G@3%h(Ki0
z0o|XBpMyMvMRQ?hUZg?sAu(^KwweYyB!B?x7Rl5JRBA#f=+>W@bh+h{
zkwkz<$v=JNnIqRcV|*wZRJ1R3eLgDoVg)ok&_3MTfGikBG2e_
z#FvR6Y;uG+rQ{fyIIJU$y>mqGNDgQR(#))qBfyq$Ovsr;>tJSDNYV~3n6Aa64um$W
zt!=b)n@IY<^d|>vqx1KY1ue$lUdV1#VvC?1Ya43`))<{uT<*}n*^|mHHMz!4rt6B}
z(y)GKjAz2%1l1}Fuza<4J4DmV9^i~DN`^BhAptIe0A^LP9VpXtooL>fo@9=b4>wwI
zp{7k2)ZwJT^GaCZ4XS%!n+azstwOe7PFC~}{KN65l-9a}q6i`A`YzK`R)5CTn!5_g
zg$+Pci;%s=S0sbgMj#!1Ef&$T+#+U+=Srf~rFO@+H`lCe(1B;#v!Uz-J-*Z_vl^E%xcO
z=?=`k1bDj#6Ul@Rkd4q3&8QW*`EDfTs87XHSRdo=|h-J&(c
z0)5b_sNEz2%*_R-mzO!@7DTF%)0vb8NnMSczv(a6LnO7O0Vy8q(bHwEEtX8pNVF-e
zR@l%}WE22wm%`~^d@zus-jmwJO4vkqCz#Hu9_pHJ-=qdDlYEvu1T5n7@<{6BTSw2XPg0ZVpnIsUQWi~~Q2?JAH2h(av1(ZH_KC|1hTDSE9
ziI_nj%RMMS*BF^RcEuoa9r-ot8KTXEvvZ~n?*_NoR
z4eFjWIx3x@)H_-SJ_$fjsNL=hN+NB6ko-W*UFfY4Ltdop&cXATN<88|=2F9ul9+_P
z$UdlvnE$3Dw7d&}r4oTnZ4nO04p#oW{8ow?r9U=??peLq$(W3!NFZHKFE1YKQ_fC_
z9Y;CF=w4xSfwHzv)1FnPf5|`Pj0Jb167*+(CV_U7b21Z^1GjSz<8>0!-Ft$rX&V(z
zMCsaJT~qu~*`K~sDBQu&gRV?BX}G^=i%F`1zY?AG1yvs(2@n%^eAl9!Yk@8CMs3_s
za^)M0A=yr0^Mn*Z!s0bpTg3#tK{fLcy3#YiV?lj$`IVddJN*C4qwg#vv8^sh`fw}b
zah|xcthpGtlggYBx1_PFJ^X|cPE>oXAKu7BKiR`skeXL8jP#I-e(CeykWc8S>#R2K
z1TByr%-E1X(}s*V4Xr7fmARFn_HqDDo*6!;uZ&PJ5%i(`sT@kT>MJd7r;FiTsI8ey
z7PN>F$8SWVfy`o8H)UZ!KXN;MTDa-+{>gH?Ehlv34WWS`p|aXwvIYxNT;QPJEb7&|
zHbGmuTHX_V|s9>l=74&qIOqnDfR2y`h*OR8={oY3p44Jrj}c6D9b
z9?@2vAOUAXDjK~cb&f7le_()td7@!B&ZD*RA91HIQvKjGkk&3vK&r@pX3^m~L2LoS
zw#SK&lzHK4^<^cS2XqPPv`$i5>V*AVLNHKuwX49j
zBMpW=ARJ1$rkhwS$h0@Ys~Vq@C^fSAP$BVl>7)~Ah!4Oc>NDLuQzL!zLOny+Ks{Pe
ztHro=MfxA-g9X&pUsT_)!32K`lnETevW9KI5ZkvJ3uRd`weX7692(oB+9df@of_rG
zDW`y{gawCigtKykd-dEWsbPhzHNO#qat79nq(3d6g3rlwA2-IyHt4dN@wy>+_;Ftj
z9_E_^$o#w7Xm&~Nd3Am?3}AlIz8TPt;I`qAhv~%o$+jkoO{@*#7D(D!z*_Ti$?6H;
zDh|)SHTEp@fN1o6Hvn}K>#2Q`tPXFQ-W$1VA*!yAU$5&@evvgsKLMlXF#Fr;oEOI
zP9sr;Af^8oz`c+No48%9M)n{6q7yB|bd;jhL~W{e!!c&T46!R^m#`|4A4yj2j
zlhkUlrReXR{HGhS<0sJmhCl6?)i-9AQx11UYnK;^jaByBSpg_%FRzd$
z=-#o?!)WNI;C|r~ZlqJLvJw=*sxy<{JS?ofYCwgt=E;UPAr>a}A^z%KBkOGGN6hrH
znYBREstRljrdE>nSAfLB2A1{IqgaCdHEtV&{|Y6UNwBwp0&){jJ?@NPb0s^l-
zwk3w~1rRGmo=H8{6ZeDmg-`-idX-kJ;2h$2t=
z$L+uho3B7q<9|w9R{d9`zCdnNT+2-GdNl~sK!xc>STBT6*Vgd=7;oz)r
z3nk?w|B5{d41pLj(rKv+ms~#?t!i){nHz*;V~&wC
z!##gDZDx@x%S|s@duZvv1R@PzHR3B_P0DlOBp$A#!n(|pSHfIu_&8zTox~Q9hr8Q4
z!ATT8%}yq>AiDQqgHRrH&iYlTugUtWt>5E5;Q1DYjksm%Lg%2`e^ELd_JMeH^?sb>
z9H`xCbj!iVb)d5IRzW)sDW9J9TCMnFTojF^0%A%Tm()HMyh3Tmkb1j1
zAcp*jo##YZZl-#fYYvZ+bgFI^sg6KJkx^#c43dY`s4b_yQP<+HAQ;Xr?l<81`73Vr
zL=Nvv%&bDkl^KMAkXNRdt^4fGqesa>U4&L+BfAG?8Lgj{3({W&wJ!y^Y3m;44R
z$WI#rHYpi~W%r^JMe;)3@n0sf3KR?g*W8t5<9QEI(M!Gua)E+F@ove`<;9ojYdI#&
zXk22()jg`L6QlvW9<#tmqRZ(J=kw*c6JloSwBqB@gMprD?se#ql+dKQUkUZLhQy~d
zBc|oqZm4@8$xa(t%8D2Eb-?Edl^&<|g9hd7f|-wtx4L_4@ds&!!y(nG3Gu3&fu-ac
zTw>~CI;6;jr3WP*Q&2W*H(Cherhm?JLij)t6hyJCX>n1$C{^HDp-yx%n*I8#c%M6WS?5dT`qPavF&l?*%pHm4`&FBDIsKY$`}r
z(}^Rb+=)qDVj5U{UPi`d2?!{lKYVI*3Ru^cJ;Lq{k5Fx?Bmjv{jxnKrs9ZY@4rzb;
z0pDzZnJg_;XvQGb6jy0I&6ku9z?6O-KS|cV9;v=)AmW0Zu^)i>_ZM
zh-HS7(-sLhC(B=6!IVV-ZpY`-H&d91F-tCUx*a{?SR^hi;cnW}hA>fUD~`95bd+W>7R;
zG4tNxJjs_OQtAu+75y^wtu~?a<-FrH>vk4P*(XB5XVHTF9UOIxS=4^|xxyv5HAj`W
z6@1Aj)9%1~2CxtNS!U*NMn%XUFNcrqiDGrzzx0KpD@&Dxcj}
zPZ_A~1A=e|hM$|02lmdX>k&0t_GL}xifkb4p?_541n*~$RZ$j10qK+
zEvQQSFP%Q7#Qg~B(`H@adTafcU?d!4rnafMT-4|0B4<;omsK}{1{2~EgO#I8+8i^K
zpBDCz@zBv^NLrX>OqqQzI7jVLOB9`@`GIYct@1&G_rY&_&|RYZ-&B0b$YZakRB
zGl*RvhOx*^SG%=!eu(xxI;mnBoKPihye0aj=`n^Ip9$Bh3=w=i6sNJHW&7*7a9DBI
z2m-g*K1VGlzGiuMXs9a&UW5}U$+zQ=m~V9R&`?QK-u@ae_MaUfu36XgY^sC>TW+<*
zkPO>&>dBV}Us=39zEy+kYcj%Ym&l5S`qHTiIui1InhJ=DMv
zq?^0~@)?8FUSo5f;Xx#ThoVoFg`ClV9gg3+zZMia&dh|iT4I}WO){J}%v()v<7||u
zHtsFIt&|V&9$#u-9W=Nkh2b&KUoPqCl@Gi=>rsB*cow;VOkHrY~i;^)4A)#!D&`6_!XJH6VuH3EdP3
z4eP{(zxK9I-1j&OQ_a9NG+X8)HrFs+oVo7?2=}hZh(7gc`;D6=A>~*0BE~H(l@H~?&duTo^Rd*
z_lV`6hWD-xW}v35#E^*B23qr_y_M;dknC$5bc!PjKR$}6Vga4W*Piq%=2mMumj*k6
zWgbR;TN-_nRlqRnyAyBgw5BYM^A5ILTz}kHeLKB$HL*1gR|~CyI#eqh!@de4{4b6e
zFus8E^l?$^3%dXE1K3(QV;9zCP|B|L1$dSdlbOtb&?9(lU8xHsh~I3e;kDUk^V5EQ
z_TF?$WCT>D_3&}6W)}~IM@SF6Hx#o#p;7GwBt;nm`3$aFO3NS=&&Ji8!ZCY$4F0$o
z?g;wpdayUL^cCGwkYn-lY}34*fS
za9?m}v9iU$L|j5rJSc2IB&r%qx%_mcIk`noy5PAgCzCjp!aC~8yts_PIBA5TL#ePK
zlVcR)sKzeNDQUdl+Ia|>I7C5g@=(rQu@Xyxy`}AY_SctompEs1nN{?pHbn3acjNQK
z-pxflWp*Q*2i3;7v3hP&zLBoGEbwU;E97ds!@kw#kDcsC1PN=2GYuA7C*+c>&Vj`{Cj+AZ_?ToEUc6lKeg)&g
zTy=UyT^_$ME;9$Mu~E&{ya`AifEfaF0hh+N4m=lbg@bC;@oS9Tb2yjQ9Be*VUm|U{
zUX2XVa<9v=iVfx5Ai*tk0|=o$s@5FosSt~mO)r;VI7{;*;pd1W2V%U&F3b3juSPXI
zo{@GRd4j!-A2V|A*KSs{T80Dz&Io4zOYt%&$ugC)F8YG0i5VoU`*ir<#-xsHTk)
zz0XOiJ|lOCgILkq%^taBuZ9k%Z5WBK%#>30-Griwau|ZfwE!><#N$I}x6r@ZhU~A<
z6`>r`p37O35@3&0J9RCBGldy?dww&Pst0iZ^5MWJh1<~DuE$>qcl^OW35
zSeYH4cogZz4c8+F38SJu(({Q6KeQT(03u0CwRJmeG-96vS;;_92QmGsQigns3Ml!&
zcI1Vavd66*&R~}2H@rQ4LVN8MbquMw$OrCK1FZE8FoXjOfIW?M$RZxS<^#e`mRX8v
z&yXm+&qSF%VV0cEjsiuR5p
zv|f`b&ag*5#`c=R*E!4e-Q|Ax5PDtGM!_I>xdmuVu&}RZAq(F%QJ;GdF8RjFxz}p3
za_l0%o@!@2Z{rRKFUN5dT7?62xP>4zYf;mNNW1Kw!3M*cvpnIQSJc(^nED`Qv!#VS
zM3|Zn>Lx@?7AK)o@+)I1*07Yn0-)5Z>(FI3C&hj{QJey|FlZ8KC}LHQ{G{o^q!!U#
zSWX!(F0PP*VJkPGb8{zXwegBwCbNIOP@rC+-9zm-mj-3B_SIN8WChptVZVpFi>uom
z+zo2V?GME#v$%15u;`t=3m9N%;P8#DUAmL8-(-$#i)${eNFfLnko44(ds6N#83tA|
zO`-z1?T#yD4jlFS0EEys)q~44N$?dqJL{OKR4;X|_qNXK@^i!R@q~(Db_o@4vpK*S
zC)C1$b2(4YECrunWdoX`9u`p8C!i1fUNq>I`3R)|kURK-0$@ATj*O
zQQPXF0SR*wkpVlEubW#q*`yE1T#vKF88v>&LmAY@O6Eif`ji$VMBEw5!aRz!Kf!cRTwVEr;t
zinU}MfEFFd$jH`|1rb_kHdf&}3DK+SeJEGLCE(vbZ**^M!TpAJ%eDBSe-oA*lt}VQ
zP*e$Ex)HsOQHam@a{Rfv!uc-5*g4gg0I$#)S}Q~)rSPt+MJak(?U2Kt65@Q~lOHBC@e%g`D7Z>MTYiq}
zt)~_qU@IOv6_7DStmV@uh}VW8=Y()`Np_RFGEmqs9Oe3jT~8ZwN~wr;;qF;o!U}+k
z3AS_&0aNieMx|pKwR~S{9tu@>q6w!d#X6%G;f><8m?nzXI<9{kb&Zag_;C1~=!C7y
zZ