Architecture: RAG workspace + schema-validated HTML document templates
An allowlisted knowledge assistant that ingests office documents into a shared vector workspace, answers questions through retrieval-grounded chat, and generates structured HTML documents from uploaded templates—validated with Zod, previewed in React, and exported to PDF on the server.
The system is implemented as a single Next.js 16 application deployed to the edge on Cloudflare Workers through OpenNext. It includes a complete retrieval-augmented generation (RAG) pipeline and a document-template platform that evolved from Markdown payloads into a fixed block grammar and section DAGs.
Important
Ownership and showcase notice: This system was built by Armando Valera for JMCG under contract. The product and its associated proprietary materials are owned by JMCG. This public repository is presented solely as a limited technical and portfolio showcase. It does not grant any right or license to use, copy, modify, distribute, or reproduce the underlying product or proprietary materials.
Showcase scope: This repository documents the system architecture and representative implementation patterns while omitting production application source, credentials, client data, proprietary template copy, production hostnames, business logos, and operational runbooks. Screenshots contain only empty or synthetic data and show the product branding and UI chrome. The complete implementation and operational documentation remain private where applicable.
- What it does
- UI & design
- System overview
- Stack and why
- Architecture evolution
- Key flows
- Auth and access control
- Edge runtime decisions
- Project shape
- Notable engineering decisions
- What I would improve next
- Ingest knowledge documents (PDF, Word, Excel) uploaded by signed-in users: extract text, chunk, embed, upsert vectors, persist originals and metadata in object storage.
- Answer questions with optional workspace retrieval, chunk-level traceability back to source documents, and prompts that steer the model to use provided context when available.
- Manage a shared template catalog stored in object storage (not vectorized): JSON envelopes describing HTML or Excel document shells.
- Generate structured documents from chat using one of three strategies: conversational markdown, two-phase brief → document compose, or a section DAG for graph-registered templates.
- Preview and export PDFs using the same React renderer and theme CSS in the dashboard and on a tokenized print route, with headless Chromium on Cloudflare Browser Rendering.
Everything runs behind Google OAuth with a server-side email/domain allowlist. There is no anonymous path to model calls or mutating APIs.
Product UI (site mark, not a business seal):
| Artifact | Description |
|---|---|
| Logo lockup | Icon + Service Structure wordmark (above) |
| Favicon | Same icon in the browser tab |
| Login | Google sign-in chrome; no user PII in the layout |
| Chat | Conversation + composer shell (synthetic or empty history in public shots) |
| Documents | Catalog UI captured against an empty local/dev catalog |
| Chat output | Markdown assistant bubble layout |
| Motion | Looping video of the icon “thinking” / buffer animation |
Public screenshots omit production URLs, real document catalogs, template previews with third-party logos, and any retrieved workspace content.
┌──────────────────────────────┐
│ Next.js 16 (App Router) │
│ React 19 + Tailwind v4 │
│ shadcn/ui + Base UI │
└──────────────┬───────────────┘
│ Route Handlers / Server Actions
▼
┌──────────────────────────────────────────────────────────┐
│ @opennextjs/cloudflare Worker │
│ (nodejs_compat, global_fetch_strictly_public) │
└───┬─────────────┬─────────────┬──────────────┬───────────┘
│ │ │ │
┌──────▼─────┐ ┌─────▼──────┐ ┌────▼──────┐ ┌─────▼────────────┐
│ Cloudflare │ │ Pinecone │ │ OpenAI │ │ Anthropic │
│ R2 │ │ (vectors) │ │(embeddings│ │ (Claude) │
│ documents, │ │ │ │ 1536-d) │ │ │
│ templates, │ │ │ │ │ │ │
│ print sess.│ │ │ │ │ │ │
└────────────┘ └────────────┘ └───────────┘ └──────────────────┘
│
│ POST /api/templates/export/pdf
▼
┌──────────────────────┐
│ Browser Rendering │
│ (Puppeteer pdf()) │──► GET /print/document/:sessionId?token=…
└──────────────────────┘ same DocumentPreview + theme CSS
Retrieval “graph” context is not a graph database. It is a lightweight rank over document metadata in object storage (name-contains scoring), run in parallel with Pinecone vector search. An early Neo4j integration was removed when the stack consolidated onto Workers.
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 16 (App Router) | Server Components for dashboards, Route Handlers for APIs, streaming-friendly chat UX. |
| Hosting | Cloudflare Workers via @opennextjs/cloudflare |
Global edge, R2 and Browser bindings on the same runtime, no cross-cloud egress for file reads. |
| Storage | Cloudflare R2 | Documents, ingestion metadata, template catalog, ephemeral print sessions. |
| Vectors | Pinecone | Managed serverless index; embedding dimension negotiated with index describe. |
| Embeddings | OpenAI text-embedding-3-small |
Default 1536-d; strong cost/quality for document chunks. |
| Generation | Anthropic Claude | Instruction following for JSON legs, section fills, and grounded chat; usage logged via callbacks. |
| Auth | NextAuth (JWT) + Google OAuth | No session database; allowlist enforced in signIn. |
| UI | React 19, Tailwind v4, shadcn/ui, Base UI | Owned components, design tokens for chat and document preview. |
| Chat Markdown | Streamdown | Assistant bubbles render Markdown with code/math/CJK plugins. |
| Validation | Zod 4 | Template envelopes, per-block unions, per-DAG-node output schemas. |
| Env | @t3-oss/env-nextjs |
Typed auth-related env; other secrets via Wrangler at runtime. |
| PDF text | unpdf |
Workers-compatible PDF text extraction (no native deps). |
| Word | mammoth, word-extractor |
DOCX vs legacy DOC paths after container sniffing. |
| Excel | xlsx |
Spreadsheet text extraction for ingestion. |
| PDF export | @cloudflare/puppeteer + print route |
WYSIWYG with dashboard preview; Worker-friendly vs bundling a full layout engine. |
| Sanitization | sanitize-html |
Inline markdown in blocks; replaces DOMPurify (not Worker-safe). |
There is no separate Express API in the current architecture; route handlers call into src/server/ and src/domains/.
Commit history tells a deliberate simplification and specialization story:
| Phase | What changed |
|---|---|
| Sidecar era | Next.js UI proxied to a Node Express service for ingestion, RAG, and chat. |
| Markdown templates | Templates stored in R2 only (skipped vector index); chat composed markdown with a template picker. |
| Workers monolith | Express removed; logic moved to src/server/ compatible with the Worker runtime. |
| Definition-driven chat | /api/chat/generate gained conversation vs template fill/update routing from prompts and draft state. |
| Client PDF experiment | @react-pdf/renderer in the browser to avoid Worker CPU; later abandoned for layout parity. |
| HTML block documents | Fixed block grammar (documentType, blocks[]), React DocumentPreview, theme CSS per document type. |
| Server PDF | Puppeteer pdf() on Cloudflare Browser Rendering; HTML string renderer dropped in favor of a print route. |
| Section DAG | Complex templates split into topological section graphs (LLM + deterministic nodes); assembler merges validated blocks. |
| Two-phase compose | Templates without a registered graph: brief JSON leg → second leg fills full HtmlDocument. |
| Hardening | Print session tokens, documents meta manifest for faster listing, LLM/retrieval metrics, structured ingestion errors. |
Upload → MIME/extension/magic-byte policy → extract text → quality gates → chunk → embed → Pinecone upsert → R2 object + metadata sidecar (and manifest entry for catalog listing).
Representative PDF path (illustrative; private repo may differ slightly):
export async function ingestPdfDocument(params: {
buffer: Uint8Array;
originalName: string;
mimetype: string;
uploadedBy: string;
}): Promise<IngestPdfResult> {
// Stable ID + safe R2 key segments from the original filename
const documentId = generateKnowledgeDocumentId();
const { displayName, safeKeySegment } = normalizeOriginalFilename(params.originalName);
const r2Key = `documents/${documentId}-${safeKeySegment}`;
// Reject early if MIME type, magic bytes, or file size fall outside policy
validateClientPdfUpload({
buffer: params.buffer,
mimetype: params.mimetype,
size: params.buffer.length,
});
// Workers-compatible text extraction (unpdf)
const { text, numPages } = await extractTextFromPdf(params.buffer);
// Scanned-only PDFs often extract near-empty strings; fail closed before indexing
const quality = assessExtractedTextQuality(text, { minChars, warnBelowChars });
assertMeetsMinimumTextForIndexing(quality, minChars);
// Persist the original before Pinecone mutation
await putObject({ key: r2Key, body: params.buffer, contentType: params.mimetype });
// Chunk → embed → upsert with bounded metadata for citeable RAG
const chunks = chunkTextByTokens(quality.normalizedText);
const vectors = await embedTexts(chunks);
const records = chunks.map((_, i) => ({
id: `${documentId}_chunk_${i}`,
values: vectors[i],
metadata: {
document_id: documentId,
document_name: displayName,
chunk_index: i,
chunk_count: chunks.length,
r2_key: r2Key,
ingestion_pipeline: "pdf_v1",
// chunk_text stored here (bounded) for citation in chat
},
}));
await vectorIndex.upsert({ records });
await putDocumentMeta({ id: documentId, name: displayName, r2Key, chunkCount: chunks.length /* … */ });
return { documentId, r2Key, chunkCount: chunks.length, numPages, pineconeUpserted: records.length };
}Parallel pipelines exist for Word (ingestion_pipeline: word_v1) and Excel (excel_v1) with format-specific validation in a shared document policy module.
Chunking trade-off — overlapping character window instead of a BPE tokenizer to avoid bundling ~1 MB of tokenizer tables into the Worker:
// ~4 characters per token for typical English prose.
// A real BPE tokenizer would be more precise but adds ~1 MB of WASM
// to the Worker bundle — not worth it at this chunk size.
const CHARS_PER_TOKEN = 4;
export function chunkTextByTokens(
text: string,
maxTokens = 800,
overlapTokens = 100,
): string[] {
const maxChars = maxTokens * CHARS_PER_TOKEN;
const step = Math.max(1, maxChars - overlapTokens * CHARS_PER_TOKEN);
// slide window, trim, return non-empty chunks
}Embedding dimension alignment — prefer env override, else read Pinecone index dimension once and cache:
// Dimension must match the Pinecone index.
async function getDesiredEmbeddingDimensions(): Promise<number | undefined> {
const fromEnv = await parseEnvEmbeddingDimensions(); // OPENAI_EMBEDDING_DIMENSIONS
if (fromEnv !== undefined) return fromEnv;
return getPineconeIndexDimension();
}Vector search plus metadata-graph ranking, returned as one RetrievalResult for chat, DAG fill, and debug query endpoints:
export async function retrieveContext(params: {
message: string;
topK?: number;
graphLimit?: number;
}): Promise<RetrievalResult> {
const topK = clamp(params.topK ?? 8, 1, 20);
const graphLimit = clamp(params.graphLimit ?? 5, 1, 20);
const [vector] = await embedTexts([params.message]);
const index = await getPineconeVectorIndex();
// Parallel: vector NN + lightweight name-contains rank over R2 document meta
const [pineconeResult, graphContext] = await Promise.all([
index.query({ vector, topK, includeMetadata: true }),
queryMetaGraphContext({ query: params.message, limit: graphLimit }),
]);
return {
query: params.message,
retrieval: {
topK,
chunkCount: pineconeResult.matches.length,
graphCount: graphContext.length,
},
chunks: pineconeResult.matches.map(toRetrievalChunk),
graphContext,
};
}Conversation mode optionally retrieves workspace context, then calls Claude with a system prompt scoped to the product:
function buildConversationSystemPrompt() {
return [
"You are a helpful assistant for Service Structure.",
"Answer the user's questions and requests clearly and accurately.",
"Respond in Markdown (headings, lists, bold where useful).",
"Do not output JSON or template-schema unless the user explicitly asks for structured data.",
"If document-store context is provided, use it to ground answers and cite or paraphrase it naturally.",
].join(" ");
}Responses are Markdown in the chat bubble (Streamdown in the UI). Template mode uses separate system prompts for brief JSON vs full-document compose.
Templates are JSON envelopes uploaded to the shared catalog:
document.type: "html"—documentTypeis one ofscript,workflow,checklist,reference, ordiagnostic; each type has a Zod-validatedheaderand ablocksarray from a fixed union (headings, body, lists, steps, tables, KPIs, callouts, plus type-specific blocks where needed).document.type: "excel"— header / sheet shell today; schema can grow with tabular catalog needs.
Routing — resolveTemplateRoute() picks conversation, template_fill, or template_update from prompt verbs, transcript presence, and whether a draft document already exists.
Three generation paths:
Section DAG (registered templateId only)
SectionGraph: nodes arellmordeterministic, executed in topological waves (parallel where independent).- Each LLM node returns JSON validated against a per-node Zod schema; failures are tracked per node.
assembleDocumentmerges section drafts;pageBreakblocks come from graphprintChaptersBefore, not from free-form model output.- Optional cross-validation after assembly for stricter document shapes.
- Transcript facts may be extracted upstream to structure DAG context.
Two-phase compose (templates without a section graph)
- First leg: strict JSON
{ replyMarkdown, brief }— chat bubble vs work order. - Second leg:
composeTemplateDocumentfills the fullHtmlDocumentfrom the brief + retrieval + transcript. - One silent retry on invalid JSON / truncation before surfacing
TemplateValidationError.
Conversation — markdown-only assistant leg; no document mutation.
DAG fast path (conceptually):
if (mode === "template_fill" && hasSectionGraph(template.id)) {
const dagResult = await executeTemplateDagFill(input);
return {
mode,
replyMarkdown: dagResult.replyMarkdown,
document: dagResult.document,
sources,
failedNodeIds: dagResult.failedNodeIds.length
? dagResult.failedNodeIds
: undefined,
};
}Presentation is downstream of generation. Themes live under document-themes/ (generic vs diagnostic resolved from documentType). DocumentPreview maps blocks to React; inline strings pass through sanitize-html.
Evolution: client React-PDF → server HTML strings → print route + Puppeteer (final).
Current flow:
POST /api/templates/export/pdf (authenticated)
│
├─ 1. Validate the document envelope against the Zod schema
│
├─ 2. Write an ephemeral print session to R2
│ print-sessions/{uuid}.json
│
├─ 3. Issue a short-lived signed token for the print route
│ /print/document/:uuid?token=…
│
├─ 4. Puppeteer launches via the BROWSER binding
│ page.goto(same-origin print URL)
│ → renders DocumentPreview + theme CSS, identical to dashboard preview
│
├─ 5. pdf() called with type-aware @page / viewport rules
│ (generic vs diagnostic print CSS)
│
├─ 6. Print session deleted from R2 (one-time use)
│
└─ 7. Return application/pdf to the client
Why a print route: Dashboard preview and PDF share one DOM and one stylesheet bundle per theme. Pagination rules differ (scroll preview vs Chromium print media), but content parity is intentional.
Auth note: Puppeteer does not use the user session cookie. A one-time query token validates the print page; export and print handlers both enforce session lifecycle.
- Fail-closed: if both email and domain allowlists are empty, all sign-ins are denied.
- Google only, with
email_verifiedchecked in thesignIncallback. - JWT sessions — no session store on the Worker.
requireAuthedUser()on mutating API routes and sensitive reads.- Public surfaces: login, NextAuth routes, print page with valid token only.
async signIn({ account, profile, user }) {
if (account?.provider !== "google") return false;
if (profile?.email_verified === false) return false;
const email = profile?.email ?? user?.email ?? null;
return isEmailAllowed(email, loadAccessPolicy());
}The Worker runtime dominated product choices:
| Constraint | Response |
|---|---|
No process.env sprawl |
Single getEnvVar helper: Cloudflare bindings at runtime, process.env fallback in next dev. |
| No native PDF parsers | unpdf for text extraction. |
| No huge tokenizer WASM | Character-based chunker (~4 chars/token). |
| SDK compatibility | nodejs_compat + global_fetch_strictly_public in Wrangler. |
| DOM sanitization in Workers | sanitize-html, not DOMPurify. |
| PDF CPU / layout | Browser Rendering binding, not in-Worker layout engines. |
| Typed bindings | wrangler types → CloudflareEnv used across server code. |
src/
app/
(auth)/login/
api/
auth/[...nextauth]/
documents/ # list, upload, meta, download, delete
chat/generate/ # conversation + template compose
query/ # retrieval-only (debug/integration)
templates/ # catalog CRUD
templates/export/pdf/ # Puppeteer PDF
dashboard/ # chat + documents tabs
print/document/[sessionId]/ # headless print target
components/document-templates/ # block renderers, DocumentPreview
document-themes/ # generic + diagnostic CSS
domains/generation/ # section graphs, DAG executor, assembler
lib/ # auth, extraction, file types
server/ # ingest, RAG, templates, PDF, R2, Pinecone, policy
docs/showcase/ # public logo / UI screenshots (product brand only)
Proprietary template JSON, registered production graphs, business logos, and operator deployment docs remain in the private codebase and are not part of this showcase surface.
- Monolith on Workers — Removed the Express sidecar; one deployable app, thinner operational surface.
- Templates: markdown → HTML block grammar — Predictable structure for preview, validation, and PDF; Zod is the contract.
- DAG vs monolithic JSON — Section graphs for long, multi-part documents; two-phase brief → document for simpler templates.
- Generation ≠ presentation — DAG output is JSON blocks; CSS themes and React rendering are separate layers.
- PDF fidelity over client generation — Print route guarantees the export matches what users preview.
- One retrieval shape, many consumers — Same
RetrievalResultfor chat, DAG context, and/api/query. - Ingestion policy as code — MIME, size, magic bytes, minimum extractable text, structured errors—tunable without redeploying prompts.
- Templates not in the vector index — Catalog files in R2; knowledge docs in Pinecone—clear separation of concerns.
- Observability hooks — LLM usage callbacks, retrieval metrics, prompt character counts on compose legs.
- Fail-closed auth everywhere — No anonymous model or mutation paths.
- Reranker over vector + meta-graph candidates for multi-hop questions.
- Real tokenizer chunking when Worker bundle budget allows.
- Streaming partial document JSON in the UI during DAG waves (field- or section-level).
- Eval harness — fixtures of
(template, transcript, expected document shape)replayed against a pinned model. - Async ingestion queue for large files and back-pressure on embed/upsert.
- Per-tenant workspaces — today is shared corpus + allowlist; fine for a single org, not multi-tenant SaaS.
- Broader API integration tests — expand beyond unit tests on auth, RAG errors, and export paths.
This showcase describes a system built end-to-end: multi-format ingestion, RAG retrieval, grounded chat, schema-driven HTML templates, section DAG generation, Worker-native runtime choices, and server-side PDF export with preview parity.





{ "id": "...", "name": "...", "version": "...", "description": "...", // optional "document": { ... } // html or excel shell — see block types below }