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} >
+ {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 ( +