mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 11:13:14 +00:00
* Fix NuGet versions and add InitialCreate EF Core migration - Update AWSSDK.SQS and AWSSDK.SecretsManager to 3.7.500.0 (actual available versions) - Update AWSSDK.Extensions.NETCore.Setup to 3.7.400 - Generate InitialCreate migration for PostgreSQL (all 8 entities) - Build verified: 0 errors, 0 warnings * Implement Dispatcher Frontend (Phase 2) React 19 + MUI v7 + TypeScript + Vite SPA matching SHOC patterns: Redux Toolkit (auth/ui slices), TanStack React Query, axios interceptors, react-toastify, Cognito OAuth PKCE login flow, paginated proposal list, new proposal form with customer autocomplete and vendor PDF upload, read-only proposal detail with status stepper timeline. * Add AuthController for Cognito code exchange and .env.example Backend endpoint POST /api/auth/callback exchanges the OAuth authorization code with Cognito's token endpoint, auto-provisions the user in the DB, and returns the access token to the frontend. * Implement Admin Frontend Experience (Phase 3) Three-panel admin workspace: left reference panel (submission details, vendor data), center editor (refined scope, inline line item table with reorder/add/remove/pricing), right similar proposals panel (KB results with pull-to-editor). Admin dashboard with stats cards and proposal queue table. Approval flow with confirmation dialog, mark-as-sent, and create-revision actions. Role-based sidebar navigation. * Implement backend dev mode, internal API auth, and service layer enhancements - Add dev-login endpoint with local JWT signing for local development - Add InternalApiKeyMiddleware with timing-safe comparison for Lambda-to-API auth - Add DevS3Service and NoOpJobPublisher for running without AWS services - Implement CurrentUserService cascading user resolution (ID → sub → email → create) - Add async ResolveAsync() to avoid synchronous DB calls in request pipeline - Add /proposals/stats endpoint for efficient server-side status counts - Guard status transitions: only allow Draft → InReview via update endpoint - Add vendor proposals, generated PDFs, and similar proposals controllers - Add ISimilarProposalService and SimilarProposalService - Add [Authorize] to AddSimilarReference endpoint * Implement Lambda functions for PDF processing, suggestions, and library ingest - pdf-extract: Parse vendor PDFs with pdfplumber, fallback to Claude multimodal - pdf-generate: Generate branded proposal PDFs with reportlab Platypus - library-ingest: Format approved proposals as markdown and sync to Bedrock KB - suggestions: Query KB for similar proposals, generate line items via Claude - All Lambdas use internal API key auth and cold-start secret caching - Fix pdf_path unbound variable in pdf-extract error handling * Add Bedrock Knowledge Base, OpenSearch Serverless, and SQS message filtering - Provision OpenSearch Serverless collection for vector search - Create Bedrock Knowledge Base with Titan embedding model - Configure S3 data source with fixed-size chunking (512 tokens, 20% overlap) - Add suggestions Lambda with SQS event source filtering - Scope bedrock:InvokeModel IAM to specific model ARN patterns - Add internal API key secret in Secrets Manager - Add log retention (2 months) to all Lambda functions - Add docker-compose.yml for local PostgreSQL * Apply SHOC design system styling across frontend - Rewrite theme with SHOC palette (#0c4f6f primary, Nunito font, 4px radius) - Add global CSS with Google Fonts import for Nunito - Redesign Topbar with avatar initials, role subtitle, gradient header - Redesign Sidebar with 220px width, section headers, active state border - Restyle LoginPage with SHOC branded card and dev-mode role selector - Update AdminDashboard KPI cards to centered SHOC style - Add devLogin API method for local development auth flow * Fix frontend navigation bugs, differentiate Dashboard from Proposals list - Fix double nav selection by adding isNavActive() with ALL_NAV_PATHS set - Fix /admin/users routing to placeholder instead of redirect to / - Fix ProposalDetailPage Back button navigating to / instead of /proposals - Differentiate Dashboard (KPI cards + recent 5) from ProposalListPage (full paginated table) - Dashboard now uses dedicated /proposals/stats endpoint for accurate counts - Fix adminApi.getPdf dead code (axios rejects before status check) - Wire up PDF generation button in AdminWorkspace - Adjust layout: 220px drawer, 10px content padding, 64px toolbar height * Add appsettings.Development.json to gitignore Prevent dev-only signing keys and connection strings from being committed. * Fix CI failures: unused Python imports and CDK synth asset path CDK synth job needs the .NET API published first so the Lambda asset path exists. Python lint had 3 unused imports in pdf-generate. * Apply ruff formatting to all Lambda Python files
196 lines
5.9 KiB
Python
196 lines
5.9 KiB
Python
"""Proposal System - Library Ingest Lambda.
|
|
|
|
Processes approved/sent proposals into the Bedrock Knowledge Base library.
|
|
Formats proposal data as structured markdown and uploads to the library bucket,
|
|
then triggers a KB sync.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
|
|
import boto3
|
|
import httpx
|
|
|
|
LIBRARY_BUCKET = os.environ.get("LIBRARY_BUCKET", "")
|
|
KNOWLEDGE_BASE_ID = os.environ.get("KNOWLEDGE_BASE_ID", "")
|
|
DATA_SOURCE_ID = os.environ.get("DATA_SOURCE_ID", "")
|
|
API_BASE_URL = os.environ.get("API_BASE_URL", "")
|
|
INTERNAL_API_KEY_SECRET_ARN = os.environ.get("INTERNAL_API_KEY_SECRET_ARN", "")
|
|
|
|
s3 = boto3.client("s3")
|
|
bedrock_agent = boto3.client("bedrock-agent")
|
|
secrets_client = boto3.client("secretsmanager")
|
|
|
|
_cached_api_key: str | None = None
|
|
|
|
|
|
def _get_api_key() -> str:
|
|
global _cached_api_key
|
|
if _cached_api_key is None:
|
|
if INTERNAL_API_KEY_SECRET_ARN:
|
|
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
|
|
_cached_api_key = resp["SecretString"]
|
|
else:
|
|
_cached_api_key = ""
|
|
return _cached_api_key
|
|
|
|
|
|
def handler(event, context):
|
|
for record in event.get("Records", []):
|
|
body = json.loads(record["body"])
|
|
payload = body.get("payload", body)
|
|
proposal_id = payload["proposalId"]
|
|
process_ingestion(proposal_id)
|
|
return {"statusCode": 200}
|
|
|
|
|
|
def process_ingestion(proposal_id: str):
|
|
proposal = fetch_proposal(proposal_id)
|
|
if not proposal:
|
|
print(f"Proposal {proposal_id} not found")
|
|
return
|
|
|
|
line_items = fetch_line_items(proposal_id)
|
|
|
|
document = format_proposal_document(proposal, line_items)
|
|
|
|
s3_key = upload_to_library(proposal, document)
|
|
|
|
if s3_key:
|
|
trigger_kb_sync()
|
|
|
|
|
|
def fetch_proposal(proposal_id: str) -> dict | None:
|
|
try:
|
|
resp = httpx.get(
|
|
f"{API_BASE_URL}/api/proposals/{proposal_id}",
|
|
headers=_api_headers(),
|
|
timeout=10,
|
|
)
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
except Exception as e:
|
|
print(f"Error fetching proposal: {e}")
|
|
return None
|
|
|
|
|
|
def fetch_line_items(proposal_id: str) -> list[dict]:
|
|
try:
|
|
resp = httpx.get(
|
|
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
|
|
headers=_api_headers(),
|
|
timeout=10,
|
|
)
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
except Exception as e:
|
|
print(f"Error fetching line items: {e}")
|
|
return []
|
|
|
|
|
|
def format_proposal_document(proposal: dict, line_items: list[dict]) -> str:
|
|
total = sum(li.get("totalPrice", 0) for li in line_items)
|
|
submitted_at = proposal.get("submittedAt", "")
|
|
if submitted_at:
|
|
try:
|
|
dt = datetime.fromisoformat(submitted_at.replace("Z", "+00:00"))
|
|
submitted_at = dt.strftime("%Y-%m-%d")
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
lines = [
|
|
f"# Proposal: {proposal['proposalNumber']}",
|
|
"",
|
|
f"**Customer:** {proposal['customerName']}",
|
|
f"**Address:** {proposal.get('customerAddress', '')}",
|
|
f"**Service Category:** {proposal['serviceCategory']}",
|
|
f"**Priority:** {proposal['priority']}",
|
|
f"**Date:** {submitted_at}",
|
|
f"**Total Bid Amount:** ${total:,.2f}",
|
|
f"**Work Order:** {proposal.get('workOrderNumber', '')}",
|
|
"",
|
|
"## Scope of Work",
|
|
"",
|
|
proposal.get("refinedScope") or proposal.get("scopeOfWork", ""),
|
|
"",
|
|
"## Line Items",
|
|
"",
|
|
"| # | Description | Qty | Unit | Unit Price | Total |",
|
|
"|---|---|---|---|---|---|",
|
|
]
|
|
|
|
for i, li in enumerate(line_items, 1):
|
|
desc = li.get("description", "")
|
|
qty = li.get("quantity", "")
|
|
unit = li.get("unit", "")
|
|
unit_price = li.get("unitPrice")
|
|
total_price = li.get("totalPrice", 0)
|
|
|
|
up_str = f"${unit_price:,.2f}" if unit_price else "-"
|
|
tp_str = f"${total_price:,.2f}"
|
|
|
|
lines.append(f"| {i} | {desc} | {qty} | {unit} | {up_str} | {tp_str} |")
|
|
|
|
lines.extend(
|
|
[
|
|
"",
|
|
f"**Total: ${total:,.2f}**",
|
|
]
|
|
)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def upload_to_library(proposal: dict, document: str) -> str | None:
|
|
if not LIBRARY_BUCKET:
|
|
print("No library bucket configured")
|
|
return None
|
|
|
|
proposal_number = proposal["proposalNumber"]
|
|
category = proposal.get("serviceCategory", "General")
|
|
s3_key = f"proposals/{category.lower()}/{proposal_number}.md"
|
|
|
|
try:
|
|
s3.put_object(
|
|
Bucket=LIBRARY_BUCKET,
|
|
Key=s3_key,
|
|
Body=document.encode("utf-8"),
|
|
ContentType="text/markdown",
|
|
Metadata={
|
|
"service-category": category,
|
|
"proposal-number": proposal_number,
|
|
"customer-name": proposal.get("customerName", ""),
|
|
"total-amount": str(proposal.get("totalBidAmount", 0)),
|
|
"date-submitted": proposal.get("submittedAt", ""),
|
|
},
|
|
)
|
|
print(f"Uploaded {s3_key} to library bucket")
|
|
return s3_key
|
|
except Exception as e:
|
|
print(f"Error uploading to library: {e}")
|
|
return None
|
|
|
|
|
|
def trigger_kb_sync():
|
|
if not KNOWLEDGE_BASE_ID or not DATA_SOURCE_ID:
|
|
print("KB or data source ID not configured, skipping sync")
|
|
return
|
|
|
|
try:
|
|
response = bedrock_agent.start_ingestion_job(
|
|
knowledgeBaseId=KNOWLEDGE_BASE_ID,
|
|
dataSourceId=DATA_SOURCE_ID,
|
|
)
|
|
job_id = response.get("ingestionJob", {}).get("ingestionJobId", "")
|
|
print(f"Started KB ingestion job: {job_id}")
|
|
except Exception as e:
|
|
print(f"Error triggering KB sync: {e}")
|
|
|
|
|
|
def _api_headers() -> dict:
|
|
headers = {"Content-Type": "application/json"}
|
|
api_key = _get_api_key()
|
|
if api_key:
|
|
headers["X-Internal-Api-Key"] = api_key
|
|
return headers
|