Skip to content

Latest commit

History

21 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Service Structure Logo

Service Structure Showcase

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.


Table of contents


What it does

  • 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.


UI & design

Product UI (site mark, not a business seal):

ArtifactDescription
Logo lockupIcon + Service Structure wordmark (above)
FaviconSame icon in the browser tab
LoginGoogle sign-in chrome; no user PII in the layout
ChatConversation + composer shell (synthetic or empty history in public shots)
DocumentsCatalog UI captured against an empty local/dev catalog
Chat outputMarkdown assistant bubble layout
MotionLooping 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.

Favicon

Service Structure Favicon

Login

Service Structure Login

Chat

Service Structure Chat

Documents

Documents Tab

Chat Output

Chat Output

Buffer Motion

Buffer Animation


System overview

 ┌──────────────────────────────┐
│ 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.


Stack and why

LayerChoiceWhy
FrameworkNext.js 16 (App Router)Server Components for dashboards, Route Handlers for APIs, streaming-friendly chat UX.
HostingCloudflare Workers via @opennextjs/cloudflareGlobal edge, R2 and Browser bindings on the same runtime, no cross-cloud egress for file reads.
StorageCloudflare R2Documents, ingestion metadata, template catalog, ephemeral print sessions.
VectorsPineconeManaged serverless index; embedding dimension negotiated with index describe.
EmbeddingsOpenAI text-embedding-3-smallDefault 1536-d; strong cost/quality for document chunks.
GenerationAnthropic ClaudeInstruction following for JSON legs, section fills, and grounded chat; usage logged via callbacks.
AuthNextAuth (JWT) + Google OAuthNo session database; allowlist enforced in signIn.
UIReact 19, Tailwind v4, shadcn/ui, Base UIOwned components, design tokens for chat and document preview.
Chat MarkdownStreamdownAssistant bubbles render Markdown with code/math/CJK plugins.
ValidationZod 4Template envelopes, per-block unions, per-DAG-node output schemas.
Env@t3-oss/env-nextjsTyped auth-related env; other secrets via Wrangler at runtime.
PDF textunpdfWorkers-compatible PDF text extraction (no native deps).
Wordmammoth, word-extractorDOCX vs legacy DOC paths after container sniffing.
ExcelxlsxSpreadsheet text extraction for ingestion.
PDF export@cloudflare/puppeteer + print routeWYSIWYG with dashboard preview; Worker-friendly vs bundling a full layout engine.
Sanitizationsanitize-htmlInline 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/.


Architecture evolution

Commit history tells a deliberate simplification and specialization story:

PhaseWhat changed
Sidecar eraNext.js UI proxied to a Node Express service for ingestion, RAG, and chat.
Markdown templatesTemplates stored in R2 only (skipped vector index); chat composed markdown with a template picker.
Workers monolithExpress 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 documentsFixed block grammar (documentType, blocks[]), React DocumentPreview, theme CSS per document type.
Server PDFPuppeteer pdf() on Cloudflare Browser Rendering; HTML string renderer dropped in favor of a print route.
Section DAGComplex templates split into topological section graphs (LLM + deterministic nodes); assembler merges validated blocks.
Two-phase composeTemplates without a registered graph: brief JSON leg → second leg fills full HtmlDocument.
HardeningPrint session tokens, documents meta manifest for faster listing, LLM/retrieval metrics, structured ingestion errors.

Key flows

1. Document ingestion

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):

exportasyncfunctioningestPdfDocument(params: {buffer: Uint8Array;originalName: string;mimetype: string;uploadedBy: string;}): Promise<IngestPdfResult>{// Stable ID + safe R2 key segments from the original filenameconstdocumentId=generateKnowledgeDocumentId();const{ displayName, safeKeySegment }=normalizeOriginalFilename(params.originalName);constr2Key=`documents/${documentId}-${safeKeySegment}`;// Reject early if MIME type, magic bytes, or file size fall outside policyvalidateClientPdfUpload({buffer: params.buffer,mimetype: params.mimetype,size: params.buffer.length,});// Workers-compatible text extraction (unpdf)const{ text, numPages }=awaitextractTextFromPdf(params.buffer);// Scanned-only PDFs often extract near-empty strings; fail closed before indexingconstquality=assessExtractedTextQuality(text,{ minChars, warnBelowChars });assertMeetsMinimumTextForIndexing(quality,minChars);// Persist the original before Pinecone mutationawaitputObject({key: r2Key,body: params.buffer,contentType: params.mimetype});// Chunk → embed → upsert with bounded metadata for citeable RAGconstchunks=chunkTextByTokens(quality.normalizedText);constvectors=awaitembedTexts(chunks);constrecords=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},}));awaitvectorIndex.upsert({ records });awaitputDocumentMeta({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.constCHARS_PER_TOKEN=4;exportfunctionchunkTextByTokens(text: string,maxTokens=800,overlapTokens=100,): string[]{constmaxChars=maxTokens*CHARS_PER_TOKEN;conststep=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.asyncfunctiongetDesiredEmbeddingDimensions(): Promise<number|undefined>{constfromEnv=awaitparseEnvEmbeddingDimensions();// OPENAI_EMBEDDING_DIMENSIONSif(fromEnv!==undefined)returnfromEnv;returngetPineconeIndexDimension();}

2. Retrieval

Vector search plus metadata-graph ranking, returned as one RetrievalResult for chat, DAG fill, and debug query endpoints:

exportasyncfunctionretrieveContext(params: {message: string;topK?: number;graphLimit?: number;}): Promise<RetrievalResult>{consttopK=clamp(params.topK??8,1,20);constgraphLimit=clamp(params.graphLimit??5,1,20);const[vector]=awaitembedTexts([params.message]);constindex=awaitgetPineconeVectorIndex();// Parallel: vector NN + lightweight name-contains rank over R2 document metaconst[pineconeResult,graphContext]=awaitPromise.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,};}

3. Grounded chat

Conversation mode optionally retrieves workspace context, then calls Claude with a system prompt scoped to the product:

functionbuildConversationSystemPrompt(){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.


4. Template generation

Templates are JSON envelopes uploaded to the shared catalog:

{
"id": "...",
"name": "...",
"version": "...",
"description": "...", // optional"document": { ... } // html or excel shell — see block types below
}
  • document.type: "html"documentType is one of script, workflow, checklist, reference, or diagnostic; each type has a Zod-validated header and a blocks array 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.

RoutingresolveTemplateRoute() 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 are llm or deterministic, executed in topological waves (parallel where independent).
  • Each LLM node returns JSON validated against a per-node Zod schema; failures are tracked per node.
  • assembleDocument merges section drafts; pageBreak blocks come from graph printChaptersBefore, 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: composeTemplateDocument fills the full HtmlDocument from 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)){constdagResult=awaitexecuteTemplateDagFill(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.


5. PDF export

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.


Auth and access control

  • Fail-closed: if both email and domain allowlists are empty, all sign-ins are denied.
  • Google only, with email_verified checked in the signIn callback.
  • 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.
asyncsignIn({ account, profile, user }){if(account?.provider!=="google")returnfalse;if(profile?.email_verified===false)returnfalse;constemail=profile?.email??user?.email??null;returnisEmailAllowed(email,loadAccessPolicy());}

Edge runtime decisions

The Worker runtime dominated product choices:

ConstraintResponse
No process.env sprawlSingle getEnvVar helper: Cloudflare bindings at runtime, process.env fallback in next dev.
No native PDF parsersunpdf for text extraction.
No huge tokenizer WASMCharacter-based chunker (~4 chars/token).
SDK compatibilitynodejs_compat + global_fetch_strictly_public in Wrangler.
DOM sanitization in Workerssanitize-html, not DOMPurify.
PDF CPU / layoutBrowser Rendering binding, not in-Worker layout engines.
Typed bindingswrangler typesCloudflareEnv used across server code.

Project shape

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.


Notable engineering decisions

  • 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 RetrievalResult for 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.

What I would improve next

  • 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.

About

Architecture case study: a Next.js + Cloudflare Workers RAG app—PDF ingest to object storage, embeddings + vector search, grounded Q&A with citations, and schema-validated “template mode” over Claude. Docs and media only; no application source

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors