diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..e054fcd
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,13 @@
+{
+ "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:*)",
+ "Bash(ls:*)"
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
index fb92aff..f9f027b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -94,3 +94,5 @@ coverage/
# ── CloudForge specific ───────────────────────────────────────
backend/outputs
+
+*/.env
diff --git a/backend/.env.example b/backend/.env.example
deleted file mode 100644
index 866ee91..0000000
--- a/backend/.env.example
+++ /dev/null
@@ -1,12 +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
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/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
diff --git a/backend/README.md b/backend/README.md
index f2b1637..ddf4d33 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
@@ -23,8 +23,10 @@ Copy `.env.example` to `.env` and adjust values if needed.
Important settings:
- `OLLAMA_BASE_URL` (default: `http://localhost:11434`)
-- `QWEN_MODEL` (default: `qwen3.5:latest`)
+- `LLM_MODEL` (default: `gemma3:latest`)
- `ENABLE_WEB_SEARCH` (`true`/`false`)
+- `ENABLE_TINYFISH_SEARCH` (`true`/`false`)
+- `TINYFISH_TIMEOUT_SECONDS` (default: `25`)
- `MAX_CLARIFICATION_ROUNDS`
## Run
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/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/llm.py b/backend/app/agents/agent1/llm.py
index 82971b1..7d35ad7 100644
--- a/backend/app/agents/agent1/llm.py
+++ b/backend/app/agents/agent1/llm.py
@@ -1,9 +1,100 @@
from __future__ import annotations
-from langchain_ollama import ChatOllama
+import logging
+
+from langchain_anthropic import ChatAnthropic
+from langchain_core.language_models.chat_models import BaseChatModel
+
+try:
+ from langchain_ollama import ChatOllama
+except ImportError: # pragma: no cover - optional dependency
+ ChatOllama = None
+
+try:
+ from langchain_openai import ChatOpenAI
+except ImportError: # pragma: no cover - optional dependency
+ ChatOpenAI = None
+
+try:
+ from langchain_google_genai import ChatGoogleGenerativeAI
+except ImportError: # pragma: no cover - optional dependency
+ ChatGoogleGenerativeAI = None
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)
+
+logger = logging.getLogger(__name__)
+
+
+def supported_llm_providers() -> list[str]:
+ return ["ollama", "anthropic", "openai", "google"]
+
+
+def _normalize_provider(provider: str | None) -> str:
+ requested = (provider or settings.llm_provider or "auto").strip().lower()
+ if requested in {"", "auto"}:
+ return "ollama"
+ if requested not in supported_llm_providers():
+ logger.warning("Unknown llm provider '%s'; defaulting to ollama", requested)
+ return "ollama"
+ return requested
+
+
+def _ensure_provider_configuration(provider: str) -> str:
+ if provider == "anthropic" and not settings.anthropic_api_key.strip():
+ logger.warning("Anthropic API key not configured; defaulting to ollama")
+ return "ollama"
+ if provider == "openai" and not settings.openai_api_key.strip():
+ logger.warning("OpenAI API key not configured; defaulting to ollama")
+ return "ollama"
+ if provider == "google" and not settings.google_api_key.strip():
+ logger.warning("Google API key not configured; defaulting to ollama")
+ return "ollama"
+ return provider
+
+
+def get_llm(provider: str | None = None, model: str | None = None) -> BaseChatModel:
+ resolved_provider = _ensure_provider_configuration(_normalize_provider(provider))
+
+ if resolved_provider == "ollama":
+ if ChatOllama is None:
+ raise RuntimeError("langchain-ollama is not installed; cannot use ollama provider")
+ return ChatOllama(
+ model=model or settings.ollama_model,
+ base_url=settings.ollama_base_url,
+ temperature=settings.llm_temperature,
+ timeout=settings.llm_timeout_seconds,
+ )
+
+ if resolved_provider == "anthropic":
+ return ChatAnthropic(
+ model=model or settings.anthropic_model or settings.llm_model,
+ api_key=settings.anthropic_api_key,
+ temperature=settings.llm_temperature,
+ timeout=settings.llm_timeout_seconds,
+ max_tokens=16384,
+ )
+
+ if resolved_provider == "openai":
+ if ChatOpenAI is None:
+ raise RuntimeError("langchain-openai is not installed; cannot use openai provider")
+ return ChatOpenAI(
+ model=model or settings.openai_model,
+ api_key=settings.openai_api_key,
+ temperature=settings.llm_temperature,
+ timeout=settings.llm_timeout_seconds,
+ max_tokens=16384,
+ )
+
+ if resolved_provider == "google":
+ if ChatGoogleGenerativeAI is None:
+ raise RuntimeError("langchain-google-genai is not installed; cannot use google provider")
+ return ChatGoogleGenerativeAI(
+ model=model or settings.google_model,
+ google_api_key=settings.google_api_key,
+ temperature=settings.llm_temperature,
+ timeout=settings.llm_timeout_seconds,
+ max_output_tokens=16384,
+ )
+
+ raise RuntimeError(f"Unsupported llm provider: {resolved_provider}")
diff --git a/backend/app/agents/agent1/nodes.py b/backend/app/agents/agent1/nodes.py
index e5b9521..7957797 100644
--- a/backend/app/agents/agent1/nodes.py
+++ b/backend/app/agents/agent1/nodes.py
@@ -2,12 +2,13 @@
import json
import logging
+import re
import time
from typing import Any
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
@@ -26,16 +27,29 @@
]
+FALLBACK_CLARIFICATION_QUESTIONS = [
+ "What user personas and core workflows should this product support?",
+ "What are target scale, latency, availability, and compliance requirements?",
+ "What are your primary data retention and lifecycle requirements?",
+ "Which third-party systems must be integrated at launch?",
+ "What are your observability, alerting, and incident response expectations?",
+ "What are your budget guardrails and cost optimization constraints?",
+]
+
+
def _extract_json(text: str) -> dict[str, Any]:
- # The LLM may prepend explanation text; we recover the first JSON object safely.
+ # The LLM may prepend/append prose or emit multiple JSON blocks.
+ # Parse the first JSON object safely and ignore trailing data.
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
- end = text.rfind("}")
- if start >= 0 and end > start:
- return json.loads(text[start : end + 1])
+ if start >= 0:
+ decoder = json.JSONDecoder()
+ obj, _ = decoder.raw_decode(text[start:])
+ if isinstance(obj, dict):
+ return obj
raise
@@ -79,9 +93,392 @@ 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 _normalize_question(text: str) -> str:
+ return " ".join(text.strip().lower().split())
+
+
+def _question_fingerprint(text: str) -> str:
+ """Build a stable semantic fingerprint for near-duplicate question detection."""
+ normalized = _normalize_question(text)
+ # Drop parenthetical examples to avoid mismatches like
+ # "What deployment environment?" vs "What is the deployment environment (e.g., containers, serverless)?"
+ normalized = re.sub(r"\([^)]*\)", "", normalized)
+ normalized = re.sub(r"^(what is|what are|what|which|are there any)\s+", "", normalized)
+ normalized = re.sub(r"[^a-z0-9\s]", " ", normalized)
+ normalized = " ".join(normalized.split())
+
+ stop_words = {
+ "the",
+ "a",
+ "an",
+ "for",
+ "of",
+ "to",
+ "be",
+ "should",
+ "required",
+ "requirements",
+ "platform",
+ "project",
+ "system",
+ }
+ tokens = [tok for tok in normalized.split() if tok not in stop_words]
+ return " ".join(tokens)
+
+
+def _extract_answered_question_set(state: AgentState) -> set[str]:
+ exact = {
+ _normalize_question(item.get("question", ""))
+ for item in state.answered_qa_pairs
+ if item.get("question")
+ }
+ semantic = {
+ _question_fingerprint(item.get("question", ""))
+ for item in state.answered_qa_pairs
+ if item.get("question")
+ }
+ return {k for k in [*exact, *semantic] if k}
+
+
+def _coerce_clarifier_payload(raw_payload: dict[str, Any]) -> dict[str, Any]:
+ """Normalize model output into ClarifierOutput-compatible shape.
+
+ Some local models (e.g. Gemma) return follow_up_questions entries as objects.
+ This coercion extracts question strings and preserves option blocks when present.
+ """
+ normalized = dict(raw_payload)
+
+ followups = normalized.get("follow_up_questions", [])
+ qwo = normalized.get("questions_with_options", [])
+
+ normalized_followups: list[str] = []
+ normalized_qwo: list[dict[str, Any]] = []
+
+ for item in followups if isinstance(followups, list) else []:
+ if isinstance(item, str):
+ normalized_followups.append(item)
+ continue
+ if not isinstance(item, dict):
+ continue
+
+ q_text = str(item.get("question", "")).strip()
+ if not q_text:
+ continue
+ normalized_followups.append(q_text)
+
+ options = item.get("options", [])
+ if isinstance(options, list) and options:
+ normalized_qwo.append(
+ {
+ "question": q_text,
+ "original_question": q_text,
+ "options": options,
+ }
+ )
+
+ for item in qwo if isinstance(qwo, list) else []:
+ if not isinstance(item, dict):
+ continue
+ q_text = str(item.get("original_question") or item.get("question") or "").strip()
+ if not q_text:
+ continue
+ normalized_qwo.append(
+ {
+ "question": str(item.get("question") or q_text),
+ "original_question": q_text,
+ "options": item.get("options", []),
+ }
+ )
+ normalized_followups.append(q_text)
+
+ # Keep stable order while deduplicating.
+ dedup_followups: list[str] = []
+ seen_followups: set[str] = set()
+ for q in normalized_followups:
+ k = _normalize_question(q)
+ if not k or k in seen_followups:
+ continue
+ seen_followups.add(k)
+ dedup_followups.append(q)
+
+ dedup_qwo: list[dict[str, Any]] = []
+ seen_qwo: set[str] = set()
+ for item in normalized_qwo:
+ k = _normalize_question(item.get("original_question", ""))
+ if not k or k in seen_qwo:
+ continue
+ seen_qwo.add(k)
+ dedup_qwo.append(item)
+
+ normalized["follow_up_questions"] = dedup_followups
+ normalized["questions_with_options"] = dedup_qwo
+ return normalized
+
+
+def _fallback_unanswered_questions(state: AgentState, limit: int = 2) -> list[str]:
+ answered = _extract_answered_question_set(state)
+ questions = [q for q in FALLBACK_CLARIFICATION_QUESTIONS if _normalize_question(q) not in answered]
+ return questions[:limit]
+
+
+def _coerce_planner_payload(raw_payload: dict[str, Any]) -> dict[str, Any]:
+ """Normalize planner payload shape for fields where local models may emit dicts."""
+ payload = dict(raw_payload)
+
+ # Some models return only the inner plan_json object without wrapper keys.
+ # Wrap it into PlannerOutput-compatible shape to avoid hard parse failures.
+ if "plan_json" not in payload:
+ plan_like_keys = {
+ "scope",
+ "product_summary",
+ "functional_requirements",
+ "non_functional_requirements",
+ "proposed_cloud_services",
+ "architecture_decisions",
+ "deployment_plan",
+ "risks_and_mitigations",
+ "assumptions",
+ "open_questions",
+ "references",
+ "architecture_overview",
+ }
+ if any(k in payload for k in plan_like_keys):
+ payload = {
+ "plan_markdown": payload.get(
+ "plan_markdown",
+ "# Final Product Requirement Document\n\nGenerated from clarified requirements.",
+ ),
+ "plan_json": payload,
+ }
+
+ plan_json = payload.get("plan_json")
+ if not isinstance(plan_json, dict):
+ return payload
+
+ normalized_plan = dict(plan_json)
+ if not normalized_plan.get("scope"):
+ normalized_plan["scope"] = str(normalized_plan.get("architecture_overview", "Cloud architecture planning"))
+ if not normalized_plan.get("product_summary"):
+ normalized_plan["product_summary"] = str(normalized_plan.get("architecture_overview", "Generated architecture summary"))
+ list_fields = [
+ "functional_requirements",
+ "non_functional_requirements",
+ "proposed_cloud_services",
+ "architecture_decisions",
+ "deployment_plan",
+ "risks_and_mitigations",
+ "assumptions",
+ "open_questions",
+ "references",
+ ]
+
+ for field in list_fields:
+ value = normalized_plan.get(field)
+ if isinstance(value, list):
+ continue
+ if isinstance(value, dict):
+ # Preserve deterministic order by key when model emits phase_1/phase_2 maps.
+ normalized_plan[field] = [str(value[k]) for k in sorted(value)]
+ elif value is None:
+ normalized_plan[field] = []
+ else:
+ normalized_plan[field] = [str(value)]
+
+ payload["plan_json"] = normalized_plan
+ return payload
+
+
+def _ingest_selected_answers(state: AgentState) -> None:
+ """Convert selected answers into stable Q&A history before research mutates questions."""
+ if not state.selected_option_answers:
+ return
+
+ for q_idx, selected_value in state.selected_option_answers.items():
+ if q_idx >= len(state.questions_with_options):
+ continue
+
+ question_obj = state.questions_with_options[q_idx]
+ question_text = question_obj.original_question or question_obj.question
+
+ matched_option = None
+ for opt in question_obj.options:
+ if opt.value == selected_value:
+ matched_option = opt
+ break
+
+ if matched_option:
+ answer_text = matched_option.label if not matched_option.is_custom else selected_value
+ logger.info("User selected option for '%s': %s", question_text, answer_text)
+ else:
+ answer_text = selected_value
+ logger.info("User provided custom input for '%s': %s", question_text, answer_text)
+
+ state.user_answers.append(answer_text)
+ state.answered_qa_pairs.append({"question": question_text, "answer": answer_text})
+
+ state.selected_option_answers = {}
+
+
+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:
+ question_to_options: dict[str, QuestionWithOptions] = {}
+
+ for item in payload.questions_with_options:
+ q = item.original_question or item.question
+ if not q:
+ continue
+ question_to_options[_normalize_question(q)] = item
+
+ for question in payload.follow_up_questions:
+ key = _normalize_question(question)
+ if key in question_to_options:
+ continue
+ question_to_options[key] = QuestionWithOptions(
+ question=question,
+ original_question=question,
+ options=_fallback_options_for_question(question),
+ )
+
+ payload.questions_with_options = list(question_to_options.values())
+
+
def user_input_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
model_state = _as_state(state)
logger.info("Processing user input")
+ _ingest_selected_answers(model_state)
model_state.status = "running"
return _dump_state(model_state)
@@ -101,11 +498,23 @@ 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 qa_pair in model_state.answered_qa_pairs:
+ q = qa_pair.get("question", "")
+ a = qa_pair.get("answer", "")
+ 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"
)
@@ -114,15 +523,30 @@ def research_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
response = llm.invoke(prompt)
elapsed = time.perf_counter() - start
logger.info("Research analysis completed in %.2f seconds", elapsed)
- payload = ClarifierOutput.model_validate(_extract_json(str(response.content)))
+ raw_payload = _extract_json(str(response.content))
+ payload = ClarifierOutput.model_validate(_coerce_clarifier_payload(raw_payload))
+ _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"
- if not model_state.follow_up_questions:
- model_state.follow_up_questions = [
- "What user personas and core workflows should this product support?",
- "What are target scale, latency, availability, and compliance requirements?",
- ]
+ next_questions = _fallback_unanswered_questions(model_state, limit=2)
+ if not next_questions:
+ model_state.is_information_enough = True
+ model_state.follow_up_questions = []
+ model_state.questions_with_options = []
+ model_state.run_web_search = False
+ return _dump_state(model_state)
+
+ model_state.follow_up_questions = next_questions
+ 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.
@@ -132,10 +556,62 @@ def research_node(state: dict[str, Any] | AgentState) -> dict[str, Any]:
(payload.follow_up_questions[0] if payload.follow_up_questions else "none"),
)
- model_state.follow_up_questions = payload.follow_up_questions[:8]
- model_state.questions_with_options = payload.questions_with_options[:8]
- model_state.research_queries = _augment_research_queries(model_state, payload.research_queries)
+ answered_questions = _extract_answered_question_set(model_state)
+
+ filtered_qwo: list[QuestionWithOptions] = []
+ for qwo in payload.questions_with_options:
+ q_text = qwo.original_question or qwo.question
+ if _normalize_question(q_text) in answered_questions or _question_fingerprint(q_text) in answered_questions:
+ continue
+ filtered_qwo.append(qwo)
+
+ filtered_followups = [
+ q
+ for q in payload.follow_up_questions
+ if _normalize_question(q) not in answered_questions and _question_fingerprint(q) not in answered_questions
+ ]
+
+ # Remove near-duplicate questions within the same round.
+ dedup_qwo: list[QuestionWithOptions] = []
+ seen_fingerprints: set[str] = set()
+ for qwo in filtered_qwo:
+ fp = _question_fingerprint(qwo.original_question or qwo.question)
+ if not fp or fp in seen_fingerprints:
+ continue
+ seen_fingerprints.add(fp)
+ dedup_qwo.append(qwo)
+ filtered_qwo = dedup_qwo
+
+ dedup_followups: list[str] = []
+ seen_followup_fingerprints: set[str] = set()
+ for q in filtered_followups:
+ fp = _question_fingerprint(q)
+ if not fp or fp in seen_followup_fingerprints:
+ continue
+ seen_followup_fingerprints.add(fp)
+ dedup_followups.append(q)
+ filtered_followups = dedup_followups
+
model_state.is_information_enough = payload.is_information_enough
+ model_state.follow_up_questions = filtered_followups[:8]
+ model_state.questions_with_options = filtered_qwo[:8]
+
+ if not model_state.is_information_enough and not model_state.follow_up_questions:
+ next_questions = _fallback_unanswered_questions(model_state, limit=2)
+ if next_questions:
+ model_state.follow_up_questions = next_questions
+ fallback_payload = ClarifierOutput(
+ is_information_enough=False,
+ follow_up_questions=next_questions,
+ research_queries=payload.research_queries,
+ )
+ _ensure_questions_have_options(fallback_payload)
+ _normalize_options_metadata(fallback_payload)
+ model_state.questions_with_options = fallback_payload.questions_with_options
+ else:
+ model_state.is_information_enough = True
+
+ model_state.research_queries = _augment_research_queries(model_state, payload.research_queries)
logger.info(
"Requirement clarity check: enough=%s, questions=%s, research_queries=%s",
model_state.is_information_enough,
@@ -160,23 +636,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(
@@ -193,38 +671,24 @@ 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 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):
- question_obj = model_state.questions_with_options[q_idx]
- # Find matching option to create a natural language response
- 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 for display
- 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)
- 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)
- model_state.selected_option_answers = {} # Clear after processing
-
if not model_state.is_information_enough:
model_state.status = "needs_input"
- questions = model_state.follow_up_questions
- if not questions:
- model_state.follow_up_questions = [
- "What are your expected monthly active users and peak RPS?",
- "Which data compliance requirements must be met (e.g., SOC2, HIPAA)?",
- ]
+ if not model_state.follow_up_questions:
+ next_questions = _fallback_unanswered_questions(model_state, limit=2)
+ if next_questions:
+ model_state.follow_up_questions = next_questions
+ fallback_payload = ClarifierOutput(
+ is_information_enough=False,
+ follow_up_questions=next_questions,
+ research_queries=[],
+ )
+ _ensure_questions_have_options(fallback_payload)
+ _normalize_options_metadata(fallback_payload)
+ model_state.questions_with_options = fallback_payload.questions_with_options
+ else:
+ model_state.is_information_enough = True
+ model_state.status = "running"
+ return _dump_state(model_state)
logger.info(
"Some requirements are not clear, asking user via multiple-choice options",
)
@@ -266,7 +730,8 @@ 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_payload = _extract_json(str(response.content))
+ payload = PlannerOutput.model_validate(_coerce_planner_payload(raw_payload))
# CoT is not logged; show a safe reasoning summary from structured model output.
logger.info(
@@ -325,6 +790,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 +813,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..10aaa88 100644
--- a/backend/app/agents/agent1/prompts.py
+++ b/backend/app/agents/agent1/prompts.py
@@ -1,8 +1,11 @@
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.
+
+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:
{
@@ -13,10 +16,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
+ }
]
}
],
@@ -24,13 +48,21 @@
}
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.
+- `follow_up_questions` MUST be a plain array of strings only, never objects.
+- Put all option metadata only inside `questions_with_options`, not inside `follow_up_questions`.
+- 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.
-- 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/standalone_smoke_test.py b/backend/app/agents/agent1/standalone_smoke_test.py
index 2a4d455..6fc55b1 100644
--- a/backend/app/agents/agent1/standalone_smoke_test.py
+++ b/backend/app/agents/agent1/standalone_smoke_test.py
@@ -4,9 +4,11 @@
from urllib.parse import urlparse
from app.agents.agent1 import run_until_interrupt
+from app.agents.agent1.llm import supported_llm_providers
from app.agents.agent1.nodes import acceptance_node
from app.agents.agent1.state import AgentState
from app.agents.agent1.tools import trusted_doc_domains
+from app.config import settings
logger = logging.getLogger(__name__)
@@ -81,8 +83,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:
@@ -94,17 +104,64 @@ def process_option_selection_by_text(state: AgentState, question_idx: int, user_
question = state.questions_with_options[question_idx]
normalized_input = _normalize(user_input)
+ # First pass: exact match on label or value.
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:
state.selected_option_answers[question_idx] = option.value
logger.info("User matched option text for question %s: %s", question_idx + 1, option.label)
return
+ # Second pass: contains match, prefer the longest label to avoid prefix collisions
+ # like "S3 Standard" matching "S3 Standard-IA".
+ contains_matches: list[tuple[int, QuestionOption]] = []
+ for option in question.options:
+ option_label = _normalize(option.label)
+ if normalized_input in option_label or option_label in normalized_input:
+ contains_matches.append((len(option_label), option))
+
+ if contains_matches:
+ _, best_option = max(contains_matches, key=lambda item: item[0])
+ state.selected_option_answers[question_idx] = best_option.value
+ logger.info("User matched option text for question %s: %s", question_idx + 1, best_option.label)
+ return
+
# No exact option match -> preserve as custom free-form input.
state.selected_option_answers[question_idx] = user_input
logger.info("User provided custom input for question %s", question_idx + 1)
+def configure_llm_for_run() -> None:
+ providers = supported_llm_providers()
+ provider_input = input(f"LLM provider ({'/'.join(providers)}) [ollama]: ").strip().lower()
+ provider = provider_input or "ollama"
+ if provider not in providers:
+ print_white(f"Unknown provider '{provider}'. Falling back to ollama.")
+ provider = "ollama"
+
+ settings.llm_provider = provider
+
+ model_hint = {
+ "ollama": settings.ollama_model,
+ "anthropic": settings.anthropic_model,
+ "openai": settings.openai_model,
+ "google": settings.google_model,
+ }[provider]
+ model_input = input(f"Model for {provider} [{model_hint}]: ").strip()
+ if not model_input:
+ return
+
+ if provider == "ollama":
+ settings.ollama_model = model_input
+ elif provider == "anthropic":
+ settings.anthropic_model = model_input
+ elif provider == "openai":
+ settings.openai_model = model_input
+ else:
+ settings.google_model = model_input
+
+
def capture_user_answers(state: AgentState) -> None:
if state.questions_with_options:
display_questions_with_options(state)
@@ -155,6 +212,7 @@ def handle_plan_acceptance(state: AgentState) -> AgentState:
def run_interactive_session() -> None:
+ configure_llm_for_run()
cloud_provider = input("Cloud provider (aws/azure/gcp) [aws]: ").strip().lower() or "aws"
prd_text = input("Enter initial PRD text: ").strip()
while not prd_text:
diff --git a/backend/app/agents/agent1/state.py b/backend/app/agents/agent1/state.py
index 94741d4..f1da4fb 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
@@ -52,6 +54,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 +72,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
@@ -91,6 +97,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[dict[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/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..26019fd
--- /dev/null
+++ b/backend/app/agents/agent3/config.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+# 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
+# ---------------------------------------------------------------------------
+
+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 = 6 # ~2 super-steps per tool call × ~3 tool calls per task
+
+# ---------------------------------------------------------------------------
+# 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..675ada9
--- /dev/null
+++ b/backend/app/agents/agent3/graph.py
@@ -0,0 +1,177 @@
+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"):
+ # 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"
+
+
+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..12f8751
--- /dev/null
+++ b/backend/app/agents/agent3/llm.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+import threading
+
+from langchain_anthropic import ChatAnthropic
+
+from app.config import settings
+
+_lock = threading.Lock()
+_clients: dict[str, ChatAnthropic] = {}
+
+
+def get_llm(model: str | None = None, temperature: float = 0.0) -> ChatAnthropic:
+ """
+ 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.llm_model
+ key = f"{resolved_model}:{temperature}"
+ if key not in _clients:
+ with _lock:
+ if key not in _clients:
+ _clients[key] = ChatAnthropic(
+ model=resolved_model,
+ api_key=settings.anthropic_api_key,
+ temperature=temperature,
+ timeout=settings.llm_timeout_seconds,
+ max_tokens=16384,
+ )
+ return _clients[key]
+
+
+def get_default_llm() -> ChatAnthropic:
+ """Return the primary LLM for heavy tasks."""
+ return get_llm(settings.llm_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/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..0de3c66
--- /dev/null
+++ b/backend/app/agents/agent3/nodes/orchestrator.py
@@ -0,0 +1,341 @@
+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.
+ """
+ # 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, "")
+
+ 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.
+ # 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")
+ prior_messages = [
+ HumanMessage(
+ content=(
+ f"Start generating code. There are {pending_count} pending tasks. "
+ "Use the get_pending_tasks tool first to see the full task list, "
+ "then work through each service systematically."
+ )
+ )
+ ]
+
+ # 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)
+ 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
+ )
+
+ 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,
+ "code_errors": ctx.code_errors,
+ "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
new file mode 100644
index 0000000..d26a495
--- /dev/null
+++ b/backend/app/agents/agent3/nodes/parse_input.py
@@ -0,0 +1,198 @@
+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 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(
+ 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..86936ca
--- /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
+
+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
+- 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/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/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 b7b59f3..28f5b35 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
@@ -73,7 +74,7 @@ async 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..9fa737c 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,
+ )
# ---------------------------------------------------------------------------
@@ -165,15 +156,14 @@ def create_graph(
graph_json_path: str | None = None,
community_summaries_path: str | None = None,
terraform_mcp_cmd: list[str] | None = None,
+ kuzu_conn=None,
):
"""
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.
@@ -204,7 +194,8 @@ def create_graph(
_summaries = community_summaries_path or os.environ.get(
"CLOUDFORGE_COMMUNITY_SUMMARIES", "community_summaries.json"
)
- conn = init_kuzu(_graph_json)
+ # Use the already-open connection if provided (avoids double-lock on the DB file)
+ conn = kuzu_conn if kuzu_conn is not None else init_kuzu(_graph_json)
kg_traversal = build_kg_subgraph(llm, conn, _summaries)
# Build subgraphs
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 1b46c70..e23d9f2 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)
@@ -58,7 +59,7 @@ async 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)
@@ -70,7 +71,7 @@ async 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 0001c49..450fd75 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,15 +9,62 @@ class Settings(BaseSettings):
debug: bool = True
host: str = "0.0.0.0"
port: int = 8000
+
+ # LLM configuration
+ # Supported providers: auto, ollama, anthropic, openai, google
+ llm_provider: str = "auto"
+ llm_model: str = HAIKU_MODEL
+
+ # Local/offline default (used when provider=auto, or when cloud provider keys are missing)
+ ollama_model: str = "qwen2.5:7b-instruct"
ollama_base_url: str = "http://localhost:11434"
- qwen_model: str = "qwen3.5:latest"
+
+ # Anthropic
+ anthropic_api_key: str = ""
+ anthropic_model: str = HAIKU_MODEL
+
+ # OpenAI
+ openai_api_key: str = ""
+ openai_model: str = "gpt-4.1-mini"
+
+ # Google (Gemini)
+ google_api_key: str = ""
+ google_model: str = "gemini-2.5-flash"
+
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
- model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
+ # 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 = ""
+ fernet_keys: str = "" # Comma-separated list of Fernet keys, newest first. Overrides fernet_key if set.
+
+ # GitHub OAuth
+ github_client_id: str = ""
+ github_client_secret: str = ""
+ github_redirect_uri: str = "http://localhost:8000/auth/github/callback"
+
+ 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", extra="ignore")
settings = Settings()
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/dependencies.py b/backend/app/core/dependencies.py
new file mode 100644
index 0000000..ad21a35
--- /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 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",
+ )
+ return project
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
new file mode 100644
index 0000000..de66929
--- /dev/null
+++ b/backend/app/core/security.py
@@ -0,0 +1,46 @@
+from datetime import datetime, timedelta, timezone
+
+import bcrypt
+from fastapi import HTTPException, status
+import jwt
+from jwt.exceptions import InvalidTokenError
+
+from app.config import settings
+
+
+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 InvalidTokenError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+
+def hash_password(password: str) -> str:
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
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/encryption.py b/backend/app/db/encryption.py
new file mode 100644
index 0000000..2592707
--- /dev/null
+++ b/backend/app/db/encryption.py
@@ -0,0 +1,23 @@
+from cryptography.fernet import Fernet, MultiFernet
+
+from app.config import settings
+
+
+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:
+ return _get_fernet().encrypt(plaintext.encode()).decode()
+
+
+def decrypt(ciphertext: str) -> str:
+ return _get_fernet().decrypt(ciphertext.encode()).decode()
diff --git a/backend/app/db/mongo.py b/backend/app/db/mongo.py
new file mode 100644
index 0000000..967a763
--- /dev/null
+++ b/backend/app/db/mongo.py
@@ -0,0 +1,86 @@
+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 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:
+ 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()
diff --git a/backend/app/main.py b/backend/app/main.py
index 2b7f544..8084637 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,14 +1,97 @@
+import logging
+from contextlib import asynccontextmanager
+
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.routers import 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.history import router as history_router
+from app.routers.prd import router as prd_router
+
+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
+
+ 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.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],
+ 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.include_router(history_router)
@app.get("/")
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)
diff --git a/backend/app/routers/agent3.py b/backend/app/routers/agent3.py
new file mode 100644
index 0000000..241fc8c
--- /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="/workflows/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/app/routers/architecture.py b/backend/app/routers/architecture.py
new file mode 100644
index 0000000..a45d714
--- /dev/null
+++ b/backend/app/routers/architecture.py
@@ -0,0 +1,329 @@
+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(
+ 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/architecture_sse.py b/backend/app/routers/architecture_sse.py
new file mode 100644
index 0000000..3d348df
--- /dev/null
+++ b/backend/app/routers/architecture_sse.py
@@ -0,0 +1,461 @@
+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):
+ global _arch_graph_v2
+ if _arch_graph_v2 is None:
+ _arch_graph_v2 = create_graph(
+ graph_json_path=_GRAPH_JSON,
+ community_summaries_path=_SUMMARIES_JSON,
+ kuzu_conn=kuzu_conn,
+ )
+ 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,
+ 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)})
+ 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") or "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"):
+ if await request.is_disconnected():
+ logger.info("Client disconnected, stopping architecture stream")
+ return
+
+ 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.exception("Workflow error in arch stream")
+ yield _sse({"type": "error", "message": "An internal error occurred. Please try again."})
+ 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,
+ request: Request,
+) -> 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"
+ ):
+ if await request.is_disconnected():
+ logger.info("Client disconnected, stopping architecture stream")
+ return
+
+ 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.exception("Workflow error in arch stream")
+ yield _sse({"type": "error", "message": "An internal error occurred. Please try again."})
+ 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, request),
+ 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),
+):
+ 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)],
+ )
+ 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, request),
+ 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),
+):
+ 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)],
+ )
+ 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, request),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+@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,
+ 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"}
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
new file mode 100644
index 0000000..13431de
--- /dev/null
+++ b/backend/app/routers/auth.py
@@ -0,0 +1,212 @@
+from datetime import datetime, timedelta, timezone
+from secrets import token_urlsafe
+
+import httpx
+from bson import ObjectId
+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
+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"])
+
+limiter = Limiter(key_func=get_remote_address)
+
+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)
+@limiter.limit("5/minute")
+async def register(request: Request, 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)
+@limiter.limit("10/minute")
+async def login(request: Request, 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)
+@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":
+ 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")
+@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"]),
+ "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")
+@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")
+
+ 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}
diff --git a/backend/app/routers/build.py b/backend/app/routers/build.py
new file mode 100644
index 0000000..2af5f26
--- /dev/null
+++ b/backend/app/routers/build.py
@@ -0,0 +1,385 @@
+from __future__ import annotations
+
+import json
+import logging
+from datetime import datetime, timezone
+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
+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
+# ---------------------------------------------------------------------------
+
+_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
+
+
+
+# ---------------------------------------------------------------------------
+# 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 as e:
+ logger.exception("Error processing build SSE event")
+ yield _sse({"phase": "error", "message": "Internal error during build processing"})
+ return
+
+ 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"
+ )
+
+ 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:
+ 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"
+ )
+
+ 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
new file mode 100644
index 0000000..82f394d
--- /dev/null
+++ b/backend/app/routers/deploy.py
@@ -0,0 +1,228 @@
+import asyncio
+import json
+import logging
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+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
+from app.utils.serialization import serialize_doc
+
+router = APIRouter(prefix="/workflows/deploy", tags=["deploy"])
+logger = logging.getLogger(__name__)
+
+
+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, 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")
+ 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")
+
+ 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")
+
+ 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():
+ 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)
+ 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:
+ 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": "An internal error occurred. Please try again."})
+
+ 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")
+
+ 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")
+
+ 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")
+
+ return serialize_doc(deployment)
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
new file mode 100644
index 0000000..af269b1
--- /dev/null
+++ b/backend/app/routers/prd.py
@@ -0,0 +1,449 @@
+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, Request, 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 _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 []
+ 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
+# ---------------------------------------------------------------------------
+
+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,
+ 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": _build_messages(result),
+ "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}},
+ )
+
+
+# ---------------------------------------------------------------------------
+# 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}
+# ---------------------------------------------------------------------------
+
+@router.post("/start/{project_id}")
+async def start_prd(
+ project_id: str,
+ payload: PrdStartRequest,
+ request: Request,
+ 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:
+ 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())
+
+ 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")
+ yield _sse({"type": "error", "message": "An internal error occurred. Please try again."})
+
+ 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,
+ request: Request,
+ 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") or "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:
+ if await request.is_disconnected():
+ return
+ result = await _run_agent1(state)
+ if await request.is_disconnected():
+ return
+ session_store.save(result.as_graph_state())
+
+ 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")
+ yield _sse({"type": "error", "message": "An internal error occurred. Please try again."})
+
+ 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/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}
diff --git a/backend/app/routers/workflows.py b/backend/app/routers/workflows.py
index 9e3ec69..d23fedc 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,15 +21,19 @@ 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,
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/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
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/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"
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"
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
diff --git a/backend/app/schemas/prd.py b/backend/app/schemas/prd.py
new file mode 100644
index 0000000..694e69c
--- /dev/null
+++ b/backend/app/schemas/prd.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class PrdStartRequest(BaseModel):
+ prd_text: str = Field(..., min_length=10, max_length=50_000)
+ cloud_provider: str = Field("aws", max_length=20)
+
+
+class PrdRespondRequest(BaseModel):
+ message: str
+
+
+class ConstraintChip(BaseModel):
+ id: str
+ label: str
+ category: str # "performance" | "security" | "cost" | "reliability"
diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py
new file mode 100644
index 0000000..4ce0dd4
--- /dev/null
+++ b/backend/app/schemas/project.py
@@ -0,0 +1,43 @@
+from datetime import datetime
+from typing import Optional
+
+from pydantic import BaseModel, Field
+
+
+class ProjectCreate(BaseModel):
+ 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] = 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):
+ 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
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/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()
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
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
diff --git a/backend/cloudforge_db b/backend/cloudforge_db
new file mode 100644
index 0000000..e309d74
Binary files /dev/null and b/backend/cloudforge_db differ
diff --git a/backend/cloudforge_db.wal b/backend/cloudforge_db.wal
new file mode 100644
index 0000000..30f65b8
Binary files /dev/null and b/backend/cloudforge_db.wal differ
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index b0ab724..1f5d003 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -9,20 +9,46 @@ dependencies = [
"duckduckgo-search>=8.1.1",
"fastapi>=0.135.1",
"jinja2>=3.1.6",
- "langchain[ollama]>=1.2.13",
+ "langchain>=1.2.13",
"langchain-anthropic>=1.4.0",
"langchain-community>=0.4.1",
+ "langchain-google-genai>=2.1.0",
+ "langchain-openai>=0.3.0",
+ "langchain-ollama>=1.0.0",
"kuzu>=0.7.0",
"sentence-transformers>=3.0.0",
"scikit-learn>=1.5.0",
- "langchain-ollama>=1.0.0",
"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",
"pydantic-settings>=2.13.1",
"python-dotenv>=1.2.2",
+ "tinyfish>=0.1.0",
"uvicorn[standard]>=0.42.0",
+ "pyyaml>=6.0.2",
+ "checkov>=3.2.0",
+ "jsonschema>=4.23.0",
+ "jinja2>=3.1.4",
+ "motor>=3.3",
+ "pymongo>=4.6",
+ "cryptography",
+ "PyJWT>=2.8.0",
+ "bcrypt>=4.1.0",
+ "httpx",
+ "boto3",
+ "slowapi>=0.1.9",
+ "json-repair>=0.30.0",
+ "pandas>=3.0.1",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.3.0",
+ "pytest-asyncio>=0.24.0",
+ "httpx>=0.28.0",
"httpx>=0.27.0",
"pandas>=3.0.1",
"mcp>=1.0.0",
diff --git a/backend/requirements.txt b/backend/requirements.txt
index c0adcfa..b652e4e 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -5,7 +5,11 @@ pydantic-settings==2.13.1
python-dotenv==1.2.2
langgraph==1.1.3
langchain==1.2.13
+langchain-anthropic>=1.4.0
langchain-community==0.4.1
+langchain-google-genai>=2.1.0
+langchain-openai>=0.3.0
langchain-ollama>=1.0.0
duckduckgo-search==8.1.1
ddgs>=9.9.1
+tinyfish>=0.1.0
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 25cdf07..b40286b 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -13,6 +13,18 @@ resolution-markers = [
"python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
+[[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"
@@ -107,6 +119,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"
@@ -120,6 +141,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"
@@ -170,6 +200,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"
@@ -184,51 +232,286 @@ name = "backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
+ { name = "bcrypt" },
+ { name = "boto3" },
+ { name = "checkov" },
+ { name = "cryptography" },
+ { name = "ddgs" },
+ { name = "duckduckgo-search" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" },
+ { name = "json-repair" },
+ { name = "jsonschema" },
{ name = "kuzu" },
- { name = "langchain", extra = ["ollama"] },
+ { name = "langchain" },
{ name = "langchain-anthropic" },
{ name = "langchain-community" },
{ name = "langgraph" },
+ { name = "langgraph-checkpoint-sqlite" },
{ name = "langgraph-cli" },
+ { name = "langgraph-prebuilt" },
{ name = "langsmith" },
+ { name = "motor" },
{ name = "pandas" },
{ name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pyjwt" },
+ { name = "pymongo" },
{ name = "python-dotenv" },
+ { name = "pyyaml" },
{ name = "scikit-learn" },
{ name = "sentence-transformers" },
+ { name = "slowapi" },
{ name = "uvicorn", extra = ["standard"] },
]
+[package.optional-dependencies]
+dev = [
+ { name = "httpx" },
+ { name = "mcp" },
+ { name = "pandas" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+]
+
[package.dev-dependencies]
dev = [
{ name = "pytest" },
+ { name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
+ { name = "bcrypt", specifier = ">=4.1.0" },
+ { 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 = "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 = "json-repair", specifier = ">=0.30.0" },
+ { name = "jsonschema", specifier = ">=4.23.0" },
{ name = "kuzu", specifier = ">=0.7.0" },
- { name = "langchain", extras = ["ollama"], specifier = ">=1.2.13" },
+ { 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 = "mcp", marker = "extra == 'dev'", specifier = ">=1.0.0" },
+ { name = "motor", specifier = ">=3.3" },
{ name = "pandas", specifier = ">=3.0.1" },
+ { name = "pandas", marker = "extra == 'dev'", specifier = ">=3.0.1" },
{ name = "pydantic", specifier = ">=2.12.5" },
+ { name = "pydantic-settings", specifier = ">=2.13.1" },
+ { name = "pyjwt", specifier = ">=2.8.0" },
+ { 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 = "pyyaml", specifier = ">=6.0.2" },
{ name = "scikit-learn", specifier = ">=1.5.0" },
{ name = "sentence-transformers", specifier = ">=3.0.0" },
+ { name = "slowapi", specifier = ">=0.1.9" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.42.0" },
]
+provides-extras = ["dev"]
[package.metadata.requires-dev]
-dev = [{ name = "pytest", specifier = ">=9.0.2" }]
+dev = [
+ { name = "pytest", specifier = ">=9.0.2" },
+ { name = "pytest-asyncio", specifier = ">=0.23.0" },
+]
+
+[[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 = "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"
+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"
@@ -239,6 +522,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"
@@ -312,6 +652,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"
@@ -324,6 +717,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"
@@ -333,6 +759,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"
@@ -356,6 +853,21 @@ 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 = "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"
@@ -369,6 +881,50 @@ 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"
+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 = "deprecated"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
+]
+
[[package]]
name = "distro"
version = "1.9.0"
@@ -378,6 +934,38 @@ 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"
+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"
@@ -387,6 +975,29 @@ 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 = "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 = "fastapi"
version = "0.135.1"
@@ -510,6 +1121,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
]
+[[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"
@@ -519,6 +1154,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" },
@@ -527,6 +1163,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" },
@@ -535,6 +1172,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" },
@@ -543,6 +1181,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" },
@@ -685,6 +1324,18 @@ 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"
@@ -774,6 +1425,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 = "joblib"
version = "1.5.3"
@@ -783,6 +1443,15 @@ 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" },
]
+[[package]]
+name = "json-repair"
+version = "0.58.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/70/484f97a744d2614218a2b162004accda3f3c4ccc8c5d688712624567ebec/json_repair-0.58.6.tar.gz", hash = "sha256:aa740113a1c9dede4ba84c29aa8f81493253aede6f0e4edde9a560ec4b1d7762", size = 44804, upload-time = "2026-03-16T13:43:34.722Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/bb/c019ac05a6923c5776fa134c65e5b19d216ef17227618d93b1f608bc2806/json_repair-0.58.6-py3-none-any.whl", hash = "sha256:e438a1e4ea03179dfe9a05dfd738e678e888f1ea5b4a40398f8f220925df1c5c", size = 43482, upload-time = "2026-03-16T13:43:33.569Z" },
+]
+
[[package]]
name = "jsonpatch"
version = "1.33"
@@ -804,6 +1473,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 = "kuzu"
version = "0.11.3"
@@ -842,11 +1550,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/1d/a509af07535d8f4621d77f3ba5ec846ee6d52c59d2239e1385ec3b29bf92/langchain-1.2.13-py3-none-any.whl", hash = "sha256:37d4526ac4b0cdd3d7706a6366124c30dc0771bf5340865b37cdc99d5e5ad9b1", size = 112488, upload-time = "2026-03-19T17:16:06.134Z" },
]
-[package.optional-dependencies]
-ollama = [
- { name = "langchain-ollama" },
-]
-
[[package]]
name = "langchain-anthropic"
version = "1.4.0"
@@ -921,19 +1624,6 @@ 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-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"
@@ -976,6 +1666,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"
@@ -1037,6 +1741,130 @@ 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 = "limits"
+version = "5.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "deprecated" },
+ { name = "packaging" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" },
+]
+
+[[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"
@@ -1124,13 +1952,50 @@ 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" },
]
+[[package]]
+name = "mcp"
+version = "1.26.0"
+source = { registry = "https://pypi.org/simple" }
+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/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 = "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/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/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 = "motor"
+version = "3.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pymongo" },
+]
+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]]
@@ -1252,11 +2117,11 @@ wheels = [
[[package]]
name = "networkx"
-version = "3.6.1"
+version = "2.6.3"
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" }
+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/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
+ { 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]]
@@ -1454,19 +2319,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
]
-[[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"
@@ -1559,13 +2411,22 @@ 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/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/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/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/ec/1a/610693ac4ee14fcdf2d9bf3c493370e4f2ef7ae2e19217d7a237ff42367d/packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7", size = 53011, upload-time = "2023-10-01T13:50:03.745Z" },
]
[[package]]
@@ -1629,6 +2490,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"
@@ -1713,6 +2650,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"
@@ -1822,6 +2857,80 @@ 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 = "pyjwt"
+version = "2.12.1"
+source = { registry = "https://pypi.org/simple" }
+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/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.optional-dependencies]
+crypto = [
+ { name = "cryptography" },
+]
+
+[[package]]
+name = "pymongo"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+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]]
+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"
@@ -1838,6 +2947,19 @@ 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"
@@ -1859,6 +2981,31 @@ 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-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"
+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"
@@ -1905,6 +3052,32 @@ 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"
@@ -2033,6 +3206,121 @@ 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 = "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"
@@ -2055,6 +3343,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"
@@ -2160,6 +3460,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"
@@ -2206,6 +3515,27 @@ 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 = "slowapi"
+version = "0.1.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "limits" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" },
+]
+
+[[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"
@@ -2215,6 +3545,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"
@@ -2261,6 +3629,31 @@ 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 = "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"
@@ -2286,6 +3679,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
+[[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"
@@ -2295,6 +3697,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"
@@ -2492,6 +3903,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
+[[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"
@@ -2649,6 +4078,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"
@@ -2694,6 +4132,79 @@ 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 = "wrapt"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" },
+ { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" },
+ { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" },
+ { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" },
+ { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" },
+ { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" },
+ { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" },
+ { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" },
+ { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" },
+ { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" },
+ { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" },
+ { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" },
+ { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" },
+ { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" },
+ { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
+ { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
+ { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
+ { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
+ { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
+ { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
+ { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
+ { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
+]
+
+[[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"
@@ -2881,6 +4392,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"
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.
diff --git a/frontend/src/app/(app)/app/[id]/architecture/page.tsx b/frontend/src/app/(app)/app/[id]/architecture/page.tsx
new file mode 100644
index 0000000..1436445
--- /dev/null
+++ b/frontend/src/app/(app)/app/[id]/architecture/page.tsx
@@ -0,0 +1,5 @@
+import ArchitecturePanel from '@/components/forge/ArchitecturePanel';
+
+export default function ArchitecturePage() {
+ return ;
+}
diff --git a/frontend/src/app/(app)/app/[id]/deploy/page.tsx b/frontend/src/app/(app)/app/[id]/deploy/page.tsx
new file mode 100644
index 0000000..af5bf66
--- /dev/null
+++ b/frontend/src/app/(app)/app/[id]/deploy/page.tsx
@@ -0,0 +1,5 @@
+import DeployPanel from '@/components/forge/DeployPanel';
+
+export default function DeployPage() {
+ return ;
+}
diff --git a/frontend/src/app/(app)/app/[id]/layout.tsx b/frontend/src/app/(app)/app/[id]/layout.tsx
new file mode 100644
index 0000000..299f268
--- /dev/null
+++ b/frontend/src/app/(app)/app/[id]/layout.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { useParams, usePathname } from 'next/navigation';
+import ForgeTopNav from '@/components/forge/ForgeTopNav';
+import ForgeChatPanel from '@/components/forge/ForgeChatPanel';
+import ForgeDeployModal from '@/components/forge/ForgeDeployModal';
+import { useForgeStore, type ForgeStage } from '@/store/forgeStore';
+
+const PATHNAME_TO_STAGE: Record = {
+ requirements: 'requirements',
+ architecture: 'architecture',
+ build: 'build',
+ deploy: 'deploy',
+};
+
+export default function ForgeLayout({ children }: { children: React.ReactNode }) {
+ const params = useParams();
+ const pathname = usePathname();
+ const hydrated = useRef(false);
+
+ const id = typeof params.id === 'string' ? params.id : Array.isArray(params.id) ? params.id[0] : '';
+
+ // Hydrate project state once on mount
+ useEffect(() => {
+ if (hydrated.current || !id) return;
+ hydrated.current = true;
+ useForgeStore.getState().setCurrentProjectId(id);
+ useForgeStore.getState().hydrateProject(id);
+ }, [id]);
+
+ // Sync activeStage to store based on pathname segment
+ useEffect(() => {
+ const segment = pathname.split('/').filter(Boolean).pop() ?? '';
+ const stage = PATHNAME_TO_STAGE[segment];
+ if (!stage) return;
+ useForgeStore.setState({ activeStage: stage });
+ }, [pathname]);
+
+ return (
+
+ {/* Persistent top nav — never unmounts */}
+
+
+ {/* Body: chat panel + main content */}
+
+ {/* Persistent chat panel — never unmounts across route changes */}
+
+
+ {/* Right panel — swapped per route */}
+
+ {children}
+
+
+
+ {/* Deploy confirmation modal — rendered at root so it overlays everything */}
+
+
+ );
+}
diff --git a/frontend/src/app/(app)/app/[id]/requirements/page.tsx b/frontend/src/app/(app)/app/[id]/requirements/page.tsx
new file mode 100644
index 0000000..e0e3e87
--- /dev/null
+++ b/frontend/src/app/(app)/app/[id]/requirements/page.tsx
@@ -0,0 +1,5 @@
+import RequirementsPanel from '@/components/forge/RequirementsPanel';
+
+export default function RequirementsPage() {
+ return ;
+}
diff --git a/frontend/src/app/(app)/billing/page.tsx b/frontend/src/app/(app)/billing/page.tsx
index 8c49c60..7e2bf48 100644
--- a/frontend/src/app/(app)/billing/page.tsx
+++ b/frontend/src/app/(app)/billing/page.tsx
@@ -3,7 +3,7 @@
import { motion } from 'framer-motion';
import { Check, Download, Zap, Server, GitBranch, Hammer } from 'lucide-react';
-// ── Mock data ─────────────────────────────────────────────────────────────────
+// ── Static data ───────────────────────────────────────────────────────────────
interface Invoice {
id: string;
@@ -13,7 +13,8 @@ interface Invoice {
period: string;
}
-const MOCK_INVOICES: Invoice[] = [
+// Static placeholder — billing integration coming soon
+const SAMPLE_INVOICES: Invoice[] = [
{ id: 'inv-2026-03', date: 'Mar 1, 2026', amount: '$49.00', status: 'paid', period: 'Mar 2026' },
{ id: 'inv-2026-02', date: 'Feb 1, 2026', amount: '$49.00', status: 'paid', period: 'Feb 2026' },
{ id: 'inv-2026-01', date: 'Jan 1, 2026', amount: '$49.00', status: 'paid', period: 'Jan 2026' },
@@ -508,7 +509,7 @@ export default function BillingPage() {
{/* Rows */}
- {MOCK_INVOICES.map((inv, i) => (
+ {SAMPLE_INVOICES.map((inv, i) => (
diff --git a/frontend/src/app/(app)/dashboard/page.tsx b/frontend/src/app/(app)/dashboard/page.tsx
index cadb7b6..ed1b978 100644
--- a/frontend/src/app/(app)/dashboard/page.tsx
+++ b/frontend/src/app/(app)/dashboard/page.tsx
@@ -1,19 +1,21 @@
'use client';
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
-import { Plus, Globe2, Clock } from 'lucide-react';
+import { Plus, Globe2, Clock, Trash2 } from 'lucide-react';
import { useProjectStore } from '@/store/projectStore';
import { useForgeStore } from '@/store/forgeStore';
+import { useAuthStore } from '@/store/authStore';
import type { Project } from '@/lib/mock-data';
import type { ForgeStage } from '@/store/forgeStore';
import StatusBadge from '@/components/cloudforge/StatusBadge';
// ── Sub-components ────────────────────────────────────────────────────────────
-function ProjectCard({ project, index, onClick }: { project: Project; index: number; onClick?: () => void }) {
+function ProjectCard({ project, index, onClick, onDelete }: { project: Project; index: number; onClick?: () => void; onDelete?: () => void }) {
const [hovered, setHovered] = useState(false);
+ const [deleteHovered, setDeleteHovered] = useState(false);
return (
{ if (e.key === 'Enter' || e.key === ' ') onClick?.(); }}
style={{
+ position: 'relative',
background: 'var(--lp-surface)',
border: `0.5px solid ${hovered ? 'var(--lp-border-hover)' : 'var(--lp-border)'}`,
borderRadius: '12px',
@@ -36,9 +39,39 @@ function ProjectCard({ project, index, onClick }: { project: Project; index: num
transition: 'border-color 150ms ease',
display: 'flex',
flexDirection: 'column',
+ minHeight: '180px',
}}
aria-label={`Project: ${project.name}, status: ${project.status}`}
>
+ {hovered && (
+
+ )}
+
{/* Top row: name + badge */}
void }) {
- const [hovered, setHovered] = useState(false);
-
- return (
-
setHovered(true)}
- onHoverEnd={() => setHovered(false)}
- style={{
- background: 'transparent',
- border: `1px dashed ${hovered ? 'var(--lp-accent)' : 'var(--lp-border-hover)'}`,
- borderRadius: '12px',
- padding: '20px',
- cursor: 'pointer',
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'center',
- justifyContent: 'center',
- gap: '8px',
- minHeight: '160px',
- transition: 'border-color 150ms ease',
- width: '100%',
- textAlign: 'center',
- }}
- aria-label="Create a new project"
- type="button"
- onClick={onClick}
- >
-
-
-
-
- New project
-
-
- Start from a PRD or idea
-
-
- );
-}
-
// ── Page ──────────────────────────────────────────────────────────────────────
const OLD_STAGE_TO_FORGE: Record
= {
@@ -206,19 +168,87 @@ const OLD_STAGE_TO_FORGE: Record = {
};
export default function DashboardPage() {
- const { projects } = useProjectStore();
+ const { projects, loadProjects, createApiProject, deleteApiProject, isLoading, loadError } = useProjectStore();
+ const { accessToken } = useAuthStore();
const router = useRouter();
- function handleNewProject() {
- useForgeStore.getState().setProjectName('New Project');
- useForgeStore.getState().setStageStatus('requirements', 'processing');
- router.push('/app/requirements');
+ const [isCreating, setIsCreating] = useState(false);
+ const [error, setError] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ useEffect(() => {
+ if (accessToken) {
+ loadProjects(accessToken);
+ }
+ }, [accessToken, loadProjects]);
+
+ async function handleNewProject() {
+ if (!accessToken) {
+ setError('You must be logged in to create a project.');
+ return;
+ }
+ setIsCreating(true);
+ setError(null);
+ try {
+ const name = `project-${Date.now()}`;
+ const project = await createApiProject(name, accessToken);
+ useForgeStore.getState().setProjectName(project.name);
+ useForgeStore.getState().setCurrentProjectId(project.id);
+ useForgeStore.getState().setStageStatus('requirements', 'locked');
+ useForgeStore.getState().setPrdText('');
+ router.push(`/app/${project.id}/requirements`);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to create project. Please try again.');
+ } finally {
+ setIsCreating(false);
+ }
+ }
+
+ async function handleDeleteProject(id: string) {
+ if (!accessToken) return;
+ setIsDeleting(true);
+ try {
+ await deleteApiProject(id, accessToken);
+ setDeleteTarget(null);
+ } catch {
+ // stay open on error
+ } finally {
+ setIsDeleting(false);
+ }
}
function handleOpenProject(project: Project) {
useForgeStore.getState().setProjectName(project.name);
+ useForgeStore.getState().setCurrentProjectId(project.id);
+ useForgeStore.getState().hydrateProject(project.id);
const forgeStage: ForgeStage = OLD_STAGE_TO_FORGE[project.stage] ?? 'requirements';
- router.push(`/app/${forgeStage}`);
+ router.push(`/app/${project.id}/${forgeStage}`);
+ }
+
+ if (isLoading && projects.length === 0) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ if (loadError) {
+ return (
+
+
+ {loadError}
+
+
+
+ );
}
return (
@@ -262,15 +292,31 @@ export default function DashboardPage() {
fontSize: '13px',
fontWeight: 500,
fontFamily: 'var(--font-inter), system-ui, sans-serif',
+ opacity: isCreating ? 0.6 : 1,
+ cursor: isCreating ? 'not-allowed' : 'pointer',
}}
aria-label="Create a new project"
onClick={handleNewProject}
+ disabled={isCreating}
>
- New project
+ {isCreating ? 'Creating…' : 'New project'}
+ {error && (
+
+ {error}
+
+ )}
+
{/* Empty state */}
{projects.length === 0 && (
@@ -314,15 +360,107 @@ export default function DashboardPage() {
project={project}
index={i}
onClick={() => handleOpenProject(project)}
+ onDelete={() => setDeleteTarget(project.id)}
/>
))}
- {/* Empty state / new project card */}
-
-
-
+
+ {deleteTarget !== null && (
+ setDeleteTarget(null)}
+ >
+
e.stopPropagation()}
+ >
+
+ Delete project?
+
+
+ This action cannot be undone. All project data will be permanently deleted.
+
+
+
+
+
+
+
+ )}
);
}
diff --git a/frontend/src/app/(app)/history/page.tsx b/frontend/src/app/(app)/history/page.tsx
index fdfb21a..1548e8c 100644
--- a/frontend/src/app/(app)/history/page.tsx
+++ b/frontend/src/app/(app)/history/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import {
Rocket,
@@ -13,7 +13,7 @@ import {
Filter,
} from 'lucide-react';
-// ── Mock data ─────────────────────────────────────────────────────────────────
+// ── Types ─────────────────────────────────────────────────────────────────────
type EventType = 'deploy' | 'build' | 'prd' | 'error';
type EventStatus = 'success' | 'failed' | 'in_progress';
@@ -26,126 +26,159 @@ interface HistoryEvent {
description: string;
region: string;
timestamp: string;
- duration?: string;
}
-const MOCK_HISTORY: HistoryEvent[] = [
- {
- id: 'evt-001',
- type: 'deploy',
- status: 'success',
- project: 'auth-service-api',
- description: 'Deployed to us-east-1 — 4 resources provisioned',
- region: 'us-east-1',
- timestamp: '2026-03-21 · 14:32',
- duration: '2m 14s',
- },
- {
- id: 'evt-002',
- type: 'build',
- status: 'success',
- project: 'auth-service-api',
- description: 'Terraform plan generated — 4 to add, 0 to change',
- region: 'us-east-1',
- timestamp: '2026-03-21 · 14:28',
- duration: '44s',
- },
- {
- id: 'evt-003',
- type: 'prd',
- status: 'success',
- project: 'data-pipeline',
- description: 'PRD confirmed — 6 functional requirements locked',
- region: 'eu-west-2',
- timestamp: '2026-03-21 · 11:05',
- },
- {
- id: 'evt-004',
- type: 'build',
- status: 'in_progress',
- project: 'data-pipeline',
- description: 'Generating Terraform modules — writing lambda.tf',
- region: 'eu-west-2',
- timestamp: '2026-03-21 · 11:09',
- duration: '1m 32s',
- },
- {
- id: 'evt-005',
- type: 'error',
- status: 'failed',
- project: 'ml-inference-layer',
- description: 'Deploy failed — IAM role limit reached in us-west-2',
- region: 'us-west-2',
- timestamp: '2026-03-20 · 17:44',
- },
- {
- id: 'evt-006',
- type: 'build',
- status: 'failed',
- project: 'ml-inference-layer',
- description: 'Terraform apply aborted — resource limit exceeded',
- region: 'us-west-2',
- timestamp: '2026-03-20 · 17:42',
- duration: '58s',
- },
- {
- id: 'evt-007',
- type: 'deploy',
- status: 'success',
- project: 'auth-service-api',
- description: 'Rolled back to v1.2.0 — previous state restored',
- region: 'us-east-1',
- timestamp: '2026-03-19 · 09:18',
- duration: '1m 03s',
- },
- {
- id: 'evt-008',
- type: 'prd',
- status: 'success',
- project: 'auth-service-api',
- description: 'PRD updated — added rate limiting requirement',
- region: 'us-east-1',
- timestamp: '2026-03-18 · 16:55',
- },
- {
- id: 'evt-009',
- type: 'build',
- status: 'success',
- project: 'auth-service-api',
- description: 'Architecture diagram finalized — 8 nodes, 4 edges',
- region: 'us-east-1',
- timestamp: '2026-03-18 · 15:30',
- duration: '22s',
- },
- {
- id: 'evt-010',
- type: 'deploy',
- status: 'success',
- project: 'data-pipeline',
- description: 'Initial scaffold deployed — S3 + Glue + Lambda',
- region: 'eu-west-2',
- timestamp: '2026-03-17 · 13:10',
- duration: '3m 07s',
- },
- {
- id: 'evt-011',
- type: 'error',
- status: 'failed',
- project: 'data-pipeline',
- description: 'Build timeout — Glue job exceeded 5 min limit',
- region: 'eu-west-2',
- timestamp: '2026-03-16 · 22:11',
- },
- {
- id: 'evt-012',
+// ── API ───────────────────────────────────────────────────────────────────────
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
+
+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 {};
+ }
+}
+
+interface BuildRecord {
+ id: string;
+ project_id: string;
+ project_name: string;
+ status: string;
+ created_at: string;
+ artifacts_count?: number;
+ generated_files_count?: number;
+}
+
+interface DeploymentRecord {
+ id: string;
+ project_id: string;
+ project_name: string;
+ status: string;
+ provider?: string;
+ region?: string;
+ created_at: string;
+}
+
+interface PrdRecord {
+ id: string;
+ project_id: string;
+ project_name: string;
+ session_id?: string;
+ status: string;
+ created_at: string;
+}
+
+function normalizeStatus(raw: string): EventStatus {
+ const s = raw.toLowerCase();
+ if (s === 'success' || s === 'completed' || s === 'done') return 'success';
+ if (s === 'failed' || s === 'error') return 'failed';
+ return 'in_progress';
+}
+
+function formatTimestamp(iso: string): string {
+ try {
+ const d = new Date(iso);
+ const date = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ const time = d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
+ return `${date} · ${time}`;
+ } catch {
+ return iso;
+ }
+}
+
+function mapBuildToEvent(b: BuildRecord): HistoryEvent {
+ const status = normalizeStatus(b.status);
+ const fileCount = b.generated_files_count ?? b.artifacts_count;
+ const description = fileCount != null
+ ? `Terraform plan generated — ${fileCount} file${fileCount === 1 ? '' : 's'} produced`
+ : 'Build completed';
+ return {
+ id: `build-${b.id}`,
+ type: status === 'failed' ? 'error' : 'build',
+ status,
+ project: b.project_name || b.project_id,
+ description,
+ region: '',
+ timestamp: formatTimestamp(b.created_at),
+ };
+}
+
+function mapDeploymentToEvent(d: DeploymentRecord): HistoryEvent {
+ const status = normalizeStatus(d.status);
+ const region = d.region || d.provider || 'unknown';
+ const description = status === 'failed'
+ ? `Deploy failed — ${region}`
+ : `Deployed to ${region}`;
+ return {
+ id: `deploy-${d.id}`,
+ type: status === 'failed' ? 'error' : 'deploy',
+ status,
+ project: d.project_name || d.project_id,
+ description,
+ region,
+ timestamp: formatTimestamp(d.created_at),
+ };
+}
+
+function mapPrdToEvent(p: PrdRecord): HistoryEvent {
+ const status = normalizeStatus(p.status);
+ const description = status === 'failed'
+ ? 'PRD processing failed'
+ : 'PRD confirmed — requirements locked';
+ return {
+ id: `prd-${p.id}`,
type: 'prd',
- status: 'success',
- project: 'ml-inference-layer',
- description: 'PRD saved — SageMaker endpoint spec confirmed',
- region: 'us-west-2',
- timestamp: '2026-03-15 · 10:02',
- },
-];
+ status,
+ project: p.project_name || p.project_id,
+ description,
+ region: '',
+ timestamp: formatTimestamp(p.created_at),
+ };
+}
+
+async function fetchHistory(): Promise {
+ const headers = { ...authHeaders(), 'Content-Type': 'application/json' };
+
+ const [buildsRes, deploymentsRes, prdsRes] = await Promise.allSettled([
+ fetch(`${API_URL}/history/builds?limit=20`, { headers }),
+ fetch(`${API_URL}/history/deployments?limit=20`, { headers }),
+ fetch(`${API_URL}/history/prd?limit=20`, { headers }),
+ ]);
+
+ const events: HistoryEvent[] = [];
+
+ if (buildsRes.status === 'fulfilled' && buildsRes.value.ok) {
+ const data: BuildRecord[] = await buildsRes.value.json();
+ events.push(...data.map(mapBuildToEvent));
+ }
+
+ if (deploymentsRes.status === 'fulfilled' && deploymentsRes.value.ok) {
+ const data: DeploymentRecord[] = await deploymentsRes.value.json();
+ events.push(...data.map(mapDeploymentToEvent));
+ }
+
+ if (prdsRes.status === 'fulfilled' && prdsRes.value.ok) {
+ const data: PrdRecord[] = await prdsRes.value.json();
+ events.push(...data.map(mapPrdToEvent));
+ }
+
+ // Sort by timestamp descending (newest first)
+ events.sort((a, b) => {
+ try {
+ return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
+ } catch {
+ return 0;
+ }
+ });
+
+ return events;
+}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -179,8 +212,8 @@ function statusIcon(status: EventStatus) {
function statusColor(status: EventStatus): string {
switch (status) {
- case 'success': return '#34d399'; // emerald
- case 'failed': return '#f87171'; // red
+ case 'success': return '#34d399';
+ case 'failed': return '#f87171';
case 'in_progress': return 'var(--lp-accent)';
}
}
@@ -236,6 +269,41 @@ function FilterPill({
);
}
+function SkeletonRow({ index }: { index: number }) {
+ return (
+
+
+
+
+
+ );
+}
+
function EventRow({ event, index }: { event: HistoryEvent; index: number }) {
const color = statusColor(event.status);
@@ -284,7 +352,6 @@ function EventRow({ event, index }: { event: HistoryEvent; index: number }) {
flexWrap: 'wrap',
}}
>
- {/* Type pill */}
·
- {/* Project name */}
{event.timestamp}
- {event.duration && (
+ {event.region && (
- {event.duration}
+ {event.region}
)}
@@ -380,12 +446,38 @@ function EventRow({ event, index }: { event: HistoryEvent; index: number }) {
export default function HistoryPage() {
const [filter, setFilter] = useState('all');
+ const [events, setEvents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ setError(null);
+
+ fetchHistory()
+ .then((data) => {
+ if (!cancelled) {
+ setEvents(data);
+ }
+ })
+ .catch((err) => {
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : 'Failed to load history.');
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+
+ return () => { cancelled = true; };
+ }, []);
const filtered = filter === 'all'
- ? MOCK_HISTORY
+ ? events
: filter === 'error'
- ? MOCK_HISTORY.filter((e) => e.status === 'failed')
- : MOCK_HISTORY.filter((e) => e.type === filter);
+ ? events.filter((e) => e.status === 'failed')
+ : events.filter((e) => e.type === filter);
return (
- {filtered.length === 0 ? (
+ {loading ? (
+ Array.from({ length: 6 }).map((_, i) => (
+
+
+
+ ))
+ ) : error ? (
+
+ {error}
+
+ ) : filtered.length === 0 ? (
- No events match this filter.
+ {events.length === 0 ? 'No activity yet.' : 'No events match this filter.'}
) : (
filtered.map((event, i) => (
diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx
index 382fa2a..5f54f34 100644
--- a/frontend/src/app/(app)/layout.tsx
+++ b/frontend/src/app/(app)/layout.tsx
@@ -2,9 +2,9 @@ import AppSidebar from '@/components/cloudforge/AppSidebar';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
-
+
-
{children}
+
{children}
);
}
diff --git a/frontend/src/app/(app)/project/[id]/ProjectShell.tsx b/frontend/src/app/(app)/project/[id]/ProjectShell.tsx
index 3863259..fdde2ab 100644
--- a/frontend/src/app/(app)/project/[id]/ProjectShell.tsx
+++ b/frontend/src/app/(app)/project/[id]/ProjectShell.tsx
@@ -1,23 +1,33 @@
'use client';
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useParams, usePathname, useRouter } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion';
import ProjectTabBar from '@/components/cloudforge/ProjectTabBar';
import { useProjectStore, STAGE_ORDER } from '@/store/projectStore';
+import { useForgeStore } from '@/store/forgeStore';
+import { authHeaders } from '@/lib/forge-agents';
import type { ProjectStage } from '@/store/projectStore';
+import type { Project } from '@/lib/mock-data';
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export default function ProjectShell({ children }: { children: React.ReactNode }) {
const params = useParams();
const id = typeof params.id === 'string' ? params.id : Array.isArray(params.id) ? params.id[0] : '';
const pathname = usePathname();
const router = useRouter();
- const { projects } = useProjectStore();
+ const { projects, apiProjects } = useProjectStore();
+ const { setCurrentProjectId, hydrateProject } = useForgeStore();
+
+ // Local fetched project fallback for hard reloads when store is empty
+ const [fetchedProject, setFetchedProject] = useState
(null);
+ const hydratedRef = useRef(false);
const segments = pathname.split('/');
const currentRoute = segments[segments.length - 1];
- const project = projects.find((p) => p.id === id);
+ const project = projects.find((p) => p.id === id) ?? fetchedProject;
// Direction tracking: ref mutation is deferred to useEffect, never during render
const prevIndexRef = useRef(null);
@@ -32,19 +42,61 @@ export default function ProjectShell({ children }: { children: React.ReactNode }
}
}, [currentIndex]);
- // Guard: redirect if project missing or route is locked
+ // On mount: fetch project from API if not in store (hard reload case), then hydrate forge state
useEffect(() => {
- if (!project) {
- router.replace('/dashboard');
- return;
+ if (!id) return;
+
+ setCurrentProjectId(id);
+
+ const headers = authHeaders();
+ if (!headers.Authorization) return;
+
+ // Fetch the project if not already in store
+ if (!projects.find((p) => p.id === id) && !apiProjects.find((p) => p.id === id)) {
+ fetch(`${API_URL}/projects/${id}`, { headers })
+ .then((r) => r.ok ? r.json() : null)
+ .then((data) => {
+ if (!data) return;
+ const now = Date.now();
+ const diffMs = now - new Date(data.updated_at).getTime();
+ const diffMin = Math.floor(diffMs / 60000);
+ const diffHr = Math.floor(diffMs / 3600000);
+ const updatedAt =
+ diffMin < 60 ? `${diffMin}m ago` :
+ diffHr < 24 ? `${diffHr}h ago` :
+ new Date(data.updated_at).toLocaleDateString();
+ setFetchedProject({
+ id: data.id,
+ name: data.name,
+ description: data.description ?? '',
+ stage: data.stage,
+ status: data.status,
+ region: data.region ?? 'us-east-1',
+ updatedAt,
+ });
+ })
+ .catch(() => {});
}
- const routeIndex = STAGE_ORDER.indexOf(currentRoute as ProjectStage);
- if (routeIndex !== -1 && routeIndex > STAGE_ORDER.indexOf(project.stage)) {
- router.replace(`/project/${id}/${project.stage}`);
+
+ // Hydrate forge state once per mount
+ if (!hydratedRef.current) {
+ hydratedRef.current = true;
+ hydrateProject(id);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [id]);
+
+ // Guard: redirect if project confirmed missing
+ useEffect(() => {
+ if (project) {
+ const routeIndex = STAGE_ORDER.indexOf(currentRoute as ProjectStage);
+ if (routeIndex !== -1 && routeIndex > STAGE_ORDER.indexOf(project.stage)) {
+ router.replace(`/project/${id}/${project.stage}`);
+ }
}
}, [project, currentRoute, id, router]);
- // While guard is pending (project not found yet), show nothing to avoid flash
+ // While loading, show nothing to avoid flash
if (!project) return null;
return (
diff --git a/frontend/src/app/(app)/project/[id]/arch/page.tsx b/frontend/src/app/(app)/project/[id]/arch/page.tsx
index a238371..00e37e8 100644
--- a/frontend/src/app/(app)/project/[id]/arch/page.tsx
+++ b/frontend/src/app/(app)/project/[id]/arch/page.tsx
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
-export default function ArchPage() {
- redirect('/app/architecture');
+export default function ArchPage({ params }: { params: { id: string } }) {
+ redirect(`/app/${params.id}/architecture`);
}
diff --git a/frontend/src/app/(app)/project/[id]/live/page.tsx b/frontend/src/app/(app)/project/[id]/live/page.tsx
index 30756ab..9e663fd 100644
--- a/frontend/src/app/(app)/project/[id]/live/page.tsx
+++ b/frontend/src/app/(app)/project/[id]/live/page.tsx
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
-export default function LivePage() {
- redirect('/app/deploy');
+export default function LivePage({ params }: { params: { id: string } }) {
+ redirect(`/app/${params.id}/deploy`);
}
diff --git a/frontend/src/app/(app)/project/[id]/prd/page.tsx b/frontend/src/app/(app)/project/[id]/prd/page.tsx
index 323cd0d..a296672 100644
--- a/frontend/src/app/(app)/project/[id]/prd/page.tsx
+++ b/frontend/src/app/(app)/project/[id]/prd/page.tsx
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
-export default function PrdPage() {
- redirect('/app/requirements');
+export default function PrdPage({ params }: { params: { id: string } }) {
+ redirect(`/app/${params.id}/requirements`);
}
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
index 96fb7ab..5bf12d7 100644
--- a/frontend/src/app/(auth)/login/page.tsx
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -4,78 +4,12 @@ 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 { Eye, EyeOff, ArrowRight } from 'lucide-react';
+import { useAuthStore } from '@/store/authStore';
-// ── Sub-components ────────────────────────────────────────────────────────────
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
-function OAuthButton({
- icon,
- label,
- onClick,
-}: {
- icon: React.ReactNode;
- label: string;
- onClick: () => void;
-}) {
- const [hovered, setHovered] = useState(false);
- return (
-
- );
-}
-
-function Divider() {
- return (
-
- );
-}
+// ── Sub-components ────────────────────────────────────────────────────────────
function FormInput({
id,
@@ -160,25 +94,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(() => {
- router.push('/dashboard');
- }, 800);
- }
-
- function handleOAuth() {
- setLoading(true);
- 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');
- }, 600);
+ } catch {
+ setError('Unable to reach the server. Please try again.');
+ } finally {
+ setLoading(false);
+ }
}
return (
@@ -254,28 +199,24 @@ export default function LoginPage() {
Sign in to your CloudForge account
- {/* OAuth */}
-
-
}
- label="GitHub"
- onClick={handleOAuth}
- />
-
-
-
-
-
-
- }
- label="Google"
- onClick={handleOAuth}
- />
-
-
-
+ {/* Error message */}
+ {error && (
+
+ {error}
+
+ )}
{/* Email form */}
{/* Nav links */}
*/}
{/* Tabs */}
) : (
- messages.map((msg) => (
-
- ))
+ <>
+ {messages.map((msg) => (
+
handleClarificationSubmit(msg.clarificationCard!.id, answers)
+ : undefined
+ }
+ clarificationSubmitted={
+ msg.clarificationCard ? submittedCards.has(msg.clarificationCard.id) : undefined
+ }
+ />
+ ))}
+
+ {stageStatus[activeStage] === 'processing' && }
+
+ >
)}
) : (
@@ -742,7 +1439,11 @@ export default function ForgeChatPanel() {
{/* Input bar — always rendered */}
-
+ { clarificationHandlerRef.current = fn; }}
+ />
+ >
);
}
diff --git a/frontend/src/components/forge/ForgeDeployModal.tsx b/frontend/src/components/forge/ForgeDeployModal.tsx
index 4d82e82..1bc8ea1 100644
--- a/frontend/src/components/forge/ForgeDeployModal.tsx
+++ b/frontend/src/components/forge/ForgeDeployModal.tsx
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
-import { useRouter } from 'next/navigation';
+import { useRouter, useParams } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { AlertTriangle, X } from 'lucide-react';
import { useForgeStore } from '@/store/forgeStore';
@@ -10,12 +10,14 @@ export default function ForgeDeployModal() {
const { deployModalOpen, setDeployModalOpen, projectName, advanceStage, setStageStatus } =
useForgeStore();
const router = useRouter();
+ const params = useParams();
+ const id = typeof params.id === 'string' ? params.id : Array.isArray(params.id) ? params.id[0] : '';
function handleDeploy() {
setDeployModalOpen(false);
setStageStatus('deploy', 'processing');
advanceStage();
- router.push('/app/deploy');
+ router.push(`/app/${id}/deploy`);
}
function handleCancel() {
diff --git a/frontend/src/components/forge/ForgeTopNav.tsx b/frontend/src/components/forge/ForgeTopNav.tsx
index ffd0ff8..d2d22e7 100644
--- a/frontend/src/components/forge/ForgeTopNav.tsx
+++ b/frontend/src/components/forge/ForgeTopNav.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useRouter } from 'next/navigation';
+import { useRouter, useParams } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Lock } from 'lucide-react';
import {
@@ -15,12 +15,14 @@ import {
function StageBreadcrumb() {
const { activeStage, stageStatus, navigateToStage } = useForgeStore();
const router = useRouter();
+ const params = useParams();
+ const projectId = typeof params.id === 'string' ? params.id : Array.isArray(params.id) ? params.id[0] : '';
function handleStageClick(stage: ForgeStage) {
const isDev = process.env.NODE_ENV === 'development';
if (!isDev && stageStatus[stage] === 'locked') return;
navigateToStage(stage);
- router.push(`/app/${stage}`);
+ router.push(`/app/${projectId}/${stage}`);
}
return (
diff --git a/frontend/src/components/forge/RequirementsPanel.tsx b/frontend/src/components/forge/RequirementsPanel.tsx
index d8e434f..ac46ca6 100644
--- a/frontend/src/components/forge/RequirementsPanel.tsx
+++ b/frontend/src/components/forge/RequirementsPanel.tsx
@@ -1,11 +1,9 @@
'use client';
import { useEffect, useRef, useState } from 'react';
-import { useRouter } from 'next/navigation';
+import { useRouter, useParams } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { useForgeStore } from '@/store/forgeStore';
-import { runAgent1 } from '@/lib/forge-agents';
-import type { ConstraintChip } from '@/store/forgeStore';
// ── Pulsing dot for processing state ─────────────────────────────────────────
@@ -29,10 +27,86 @@ function PulsingDot() {
);
}
+// ── Simple markdown → HTML renderer ──────────────────────────────────────────
+
+function renderMarkdown(raw: string): string {
+ // Step 1: escape HTML
+ let s = raw
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+
+ // Step 2: fenced code blocks (protect from further processing)
+ const codeBlocks: string[] = [];
+ s = s.replace(/```(?:[a-zA-Z]*)\n?([\s\S]*?)```/g, (_m, code: string) => {
+ const idx = codeBlocks.length;
+ codeBlocks.push(
+ `${code}
`
+ );
+ return `\x00CB${idx}\x00`;
+ });
+
+ // Step 3: inline code
+ s = s.replace(/`([^`\n]+)`/g, (_m, code: string) =>
+ `${code}`
+ );
+
+ // Step 4: headers
+ s = s
+ .replace(/^#### (.+)$/gm, '$1
')
+ .replace(/^### (.+)$/gm, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
');
+
+ // Step 5: bold + italic
+ s = s
+ .replace(/\*\*\*([^*\n]+)\*\*\*/g, '$1')
+ .replace(/\*\*([^*\n]+)\*\*/g, '$1')
+ .replace(/\*([^*\n]+)\*/g, '$1');
+
+ // Step 6: horizontal rules
+ s = s.replace(/^---$/gm, '
');
+
+ // Step 7: blockquotes (> is already > after escaping)
+ s = s.replace(/^> (.+)$/gm,
+ '$1
'
+ );
+
+ // Step 8: list items
+ s = s.replace(/^[*-] (.+)$/gm,
+ '$1'
+ );
+ s = s.replace(/^\d+\. (.+)$/gm,
+ '$1'
+ );
+ // Wrap consecutive li elements
+ s = s.replace(/(]*>[\s\S]*?<\/li>\n?)+/g,
+ m => ``
+ );
+
+ // Step 9: paragraphs — split by double newlines
+ const blocks = s.split(/\n\n+/);
+ s = blocks.map(block => {
+ block = block.trim();
+ if (!block) return '';
+ if (/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(block) || block.startsWith('\x00CB')) return block;
+ return `${block.replace(/\n/g, '
')}
`;
+ }).join('\n');
+
+ // Step 10: restore code blocks
+ codeBlocks.forEach((block, idx) => {
+ s = s.replace(`\x00CB${idx}\x00`, block);
+ });
+
+ return s;
+}
+
// ── Requirements panel ────────────────────────────────────────────────────────
export default function RequirementsPanel() {
const router = useRouter();
+ const params = useParams();
+ const id = typeof params.id === 'string' ? params.id : Array.isArray(params.id) ? params.id[0] : '';
const {
prdText,
setPrdText,
@@ -40,13 +114,18 @@ export default function RequirementsPanel() {
setConstraints,
stageStatus,
setStageStatus,
- addChatMessage,
advanceStage,
+ currentProjectId,
} = useForgeStore();
- const agentRan = useRef(false);
const [textareaFocused, setTextareaFocused] = useState(false);
const [genButtonPulsed, setGenButtonPulsed] = useState(false);
+ const [previewMode, setPreviewMode] = useState(false);
+
+ // Refs for scroll-position sync between edit and preview
+ const editRef = useRef(null);
+ const previewRef = useRef(null);
+ const scrollRatioRef = useRef(0);
const reqStatus = stageStatus.requirements;
const isDone = reqStatus === 'done';
@@ -64,65 +143,51 @@ export default function RequirementsPanel() {
prevDoneRef.current = isDone;
}, [isDone]);
- // Run agent 1 on mount if processing or locked (dev direct-nav) — Strict Mode safe via ref guard
- useEffect(() => {
- if (agentRan.current) return;
- if (reqStatus !== 'processing' && reqStatus !== 'locked') return;
-
- agentRan.current = true;
-
- addChatMessage('requirements', {
- id: `agent1-start-${Date.now()}`,
- role: 'agent',
- content:
- "I'm analyzing your PRD to extract non-functional requirements and constraints…",
- });
-
- const collectedChips: ConstraintChip[] = [];
-
- runAgent1(prdText, (chip: ConstraintChip) => {
- collectedChips.push(chip);
- addChatMessage('requirements', {
- id: `agent1-chip-${chip.id}-${Date.now()}`,
- role: 'agent',
- content: '',
- chips: [chip],
- });
- }).then((chips) => {
- setConstraints(chips);
- setStageStatus('requirements', 'done');
- addChatMessage('requirements', {
- id: `agent1-done-${Date.now()}`,
- role: 'agent',
- content: `Extracted ${chips.length} constraints. Ready to generate the architecture.`,
- chips,
- });
- }).catch(() => {
- addChatMessage('requirements', {
- id: `agent1-error-${Date.now()}`,
- role: 'agent',
- content: 'Failed to extract constraints. Please try again.',
- });
- setStageStatus('requirements', 'locked');
- agentRan.current = false;
- });
- }, [reqStatus, prdText, addChatMessage, setConstraints, setStageStatus]);
+ function captureScrollRatio(el: HTMLElement | null) {
+ if (!el) return;
+ const max = el.scrollHeight - el.clientHeight;
+ scrollRatioRef.current = max > 0 ? el.scrollTop / max : 0;
+ }
- function handleEdit() {
- setStageStatus('requirements', 'processing');
+ function applyScrollRatio(el: HTMLElement | null) {
+ if (!el) return;
+ const max = el.scrollHeight - el.clientHeight;
+ el.scrollTop = max * scrollRatioRef.current;
+ }
+
+ function togglePreview() {
+ if (!previewMode) {
+ // Going to preview — capture edit scroll position
+ captureScrollRatio(editRef.current);
+ setPreviewMode(true);
+ setTimeout(() => applyScrollRatio(previewRef.current), 30);
+ } else {
+ // Going to edit — capture preview scroll position
+ captureScrollRatio(previewRef.current);
+ setPreviewMode(false);
+ setTimeout(() => applyScrollRatio(editRef.current), 30);
+ }
+ }
+
+ function handleSubmitPrd() {
+ if (!prdText.trim() || !currentProjectId) return;
setConstraints([]);
- agentRan.current = false;
- addChatMessage('requirements', {
- id: `system-reset-${Date.now()}`,
- role: 'agent',
- content: 'PRD updated. Re-analyzing requirements…',
- });
+ setStageStatus('requirements', 'locked');
}
- function handleGenerateArchitecture() {
- if (!isDone) return;
+ async function handleGenerateArchitecture() {
+ if (!isDone || !currentProjectId) return;
+ // Accept the PRD so architecture_sse gate passes
+ try {
+ const { authHeaders } = await import('@/lib/forge-agents');
+ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
+ await fetch(`${API_URL}/workflows/prd/v2/accept/${currentProjectId}`, {
+ method: 'POST',
+ headers: authHeaders(),
+ });
+ } catch { /* non-fatal — proceed anyway */ }
advanceStage();
- router.push('/app/architecture');
+ router.push(`/app/${id}/architecture`);
}
return (
@@ -188,33 +253,119 @@ export default function RequirementsPanel() {
- {/* ── PRD textarea ─────────────────────────────────────────────────────── */}
-
-
- {submitted ? (
-
- {/* Confirmation badge */}
-
-
-
- You're on the list — we'll be in touch.
-
-
-
-
- Create your account →
-
-
- ) : (
-
-
+
+
+ Get Started →
+
-
- or{' '}
-
- sign in to existing account →
-
-
-
- )}
-
+
+ Sign in to existing account
+
+
);
diff --git a/frontend/src/lib/forge-agents.ts b/frontend/src/lib/forge-agents.ts
index 501131c..25655a2 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,36 +10,134 @@ 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));
+export 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 {};
+ }
}
-// ── Agent 1 — Requirements → Constraint extraction ────────────────────────────
+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 MOCK_CONSTRAINTS: ConstraintChip[] = [
- { id: 'c1', label: 'P95 latency < 200ms', category: 'performance' },
- { id: 'c2', label: 'JWT RS256 signing', category: 'security' },
- { id: 'c3', label: 'Cost < $50/mo at 10k users', category: 'cost' },
- { id: 'c4', label: '99.9% uptime SLA', category: 'reliability' },
-];
+ 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;
+ }
+}
+
+export async function streamSSE(
+ url: string,
+ fetchInit: RequestInit,
+ onEvent: (data: string) => void,
+ signal?: AbortSignal,
+): Promise {
+ 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');
+
+ const reader = resp.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ 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);
+ }
+ }
+ }
+ }
+ } finally {
+ reader.cancel();
+ }
+}
+
+// ── Agent 1 — Requirements → Constraint extraction ────────────────────────────
-/**
- * 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,
+ signal?: AbortSignal,
): Promise {
- await delay(2000);
- for (let i = 0; i < MOCK_CONSTRAINTS.length; i++) {
- onChip?.(MOCK_CONSTRAINTS[i], i);
- await delay(350);
+ if (!projectId) {
+ throw new Error('runAgent1 requires a projectId');
}
- return MOCK_CONSTRAINTS;
+
+ const chips: ConstraintChip[] = [];
+
+ 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 */ }
+ },
+ signal,
+ );
+
+ return chips;
}
// ── Agent 2 — Constraints → Architecture ──────────────────────────────────────
@@ -49,125 +146,6 @@ export interface Agent2StepCallback {
(step: number, total: number): void;
}
-const MOCK_ARCH_NODES: ForgeArchNode[] = [
- {
- id: 'apigw',
- label: 'API Gateway',
- sublabel: 'REST · HTTP/2',
- type: 'gateway',
- x: 300,
- y: 30,
- terraformResource: 'aws_apigatewayv2_api',
- estimatedCost: '$3.50/mo',
- config: {
- protocol: 'HTTP',
- cors: 'enabled',
- throttle: '10,000 req/s',
- stage: '$default',
- },
- whyChosen:
- 'HTTP/2 API Gateway provides built-in throttling satisfying the rate-limiting NFR without Lambda-side logic, and its managed TLS satisfies the security NFR.',
- validates: ['P95 latency < 200ms', '99.9% uptime SLA'],
- blocks: [],
- deployStatus: 'queued',
- },
- {
- id: 'lambda',
- label: 'Lambda Auth',
- sublabel: 'Node 20 · 512 MB',
- type: 'compute',
- x: 300,
- y: 160,
- terraformResource: 'aws_lambda_function',
- estimatedCost: '$1.20/mo',
- config: {
- runtime: 'nodejs20.x',
- memory: '512 MB',
- timeout: '10s',
- concurrency: '100',
- architecture: 'arm64',
- },
- whyChosen:
- 'Stateless Lambda is ideal for JWT validation — cold start under 200ms on Node 20 arm64. Scales to zero when idle, satisfying the cost NFR.',
- validates: ['P95 latency < 200ms', 'JWT RS256 signing', 'Cost < $50/mo at 10k users'],
- blocks: [],
- deployStatus: 'queued',
- },
- {
- id: 'redis',
- label: 'ElastiCache',
- sublabel: 'Redis 7 · t3.micro',
- type: 'cache',
- x: 100,
- y: 300,
- terraformResource: 'aws_elasticache_cluster',
- estimatedCost: '$12.80/mo',
- config: {
- engine: 'redis',
- version: '7.0',
- instance: 'cache.t3.micro',
- ttl: '3600s',
- maxmemory_policy: 'allkeys-lru',
- },
- whyChosen:
- 'Redis session cache brings token validation lookups to sub-5ms — critical for the P95 < 200ms constraint. Refresh token storage also lands here.',
- validates: ['P95 latency < 200ms', 'Cost < $50/mo at 10k users'],
- blocks: [],
- deployStatus: 'queued',
- },
- {
- id: 'rds',
- label: 'RDS Postgres',
- sublabel: 'pg 15 · t3.micro',
- type: 'storage',
- x: 500,
- y: 300,
- terraformResource: 'aws_db_instance',
- estimatedCost: '$14.40/mo',
- config: {
- engine: 'postgres',
- version: '15',
- instance: 'db.t3.micro',
- storage: '20 GB',
- multi_az: 'false',
- backup_retention: '7 days',
- },
- whyChosen:
- 'RDS Postgres satisfies the audit logging requirement — WAL-based change capture is built in. Multi-AZ disabled to stay under the cost NFR at this scale.',
- validates: ['99.9% uptime SLA'],
- blocks: [],
- deployStatus: 'queued',
- },
- {
- id: 'secrets',
- label: 'Secrets Manager',
- sublabel: 'RS256 keys',
- type: 'auth',
- x: 300,
- y: 420,
- terraformResource: 'aws_secretsmanager_secret',
- estimatedCost: '$0.80/mo',
- config: {
- rotation: '90 days',
- kms: 'aws/secretsmanager',
- replication: 'disabled',
- versions: '2',
- },
- whyChosen:
- 'Stores RS256 private key with automatic rotation, satisfying the JWT RS256 signing NFR without hard-coding credentials in environment variables.',
- validates: ['JWT RS256 signing'],
- blocks: [],
- deployStatus: 'queued',
- },
-];
-
-const MOCK_ARCH_EDGES: ForgeArchEdge[] = [
- { from: 'apigw', to: 'lambda' },
- { from: 'lambda', to: 'redis' },
- { from: 'lambda', to: 'rds' },
- { from: 'lambda', to: 'secrets' },
-];
-
export const AGENT2_STEPS = [
'Parsing NFR constraints',
'Graph traversal',
@@ -175,24 +153,74 @@ 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,
+ signal?: AbortSignal,
): 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) {
+ throw new Error('runAgent2 requires a projectId');
}
- await delay(500);
- return { nodes: MOCK_ARCH_NODES, edges: MOCK_ARCH_EDGES };
-}
-export { MOCK_ARCH_NODES, 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.type === 'error') {
+ throw new Error((event.message as string) || 'Architecture generation failed');
+ }
+ 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 */ }
+ },
+ signal,
+ );
+
+ return { nodes, edges };
+}
// ── Agent 3 — Architecture → Code generation ──────────────────────────────────
@@ -201,163 +229,76 @@ export interface Agent3Callbacks {
onFileReady: (file: GeneratedFile) => void;
}
-const MOCK_FILES: GeneratedFile[] = [
- {
- id: 'f1',
- name: 'main.tf',
- path: 'infra/main.tf',
- lang: 'hcl',
- status: 'new',
- lines: [
- { content: 'terraform {' },
- { content: ' required_version = ">= 1.6"' },
- { content: ' required_providers {' },
- { content: ' aws = {' },
- { content: ' source = "hashicorp/aws"' },
- { content: ' version = "~> 5.0"' },
- { content: ' }' },
- { content: ' }' },
- { content: ' backend "s3" {' },
- { content: ' bucket = var.tf_state_bucket' },
- { content: ' key = "auth-service/terraform.tfstate"' },
- { content: ' region = var.aws_region' },
- { content: ' }' },
- { content: '}' },
- { content: '' },
- { content: 'provider "aws" {' },
- { content: ' region = var.aws_region' },
- { content: '}' },
- ],
- },
- {
- id: 'f2',
- name: 'lambda.tf',
- path: 'infra/lambda.tf',
- lang: 'hcl',
- status: 'new',
- nodeId: 'lambda',
- lines: [
- { content: 'resource "aws_lambda_function" "auth" {' },
- { content: ' function_name = "${var.project_name}-auth"' },
- { content: ' runtime = "nodejs20.x"' },
- { content: ' handler = "index.handler"' },
- { content: ' memory_size = 512' },
- { content: ' timeout = 10' },
- { content: ' architectures = ["arm64"]' },
- { content: '' },
- { content: ' environment {' },
- { content: ' variables = {' },
- { content: ' REDIS_URL = aws_elasticache_cluster.cache.cache_nodes[0].address' },
- { content: ' DATABASE_URL = aws_db_instance.postgres.endpoint' },
- { content: ' SECRET_ARN = aws_secretsmanager_secret.jwt_key.arn' },
- { content: ' }' },
- { content: ' }' },
- { content: '}' },
- ],
- },
- {
- id: 'f3',
- name: 'index.ts',
- path: 'src/index.ts',
- lang: 'typescript',
- status: 'new',
- nodeId: 'lambda',
- lines: [
- { content: 'import { APIGatewayEvent, APIGatewayProxyResult } from "aws-lambda";' },
- { content: 'import { verifyJWT } from "./auth/jwt";' },
- { content: 'import { rateLimiter } from "./middleware/rateLimit";' },
- { content: 'import { auditLog } from "./services/audit";' },
- { content: '' },
- { content: 'export async function handler(' },
- { content: ' event: APIGatewayEvent' },
- { content: '): Promise {' },
- { content: ' const allowed = await rateLimiter.check(' },
- { content: ' event.requestContext.identity.sourceIp' },
- { content: ' );' },
- { content: ' if (!allowed) return { statusCode: 429, body: "Too Many Requests" };' },
- { content: '' },
- { content: ' const token = event.headers.Authorization?.split(" ")[1];' },
- { content: ' if (!token) return { statusCode: 401, body: "Unauthorized" };' },
- { content: '' },
- { content: ' const payload = await verifyJWT(token);' },
- { content: ' await auditLog({ userId: payload.sub, action: event.path });' },
- { content: '' },
- { content: ' return { statusCode: 200, body: JSON.stringify(payload) };' },
- { content: '}' },
- ],
- },
- {
- id: 'f4',
- name: 'jwt.ts',
- path: 'src/auth/jwt.ts',
- lang: 'typescript',
- status: 'new',
- nodeId: 'secrets',
- lines: [
- { content: 'import * as jwt from "jsonwebtoken";' },
- { content: 'import { getSecret } from "../services/secrets";' },
- { content: '' },
- { content: 'export async function verifyJWT(token: string) {' },
- { content: ' const publicKey = await getSecret("jwt-public-key");' },
- { content: ' return jwt.verify(token, publicKey, {' },
- { content: ' algorithms: ["RS256"],' },
- { content: ' });' },
- { content: '}' },
- { content: '' },
- { content: 'export async function signJWT(payload: Record) {' },
- { content: ' const privateKey = await getSecret("jwt-private-key");' },
- { content: ' return jwt.sign(payload, privateKey, {' },
- { content: ' algorithm: "RS256",' },
- { content: ' expiresIn: "15m",' },
- { content: ' });' },
- { content: '}' },
- ],
- },
- {
- id: 'f5',
- name: 'rds.tf',
- path: 'infra/rds.tf',
- lang: 'hcl',
- status: 'new',
- nodeId: 'rds',
- lines: [
- { content: 'resource "aws_db_instance" "postgres" {' },
- { content: ' identifier = "${var.project_name}-db"' },
- { content: ' engine = "postgres"' },
- { content: ' engine_version = "15"' },
- { content: ' instance_class = "db.t3.micro"' },
- { content: ' allocated_storage = 20' },
- { content: ' username = var.db_username' },
- { content: ' password = var.db_password' },
- { content: ' skip_final_snapshot = true' },
- { content: ' backup_retention_period = 7' },
- { content: ' deletion_protection = false' },
- { content: '}' },
- ],
- },
-];
-
-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,
+ signal?: AbortSignal,
): 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) {
+ throw new Error('runAgent3 requires a projectId');
}
- 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 */ }
+ },
+ signal,
+ );
+
+ // 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;
}
// ── Deploy agent ──────────────────────────────────────────────────────────────
@@ -370,47 +311,37 @@ export interface DeployCallbacks {
) => void;
}
-const DEPLOY_SEQUENCE: Array<{
- ms: number;
- line: string;
- nodeId?: string;
- status?: 'provisioning' | 'live';
-}> = [
- { ms: 400, line: '⟳ Initialising Terraform workspace…' },
- { ms: 700, line: '⟳ Resolving provider hashicorp/aws v5.0.3' },
- { ms: 500, line: '✓ Provider lock file written' },
- { ms: 900, line: '⟳ Creating API Gateway (aws_apigatewayv2_api.forge)…', nodeId: 'apigw', status: 'provisioning' },
- { ms: 1200, line: '✓ API Gateway created — ID: a1b2c3d4e5f6', nodeId: 'apigw', status: 'live' },
- { ms: 600, line: '⟳ Packaging Lambda source (src/ → dist.zip)' },
- { ms: 800, line: '⟳ Deploying Lambda function (aws_lambda_function.auth)…', nodeId: 'lambda', status: 'provisioning' },
- { ms: 1400, line: '✓ Lambda deployed — ARN: arn:aws:lambda:us-east-1:123456789:function:auth', nodeId: 'lambda', status: 'live' },
- { ms: 600, line: '⟳ Provisioning ElastiCache cluster (aws_elasticache_cluster.cache)…', nodeId: 'redis', status: 'provisioning' },
- { ms: 1600, line: '✓ ElastiCache ready — endpoint: auth-cache.abc123.cfg.use1.cache.amazonaws.com', nodeId: 'redis', status: 'live' },
- { ms: 500, line: '⟳ Creating RDS instance (aws_db_instance.postgres)…', nodeId: 'rds', status: 'provisioning' },
- { ms: 2000, line: '✓ RDS ready — endpoint: auth-db.xyz.us-east-1.rds.amazonaws.com:5432', nodeId: 'rds', status: 'live' },
- { ms: 400, line: '⟳ Storing RS256 key pair in Secrets Manager…', nodeId: 'secrets', status: 'provisioning' },
- { ms: 800, line: '✓ JWT RS256 key pair stored — ARN: arn:aws:secretsmanager:us-east-1:…', nodeId: 'secrets', status: 'live' },
- { ms: 500, line: '⟳ Wiring Lambda environment variables…' },
- { ms: 400, line: '✓ Environment variables updated' },
- { ms: 300, line: '✓ Terraform state written to S3 backend' },
- { 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,
+ signal?: AbortSignal,
): 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) {
+ throw new Error('runDeploy requires a projectId');
}
+
+ 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 */ }
+ },
+ signal,
+ );
}
diff --git a/frontend/src/lib/mock-data.ts b/frontend/src/lib/mock-data.ts
index 9021208..fb0daa6 100644
--- a/frontend/src/lib/mock-data.ts
+++ b/frontend/src/lib/mock-data.ts
@@ -66,238 +66,3 @@ export interface LogLine {
message: string;
level: 'info' | 'warn' | 'error' | 'main';
}
-
-// ── Mock Projects ─────────────────────────────────────────────────────────────
-
-export const MOCK_PROJECTS: Project[] = [
- {
- id: 'proj-001',
- name: 'auth-service-api',
- status: 'deployed',
- stage: 'live',
- region: 'us-east-1',
- updatedAt: '2h ago',
- description:
- 'JWT-based authentication service with rate limiting, refresh token rotation, and a built-in admin dashboard.',
- },
- {
- id: 'proj-002',
- name: 'data-pipeline',
- status: 'building',
- stage: 'build',
- region: 'eu-west-1',
- updatedAt: '12m ago',
- description:
- 'Event-driven ingestion pipeline using Kinesis, Lambda, and S3 for real-time analytics processing.',
- },
- {
- id: 'proj-003',
- name: 'ml-inference-layer',
- status: 'draft',
- stage: 'prd',
- region: 'us-west-2',
- updatedAt: 'Yesterday',
- description:
- 'SageMaker-backed inference endpoint for on-demand model serving with auto-scaling and A/B routing.',
- },
-];
-
-// ── Mock Chat Messages ────────────────────────────────────────────────────────
-
-export const MOCK_CHAT_MESSAGES: ChatMessage[] = [
- {
- id: 'msg-001',
- role: 'agent',
- content:
- "Hey! I'm CloudForge. Tell me about the product you're building — what does it do, who's it for, and what does the happy path look like?",
- timestamp: '09:12:01',
- },
- {
- id: 'msg-002',
- role: 'user',
- content:
- "I need a REST API with JWT auth, rate limiting, and a dashboard. It's for a SaaS product — users sign up, get an API key, and call our endpoints. The dashboard shows usage metrics.",
- timestamp: '09:12:44',
- },
- {
- id: 'msg-003',
- role: 'agent',
- content:
- 'Got it. A few quick clarifying questions: What database are you planning to use for user records? And do you need multi-tenancy from the start, or is single-tenant fine for now?',
- timestamp: '09:13:05',
- },
- {
- id: 'msg-004',
- role: 'user',
- content:
- "Postgres for users and audit logs. Start single-tenant — we'll add multi-tenancy in v2. Also, the rate limiter needs to support per-API-key quotas, not just IP-based.",
- timestamp: '09:13:52',
- },
- {
- id: 'msg-005',
- role: 'agent',
- content:
- "Perfect. One more: what's the expected peak request volume? This will help me size the Lambda concurrency and ElastiCache instance for the rate limiter.",
- timestamp: '09:14:10',
- },
- {
- id: 'msg-006',
- role: 'user',
- content:
- "Maybe 500 req/s at peak for now. We don't need anything massive yet — just needs to be horizontally scalable when the time comes.",
- timestamp: '09:14:38',
- },
- {
- id: 'msg-007',
- role: 'agent',
- content:
- "I've put together an infrastructure plan based on what you described. Here's what I'm proposing:",
- timestamp: '09:14:55',
- planCard: {
- functional: [
- 'API Gateway → Lambda for stateless request handling',
- 'ElastiCache (Redis) for per-key rate limiting at sub-millisecond latency',
- 'RDS Postgres (t4g.medium) for user records and audit logs',
- 'S3 for dashboard static assets and log archival',
- ],
- features: [
- 'JWT RS256 tokens with 15min access / 7d refresh rotation',
- 'Rate limiter: sliding window, 1000 req/min per API key',
- 'Admin dashboard: usage metrics, key management, audit trail',
- 'Auto-scaling Lambda concurrency up to 500 concurrent executions',
- ],
- },
- },
- {
- id: 'msg-008',
- role: 'agent',
- content:
- "Does this look right? Once you confirm I'll start scaffolding the infrastructure and writing the service code.",
- timestamp: '09:15:02',
- },
-];
-
-// ── Mock Architecture Nodes ───────────────────────────────────────────────────
-
-export const MOCK_ARCH_NODES: ArchNode[] = [
- { id: 'api-gateway', label: 'API Gateway', sublabel: 'REST API', layer: 'app', x: 80, y: 80 },
- { id: 'lambda', label: 'Lambda', sublabel: 'Node.js 20', layer: 'app', x: 260, y: 80, isActive: true },
- { id: 'elasticache', label: 'ElastiCache', sublabel: 'Redis 7', layer: 'app', x: 440, y: 80, isNew: true },
- { id: 'rds', label: 'RDS Postgres', sublabel: 'pg 15 / t4g.med', layer: 'app', x: 620, y: 80 },
- { id: 'vpc', label: 'VPC', sublabel: '10.0.0.0/16', layer: 'infra', x: 80, y: 240 },
- { id: 'iam', label: 'IAM Roles', sublabel: 'Least privilege', layer: 'infra', x: 260, y: 240 },
- { id: 'cloudwatch', label: 'CloudWatch', sublabel: 'Logs + Metrics', layer: 'infra', x: 440, y: 240 },
- { id: 's3', label: 'S3', sublabel: 'us-east-1', layer: 'infra', x: 620, y: 240 },
-];
-
-// ── Mock Architecture Edges ───────────────────────────────────────────────────
-
-export const MOCK_ARCH_EDGES: ArchEdge[] = [
- { from: 'api-gateway', to: 'lambda' },
- { from: 'lambda', to: 'elasticache' },
- { from: 'lambda', to: 'rds' },
- { from: 'lambda', to: 's3' },
-];
-
-// ── Mock File Tree ────────────────────────────────────────────────────────────
-
-export const MOCK_FILE_TREE: FileNode[] = [
- {
- name: 'infra',
- type: 'dir',
- category: 'infra',
- children: [
- { name: 'main.tf', type: 'file', status: 'done', category: 'infra' },
- { name: 'vpc.tf', type: 'file', status: 'done', category: 'infra' },
- { name: 'rds.tf', type: 'file', status: 'done', category: 'infra' },
- ],
- },
- {
- name: 'src',
- type: 'dir',
- category: 'src',
- children: [
- {
- name: 'auth',
- type: 'dir',
- children: [
- { name: 'jwt.ts', type: 'file', status: 'done' },
- { name: 'middleware.ts', type: 'file', status: 'writing' },
- ],
- },
- {
- name: 'api',
- type: 'dir',
- children: [
- { name: 'routes.ts', type: 'file', status: 'done' },
- { name: 'handlers.ts', type: 'file', status: 'writing' },
- ],
- },
- {
- name: 'db',
- type: 'dir',
- children: [{ name: 'migrations.ts', type: 'file', status: 'pending' }],
- },
- ],
- },
- {
- name: 'config',
- type: 'dir',
- category: 'config',
- children: [{ name: 'serverless.yml', type: 'file', status: 'pending', category: 'config' }],
- },
-];
-
-// ── Mock Log Lines ────────────────────────────────────────────────────────────
-
-export const MOCK_LOG_LINES: LogLine[] = [
- { id: 'log-01', timestamp: '09:14:04', agent: 'main', message: 'Scaffold project structure initialized', level: 'main' },
- { id: 'log-02', timestamp: '09:14:05', agent: 'main', message: 'Configuring AWS provider — region: us-east-1', level: 'main' },
- { id: 'log-03', timestamp: '09:14:07', agent: 'infra', message: 'Writing vpc.tf — CIDR 10.0.0.0/16, 2 public + 2 private subnets', level: 'info' },
- { id: 'log-04', timestamp: '09:14:09', agent: 'infra', message: 'Writing rds.tf — RDS Postgres t4g.medium, multi-AZ disabled (dev mode)', level: 'info' },
- { id: 'log-05', timestamp: '09:14:12', agent: 'infra', message: 'main.tf complete — provider + backend configured', level: 'info' },
- { id: 'log-06', timestamp: '09:14:14', agent: 'auth', message: 'jwt.ts — RS256 token signing with 15min expiry complete', level: 'info' },
- { id: 'log-07', timestamp: '09:14:16', agent: 'auth', message: 'Writing middleware.ts — attaching rate limiter guard to route handlers', level: 'info' },
- { id: 'log-08', timestamp: '09:14:19', agent: 'api', message: 'routes.ts — 6 endpoints registered (POST /auth, GET /keys, DELETE /keys/:id, …)', level: 'info' },
- { id: 'log-09', timestamp: '09:14:21', agent: 'api', message: 'Writing handlers.ts — wiring Lambda event parsing + response shaping', level: 'info' },
- { id: 'log-10', timestamp: '09:14:24', agent: 'db', message: 'migrations.ts queued — waiting for RDS endpoint from terraform output', level: 'warn' },
- { id: 'log-11', timestamp: '09:14:26', agent: 'auth', message: 'middleware.ts — sliding window algorithm applied, 1000 req/min per key', level: 'info' },
- { id: 'log-12', timestamp: '09:14:29', agent: 'api', message: 'handlers.ts — injecting ElastiCache client for rate limit counters', level: 'info' },
- { id: 'log-13', timestamp: '09:14:31', agent: 'main', message: 'Write services step 67% complete — 2 agents active, 1 pending', level: 'main' },
- { id: 'log-14', timestamp: '09:14:33', agent: 'main', message: 'ETA to deploy step: ~3 minutes', level: 'main' },
-];
-
-// ── Mock Build Steps ──────────────────────────────────────────────────────────
-
-export const MOCK_BUILD_STEPS: BuildStep[] = [
- {
- id: 'step-01',
- label: 'Scaffold project',
- status: 'done',
- },
- {
- id: 'step-02',
- label: 'Configure infrastructure',
- status: 'done',
- },
- {
- id: 'step-03',
- label: 'Write services',
- status: 'active',
- subAgents: [
- { name: 'auth-agent', output: 'Writing jwt.ts — token signing logic', status: 'done' },
- { name: 'api-agent', output: 'Writing routes.ts — endpoint definitions', status: 'active' },
- { name: 'db-agent', output: 'Waiting for RDS terraform output', status: 'pending' },
- ],
- },
- {
- id: 'step-04',
- label: 'Run tests',
- status: 'pending',
- },
- {
- id: 'step-05',
- label: 'Deploy to AWS',
- status: 'pending',
- },
-];
diff --git a/frontend/src/lib/mockDeploy.ts b/frontend/src/lib/mockDeploy.ts
deleted file mode 100644
index 1a5d533..0000000
--- a/frontend/src/lib/mockDeploy.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-export type DeployStatus = 'idle' | 'generating' | 'deploying' | 'live' | 'error';
-
-export interface DeployEvent {
- status: DeployStatus;
- logLine?: string;
-}
-
-const delay = (ms: number): Promise =>
- new Promise((resolve) => setTimeout(resolve, ms));
-
-/**
- * Async generator that yields DeployEvents simulating a real deploy.
- * Consume with: for await (const event of runMockDeploy()) { ... }
- */
-export async function* runMockDeploy(): AsyncGenerator {
- // Phase 1: generating (1200ms)
- yield { status: 'generating', logLine: '→ Parsing architecture graph...' };
- await delay(600);
- yield { status: 'generating', logLine: '→ Generating Terraform modules...' };
- await delay(600);
-
- // Phase 2: deploying (2800ms)
- yield { status: 'deploying', logLine: '→ Provisioning Lambda functions...' };
- await delay(700);
- yield { status: 'deploying', logLine: '→ Configuring IAM roles...' };
- await delay(700);
- yield { status: 'deploying', logLine: '→ Setting up VPC networking...' };
- await delay(700);
-
- // ============================================================
- // BACKEND HOOK: POST /api/deploy
- // Payload: CloudForgeTopology JSON
- // The Claude agent receives this and runs Terraform via
- // the AWS Cloud Control API MCP server.
- // Replace this mock generator with real API integration.
- // ============================================================
- yield { status: 'deploying', logLine: '→ Submitting to provisioning agent...' };
- await delay(700);
-
- // Phase 3: live
- yield { status: 'live', logLine: '✓ Infrastructure live' };
-}
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/canvasStore.ts b/frontend/src/store/canvasStore.ts
index 9d225c3..a35dce9 100644
--- a/frontend/src/store/canvasStore.ts
+++ b/frontend/src/store/canvasStore.ts
@@ -12,9 +12,9 @@ import {
} from '@xyflow/react';
import type { CloudForgeTopology } from '@/types/topology';
import { exportTopology } from '@/lib/exportTopology';
-import { runMockDeploy, type DeployStatus } from '@/lib/mockDeploy';
+import { runDeploy } from '@/lib/forge-agents';
-export type { DeployStatus };
+export type DeployStatus = 'idle' | 'generating' | 'deploying' | 'live' | 'error';
export interface NodeData {
serviceId: string;
@@ -46,7 +46,7 @@ interface CanvasStore {
deployLog: string[];
deployError: string | null;
deployRunId: number;
- startDeploy: () => Promise;
+ startDeploy: (projectId: string) => Promise;
resetDeploy: () => void;
// Topology export
@@ -132,44 +132,31 @@ export const useCanvasStore = create((set, get) => ({
deployError: null,
deployRunId: 0,
- startDeploy: async () => {
- const { getTopology, deployStatus } = get();
+ startDeploy: async (projectId: string) => {
+ const { deployStatus } = get();
if (deployStatus !== 'idle' && deployStatus !== 'error') return;
- const topology = getTopology();
-
const runId = get().deployRunId + 1;
set({ deployStatus: 'generating', deployLog: [], deployError: null, deployRunId: runId });
try {
- for await (const event of runMockDeploy()) {
- set((state) => ({
- deployStatus: event.status,
- deployLog: event.logLine
- ? [...state.deployLog, event.logLine]
- : state.deployLog,
- }));
-
- // ============================================================
- // BACKEND HOOK: POST /api/deploy
- // Payload: CloudForgeTopology (see src/types/topology.ts)
- // Expected response: { deploymentId: string, status: string }
- // The backend agent (Claude + AWS Cloud Control API) receives
- // this topology and generates + executes Terraform.
- // ============================================================
- if (
- event.status === 'deploying' &&
- event.logLine?.includes('provisioning agent')
- ) {
- await fetch('/api/deploy', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(topology),
- }).catch(() => {
- // Mock: swallow fetch errors during demo
- });
- }
- }
+ await runDeploy(
+ [],
+ { nodes: [], edges: [] },
+ {
+ onLog: (line: string) => {
+ set((state) => ({ deployLog: [...state.deployLog, line] }));
+ },
+ onNodeStatus: (_nodeId: string, status: string) => {
+ if (status === 'provisioning') {
+ set({ deployStatus: 'deploying' });
+ } else if (status === 'live') {
+ set({ deployStatus: 'live' });
+ }
+ },
+ },
+ projectId,
+ );
} catch (err) {
const message =
err instanceof Error ? err.message : 'Unknown deploy error';
@@ -177,10 +164,13 @@ export const useCanvasStore = create((set, get) => ({
return;
}
+ set({ deployStatus: 'live' });
+
// Auto-reset to idle after 4s on success — guard against stale timers from prior deploys
+ const capturedRunId = runId;
setTimeout(() => {
set((state) => {
- if (state.deployStatus === 'live' && state.deployRunId === runId) {
+ if (state.deployStatus === 'live' && state.deployRunId === capturedRunId) {
return { deployStatus: 'idle' };
}
return {};
diff --git a/frontend/src/store/forgeStore.ts b/frontend/src/store/forgeStore.ts
index 5f470b6..0392172 100644
--- a/frontend/src/store/forgeStore.ts
+++ b/frontend/src/store/forgeStore.ts
@@ -1,4 +1,7 @@
import { create } from 'zustand';
+import { authHeaders } from '@/lib/forge-agents';
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -17,12 +20,28 @@ export interface ActivityCard {
files: Array<{ id: string; name: string; status: 'new' | 'modified' | 'pending' }>;
}
+export interface ClarificationOption {
+ label: string;
+ value: string;
+ is_custom: boolean;
+}
+
+export interface ClarificationQuestion {
+ question: string;
+ original_question: string;
+ options: ClarificationOption[];
+}
+
export interface ForgeChatMessage {
id: string;
role: 'agent' | 'user';
content: string;
chips?: ConstraintChip[];
activityCard?: ActivityCard;
+ clarificationCard?: {
+ id: string;
+ questions: ClarificationQuestion[];
+ };
}
export interface ForgeArchNode {
@@ -73,6 +92,7 @@ interface ForgeState {
buildTotal: number;
deployLog: string[];
deployModalOpen: boolean;
+ currentProjectId: string | null;
// Actions
setStageStatus: (stage: ForgeStage, status: StageStatus) => void;
@@ -91,6 +111,8 @@ interface ForgeState {
updateNodeDeployStatus: (nodeId: string, status: ForgeArchNode['deployStatus']) => void;
setProjectName: (name: string) => void;
setDeployModalOpen: (open: boolean) => void;
+ setCurrentProjectId: (id: string | null) => void;
+ hydrateProject: (projectId: string) => Promise;
}
// ── Stage order ───────────────────────────────────────────────────────────────
@@ -114,14 +136,13 @@ export const FORGE_STAGE_LABELS: Record = {
export const useForgeStore = create((set, get) => ({
activeStage: 'requirements',
stageStatus: {
- requirements: 'processing',
+ requirements: 'locked',
architecture: 'locked',
build: 'locked',
deploy: 'locked',
},
- projectName: 'auth-service-api',
- prdText:
- 'Build a JWT authentication microservice with rate limiting, refresh tokens, and audit logging. Must support PostgreSQL for user storage and Redis for session caching. Target: 10k concurrent users, P95 latency < 200ms.',
+ projectName: '',
+ prdText: '',
constraints: [],
architectureData: null,
generatedFiles: {},
@@ -153,6 +174,7 @@ export const useForgeStore = create((set, get) => ({
buildTotal: 5,
deployLog: [],
deployModalOpen: false,
+ currentProjectId: null,
setStageStatus: (stage, status) =>
set((state) => ({
@@ -253,4 +275,114 @@ export const useForgeStore = create((set, get) => ({
setProjectName: (name) => set({ projectName: name }),
setDeployModalOpen: (open) => set({ deployModalOpen: open }),
+
+ setCurrentProjectId: (id) => set({ currentProjectId: id }),
+
+ hydrateProject: async (projectId: string) => {
+ const headers = authHeaders();
+ if (!headers.Authorization) return;
+
+ // ── Helper: mirrors _mapNodeType from forge-agents.ts ──────────────────
+ 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';
+ }
+
+ // ── PRD hydration ───────────────────────────────────────────────────────
+ const { prdText } = get();
+ if (!prdText) {
+ try {
+ const prdResp = await fetch(`${API_URL}/workflows/prd/v2/${projectId}`, { headers });
+ if (prdResp.ok) {
+ const prdData = await prdResp.json() as {
+ session_id?: string;
+ status?: string;
+ plan_markdown?: string;
+ messages?: Array<{ role?: string; type?: string; content?: string }>;
+ constraints?: Array<{ id: string; label: string; category: string }>;
+ };
+
+ const restoredMessages: ForgeChatMessage[] = (prdData.messages ?? [])
+ .filter((m) => m.content)
+ .map((m, i) => ({
+ id: `hydrated-req-${i}`,
+ role: (m.role === 'user' ? 'user' : 'agent') as ForgeChatMessage['role'],
+ content: m.content!,
+ }));
+
+ const restoredConstraints: ConstraintChip[] = (prdData.constraints ?? []).map((c) => ({
+ id: c.id,
+ label: c.label,
+ category: c.category as ConstraintChip['category'],
+ }));
+
+ set((state) => ({
+ prdText: prdData.plan_markdown ?? '',
+ constraints: restoredConstraints,
+ stageStatus: { ...state.stageStatus, requirements: 'done' },
+ chatHistory: {
+ ...state.chatHistory,
+ requirements: restoredMessages.length > 0 ? restoredMessages : state.chatHistory.requirements,
+ },
+ }));
+ }
+ // 404 → silently skip
+ } catch { /* network error — skip */ }
+ }
+
+ // ── Architecture hydration ──────────────────────────────────────────────
+ const { architectureData } = get();
+ if (!architectureData) {
+ try {
+ const archResp = await fetch(`${API_URL}/workflows/architecture/v2/${projectId}`, { headers });
+ if (archResp.ok) {
+ const archData = await archResp.json() as {
+ session_id?: string;
+ status?: string;
+ architecture_diagram?: {
+ nodes?: Array>;
+ connections?: Array>;
+ };
+ nfr_document?: string;
+ eval_score?: number;
+ };
+
+ const diagram = archData.architecture_diagram;
+ if (diagram?.nodes && diagram.nodes.length > 0) {
+ const nodes: ForgeArchNode[] = diagram.nodes.map((n) => ({
+ 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,
+ }));
+
+ const edges: ForgeArchEdge[] = (diagram.connections ?? []).map((c) => ({
+ from: String(c.from ?? c.from_ ?? c.source ?? ''),
+ to: String(c.to ?? c.target ?? ''),
+ }));
+
+ set((state) => ({
+ architectureData: { nodes, edges },
+ stageStatus: { ...state.stageStatus, architecture: 'done' },
+ }));
+ }
+ }
+ // 404 → silently skip
+ } catch { /* network error — skip */ }
+ }
+ },
}));
diff --git a/frontend/src/store/projectStore.ts b/frontend/src/store/projectStore.ts
index 1ddd16e..28fd17b 100644
--- a/frontend/src/store/projectStore.ts
+++ b/frontend/src/store/projectStore.ts
@@ -1,16 +1,48 @@
import { create } from 'zustand';
-import { MOCK_PROJECTS, type Project, type ProjectStage } from '@/lib/mock-data';
+import { type Project, type ProjectStage, type ProjectStatus } 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 {
+ // Projects list (populated from API)
projects: Project[];
advanceStage: (id: string) => void;
- createProject: (name: string) => string;
+
+ // API-backed state
+ apiProjects: ApiProject[];
+ currentProjectId: string | null;
+ isLoading: boolean;
+ loadError: string | null;
+ loadProjects: (token: string) => Promise;
+ createApiProject: (name: string, token: string) => Promise;
+ deleteApiProject: (id: string, token: string) => Promise;
+ setCurrentProjectId: (id: string | null) => void;
}
export const useProjectStore = create((set) => ({
- projects: [...MOCK_PROJECTS],
+ projects: [],
advanceStage: (id) =>
set((state) => ({
@@ -22,20 +54,80 @@ export const useProjectStore = create((set) => ({
}),
})),
- createProject: (name) => {
- const id = `proj-${Date.now()}`;
- const newProject: Project = {
- id,
- name,
- status: 'draft',
- stage: 'prd',
- region: 'us-east-1',
- updatedAt: 'Just now',
- description: 'New project — add a description in the PRD chat.',
- };
- set((state) => ({ projects: [...state.projects, newProject] }));
- return id;
+ // ── API-backed state ───────────────────────────────────────────────────────
+ apiProjects: [],
+ currentProjectId: null,
+ isLoading: false,
+ loadError: null,
+
+ loadProjects: async (token: string) => {
+ set({ isLoading: true, loadError: null });
+ try {
+ const resp = await fetch(`${API_URL}/projects`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!resp.ok) {
+ set({ loadError: `Failed to load projects (${resp.status})` });
+ return;
+ }
+ const data: ApiProject[] = await resp.json();
+ const now = Date.now();
+ const projects: Project[] = data.map((p) => {
+ const updatedMs = new Date(p.updated_at).getTime();
+ const diffMs = now - updatedMs;
+ const diffMin = Math.floor(diffMs / 60000);
+ const diffHr = Math.floor(diffMs / 3600000);
+ let updatedAt: string;
+ if (diffMin < 60) {
+ updatedAt = `${diffMin}m ago`;
+ } else if (diffHr < 24) {
+ updatedAt = `${diffHr}h ago`;
+ } else {
+ updatedAt = new Date(p.updated_at).toLocaleDateString();
+ }
+ return {
+ id: p.id,
+ name: p.name,
+ description: p.description ?? '',
+ stage: p.stage as ProjectStage,
+ status: p.status as ProjectStatus,
+ region: p.region ?? 'us-east-1',
+ updatedAt,
+ };
+ });
+ set({ apiProjects: data, projects });
+ } catch (err) {
+ set({ loadError: err instanceof Error ? err.message : 'Failed to load projects' });
+ } 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;
+ },
+
+ deleteApiProject: async (id: string, token: string) => {
+ const resp = await fetch(`${API_URL}/projects/${id}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!resp.ok) throw new Error('Failed to delete project');
+ set((state) => ({
+ apiProjects: state.apiProjects.filter((p) => p.id !== id),
+ projects: state.projects.filter((p) => p.id !== id),
+ }));
+ },
+
+ setCurrentProjectId: (id) => set({ currentProjectId: id }),
}));
export { STAGE_ORDER };