Back to Resume
PrivateNovember 2024· 2 weeks

AI Project-Scoping Assistant on Serverless AWS

A Serverless AI Assistant for Project Scoping

RoleBackend & AI Systems Engineer

A project-management SaaS startup wanted an AI feature that takes a short idea from a user and turns it into a real, structured project plan, tasks, subtasks, owners, and types, ready to drop into their product. I built the backend for it end to end on AWS: a serverless Lambda pipeline that expands a vague idea into concrete options, generates the task breakdown with Bedrock, and can optionally ground that generation in PDFs the client has already uploaded. Everything runs async behind a fast-acknowledging API, so the client's app never blocks on an AI call.

The problem

The client's users type something like "I want to build a video hosting website" and expect back a plan they can actually work from, not a paragraph of prose. A single vague sentence is not enough to produce a good breakdown, and calling a large language model straight from an API route means the caller sits on a slow, unpredictable connection until the model finishes. Some of the client's users also had their own reference material, style guides, prior specs, product docs, that should shape the breakdown instead of a generic one. A plain prompt-to-LLM call has no way to bring that material in, and there was no existing pipeline to turn an uploaded PDF into something a model could search against.

Constraints

  • ·Serverless only: the whole system had to run on Lambda and managed AWS services, no standing servers to operate
  • ·API Gateway response times: a synchronous Bedrock call, or a Bedrock call plus a vector search, does not reliably fit inside a comfortable timeout window
  • ·Bedrock as the only model access: all generation and embedding had to go through Bedrock, no direct provider APIs
  • ·Sole engineer: built and iterated by one person, so the architecture had to stay simple enough to operate alone

Architecture

API Gateway, gated by Cognito client-credentials auth, fronts a chain of Lambdas that always fast-ack and finish work asynchronously. main_bot acknowledges instantly and hands off to an enhancer Lambda that expands a one-line idea into 3 concrete variants via Bedrock. Once the client resends the chosen variant, the enhancer routes to one of two generator Lambdas: a plain Bedrock call, or a retrieval-augmented path that searches a LanceDB vector table (hosted on S3) before calling Bedrock. A separate background pipeline, triggered by an S3 upload event, chunks and embeds client PDFs into that same LanceDB table so the retrieval path has something to search.

  • API Gateway with Cognito client-credentials (M2M) authorization
  • main_bot: fast-ack entry Lambda, invokes the enhancer asynchronously
  • enhancer Lambda: Bedrock call that expands an idea into 3 variants, and later routes to a generator
  • bot_without_rag: Bedrock-only task breakdown generator (zip Lambda)
  • container_bot: retrieval-augmented generator, LanceDB similarity search plus Bedrock (container image Lambda)
  • presigned_url Lambda: issues a scoped, time-limited S3 PUT URL for PDF uploads
  • PDF ingestion Lambda: S3-event triggered, chunks and embeds PDFs into LanceDB (container image Lambda)
  • LanceDB vector table stored on S3
  • file-check / file-delete Lambdas: poll and clean up the generated result in S3
  • Amazon S3: the sole data plane for responses, the vector table, and uploaded PDFs

The live request path never waits on Bedrock or LanceDB directly. Every Lambda in that chain invokes the next one asynchronously and the client polls S3 through file-check for the final result.

How one request flows

  1. 01Idea comes in: the client calls the root API with a one-line idea. main_bot invokes the enhancer Lambda asynchronously and immediately returns a processing-started acknowledgement.
  2. 02The idea gets expanded: the enhancer calls Bedrock to turn the one-liner into 3 differentiated, more detailed project concepts, and stores them in S3.
  3. 03The client polls and picks: the client polls /file-check until the 3 variants are ready, shows them to the end user, and resends the request with the chosen variant as the full idea text.
  4. 04The enhancer routes to the right generator: on this second pass, based on bot_type, it invokes either the no-retrieval generator or the retrieval-augmented one, again asynchronously.
  5. 05The breakdown gets generated: the no-retrieval path sends the idea straight to Bedrock. The retrieval path first cleans the idea with a small Bedrock call, searches LanceDB for the 3 closest chunks from the client's uploaded PDFs, and includes them as context.
  6. 06The result lands in S3: both paths write the same JSON shape, a project name and a list of tasks with subtasks, types, and assignees, to the same S3 key pattern.
  7. 07The client retrieves and cleans up: the client polls /file-check to fetch the finished breakdown, and can call /file-delete once it no longer needs the stored copy.

Engineering decisions

Fire-and-poll instead of a synchronous chain

What. Every Lambda that touches Bedrock or LanceDB is invoked with an asynchronous Event call, and the caller gets an instant placeholder response. The client learns the real result exists by polling a check endpoint against S3.

Why. Keeps every API Gateway response fast and predictable regardless of how long a Bedrock call or vector search takes.

Tradeoff. The client owns the poll loop instead of getting a push notification, but this avoided adding a queue, a websocket, or a job-status table just to keep API Gateway inside its timeout budget.

Two generation paths sharing one contract

What. The retrieval and non-retrieval generators are two different Lambdas with different dependencies, but they write the exact same JSON shape to the exact same S3 key pattern.

Why. The client-facing poll and retrieve logic never needs to know which path ran.

Tradeoff. The alternative, one Lambda with an internal branch, would have forced the lightweight non-retrieval path to carry the same heavy dependency footprint as the RAG path for no benefit.

Container images only where the dependencies demand it

What. The two Lambdas that use LanceDB, pyarrow, and the langchain stack are packaged as container images. Every other Lambda ships as a plain zip.

Why. Those dependencies do not fit inside the standard Lambda zip package size limit.

Tradeoff. Making every Lambda a container image would have been simpler to reason about uniformly, but it was not necessary, and the container rebuild-and-push cycle is already the slower part of iterating on this project.

A two-step enhance-then-generate flow

What. A user's one-line idea is first expanded into 3 concrete variants, and the user picks one before the real breakdown is generated.

Why. The expensive, longer generation call only ever runs on a well-specified idea instead of a vague sentence likely to need redoing.

Tradeoff. Adds one extra round trip and one extra Bedrock call before the client sees a final result.

One S3 bucket as the entire data plane

What. The vector table, the generated response files, and the uploaded PDFs all live in one bucket, with no separate database.

Why. Given the fire-and-poll design, the producing Lambda and the consuming Lambda never share anything except what is durable in S3, so a single bucket was enough.

Tradeoff. No query flexibility beyond a key lookup, which is acceptable since every lookup in this system is by a single file_id.

Presigned uploads instead of routing files through the API

What. PDFs go straight from the client to S3 using a short-lived presigned PUT URL, scoped to one object key and checked for path traversal characters.

Why. Keeps large binaries off the API entirely and off the request-size limits that come with it.

Tradeoff. Requires a two-call flow (get URL, then upload) instead of a single multipart upload endpoint.

What changed along the way

  • Hit a CORS rejection once a real browser client was calling the API. Fixed it on both sides, enabled CORS in the API Gateway console and also returned the CORS headers directly from the Lambda response body, since one without the other still failed.
  • LanceDB table updates initially behaved like they were wiping existing rows instead of adding to them. The fix was switching to the correct append call rather than the more obvious one.
  • The container Lambda rebuild-and-push-to-ECR cycle turned out to be the slowest part of iterating on the retrieval path, flagged as something to streamline in a future pass rather than something fully solved here.

Security and reliability

  • ·Machine-to-machine auth: every API call requires a Cognito client-credentials OAuth2 token scoped to a specific resource server, not an open endpoint
  • ·Scoped presigned URLs: upload URLs are time-limited (30 minutes by default) and locked to one object key
  • ·Input validation on uploads: the presigned-URL Lambda rejects file names containing path separators or .. before generating a URL
  • ·Async decoupling: long-running Bedrock and vector-search work never blocks a synchronous API response
  • ·Known limitation: file_id is supplied by the caller and used directly as an S3 key with no ownership check, so any authenticated caller that knows or guesses a file_id can read or delete that result. Acceptable at the scale this shipped at, worth hardening before a wider rollout.

Metrics

3
Bedrock-backed call sites across the enhance, no-RAG, and RAG paths
measured
2 of 6
backend Lambdas built as container images to fit LanceDB + langchain
measured
30 min
default lifetime of a presigned upload URL
measured

What shipped

  • Delivered a working serverless pipeline: idea in, AI-expanded options, structured task breakdown out, with no server to operate
  • Added an optional retrieval path so a client's own PDFs can ground the generated breakdown instead of relying on the prompt alone
  • Built the full PDF-to-vector ingestion pipeline, presigned upload, chunking, embedding, and LanceDB storage, triggered automatically by the S3 upload event
  • Kept the whole system inside API Gateway's timeout budget by never letting a client request wait on a model call
  • Handed over a system a single engineer can operate: independent Lambda deploys, no shared framework, no database beyond S3

Lessons learned

  • ·Fire-and-poll is a cheap way to dodge API Gateway timeouts on slow model calls, without owning a queue or a websocket
  • ·Container image Lambdas buy a much larger dependency budget at the cost of a slower build-push-deploy loop, worth reserving for the functions that truly need it
  • ·LanceDB's append behavior needs to be verified against real updates, not assumed from the first API that looks right
  • ·CORS has to be fixed in both places, the API Gateway configuration and the Lambda's own response headers, or it only half-works
  • ·A cheap expand-then-generate step before the real generation call saves a full model run on ideas that were too vague to use as-is

Tech stack

AI & retrieval
Amazon Bedrock (Claude 3.5 Sonnet)Bedrock Cohere multilingual embeddingsLanceDB vector store on S3LangChain text splitting and PDF loading
Backend
AWS Lambda (zip and container image)API GatewayAmazon Cognito (client-credentials OAuth2)
Data & state
Amazon S3 as the sole data plane: response JSON, vector table, uploaded PDFs
Ops & quality
CloudWatch loggingDocker + Amazon ECR for the two container Lambdas