From 2d43f10c9330a2c411d88a8828c16d8c3c2e9662 Mon Sep 17 00:00:00 2001 From: Dmitry Kisselev <956988+dkisselev-zz@users.noreply.github.com> Date: Wed, 26 Nov 2025 06:27:10 -0800 Subject: [PATCH] Fixing bugs --- backend/charter/test_simple.py | 16 +++- backend/reporter/test_simple.py | 16 +++- backend/researcher/server.py | 2 +- backend/retirement/agent.py | 18 ++-- backend/retirement/test_simple.py | 16 +++- backend/tagger/agent.py | 93 ++++++++++++++---- backend/tagger/lambda_handler.py | 6 +- backend/tagger/package_docker.py | 48 ++++++++-- backend/tagger/test_full.py | 23 ++++- frontend/components/ErrorBoundary.tsx | 1 - frontend/pages/dashboard.tsx | 67 ++++++++++++- guides/4_researcher.md | 41 ++++---- guides/6_agents.md | 35 ++++--- scripts/run_local.py | 4 +- terraform/6_agents/main.tf | 133 +++++++++++++++++--------- 15 files changed, 391 insertions(+), 128 deletions(-) diff --git a/backend/charter/test_simple.py b/backend/charter/test_simple.py index 9e38a132..bb9ce645 100644 --- a/backend/charter/test_simple.py +++ b/backend/charter/test_simple.py @@ -5,6 +5,7 @@ import asyncio import json +from decimal import Decimal from dotenv import load_dotenv load_dotenv(override=True) @@ -19,8 +20,21 @@ def test_charter(): # Create a real job in the database db = Database() + + # Ensure test user exists + test_user_id = "test_user_001" + existing_user = db.users.find_by_clerk_id(test_user_id) + if not existing_user: + db.users.create_user( + clerk_user_id=test_user_id, + display_name="Test User", + years_until_retirement=25, + target_retirement_income=Decimal('75000') + ) + print(f"Created test user: {test_user_id}") + job_create = JobCreate( - clerk_user_id="test_user_001", job_type="portfolio_analysis", request_payload={"test": True} + clerk_user_id=test_user_id, job_type="portfolio_analysis", request_payload={"test": True} ) job_id = db.jobs.create(job_create.model_dump()) print(f"Created test job: {job_id}") diff --git a/backend/reporter/test_simple.py b/backend/reporter/test_simple.py index 2d4ab431..28331e5a 100644 --- a/backend/reporter/test_simple.py +++ b/backend/reporter/test_simple.py @@ -5,6 +5,7 @@ import asyncio import json +from decimal import Decimal from dotenv import load_dotenv load_dotenv(override=True) @@ -18,8 +19,21 @@ def test_reporter(): # Create a real job in the database db = Database() + + # Ensure test user exists + test_user_id = "test_user_001" + existing_user = db.users.find_by_clerk_id(test_user_id) + if not existing_user: + db.users.create_user( + clerk_user_id=test_user_id, + display_name="Test User", + years_until_retirement=25, + target_retirement_income=Decimal('75000') + ) + print(f"Created test user: {test_user_id}") + job_create = JobCreate( - clerk_user_id="test_user_001", + clerk_user_id=test_user_id, job_type="portfolio_analysis", request_payload={"test": True} ) diff --git a/backend/researcher/server.py b/backend/researcher/server.py index 55183726..e2c477fc 100644 --- a/backend/researcher/server.py +++ b/backend/researcher/server.py @@ -54,7 +54,7 @@ async def run_research_agent(topic: str = None) -> str: # bedrock/openai.gpt-oss-120b-1:0 for OpenAI OSS models # bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0 for Claude Sonnet 4 # NOTE that nova-pro is needed to support tools and MCP servers; nova-lite is not enough - thank you Yuelin L.! - MODEL = "bedrock/us.amazon.nova-pro-v1:0" + MODEL = "bedrock/amazon.nova-pro-v1:0" model = LitellmModel(model=MODEL) # Create and run the agent with MCP server diff --git a/backend/retirement/agent.py b/backend/retirement/agent.py index cd2e5550..fe73f504 100644 --- a/backend/retirement/agent.py +++ b/backend/retirement/agent.py @@ -93,7 +93,8 @@ def run_monte_carlo_simulation( real_estate_return_std = 0.12 successful_scenarios = 0 - final_values = [] + final_values = [] # After retirement withdrawals + values_at_retirement = [] # At retirement (before withdrawals start) years_lasted = [] for _ in range(num_simulations): @@ -115,6 +116,10 @@ def run_monte_carlo_simulation( portfolio_value = portfolio_value * (1 + portfolio_return) portfolio_value += 10000 # Annual contribution + # Store value at retirement (before withdrawals start) + value_at_retirement = portfolio_value + values_at_retirement.append(value_at_retirement) + # Retirement phase retirement_years = 30 annual_withdrawal = target_annual_income @@ -151,6 +156,7 @@ def run_monte_carlo_simulation( # Calculate statistics final_values.sort() + values_at_retirement.sort() # Sort for percentile calculation success_rate = (successful_scenarios / num_simulations) * 100 # Calculate expected value at retirement @@ -168,8 +174,8 @@ def run_monte_carlo_simulation( return { "success_rate": round(success_rate, 1), "median_final_value": round(final_values[num_simulations // 2], 2), - "percentile_10": round(final_values[num_simulations // 10], 2), - "percentile_90": round(final_values[9 * num_simulations // 10], 2), + "percentile_10": round(values_at_retirement[num_simulations // 10], 2), # Value at retirement + "percentile_90": round(values_at_retirement[9 * num_simulations // 10], 2), # Value at retirement "average_years_lasted": round(sum(years_lasted) / len(years_lasted), 1), "expected_value_at_retirement": round(expected_value_at_retirement, 2), } @@ -284,9 +290,9 @@ def create_agent( ## Monte Carlo Simulation Results (500 scenarios) - Success Rate: {monte_carlo["success_rate"]}% (probability of sustaining retirement income for 30 years) - Expected Portfolio Value at Retirement: ${monte_carlo["expected_value_at_retirement"]:,.0f} -- 10th Percentile Outcome: ${monte_carlo["percentile_10"]:,.0f} (worst case) -- Median Final Value: ${monte_carlo["median_final_value"]:,.0f} -- 90th Percentile Outcome: ${monte_carlo["percentile_90"]:,.0f} (best case) +- 10th Percentile Value at Retirement: ${monte_carlo["percentile_10"]:,.0f} (worst-case scenario at retirement) +- Median Final Value (after 30 years): ${monte_carlo["median_final_value"]:,.0f} +- 90th Percentile Value at Retirement: ${monte_carlo["percentile_90"]:,.0f} (best-case scenario at retirement) - Average Years Portfolio Lasts: {monte_carlo["average_years_lasted"]} years ## Key Projections (Milestones) diff --git a/backend/retirement/test_simple.py b/backend/retirement/test_simple.py index 60306169..5df523ce 100644 --- a/backend/retirement/test_simple.py +++ b/backend/retirement/test_simple.py @@ -5,6 +5,7 @@ import asyncio import json +from decimal import Decimal from dotenv import load_dotenv load_dotenv(override=True) @@ -18,8 +19,21 @@ def test_retirement(): # Create a real job in the database db = Database() + + # Ensure test user exists + test_user_id = "test_user_001" + existing_user = db.users.find_by_clerk_id(test_user_id) + if not existing_user: + db.users.create_user( + clerk_user_id=test_user_id, + display_name="Test User", + years_until_retirement=25, + target_retirement_income=Decimal('75000') + ) + print(f"Created test user: {test_user_id}") + job_create = JobCreate( - clerk_user_id="test_user_001", + clerk_user_id=test_user_id, job_type="portfolio_analysis", request_payload={"test": True} ) diff --git a/backend/tagger/agent.py b/backend/tagger/agent.py index 5efe8eac..fcbf756e 100644 --- a/backend/tagger/agent.py +++ b/backend/tagger/agent.py @@ -3,7 +3,7 @@ """ import os -from typing import List +from typing import List, Dict import logging from decimal import Decimal @@ -108,8 +108,20 @@ class InstrumentClassification(BaseModel): @field_validator("allocation_asset_class") def validate_asset_class_sum(cls, v: AllocationBreakdown): total = v.equity + v.fixed_income + v.real_estate + v.commodities + v.cash + v.alternatives - if abs(total - 100.0) > 3: # Allow small floating point errors - raise ValueError(f"Asset class allocations must sum to 100.0, got {total}") + # Normalize if sum is close to 100 (within reasonable range) + if abs(total - 100.0) > 0.1: # Only normalize if not exactly 100 + if 50.0 <= total <= 150.0: # Reasonable range for normalization + # Scale all values proportionally to sum to 100 + scale_factor = 100.0 / total + v.equity *= scale_factor + v.fixed_income *= scale_factor + v.real_estate *= scale_factor + v.commodities *= scale_factor + v.cash *= scale_factor + v.alternatives *= scale_factor + else: + # Sum is way off - reject it + raise ValueError(f"Asset class allocations must sum to approximately 100.0, got {total}") return v @field_validator("allocation_regions") @@ -125,8 +137,23 @@ def validate_regions_sum(cls, v: RegionAllocation): + v.global_ + v.international ) - if abs(total - 100.0) > 3: - raise ValueError(f"Regional allocations must sum to 100.0, got {total}") + # Normalize if sum is close to 100 (within reasonable range) + if abs(total - 100.0) > 0.1: # Only normalize if not exactly 100 + if 50.0 <= total <= 150.0: # Reasonable range for normalization + # Scale all values proportionally to sum to 100 + scale_factor = 100.0 / total + v.north_america *= scale_factor + v.europe *= scale_factor + v.asia *= scale_factor + v.latin_america *= scale_factor + v.africa *= scale_factor + v.middle_east *= scale_factor + v.oceania *= scale_factor + v.global_ *= scale_factor + v.international *= scale_factor + else: + # Sum is way off - reject it + raise ValueError(f"Regional allocations must sum to approximately 100.0, got {total}") return v @field_validator("allocation_sectors") @@ -151,8 +178,32 @@ def validate_sectors_sum(cls, v: SectorAllocation): + v.diversified + v.other ) - if abs(total - 100.0) > 3: - raise ValueError(f"Sector allocations must sum to 100.0, got {total}") + # Normalize if sum is close to 100 (within reasonable range) + if abs(total - 100.0) > 0.1: # Only normalize if not exactly 100 + if 50.0 <= total <= 150.0: # Reasonable range for normalization + # Scale all values proportionally to sum to 100 + scale_factor = 100.0 / total + v.technology *= scale_factor + v.healthcare *= scale_factor + v.financials *= scale_factor + v.consumer_discretionary *= scale_factor + v.consumer_staples *= scale_factor + v.industrials *= scale_factor + v.materials *= scale_factor + v.energy *= scale_factor + v.utilities *= scale_factor + v.real_estate *= scale_factor + v.communication *= scale_factor + v.treasury *= scale_factor + v.corporate *= scale_factor + v.mortgage *= scale_factor + v.government_related *= scale_factor + v.commodities *= scale_factor + v.diversified *= scale_factor + v.other *= scale_factor + else: + # Sum is way off - reject it + raise ValueError(f"Sector allocations must sum to approximately 100.0, got {total}") return v @@ -205,7 +256,7 @@ async def classify_instrument( raise -async def tag_instruments(instruments: List[dict]) -> List[InstrumentClassification]: +async def tag_instruments(instruments: List[dict]) -> tuple[List[InstrumentClassification], List[Dict[str, str]]]: """ Tag multiple instruments with simple retry logic. @@ -213,7 +264,7 @@ async def tag_instruments(instruments: List[dict]) -> List[InstrumentClassificat instruments: List of dicts with symbol, name, and optionally instrument_type Returns: - List of classifications + Tuple of (list of successful classifications, list of errors with symbol and error message) """ import asyncio @@ -230,26 +281,32 @@ async def classify_with_retry(symbol, name, instrument_type): return await classify_instrument(symbol, name, instrument_type) # Process instruments sequentially with small delay - results = [] + classifications = [] + errors = [] + for i, instrument in enumerate(instruments): # Small delay between requests to avoid rate limits if i > 0: await asyncio.sleep(0.5) + symbol = instrument["symbol"] try: classification = await classify_with_retry( - symbol=instrument["symbol"], + symbol=symbol, name=instrument.get("name", ""), instrument_type=instrument.get("instrument_type", "etf"), ) - logger.info(f"Successfully classified {instrument['symbol']}") - results.append(classification) + logger.info(f"Successfully classified {symbol}") + classifications.append(classification) except Exception as e: - logger.error(f"Failed to classify {instrument['symbol']}: {e}") - results.append(None) - - # Filter out None values - return [r for r in results if r is not None] + error_msg = str(e) + logger.error(f"Failed to classify {symbol}: {error_msg}") + errors.append({ + 'symbol': symbol, + 'error': error_msg + }) + + return classifications, errors def classification_to_db_format(classification: InstrumentClassification) -> InstrumentCreate: diff --git a/backend/tagger/lambda_handler.py b/backend/tagger/lambda_handler.py index b6136a1f..35b513bb 100644 --- a/backend/tagger/lambda_handler.py +++ b/backend/tagger/lambda_handler.py @@ -33,11 +33,13 @@ async def process_instruments(instruments: List[Dict[str, str]]) -> Dict[str, An """ # Run the classification logger.info(f"Classifying {len(instruments)} instruments") - classifications = await tag_instruments(instruments) + classifications, classification_errors = await tag_instruments(instruments) + + # Start with classification errors + errors = classification_errors.copy() # Update database with classifications updated = [] - errors = [] for classification in classifications: try: diff --git a/backend/tagger/package_docker.py b/backend/tagger/package_docker.py index 6f35a099..748ee762 100644 --- a/backend/tagger/package_docker.py +++ b/backend/tagger/package_docker.py @@ -99,24 +99,58 @@ def deploy_lambda(zip_path): import boto3 lambda_client = boto3.client('lambda') + s3_client = boto3.client('s3') function_name = 'alex-tagger' print(f"Deploying to Lambda function: {function_name}") + # Check file size (Lambda direct upload limit is ~50MB, but we use 70MB as threshold) + file_size = zip_path.stat().st_size + size_mb = file_size / (1024 * 1024) + max_direct_upload = 70 * 1024 * 1024 # 70 MB + try: - # Try to update existing function - with open(zip_path, 'rb') as f: + if file_size > max_direct_upload: + # Package is too large, must use S3 + print(f"Package size ({size_mb:.1f} MB) exceeds direct upload limit, using S3...") + + # Get AWS account ID + sts_client = boto3.client('sts') + account_id = sts_client.get_caller_identity()['Account'] + bucket_name = f"alex-lambda-packages-{account_id}" + key = 'tagger/tagger_lambda.zip' + + # Upload to S3 + print(f"Uploading to S3: s3://{bucket_name}/{key}") + with open(zip_path, 'rb') as f: + s3_client.upload_fileobj(f, bucket_name, key) + print(f"✅ Uploaded to S3") + + # Update Lambda from S3 + print("Updating Lambda function from S3...") response = lambda_client.update_function_code( FunctionName=function_name, - ZipFile=f.read() + S3Bucket=bucket_name, + S3Key=key ) - print(f"Successfully updated Lambda function: {function_name}") - print(f"Function ARN: {response['FunctionArn']}") + else: + # Small enough for direct upload + print(f"Uploading directly ({size_mb:.1f} MB)...") + with open(zip_path, 'rb') as f: + response = lambda_client.update_function_code( + FunctionName=function_name, + ZipFile=f.read() + ) + + print(f"✅ Successfully updated Lambda function: {function_name}") + print(f" Function ARN: {response['FunctionArn']}") + print(f" Last modified: {response['LastModified']}") + except lambda_client.exceptions.ResourceNotFoundException: - print(f"Lambda function {function_name} not found. Please deploy via Terraform first.") + print(f"❌ Lambda function {function_name} not found. Please deploy via Terraform first.") sys.exit(1) except Exception as e: - print(f"Error deploying Lambda: {e}") + print(f"❌ Error deploying Lambda: {e}") sys.exit(1) def main(): diff --git a/backend/tagger/test_full.py b/backend/tagger/test_full.py index 141973f2..08f60488 100644 --- a/backend/tagger/test_full.py +++ b/backend/tagger/test_full.py @@ -38,7 +38,28 @@ def test_tagger_lambda(): ) result = json.loads(response['Payload'].read()) - print(f"\nLambda Response: {json.dumps(result, indent=2)}") + + # Parse body if it's a string + if isinstance(result.get('body'), str): + body = json.loads(result['body']) + else: + body = result.get('body', result) + + print(f"\nLambda Response:") + print(f" Status Code: {result.get('statusCode', 'N/A')}") + print(f" Tagged: {body.get('tagged', 0)} instruments") + print(f" Updated: {body.get('updated', [])}") + + # Show errors if any + if body.get('errors'): + print(f"\n❌ Classification Errors ({len(body['errors'])}):") + for error in body['errors']: + symbol = error.get('symbol', 'Unknown') + error_msg = error.get('error', 'Unknown error') + # Truncate long error messages + if len(error_msg) > 200: + error_msg = error_msg[:200] + "..." + print(f" - {symbol}: {error_msg}") # Check database for updated instruments print("\n✅ Checking database for tagged instruments:") diff --git a/frontend/components/ErrorBoundary.tsx b/frontend/components/ErrorBoundary.tsx index 9a4963f6..6bba8e31 100644 --- a/frontend/components/ErrorBoundary.tsx +++ b/frontend/components/ErrorBoundary.tsx @@ -1,5 +1,4 @@ import React, { Component, ErrorInfo, ReactNode } from 'react'; -import Link from 'next/link'; interface Props { children: ReactNode; diff --git a/frontend/pages/dashboard.tsx b/frontend/pages/dashboard.tsx index ba8c361f..7a7bae8f 100644 --- a/frontend/pages/dashboard.tsx +++ b/frontend/pages/dashboard.tsx @@ -46,6 +46,14 @@ interface Instrument { sector_allocation?: Record; } +interface JobListItem { + id: string; + created_at: string; + completed_at?: string; + status: string; + job_type: string; +} + export default function Dashboard() { const { user, isLoaded: userLoaded } = useUser(); const { getToken } = useAuth(); @@ -192,9 +200,37 @@ export default function Dashboard() { setInstruments(instrumentsMap); } - // Get last analysis date - // This would come from the jobs endpoint in a real implementation - setLastAnalysisDate(null); + // Get last analysis date from completed jobs + try { + const jobsResponse = await fetch(`${API_URL}/api/jobs`, { + headers: { + "Authorization": `Bearer ${token}`, + }, + }); + + if (jobsResponse.ok) { + const jobsData = await jobsResponse.json(); + const jobs = jobsData.jobs || []; + + // Find the latest completed job + const latestCompletedJob = jobs + .filter((job: JobListItem) => job.status === 'completed') + .sort((a: JobListItem, b: JobListItem) => { + const dateA = a.completed_at || a.created_at; + const dateB = b.completed_at || b.created_at; + return new Date(dateB).getTime() - new Date(dateA).getTime(); + })[0]; + + if (latestCompletedJob) { + // Use completed_at if available, otherwise created_at + const analysisDate = latestCompletedJob.completed_at || latestCompletedJob.created_at; + setLastAnalysisDate(analysisDate); + } + } + } catch (err) { + console.error("Error fetching last analysis date:", err); + // Don't fail the whole page load if this fails + } } catch (err) { console.error("Error loading data:", err); @@ -261,6 +297,31 @@ export default function Dashboard() { // Portfolio will be recalculated on render } + + // Refresh last analysis date + const jobsResponse = await fetch(`${API_URL}/api/jobs`, { + headers: { + "Authorization": `Bearer ${token}`, + }, + }); + + if (jobsResponse.ok) { + const jobsData = await jobsResponse.json(); + const jobs = jobsData.jobs || []; + + const latestCompletedJob = jobs + .filter((job: JobListItem) => job.status === 'completed') + .sort((a: JobListItem, b: JobListItem) => { + const dateA = a.completed_at || a.created_at; + const dateB = b.completed_at || b.created_at; + return new Date(dateB).getTime() - new Date(dateA).getTime(); + })[0]; + + if (latestCompletedJob) { + const analysisDate = latestCompletedJob.completed_at || latestCompletedJob.created_at; + setLastAnalysisDate(analysisDate); + } + } } catch (err) { console.error("Error refreshing dashboard data:", err); } diff --git a/guides/4_researcher.md b/guides/4_researcher.md index 0bafe746..76f28981 100644 --- a/guides/4_researcher.md +++ b/guides/4_researcher.md @@ -8,7 +8,7 @@ Before starting, ensure you have: 1. Completed Guides 1-3 (SageMaker, S3 Vectors, and Ingest Pipeline deployed) 2. Docker Desktop installed and running 3. AWS CLI configured with your credentials -4. Access to AWS Bedrock OpenAI OSS models (see Step 0 below) +4. IAM permissions for AWS Bedrock (models are automatically enabled on first use - see Step 0) ## REMINDER - MAJOR TIP!! @@ -48,24 +48,21 @@ graph LR style SchedLambda fill:#FF9900 ``` -## Step 0: Request Access to Bedrock Models +## Step 0: Bedrock Model Access -The Researcher uses AWS Bedrock with OpenAI's open-source OSS 120B model. You need to request access to this model first. +The Researcher uses AWS Bedrock models. We recommend using Amazon Nova Pro for best performance and cost-effectiveness. -### Request Model Access - these instructions are for OSS models, but you can also use Nova in us-east-1 or in your region (cheaper and easier) +**Good News**: Serverless foundation models (including Nova Pro and OpenAI OSS models) are now automatically enabled across all AWS commercial regions when first invoked in your account. You no longer need to manually request model access through the Model Access page. -1. Sign in to the AWS Console -2. Navigate to **Amazon Bedrock** service -3. Switch to the **US West (Oregon) us-west-2** region (top right corner) -4. In the left sidebar, click **Model access** -5. Click **Manage model access** or **Modify model access** -6. Find the **OpenAI** section -7. Check the boxes for: - - **gpt-oss-120b** (OpenAI GPT OSS 120B) - - **gpt-oss-20b** (OpenAI GPT OSS 20B) - optional, smaller model -8. Click **Request model access** at the bottom -9. Wait for approval (usually instant for these models) -10. As an alternative - request access to the Amazon Nova models in your region or in us-east-1 +**What this means:** +- Models are automatically enabled when you first invoke them +- No manual activation required +- Simply invoke the model using the `InvokeModel` or `Converse` API operations +- Account administrators can still control access through IAM policies and Service Control Policies if needed + +**Model Options:** +- **Recommended**: Amazon Nova Pro (`us.amazon.nova-pro-v1:0` or `eu.amazon.nova-pro-v1:0`) - Available in multiple regions, cost-effective, excellent tool-calling support +- **Alternative**: OpenAI OSS models (gpt-oss-120b, gpt-oss-20b) - Only available in **us-west-2** region **Important Notes:** - ⚠️ The OSS models are ONLY available in **us-west-2** region @@ -73,6 +70,7 @@ The Researcher uses AWS Bedrock with OpenAI's open-source OSS 120B model. You ne - The OSS models are open-weight models from OpenAI, not the commercial GPT models - No API key is required for Bedrock - AWS IAM handles authentication - The researcher requires an OpenAI API key for the OpenAI Agents SDK's tracing functionality (to monitor and debug agent execution) +- The first invocation will automatically enable the model in your account ## Extra part of Step 0: IMPORTANT - ADDED SINCE THE VIDEOS!! @@ -104,8 +102,9 @@ You should see this section: model = LitellmModel(model=MODEL) ``` -Please update the value of REGION and MODEL to reflect the model you have access to. See the examples given for possible values. +Please update the value of REGION and MODEL to reflect the model you want to use. See the examples given for possible values. Note that nova-lite is not an acceptable choice as it doesn't support tool calling / MCP. Thank you Yuelin L! +Note: Models are automatically enabled on first use, so you can use any available model without manual activation. ## Step 1: Deploy the Infrastructure @@ -428,10 +427,12 @@ This will remove the scheduler but keep all your other services running. - The research should still complete and be stored ### "Invalid model identifier" or Bedrock errors -- Ensure you've requested access to the OpenAI OSS models in us-west-2 (see Step 0) +- Models are automatically enabled on first use - no manual activation needed - Check that your IAM role has Bedrock permissions (should be added by Terraform) -- The models are ONLY available in us-west-2 but can be accessed from any region -- Verify model access: Go to Bedrock console → Model access → Check status +- The OSS models are ONLY available in us-west-2 but can be accessed from any region +- Nova Pro models are available in multiple regions (us-east-1, us-west-2, eu-west-1, etc.) +- The first invocation will automatically enable the model in your account +- If you see access denied errors, verify IAM permissions allow `bedrock:InvokeModel` action ## Clean Up (Optional) diff --git a/guides/6_agents.md b/guides/6_agents.md index 114a2123..e664a6df 100644 --- a/guides/6_agents.md +++ b/guides/6_agents.md @@ -67,7 +67,7 @@ Before starting, ensure you have: - AWS CLI configured - Python with `uv` package manager installed - Docker Desktop running -- Access to AWS Bedrock models in us-west-2 +- IAM permissions for AWS Bedrock (models are automatically enabled on first use - see Step 0) ## Before we start - Context Engineering @@ -75,21 +75,19 @@ Read this seminal post by Google DeepMind Senior AI Relation Engineer Philipp Sc https://www.philschmid.de/context-engineering -## Step 0: Request Additional Bedrock Model Access +## Step 0: Bedrock Model Access -Our agents use Amazon's Nova Pro model for improved reliability. Let's ensure you have access: +Our agents use Amazon's Nova Pro model for improved reliability. -1. Sign in to the AWS Console -2. Navigate to **Amazon Bedrock** -3. Switch to **US West (Oregon) us-west-2** region -4. Click **Model access** in the left sidebar -5. Click **Manage model access** -6. Find the **Amazon** section -7. Check the box for **Amazon Nova Pro** -8. Click **Request model access** -9. Wait for approval (usually instant) +**Good News**: Serverless foundation models (including Nova Pro) are now automatically enabled across all AWS commercial regions when first invoked in your account. You no longer need to manually request model access through the Model Access page. -**Note**: The agents will use this model cross-region from your deployment region. +**What this means:** +- Models are automatically enabled when you first invoke them +- No manual activation required +- Simply invoke the model using the `InvokeModel` or `Converse` API operations +- Account administrators can still control access through IAM policies and Service Control Policies if needed + +**Note**: The agents will use this model cross-region from your deployment region. The first invocation will automatically enable the model in your account. ## Step 1: Configure Environment Variables @@ -313,10 +311,10 @@ Edit `terraform.tfvars` in Cursor and update with your values: # Your AWS region for Lambda functions (should match your database region) aws_region = "us-east-1" -# Aurora cluster ARN from Part 5 (leave empty - Terraform will find it automatically) +# Aurora cluster ARN from Part 5 (leave empty - Terraform will automatically read from Part 5's outputs) aurora_cluster_arn = "" -# Aurora secret ARN from Part 5 (leave empty - Terraform will find it automatically) +# Aurora secret ARN from Part 5 (leave empty - Terraform will automatically read from Part 5's outputs) aurora_secret_arn = "" # S3 Vectors bucket name from Part 3 @@ -336,7 +334,7 @@ polygon_api_key = "your_polygon_api_key_here" polygon_plan = "free" ``` -**Note**: The Aurora ARNs can be left empty - Terraform will automatically find them using data sources. Make sure to update the `vector_bucket` with your actual AWS account ID and add your Polygon API key. +**Note**: The Aurora ARNs can be left empty - Terraform will automatically read them from Part 5's Terraform outputs using remote state. If Part 5 hasn't been deployed yet, Terraform will fall back to data source lookups. Make sure to update the `vector_bucket` with your actual AWS account ID and add your Polygon API key. ## Step 6: Deploy Infrastructure @@ -543,7 +541,7 @@ Expected monthly cost for development: $30-50. If agents time out: 1. Check Lambda function timeout settings (should be 60s for agents, 300s for planner) -2. Verify Bedrock model access in us-west-2 +2. Verify IAM permissions for Bedrock access (models are automatically enabled on first use) 3. Check CloudWatch logs for specific errors ### Database Connection Failed @@ -570,9 +568,10 @@ If you see rate limit errors: ### Wrong Model Errors If you see model not found errors: -1. Verify Bedrock model access in us-west-2 +1. Verify IAM permissions for Bedrock access (models are automatically enabled on first use) 2. Check BEDROCK_MODEL_ID environment variable 3. Ensure using `us.amazon.nova-pro-v1:0` format +4. The first invocation will automatically enable the model in your account ### Empty Results diff --git a/scripts/run_local.py b/scripts/run_local.py index 7d471760..0aeb2556 100644 --- a/scripts/run_local.py +++ b/scripts/run_local.py @@ -231,7 +231,9 @@ def main(): import httpx except ImportError: print("\n📦 Installing httpx for health checks...") - subprocess.run(["uv", "add", "httpx"], check=True) + # Run from scripts directory where pyproject.toml exists + scripts_dir = Path(__file__).parent + subprocess.run(["uv", "add", "httpx"], cwd=scripts_dir, check=True) # Start services backend_proc = start_backend() diff --git a/terraform/6_agents/main.tf b/terraform/6_agents/main.tf index af7f4a37..117d38e1 100644 --- a/terraform/6_agents/main.tf +++ b/terraform/6_agents/main.tf @@ -19,6 +19,40 @@ provider "aws" { # Data source for current caller identity data "aws_caller_identity" "current" {} +# Reference Part 5 Database resources via remote state +data "terraform_remote_state" "database" { + backend = "local" + config = { + path = "../5_database/terraform.tfstate" + } +} + +# Data sources to automatically find Aurora resources as fallback if remote state unavailable +data "aws_rds_cluster" "aurora" { + count = var.aurora_cluster_arn == "" ? 1 : 0 + cluster_identifier = "alex-aurora-cluster" +} + +# Find all secrets to locate the Aurora credentials secret as fallback +data "aws_secretsmanager_secrets" "all" { + count = var.aurora_secret_arn == "" ? 1 : 0 +} + +# Use provided ARNs, remote state outputs, or data source ARNs (in priority order) +locals { + aurora_cluster_arn = coalesce( + var.aurora_cluster_arn != "" ? var.aurora_cluster_arn : null, + try(data.terraform_remote_state.database.outputs.aurora_cluster_arn, null), + length(data.aws_rds_cluster.aurora) > 0 ? data.aws_rds_cluster.aurora[0].arn : null + ) + aurora_secret_arn = coalesce( + var.aurora_secret_arn != "" ? var.aurora_secret_arn : null, + try(data.terraform_remote_state.database.outputs.aurora_secret_arn, null), + length(data.aws_secretsmanager_secrets.all) > 0 && length(data.aws_secretsmanager_secrets.all[0].arns) > 0 ? + try([for arn in data.aws_secretsmanager_secrets.all[0].arns : arn if length(regexall("alex-aurora-credentials-", arn)) > 0][0], null) : null + ) +} + # ======================================== # SQS Queue for Async Job Processing # ======================================== @@ -77,15 +111,10 @@ resource "aws_iam_role" "lambda_agents_role" { } } -# IAM policy for Lambda agents -resource "aws_iam_role_policy" "lambda_agents_policy" { - name = "alex-lambda-agents-policy" - role = aws_iam_role.lambda_agents_role.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - # CloudWatch Logs +# Build IAM policy statements list conditionally +locals { + iam_policy_statements = concat( + [ { Effect = "Allow" Action = [ @@ -95,7 +124,6 @@ resource "aws_iam_role_policy" "lambda_agents_policy" { ] Resource = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*" }, - # SQS access for orchestrator { Effect = "Allow" Action = [ @@ -105,7 +133,6 @@ resource "aws_iam_role_policy" "lambda_agents_policy" { ] Resource = aws_sqs_queue.analysis_jobs.arn }, - # Lambda invocation for orchestrator to call other agents { Effect = "Allow" Action = [ @@ -113,27 +140,26 @@ resource "aws_iam_role_policy" "lambda_agents_policy" { ] Resource = "arn:aws:lambda:${var.aws_region}:${data.aws_caller_identity.current.account_id}:function:alex-*" }, - # Aurora Data API access - { - Effect = "Allow" - Action = [ - "rds-data:ExecuteStatement", - "rds-data:BatchExecuteStatement", - "rds-data:BeginTransaction", - "rds-data:CommitTransaction", - "rds-data:RollbackTransaction" - ] - Resource = var.aurora_cluster_arn - }, - # Secrets Manager for database credentials - { - Effect = "Allow" - Action = [ - "secretsmanager:GetSecretValue" - ] - Resource = var.aurora_secret_arn - }, - # S3 Vectors access for all agents + ], + local.aurora_cluster_arn != "" ? [{ + Effect = "Allow" + Action = [ + "rds-data:ExecuteStatement", + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:RollbackTransaction" + ] + Resource = local.aurora_cluster_arn + }] : [], + local.aurora_secret_arn != "" ? [{ + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue" + ] + Resource = local.aurora_secret_arn + }] : [], + [ { Effect = "Allow" Action = [ @@ -145,7 +171,6 @@ resource "aws_iam_role_policy" "lambda_agents_policy" { "arn:aws:s3:::${var.vector_bucket}/*" ] }, - # S3 Vectors API access for all agents { Effect = "Allow" Action = [ @@ -154,7 +179,6 @@ resource "aws_iam_role_policy" "lambda_agents_policy" { ] Resource = "arn:aws:s3vectors:${var.aws_region}:${data.aws_caller_identity.current.account_id}:bucket/${var.vector_bucket}/index/*" }, - # SageMaker endpoint access for reporter agent { Effect = "Allow" Action = [ @@ -162,7 +186,6 @@ resource "aws_iam_role_policy" "lambda_agents_policy" { ] Resource = "arn:aws:sagemaker:${var.aws_region}:${data.aws_caller_identity.current.account_id}:endpoint/${var.sagemaker_endpoint}" }, - # Bedrock access for all agents { Effect = "Allow" Action = [ @@ -170,11 +193,27 @@ resource "aws_iam_role_policy" "lambda_agents_policy" { "bedrock:InvokeModelWithResponseStream" ] Resource = [ - "arn:aws:bedrock:${var.bedrock_region}::foundation-model/*", - "arn:aws:bedrock:${var.bedrock_region}:*:inference-profile/*" + # Allow Bedrock in common regions (LiteLLM may use different regions based on model ID parsing) + "arn:aws:bedrock:us-east-1::foundation-model/*", + "arn:aws:bedrock:us-east-1:*:inference-profile/*", + "arn:aws:bedrock:us-east-2::foundation-model/*", + "arn:aws:bedrock:us-east-2:*:inference-profile/*", + "arn:aws:bedrock:us-west-2::foundation-model/*", + "arn:aws:bedrock:us-west-2:*:inference-profile/*" ] } ] + ) +} + +# IAM policy for Lambda agents +resource "aws_iam_role_policy" "lambda_agents_policy" { + name = "alex-lambda-agents-policy" + role = aws_iam_role.lambda_agents_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = local.iam_policy_statements }) } @@ -235,8 +274,8 @@ resource "aws_lambda_function" "planner" { environment { variables = { - AURORA_CLUSTER_ARN = var.aurora_cluster_arn - AURORA_SECRET_ARN = var.aurora_secret_arn + AURORA_CLUSTER_ARN = local.aurora_cluster_arn + AURORA_SECRET_ARN = local.aurora_secret_arn DATABASE_NAME = "alex" VECTOR_BUCKET = var.vector_bucket BEDROCK_MODEL_ID = var.bedrock_model_id @@ -286,8 +325,8 @@ resource "aws_lambda_function" "tagger" { environment { variables = { - AURORA_CLUSTER_ARN = var.aurora_cluster_arn - AURORA_SECRET_ARN = var.aurora_secret_arn + AURORA_CLUSTER_ARN = local.aurora_cluster_arn + AURORA_SECRET_ARN = local.aurora_secret_arn DATABASE_NAME = "alex" BEDROCK_MODEL_ID = var.bedrock_model_id BEDROCK_REGION = var.bedrock_region @@ -326,8 +365,8 @@ resource "aws_lambda_function" "reporter" { environment { variables = { - AURORA_CLUSTER_ARN = var.aurora_cluster_arn - AURORA_SECRET_ARN = var.aurora_secret_arn + AURORA_CLUSTER_ARN = local.aurora_cluster_arn + AURORA_SECRET_ARN = local.aurora_secret_arn DATABASE_NAME = "alex" BEDROCK_MODEL_ID = var.bedrock_model_id BEDROCK_REGION = var.bedrock_region @@ -367,8 +406,8 @@ resource "aws_lambda_function" "charter" { environment { variables = { - AURORA_CLUSTER_ARN = var.aurora_cluster_arn - AURORA_SECRET_ARN = var.aurora_secret_arn + AURORA_CLUSTER_ARN = local.aurora_cluster_arn + AURORA_SECRET_ARN = local.aurora_secret_arn DATABASE_NAME = "alex" BEDROCK_MODEL_ID = var.bedrock_model_id BEDROCK_REGION = var.bedrock_region @@ -407,8 +446,8 @@ resource "aws_lambda_function" "retirement" { environment { variables = { - AURORA_CLUSTER_ARN = var.aurora_cluster_arn - AURORA_SECRET_ARN = var.aurora_secret_arn + AURORA_CLUSTER_ARN = local.aurora_cluster_arn + AURORA_SECRET_ARN = local.aurora_secret_arn DATABASE_NAME = "alex" BEDROCK_MODEL_ID = var.bedrock_model_id BEDROCK_REGION = var.bedrock_region