DocIntel
A serverless GCP platform, then a multi-agent assistant over its data
Two halves on one platform. A document pipeline where Cloud Run takes the upload and returns immediately, a storage notification triggers a Go Cloud Function, and Gemini returns schema-constrained JSON into Firestore. Then a LangGraph supervisor over six agents that answers questions about the data, routing each turn to one specialist and enforcing a single rule: exact figures come from BigQuery over text-to-SQL, narrative comes from vector search, and numbers are never read out of an embedding. Built level by level, with the agent layer added after the platform was finished rather than planned into it.
Verified: each of these is checkable in the repository
- 6
- agents behind one supervisorservices/agent/multiagent.py: supervisor, four ReAct specialists, synthesizer
- 150
- real CFPB mortgage complaints, queried both waysingest_cfpb.py TOTAL = 150 from the public consumerfinance.gov API
- L0→L5
- levels, each recording what brokedocs/architecture.md, then extended with the RAG and agent layer
- 2
- committed architecture diagrams, IAM-annotateddocs/architecture-high-level.svg and -low-level.svg
Document processing with a language model is slow enough that doing it inside the request is the wrong design, but the naive fix (a background thread) loses work on restart and gives you nothing to observe. I also wanted to build cloud architecture the way it is actually learned, one constraint at a time, rather than starting from a finished diagram. The second problem arrived once the data was in place: a single assistant asked to handle both precise figures and open questions will happily answer a revenue question from a paragraph it retrieved, and be confidently wrong by a rounding error or an order of magnitude.
The upload endpoint writes the blob to Cloud Storage, creates a Firestore record marked processing, and returns immediately. A storage finalize notification publishes to Pub/Sub, which triggers a Go Cloud Function that calls Gemini through Vertex AI with a response schema constraining the output shape, then merges the summary, entities and document type into the same Firestore record and marks it done. A separate scheduled path computes statistics. On top of that platform sits a LangGraph supervisor: it routes each turn to one of four ReAct specialists, and a synthesizer writes the final answer and names the agent that produced it. Financial figures and complaint counts resolve through text-to-SQL against BigQuery, narrative questions through Firestore vector search, and the retrieval graph abstains rather than answering when its distance gate or groundedness check fails.
One supervisor, four specialists, one hard rule about numbers
Added after the platform was finished rather than planned into it. The split down the middle is the point: exact figures resolve through text-to-SQL against BigQuery, narrative through Firestore vector search, and the retrieval graph abstains rather than answering when its distance gate or groundedness check fails.
7 stages, each with what broke recorded as it happened
Building this way meant each level failed in a specific way, and writing those down produced a more useful document than any diagram drawn up front.
- L0FoundationsProject, APIs, repo, and a $10 monthly budget with alerts at 50, 90 and 100 percent. An alert, not a hard cap.
- L1API and a modelFastAPI /ask calling Gemini, containerised onto Cloud Run through Artifact Registry.
- L2StorageUpload to Cloud Storage, schema-constrained analysis, the record in Firestore.
- L3AsyncBucket notification to Pub/Sub to a gen2 Cloud Function. Processing becomes done without the caller waiting.
- L4Frontend and opsWeb UI, Cloud Scheduler over OIDC, uptime checks, and the shared editor role retired for per-service accounts.
- L5Production polishProcessor rewritten in Go, Cloud Build on push to main, and Terraform that imported the topic created by hand in L3.
- +The AI layerFirestore vector search, a self-correcting RAG graph, then a supervisor multi-agent with threaded memory. Built after L5 rather than planned into it.
Why it is built this way
01Return before the work is done
The client gets a document ID immediately and polls. Nothing about model latency is the caller's problem, and the pipeline can retry independently of any open connection.
02Let Vertex fetch the blob directly
The prompt references the file by storage URI instead of shipping bytes through the function. That required granting the Vertex service agent read access explicitly: a small IAM detail that is invisible until it fails.
03Constrain the model's output shape
The generation request carries a response schema, so the model returns a typed object rather than prose to be parsed. Most brittle LLM plumbing is parsing, and this removes it.
04Rewrite the processor in Go
Same trigger, same service account, different language. Doing a like-for-like rewrite is how you find out what your interface boundaries were actually worth, and cold-start behaviour was the reason.
05Retire broad IAM as a distinct step
An early level used a broad editor role to get moving; a later one replaced it with per-service least-privilege accounts. Recording that transition, including the outage it caused, is more honest than presenting the end state as if it were the plan.
06Numbers from SQL, narrative from vectors
The specialists are split along that line rather than by topic. A question about revenue or a complaint count becomes a generated SELECT against BigQuery, and only prose questions go to vector search. Embeddings are good at finding the paragraph that discusses a figure and useless at guaranteeing the figure, so the boundary is enforced by which tool each agent holds rather than by asking the model to be careful.
07One specialist per turn, and a graph that abstains
The supervisor commits to a single specialist and then synthesizes, which keeps a turn debuggable: the answer names the agent that produced it. The retrieval graph grades its own results against a cosine-distance gate, rewrites the query once, checks the generated answer for groundedness, and returns nothing rather than something unsupported.
Building this in levels rather than all at once changed what I learned. Each level failed in a specific way, a missing ignore file silently changed the build system, an IAM grant that only surfaced at inference time, and writing those down as they happened produced a far more useful document than any architecture diagram I would have drawn up front. The agent layer taught me the same lesson from the other direction. It went on top of a finished platform rather than into the plan, and it only worked once I stopped treating retrieval as a general answer engine and gave the precise questions their own path to a database.
Next project
CloudTask
A three-tier AWS application, provisioned entirely as code