DEV Community

Cover image for Building an Enterprise HR Copilot with Tencent EdgeOne Makers: Edge Runtime, Section-Aware RAG & Stateful Agent Loops
Novianto778
Novianto778

Posted on Edited on

Building an Enterprise HR Copilot with Tencent EdgeOne Makers: Edge Runtime, Section-Aware RAG & Stateful Agent Loops

Introduction: The Edge AI Revolution

When building an Enterprise RAG (Retrieval-Augmented Generation) application, developers typically encounter significant infrastructure friction:

  • Hard Invocation Caps: Mainstream serverless platforms cap function runtime. AWS Lambda tops out at 900 seconds per invocation with a 3-second default (AWS docs), which kills long-running LLM tool loops and open streaming responses.
  • Storage Friction: Managing separate S3 buckets and document database catalogs just to store uploaded policies and form attachments.
  • Session Latency: Storing conversation memory in an external Redis instance located in a separate region adds noticeable round-trip latency.
  • Chunking Context Loss: Traditional naive text chunkers split paragraphs blindly, destroying complex policy tables, numbered quota lists, and section headers.
  • Setup Complexity: Manually provisioning API gateways, Web Application Firewalls (WAF), and DDoS mitigation.
  • High Initial Cost: Paying for expensive API keys just to prototype prompts.

With Tencent EdgeOne Makers, you can build and deploy a stateful, enterprise-grade AI Copilot entirely on Tencent Cloud's global edge network (3,200+ nodes across 70+ countries) with:

  1. EdgeOne Pages Blob Storage (@edgeone/pages-blob): Globally accelerated, serverless object storage for raw policy files, companion form templates, and catalog metadata.
  2. Stateful Agent Runtime: Native long-running tool execution and Server-Sent Events (SSE) streaming without timeouts.
  3. context.store (Built-in Conversation Memory): Zero-latency session memory directly inside the edge function context, auto-backed by Pages Blob storage.
  4. Makers AI Gateway: OpenAI-compatible LLM endpoint hosting top-tier models like DeepSeek.
  5. Anycast CDN & WAF by Default: Built-in enterprise security out of the box.

Key Takeaways

  • EdgeOne Makers runs stateful, SSE-streaming agent loops on 3,200+ global edge nodes, sidestepping the invocation caps that break long LLM tool loops on traditional serverless.
  • Section-aware parent-child chunking preserves tier quotas exactly: the retrieval benchmark separated the IDR 2,000,000 junior dental allowance from the IDR 4,500,000 director tier with a 0.7461 top rerank score.
  • context.store provides zero-latency conversation memory auto-backed by Pages Blob storage, replacing an external Redis round trip.
  • Strong-consistency Blob reads make approval state changes visible on the very first read; the 6-SOP benchmark ingested into 33 parent sections and 54 vectors.
  • Form schemas extract themselves from uploaded policy documents at ingestion time, so in-chat claim forms never need manual maintenance.

Acme Corp HR Copilot onboarding portal showing multi-department employee personas and HR reviewer workspaces

Figure 1: Acme Corp HR Copilot Onboarding Portal featuring multi-department employee personas and HR reviewer workspaces.

Acme HR Copilot web application interface with the global persona switcher in the header


Phase 1: Project Setup & Edge Runtime Configuration

In this phase you clone a ready Next.js 15 frontend, register edge functions and cloud functions using the EdgeOne directory convention, configure edgeone.json, and boot the local edge runtime with live observability.


Step 1: Prepare Your Frontend Application

To focus our deep-dive on Tencent EdgeOne RAG, Storage, and Agent features, we use a modern full-stack application built with Next.js 15+ (App Router), Tailwind CSS, and TanStack React Query.

Clone the Starter Repository

The starter template and UI skeleton are hosted on the starter branch of the repository:

# Clone the starter branch
git clone -b starter https://github.com/Novianto778/hr-rag-edgeone.git acme-hr-copilot
cd acme-hr-copilot
npm install
Enter fullscreen mode Exit fullscreen mode

Note: If you cloned the default repository, you can switch to the starter branch at any time:

git checkout starter

The completed production reference is available on the main branch.

What Does Our Frontend Provide?

Our frontend interface includes an interactive Multi-Persona Showcase & Operations Suite:

  1. Employee Self-Service Personas:
    • John Doe (emp_101), Software Engineer · Frontend (Engineering Division)
    • Jane Smith (emp_102), Growth Marketing Specialist (Marketing & Brand Division)
    • Alex Vance (emp_103), Product Operations Manager (Operations & Strategy Division)
    • Capabilities:
      • Conversational RAG queries with verified multi-page citations
      • Document-driven dynamic form generation with receipt proof uploads
      • Isolated user-scoped session registries per persona
      • Real-time submission tracking (/employee/requests) and printable Acme HR passes
  2. HR Admin Operations Hub & Reviewer Personas:
    • Sarah Connor (hr_001), People Operations Lead (Benefits, Total Rewards & Leave Approvals)
    • Marcus Brody (hr_002), Senior HR Business Partner (Corporate Travel, Compliance & Audit)
    • Capabilities:
      • Real-time approval queue (/admin/approvals) with side-by-side receipt proof inspection
      • Customizable reviewer signatories and scheduled payroll batch dates
      • EdgeOne Pages Blob policy management (/admin/policies)
      • Executive Decision Intelligence analytics (/admin/analytics)
  3. Global Persona Switcher: Instant 1-click persona switching directly in the header, maintaining isolated conversation histories in context.store and separate request queues for every employee.

Step 2: Organize the EdgeOne Project Structure

Tencent EdgeOne uses Directory-Based Automatic Routing for edge functions and backend logic, seamlessly integrating with your Next.js application:

acme-hr-copilot/
├── edge-functions/              # EdgeOne Edge Functions (V8 Runtime)
│   ├── _blob.ts                 # Native EdgeOne Pages Blob Storage client
│   ├── _parser.ts               # In-memory document parser (Firecrawl v2 / Mammoth)
│   ├── _chunker.ts              # Section-aware Parent-Child standalone chunker
│   ├── _embeddings.ts           # Voyage AI 1024-dim dense embedding generator
│   ├── _qdrant.ts               # Zero-dependency Qdrant Cloud REST client
│   └── _reranker.ts             # Voyage AI Rerank-2 cross-encoder
│
├── cloud-functions/             # EdgeOne Cloud Functions (Node.js Runtime)
│   ├── upload.ts                # POST /upload (EdgeOne Blob + Qdrant Ingestion)
│   ├── list-documents.ts        # GET /list-documents (EdgeOne Blob Catalog)
│   ├── delete-document.ts       # POST /delete-document (Dual-Layer Purge)
│   ├── hr-tickets.ts            # GET/POST/PATCH /hr-tickets (Approval Engine)
│   ├── hr-analytics.ts          # POST /hr-analytics (Executive Intelligence)
│   └── conversations.ts         # GET/POST /conversations (Session Scoping)
│
├── agents/                      # EdgeOne Agent Runtime
│   ├── chat/index.ts            # POST /chat (SSE streaming & tool calling)
│   ├── stop/index.ts            # POST /stop (Abort stream)
│   ├── _session.ts              # context.store conversation memory
│   ├── _tools.ts                # Tool registry for RAG & dynamic forms
│   └── _model.ts                # AI Gateway config
│
├── sample-hr-documents/         # Benchmark SOPs (.docx, .md, .xlsx form templates)
├── scripts/                     # Automated batch ingestion and retrieval test scripts
├── src/                         # Next.js Frontend (React 19 + Tailwind CSS + TanStack Query)
├── edgeone.json                 # Master EdgeOne Makers Configuration
└── package.json
Enter fullscreen mode Exit fullscreen mode

Step 3: Configure EdgeOne Build Settings (edgeone.json)

The edgeone.json file in the root directory defines your build environment and project settings for EdgeOne:

{
  "buildCommand": "npm run build",
  "installCommand": "npm install",
  "outputDirectory": ".next",
  "nodeVersion": "22.11.0"
}
Enter fullscreen mode Exit fullscreen mode

How EdgeOne Handles Routing Automatically:

  • Directory-Based Routing: EdgeOne automatically discovers function handlers in edge-functions/ and cloud-functions/ and routes incoming requests with zero manual routing config needed.
  • Edge V8 Runtime: Functions in edge-functions/ execute across Tencent Cloud's 3,200+ global Points of Presence with native access to context.store.
  • EdgeOne Pages Blob Storage: Native serverless storage bindings accessible via @edgeone/pages-blob.
  • Static Assets: Next.js static pages and assets are automatically cached and accelerated on Tencent's global Anycast CDN.

Step 4: Run the Local Edge Runtime & Real-Time Metrics

The EdgeOne CLI simulates the entire edge runtime locally.

1. Install the EdgeOne CLI globally

npm install -g edgeone
Enter fullscreen mode Exit fullscreen mode

2. Configure Environment Variables (.env)

Create a .env file with your API keys:

# EdgeOne Pages Blob Storage
BLOB_STORE_NAME=uploads

# EdgeOne Makers AI Gateway
AI_GATEWAY_API_KEY=your_edgeone_makers_api_key
AI_GATEWAY_BASE_URL=https://ai-gateway.edgeone.link/v1
AI_GATEWAY_MODEL=@makers/deepseek-v4-flash

# Qdrant Cloud Vector Database
QDRANT_URL=https://your-cluster-url.qdrant.tech
QDRANT_API_KEY=your_qdrant_api_key
QDRANT_COLLECTION=hr_rag_knowledge_base

# Firecrawl Document Extraction API
FIRECRAWL_API_KEY=your_firecrawl_api_key

# Voyage AI Embeddings & Reranker
VOYAGE_API_KEY=your_voyage_api_key
Enter fullscreen mode Exit fullscreen mode

3. Start the EdgeOne Local Development Server

edgeone makers dev
Enter fullscreen mode Exit fullscreen mode

When you start the development server, EdgeOne automatically:

  1. Boots your Next.js application at http://localhost:8088/.
  2. Starts the Edge worker for streaming and routing.
  3. Initializes the EdgeOne Blob and KV local bindings.
  4. Launches the Agent Observability Dashboard at http://localhost:8088/agent-metrics.

EdgeOne Makers dev server startup trace showing Edge Worker compilation, Pages Blob bindings, and the observability dashboard
Figure 2: EdgeOne Makers dev server startup trace showing Edge Worker compilation, Pages Blob bindings, and Observability dashboard.


Phase 2: Building the Section-Aware RAG Engine & EdgeOne Blob Storage

In Phase 2, we build the core knowledge retrieval engine and dual-layer storage architecture that turns unstructured HR policy manuals into precise, grounded answers without hallucinations.


Dual-Layer Architecture: EdgeOne Blob Storage & Vector Engine

Dual-layer architecture diagram linking EdgeOne Pages Blob Storage with the Qdrant Cloud vector engine


Step 5: EdgeOne Pages Blob Storage Client (edge-functions/_blob.ts)

Instead of requiring an external AWS S3 bucket or database setup to store uploaded files and document catalogs, we use EdgeOne Pages Blob Storage (@edgeone/pages-blob).

When running within EdgeOne functions, the runtime automatically injects the necessary security credentials (PAGES_BLOB_DEPLOY_CREDENTIAL). We create a clean wrapper in edge-functions/_blob.ts:

// edge-functions/_blob.ts
import { getStore as getEdgeOneStore } from '@edgeone/pages-blob';

export interface UnifiedBlobStore {
  set(key: string, data: Buffer | ArrayBuffer | string): Promise<void>;
  setJSON(key: string, data: unknown): Promise<void>;
  get(key: string, options?: { type?: 'text' | 'json' | 'arrayBuffer' | 'blob' }): Promise<any>;
  delete(key: string): Promise<void>;
  list(options?: { prefix?: string }): Promise<{ blobs: Array<{ key: string }> }>;
}

export function getBlobStore(storeName = process.env.BLOB_STORE_NAME || 'uploads'): UnifiedBlobStore {
  return getEdgeOneStore(storeName) as any;
}

export default getBlobStore;
Enter fullscreen mode Exit fullscreen mode

Step 6: In-Memory Document Parsing (edge-functions/_parser.ts)

Standard document upload pipelines often fail on serverless edge runtimes due to local disk write limitations. We solve this by implementing zero-disk in-memory document parsing:

  • Primary Parser: Uses the official Firecrawl SDK v2 to parse DOCX, PDF, and Markdown files in-memory directly into structured Markdown (preserving markdown tables and lists).
  • Local Fallback: If Firecrawl is offline or unconfigured, it uses Mammoth to convert DOCX structures directly into Markdown.
// edge-functions/_parser.ts
import { Firecrawl } from '@mendable/firecrawl-js';
import mammoth from 'mammoth';

export async function parseDocumentToMarkdown(
  fileBuffer: Buffer,
  fileName: string
): Promise<string> {
  const apiKey = process.env.FIRECRAWL_API_KEY;
  const isMarkdownOrText = fileName.endsWith('.md') || fileName.endsWith('.txt');

  if (isMarkdownOrText) {
    return fileBuffer.toString('utf-8');
  }

  // 1. Primary: Firecrawl v2 API
  if (apiKey) {
    try {
      const app = new Firecrawl({ apiKey });
      const result: any = await app.parse(
        { data: fileBuffer, filename: fileName },
        { formats: ['markdown'], onlyMainContent: true }
      );
      const md = result?.markdown || result?.data?.markdown || '';
      if (md && typeof md === 'string' && md.trim().length > 0) {
        return md.trim();
      }
    } catch (err: any) {
      console.warn(`[Parser] Firecrawl fallback notice for "${fileName}":`, err?.message || err);
    }
  }

  // 2. Fallback: Mammoth in-memory conversion for DOCX
  if (fileName.toLowerCase().endsWith('.docx')) {
    try {
      const mammothRes = await (mammoth as any).convertToMarkdown({ buffer: fileBuffer }).catch(() => null);
      if (mammothRes?.value && mammothRes.value.trim().length > 0) {
        return mammothRes.value.trim();
      }
    } catch (docxErr: any) {
      console.warn(`[Parser] Mammoth DOCX fallback notice:`, docxErr);
    }
  }

  const cleanTitle = fileName.replace(/\.[^/.]+$/, '').replace(/_/g, ' ');
  return `# ${cleanTitle}\n\n${fileBuffer.toString('utf-8')}`;
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Section-Aware Standalone Parent-Child Chunking (edge-functions/_chunker.ts)

Why Naive Chunking Fails

In traditional RAG systems, documents are split blindly every 500 characters or 200 words. When applied to structured policy documents (e.g., Acme Medical Benefit Quota Matrix), this naive strategy cuts tabular rows in half and separates numerical allowances from their category titles.

The Standalone Parent-Child Solution

  1. Parent Sections with Breadcrumbs:
    We track the Markdown heading hierarchy stack (#, ##, ###, __Section X: ...__) to create complete Parent Sections with breadcrumbs like:

    01 Acme Medical Policy > Section 2: Benefit Tier Structure & Annual Quotas

  2. Standalone 200-Token Child Chunks:
    Each Parent Section is divided into ~200-token Child Chunks with 20% overlap.

    The key innovation: Each child chunk payload directly encapsulates the full parent_text, parent_id, section_path, and companion attachment metadata.

  3. Deterministic UUIDs:
    Point IDs in Qdrant are generated deterministically using SHA-256 hashes (${docId}_child_${index}), allowing idempotent re-indexing without duplicate vectors.

// edge-functions/_chunker.ts
// Note: Core data structures and ID generator (see edge-functions/_chunker.ts for complete heading tree parser)
import { createHash } from 'node:crypto';

export interface ParentSection {
  parent_id: string;
  doc_id: string;
  doc_name: string;
  section_path: string;
  parent_text: string;
  heading_title: string;
  attachment_name?: string;
  attachment_type?: string;
  attachment_base64?: string;
}

export interface ChildChunk {
  child_id: string;
  child_index: number;
  parent_id: string;
  doc_id: string;
  doc_name: string;
  section_path: string;
  parent_text: string;    // Crucial: Parent text is self-contained in every child
  child_text: string;     // ~200-token search slice
  token_count: number;
  attachment_name?: string;
  attachment_type?: string;
  attachment_base64?: string;
}

export function deterministicId(seed: string): string {
  const hash = createHash('sha256').update(seed).digest('hex');
  return [
    hash.substring(0, 8),
    hash.substring(8, 12),
    '4' + hash.substring(13, 16),
    'a' + hash.substring(17, 20),
    hash.substring(20, 32),
  ].join('-');
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Qdrant Cloud Vector Database & Embeddings (edge-functions/_qdrant.ts)

We implement a zero-dependency, lightweight REST client for Qdrant Cloud and Voyage AI Embeddings (voyage-3-lite, 1024-dimension). Qdrant exposes its REST/JSON interface through a gRPC gateway (Cloud API docs), and every request authenticates with an api-key header (authentication guide).

// edge-functions/_qdrant.ts
// Note: Candidate search implementation (see edge-functions/_qdrant.ts for upsertChunksToQdrant and schema initialization)
export async function searchQdrantKnowledgeBase(
  queryVector: number[],
  topK = 20,
  filterDocId?: string
): Promise<CandidateChunk[]> {
  await ensureQdrantCollection(1024);
  const { url, apiKey, collection } = getQdrantConfig();

  const body: any = {
    vector: queryVector,
    limit: topK,
    with_payload: true,
  };

  if (filterDocId) {
    body.filter = { must: [{ key: 'doc_id', match: { value: filterDocId } }] };
  }

  const res = await fetch(`${url}/collections/${collection}/points/search`, {
    method: 'POST',
    headers: { 'api-key': apiKey, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });

  const data = await res.json();
  return (data.result || []).map((item: any) => ({
    point_id: String(item.id),
    doc_id: String(item.payload?.doc_id || ''),
    doc_name: String(item.payload?.doc_name || ''),
    parent_id: String(item.payload?.parent_id || ''),
    child_id: String(item.payload?.child_id || item.id),
    section_path: String(item.payload?.section_path || ''),
    child_text: String(item.payload?.child_text || ''),
    parent_text: String(item.payload?.parent_text || ''),
    score: typeof item.score === 'number' ? item.score : 0,
    attachment_name: item.payload?.attachment_name,
    attachment_type: item.payload?.attachment_type,
  }));
}
Enter fullscreen mode Exit fullscreen mode

Step 9: Cross-Encoder Reranking & Parent Resolution (edge-functions/_reranker.ts)

Why Reranking is Essential

Dense vector embedding search (Bi-Encoder) computes cosine similarity between query and candidate chunks quickly, but often misses subtle numerical boundaries (e.g. distinguishing between Dental Allowance: IDR 2,000,000 for Junior vs IDR 4,500,000 for Director).

Voyage AI (rerank-2) performs deep cross-attention between the query and candidate passages, per the official reranker documentation (Voyage docs).

// edge-functions/_reranker.ts
export async function rerankAndResolveParentSections(
  query: string,
  candidateChunks: CandidateChunk[],
  maxParentSections = 5
): Promise<ResolvedParentSection[]> {
  if (!candidateChunks || candidateChunks.length === 0) return [];

  let rankedCandidates = [...candidateChunks];
  const apiKey = process.env.VOYAGE_API_KEY;

  if (apiKey) {
    try {
      const resp = await fetch('https://api.voyageai.com/v1/rerank', {
        method: 'POST',
        headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          query,
          documents: candidateChunks.map((c) => c.child_text),
          model: 'rerank-2',
          top_k: candidateChunks.length,
        }),
      });

      if (resp.ok) {
        const data = await resp.json();
        if (Array.isArray(data?.data)) {
          rankedCandidates = data.data.map((r: any) => ({
            ...candidateChunks[r.index],
            score: Number(r.relevance_score),
          }));
        }
      }
    } catch (e) {
      console.warn('[Reranker] Voyage AI notice, falling back to vector scores:', e);
    }
  }

  // Deduplicate candidate matches by parent_id
  const seenParentIds = new Set<string>();
  const parentSections: ResolvedParentSection[] = [];

  for (const item of rankedCandidates) {
    if (!item.parent_id || seenParentIds.has(item.parent_id)) continue;
    seenParentIds.add(item.parent_id);

    parentSections.push({
      docId: item.doc_id,
      docName: item.doc_name,
      parentId: item.parent_id,
      sectionPath: item.section_path,
      content: item.parent_text || item.child_text,
      rerankScore: item.score,
      attachmentName: item.attachment_name,
      attachmentType: item.attachment_type,
    });

    if (parentSections.length >= maxParentSections) break;
  }

  return parentSections;
}
Enter fullscreen mode Exit fullscreen mode

Step 10: Edge Cloud Functions & TanStack Query UI

1. Dual-Storage Upload Function (cloud-functions/upload.ts)

The /upload Cloud Function receives files via Web UI or multipart API, persists binaries and metadata into EdgeOne Pages Blob Storage, generates embeddings, and indexes points into Qdrant Cloud:

// cloud-functions/upload.ts
import { getBlobStore } from '../edge-functions/_blob';
import { parseDocumentToMarkdown } from '../edge-functions/_parser';
import { chunkMarkdownDocument } from '../edge-functions/_chunker';
import { generateEmbeddings } from '../edge-functions/_embeddings';
import { ensureQdrantCollection, upsertChunksToQdrant } from '../edge-functions/_qdrant';

export async function onRequestPost(context: any): Promise<Response> {
  const request = context.request;
  const body = await request.json();
  const { docName, fileName, fileBase64, attachmentName, attachmentType, attachmentBase64 } = body;

  const fileBuffer = Buffer.from(fileBase64, 'base64');
  const markdownContent = await parseDocumentToMarkdown(fileBuffer, fileName);
  const docId = `doc_${Date.now()}_${docName.toLowerCase().replace(/[^a-z0-9_]+/g, '_')}`;
  const storedFilename = `${docId}.docx`;

  const { parents, children } = chunkMarkdownDocument(
    docId, docName, markdownContent, 200, 0.2,
    { attachmentName, attachmentType, attachmentBase64 }
  );

  // 1. Persist to EdgeOne Pages Blob Storage
  const blobStore = getBlobStore();
  await blobStore.set(`uploads/${storedFilename}`, fileBuffer);
  if (attachmentBase64 && attachmentName) {
    await blobStore.set(`attachments/${attachmentName}`, Buffer.from(attachmentBase64, 'base64'));
  }
  await blobStore.setJSON(`metadata/${docId}.json`, {
    docId, docName, storedName: storedFilename,
    fileSize: fileBuffer.length, uploadedAt: new Date().toISOString(),
    parentSections: parents.length, childChunks: children.length,
    attachmentName: attachmentName || null,
  });

  // 2. Index into Qdrant Cloud
  await ensureQdrantCollection(1024);
  const vectors = await generateEmbeddings(children.map((c) => c.child_text));
  const points = children.map((child, i) => ({
    id: child.child_id,
    vector: vectors[i],
    payload: {
      doc_id: child.doc_id, doc_name: child.doc_name,
      parent_id: child.parent_id, child_id: child.child_id,
      section_path: child.section_path, child_text: child.child_text,
      parent_text: child.parent_text, token_count: child.token_count,
      attachment_name: child.attachment_name, attachment_type: child.attachment_type,
    },
  }));
  await upsertChunksToQdrant(points);

  return new Response(JSON.stringify({ status: 'success', docId, parentSections: parents.length, childChunks: children.length }));
}
Enter fullscreen mode Exit fullscreen mode

2. Fast Catalog Listing (cloud-functions/list-documents.ts)

Instead of making slow, heavy database queries, list-documents retrieves the catalog instantly by scanning metadata/ in EdgeOne Pages Blob Storage:

// cloud-functions/list-documents.ts
import { getBlobStore } from '../edge-functions/_blob';

export async function onRequestGet(): Promise<Response> {
  const blobStore = getBlobStore();
  const listRes = await blobStore.list({ prefix: 'metadata/' });
  const documents = [];

  if (listRes?.blobs) {
    for (const blob of listRes.blobs) {
      const meta = await blobStore.get(blob.key, { type: 'json' });
      if (meta?.docId) {
        documents.push({
          docId: meta.docId,
          docName: meta.docName,
          storedName: meta.storedName,
          fileSize: meta.fileSize || 0,
          uploadedAt: meta.uploadedAt,
          chunkCount: meta.childChunks || 0,
          attachmentName: meta.attachmentName || null,
        });
      }
    }
  }
  return new Response(JSON.stringify({ status: 'success', count: documents.length, documents }));
}
Enter fullscreen mode Exit fullscreen mode

3. Coordinated Dual Purge (cloud-functions/delete-document.ts)

When an admin deletes a policy, the system purges both the Blob objects and the vector points from Qdrant Cloud:

// cloud-functions/delete-document.ts
import { getBlobStore } from '../edge-functions/_blob';
import { deleteDocumentFromQdrant } from '../edge-functions/_qdrant';

export async function onRequestPost(context: any): Promise<Response> {
  const { docId, storedName } = await context.request.json();

  // 1. Purge points from Qdrant Cloud
  await deleteDocumentFromQdrant(docId);

  // 2. Purge binary, attachments & metadata from EdgeOne Pages Blob Storage
  const blobStore = getBlobStore();
  if (storedName) await blobStore.delete(`uploads/${storedName}`);
  await blobStore.delete(`metadata/${docId}.json`);

  return new Response(JSON.stringify({ status: 'success', docId }));
}
Enter fullscreen mode Exit fullscreen mode

Policy management dashboard listing ingested SOPs with parent section stats, vector counts, and companion spreadsheet downloads
Figure 3: Policy Management dashboard showing ingested SOPs, parent section stats, vector counts, and companion spreadsheet downloads.


Step 11: Real-World Ingestion & Retrieval Benchmarks

1. Batch Policy Ingestion Benchmark (scripts/ingest-policies.ts)

The ingestion script sends requests via the POST /upload endpoint on the running EdgeOne dev server, uploading raw binaries directly to EdgeOne Cloud Blob Storage and indexing dense vectors into Qdrant Cloud:

npm run ingest:docs
Enter fullscreen mode Exit fullscreen mode

Live Terminal Benchmark Output:

=====================================================
Acme Corp HR Policy Knowledge Base Ingestion
(EdgeOne Cloud Blob Storage & Qdrant Cloud Ingestion)
=====================================================

[1/3] Checking connection to EdgeOne dev server at http://localhost:8088...
      -> EdgeOne dev server is running and ready.

[2/3] Found 6 policy documents to upload to EdgeOne Cloud:
   1. 01_Acme_Medical_and_Optical_Policy_2026.docx
   2. 02_Acme_Annual_and_Special_Leave_Policy_2026.docx
   3. 03_Acme_Business_Travel_and_PerDiem_Policy_2026.docx
   4. 04_Acme_WFH_and_Office_Equipment_Policy_2026.docx
   5. 05_Acme_Overtime_and_Weekend_Shift_Policy_2026.docx
   6. 06_Acme_Professional_Certification_and_Training_Policy_2026.docx

[3/3] Uploading and vectorizing documents via POST /upload...

[1/6] Uploading: "01_Acme_Medical_and_Optical_Policy_2026.docx"...
      -> Saved to EdgeOne Cloud Blob: uploads/doc_1787466907110_01_acme_medical...docx
      -> Indexed in Qdrant Cloud: 6 parent sections, 14 vectors.
[2/6] Uploading: "02_Acme_Annual_and_Special_Leave_Policy_2026.docx"...
      -> Saved to EdgeOne Cloud Blob: uploads/doc_1787466912170_02_acme_annual...docx
      -> Indexed in Qdrant Cloud: 6 parent sections, 11 vectors.
...
[6/6] Uploading: "06_Acme_Professional_Certification_and_Training_Policy_2026.docx"...
      -> Saved to EdgeOne Cloud Blob: uploads/doc_1787466922410_06_acme_prof...docx
      -> Indexed in Qdrant Cloud: 5 parent sections, 7 vectors.

=====================================================
EdgeOne Cloud Ingestion Completed!
   - Documents Ingested: 6 / 6
   - Parent Sections:    33
   - Vectorized Chunks:  54
=====================================================
Enter fullscreen mode Exit fullscreen mode

The six-document run produced 33 parent sections and 54 vectorized chunks overall:

Acme policy document (2026) Parent sections Vectorized chunks
01 Medical & Optical 6 14
02 Annual & Special Leave 6 11
06 Certification & Training 5 7
03 Travel, 04 WFH, 05 Overtime see full log above counted in totals
Total (six documents) 33 54

2. Retrieval Verification Benchmark (scripts/test-retrieval.ts)

Verify dense vector candidate retrieval and Voyage AI cross-encoder reranking:

npx tsx scripts/test-retrieval.ts
Enter fullscreen mode Exit fullscreen mode

Real Benchmark Output:

Testing Query: "What is the annual dental and optical allowance limit?"

1. Generating 1024-dim Voyage AI embedding for query...
2. Searching Qdrant Cloud top-10 candidate chunks...
   Found 10 candidates.

3. Reranking candidates with Voyage AI (rerank-2)...
[Reranker] Voyage AI successfully reranked 10 passages.
[Reranker] Resolved 2 unique parent section(s) for LLM context.

--- [Top Match #1] Relevance Score: 0.7461 ---
Document: 01 Acme Medical and Optical Policy 2026
Section:  01 Acme Medical and Optical Policy 2026 > Section 2: Benefit Tier Structure & Annual Quotas
Excerpt:
Section 2: Benefit Tier Structure & Annual Quotas
The table below delineates the annual entitlement quotas categorized by medical discipline and employment tier. All quota allocations reset annually on January 1st and do not accumulate across calendar years.
Dental Care & Scaling:
- Junior / Associate (G1-G3): IDR 2,000,000 / year
- Senior / Specialist (G4-G6): IDR 3,000,000 / year
- Manager & Director (G7+):    IDR 4,500,000 / year

End-to-End Vector Retrieval & Reranking Verified!
Enter fullscreen mode Exit fullscreen mode

Phase 3: Stateful Agent Runtime, In-Chat Dynamic Forms & Dual-Layer State Persistence

In Phase 1 and 2, we built our EdgeOne runtime and high-precision section-aware RAG engine with Pages Blob Storage. In Phase 3, we bring everything to life with a Stateful AI Agent, Dynamic In-Chat Forms with Excel Export, and Zero-Reset Dual-Layer State Persistence.


Step 12: Stateful Agent Node & Server-Sent Events (agents/chat/index.ts)

Traditional serverless functions impose hard invocation caps: AWS Lambda allows at most 900 seconds per invocation with a 3-second default (AWS docs), which makes long multi-round tool loops and open SSE streams fragile. Tencent EdgeOne Makers Agent Nodes solve this by maintaining stateful streaming connections with native concurrency locking:

Client (POST /chat) ----> Lock: `makers-conversation-id`
                              |
                              v
                       [Model Round 1]
                       (LLM streams tokens)
                              |
                    +---------+---------+
                    v                   v
         [query_knowledge_base]  [request_interactive_form]
         (Voyage + Qdrant RAG)   (Prefilled Form Schema)
                    |                   |
                    +---------+---------+
                              v
                       [Model Round 2]
               (Grounded Response + Citations)
                              |
                              v
                       context.store
Enter fullscreen mode Exit fullscreen mode

Agent Execution Loop

The agent loops up to 4 rounds, calling tools dynamically and streaming Server-Sent Events (SSE) directly to the client:

// agents/chat/index.ts
// Note: Core streaming and tool execution loop (simplified for brevity; see agents/chat/index.ts for the full implementation)
import { getModelConfig } from '../_model';
import { ChatSession } from '../_session';
import { buildTools } from '../_tools';

export async function onRequestPost(context: any): Promise<Response> {
  const { message, conversation_id } = await context.request.json();
  const session = new ChatSession(context.store);
  await session.saveUserMessage(conversation_id, message);

  const modelConfig = getModelConfig(context.env);
  const toolRegistry = buildTools(context);

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      const sendEvent = (event: string, data: Record<string, unknown>) => {
        controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
      };

      sendEvent('start', { messageId: conversation_id });

      let round = 0;
      let accumulatedText = '';
      let formPayload: any = null;
      let citationsPayload: any = null;

      while (round < 4) {
        round++;
        const history = await session.getHistory(conversation_id);
        const completion = await callLLMGateway(modelConfig, history, toolRegistry, {
          onTextDelta(delta: string) {
            accumulatedText += delta;
            sendEvent('text-delta', { delta });
          },
        });

        if (completion.toolCalls?.length) {
          for (const tc of completion.toolCalls) {
            sendEvent('tool-call', { tool: tc.name, args: tc.arguments });
            const result = await toolRegistry.execute(tc.name, tc.arguments);
            sendEvent('tool-output', { tool: tc.name, output: result });

            if (tc.name === 'request_interactive_form') {
              try { formPayload = JSON.parse(result); } catch { formPayload = result; }
            }
            if (tc.name === 'query_knowledge_base') {
              citationsPayload = result;
            }
          }
        } else {
          break;
        }
      }

      // Persist complete assistant bundle to EdgeOne Agent Memory
      await session.saveAssistantMessage(conversation_id, {
        text: accumulatedText,
        citations: citationsPayload,
        form: formPayload,
      });

      sendEvent('finish', { stopped: false });
      controller.close();
    }
  });

  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }
  });
}
Enter fullscreen mode Exit fullscreen mode

Step 13: context.store (Conversation Memory Store) & User-Scoped History

EdgeOne provides built-in zero-latency memory (context.store) accessible directly on the edge node.

1. User-Scoped Conversation Scoping

To ensure strict privacy across employee personas, conversation IDs follow a bounded 36-character format: emp101_<uuid_29_chars>. The GET /conversations?emp_id=emp_101 endpoint filters transcripts specifically for the active employee.

2. Parallel Sidebar Title Resolution (cloud-functions/conversations.ts)

Instead of showing fallback identifiers like "Chat 9c13b186", GET /conversations queries the first user message in parallel to extract human-readable titles immediately:

// cloud-functions/conversations.ts
export async function onRequestGet(context: any): Promise<Response> {
  const store = context.store;
  const url = new URL(context.request.url);
  const empId = url.searchParams.get('emp_id');
  const listRes = await store.listConversations({ limit: 50 });

  let items = listRes?.items || [];
  if (empId) {
    items = items.filter((c: any) => c.conversationId?.startsWith(`${empId.replace(/_/g, '')}_`));
  }

  // Parallel first-prompt resolution for clean sidebar titles
  const enriched = await Promise.all(items.map(async (conv: any) => {
    const msgs = await store.getMessages({ conversationId: conv.conversationId, limit: 2, order: 'asc' });
    const firstUserMsg = msgs?.find((m: any) => m.role === 'user');
    const prompt = firstUserMsg ? (typeof firstUserMsg.content === 'string' ? firstUserMsg.content : firstUserMsg.content?.text) : '';
    const title = prompt ? (prompt.length > 38 ? `${prompt.slice(0, 38)}...` : prompt) : `Chat ${conv.conversationId.slice(0, 8)}`;
    return { ...conv, title };
  }));

  return new Response(JSON.stringify({ success: true, conversations: enriched }));
}
Enter fullscreen mode Exit fullscreen mode

Step 14: Dynamic In-Chat Forms, Ingestion Schema Extraction & Direct Blob Downloads

In our document-driven architecture, form schemas and companion spreadsheet templates are not hardcoded. Instead, they are extracted dynamically from uploaded policy documents at ingestion time and stored in EdgeOne Pages Blob Storage.

1. Admin Uploads Policy DOCX + Official Template (.xlsx)
   |-- Extracted via EdgeOne AI Gateway (DeepSeek): `formSchema` saved in `metadata/<docId>.json`
   \-- Saved in Blob: `attachments/<attachmentName>.xlsx`
                              |
                              v
2. Employee asks about Travel / Medical in Chat
   |-- RAG searches Qdrant -> returns policy text + linked docId
   \-- Agent resolves `request_interactive_form` (0ms delay via pre-extracted Blob schema)
                              |
                              v
3. In-Chat Interactive Form Card (<InteractiveFormCard />)
   |-- [Download .xlsx] -> Directly downloads the REAL companion file from Blob Storage
   |-- [Receipt Dropzone] -> Base64 proof validation via /upload-attachment
   \-- [Submit request to HR] -> Server-side persistence in Pages Blob Storage
Enter fullscreen mode Exit fullscreen mode

In-chat HR assistant reply with verified citation chips, a formatted policy table, and a prefilled interactive claim form

Figure 4: In-chat conversational assistant displaying verified citation chips, formatted policy tables, and the prefilled interactive claim form.

1. Ingestion-Time Schema Extraction (edge-functions/_schemaExtractor.ts)

During document upload (POST /upload), the system calls the EdgeOne AI Gateway once to analyze the document and extract structured form field definitions:

// edge-functions/_schemaExtractor.ts
// Note: Schema extractor logic (simplified for brevity; see edge-functions/_schemaExtractor.ts for full parsing)
import { getModelConfig } from '../agents/_model';

export async function extractFormSchemaFromDocument(
  docName: string,
  markdownText: string,
  attachmentName?: string
): Promise<ExtractedFormSchema | null> {
  const modelConfig = getModelConfig();
  const apiKey = modelConfig.apiKey || process.env.AI_GATEWAY_API_KEY;
  if (!apiKey) return null;

  const prompt = `Analyze this HR policy ("${docName}") and extract structured form fields if actionable.
Return JSON: { "hasForm": true, "formType": "...", "title": "...", "ticketPrefix": "...", "fields": [...] }`;

  const res = await fetch(`${modelConfig.baseUrl}/chat/completions`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
    body: JSON.stringify({ model: modelConfig.model, messages: [{ role: 'user', content: prompt }] }),
  });

  const data = await res.json();
  return JSON.parse(data.choices[0].message.content.replace(/```
{% endraw %}
json\s*|\s*
{% raw %}
```/g, ''));
}
Enter fullscreen mode Exit fullscreen mode

2. Direct Blob Companion Attachment Download (src/components/chat/InteractiveFormCard.tsx)

When an employee clicks [Download .xlsx], the client checks whether the policy document has a companion template stored in Pages Blob Storage. If present, it downloads the actual official spreadsheet; otherwise, it dynamically generates an offline workbook using SheetJS:

// src/components/chat/InteractiveFormCard.tsx
const handleDownloadTemplate = () => {
  // 1. If official companion template exists in Pages Blob Storage, download directly
  if (payload.attachmentBase64 && payload.attachmentName) {
    const a = document.createElement('a');
    a.href = payload.attachmentBase64;
    a.download = payload.attachmentName;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    return;
  }

  // 2. Otherwise generate dynamic structured Excel sheet via SheetJS
  downloadFormTemplateXlsx({
    formType: payload.formType,
    title: payload.title,
    fields: payload.fields,
    values,
  });
};
Enter fullscreen mode Exit fullscreen mode

Step 15: HR Ticket Pipeline & Server-Side Persistence

The Problem: Why Simple Client Storage Fails

If form submission state is stored only in browser localStorage, clearing the cache or switching devices reverts submitted forms back to empty drafts.

The Solution: Dual-Layer Server-Side Persistence

Client clicks "Submit request to HR"
                |
                v
        POST /hr-tickets
  * Generate ID: #TRV-20260825-01
  * Save to Blob Store: `ticket:TRV-20260825-01`
  * Index in Blob: `tickets:conv:<cid>`, `tickets:user:<empId>`, `tickets:status:pending`
  * Append Confirmation to Conversation Store (context.store)
                |
                v
        POST /history
  * Loads messages from context.store
  * Loads convTickets from Blob Store
  * Injects form.submittedTicket = convTickets[0]
  * Zero-reset across localStorage wipes or device switches
Enter fullscreen mode Exit fullscreen mode

Phase 4: Employee Request Tracker & Printable HR Pass

In Phase 4, we provide employees with a comprehensive Request Tracker Portal (/employee/requests) to monitor submissions in real time and generate official printable HR passes.


Step 16: Employee Request Tracker (src/components/employee/MyRequests.tsx)

The Request Tracker connects to useEmployeeTickets(currentEmployee.id) from src/lib/api-hooks.ts, delivering a responsive, real-time dashboard:

  1. 4 Live KPI Metric Cards:
    • Total Submissions: Total count of logged tickets.
    • Pending Review: Pending queue count with SLA turnaround indicators.
    • Approved Requests: Total count of cleared submissions.
    • Total Reimbursed: Aggregated IDR sum of all approved reimbursement claims.
  2. Status Filtering Tabs: All, Pending, Approved, and Rejected tabs with live count badges.
  3. Real-Time Search & Sorting: Instant multi-field keyword filtering by Ticket ID, title, description, category, and amount, plus sorting by Newest, Oldest, or Highest Amount.
  4. Inspection Drawer (src/components/employee/TicketDetailModal.tsx):
    • Clean key-metric summary cards (Employee, Department, Claim Amount / Requested Days).
    • Dynamic submitted parameters table.
    • High-resolution zoomable receipt proof viewer with download actions.
    • HR Reviewer feedback and scheduled payroll batch date display.

Employee request tracker showing live KPI metric cards, status filtering tabs, and the ticket detail drawer
Figure 5: Request Tracker interface displaying live KPI metrics, status filtering tabs, and ticket detail drawer.


Step 17: Official Client-Side Printable HR Pass Generator (src/lib/pass-generator.ts)

Employees can generate and print an official corporate Acme Corp HR Pass slip directly from the browser:

// src/lib/pass-generator.ts
export function printTicketPass(ticket: HRTicket) {
  const printWindow = window.open('', '_blank');
  if (!printWindow) return;

  const html = `
    <!DOCTYPE html>
    <html>
      <head>
        <title>Acme HR Pass — #${ticket.id}</title>
        <style>
          body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 40px; }
          .pass-container { max-width: 700px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 16px; padding: 32px; }
          .header { display: flex; justify-content: space-between; border-bottom: 2px solid #111827; padding-bottom: 16px; }
          .status-pill { font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 9999px; }
        </style>
      </head>
      <body>
        <div class="pass-container">
          <div class="header">
            <div><h1>ACME CORP</h1><p>People Operations & HR Automation</p></div>
            <div><strong>#${ticket.id}</strong><div class="status-pill">${ticket.status}</div></div>
          </div>
          <!-- Itemized Parameters Table & Verification Hash -->
          <p>Verification Hash: ${ticket.id}-${ticket.createdAt.toString(36).toUpperCase()}</p>
        </div>
      </body>
    </html>
  `;

  printWindow.document.write(html);
  printWindow.document.close();
  printWindow.focus();
  setTimeout(() => printWindow.print(), 300);
}
Enter fullscreen mode Exit fullscreen mode

Phase 5: HR Operations Approval Hub & Strong Consistency Storage

In Phase 5, we complete the operations loop with the HR Operations Approval Hub (/admin/approvals), enabling side-by-side claim verification and 1-click decision making with strict storage consistency guarantees.


Step 18: HR Operations Approval Hub & Side-by-Side Review

+-----------------------------------------------------------------------------+
|                    HR OPERATIONS APPROVAL HUB (/admin/approvals)            |
+-----------------------------------------------------------------------------+
|  [Pending Queue: 3]   [Pending Claims: IDR 2.45M]   [Approved: IDR 4.8M]    |
+-----------------------------------------------------------------------------+
|  Verification Queue Table:                                                  |
|  * #CLM-20260822-01 | John Doe | Engineering | Dental Claim | IDR 750,000   |
|    Action: [ Review & Verify ] [ Quick Approve ] [ Quick Reject ]           |
+--------------------------------------+--------------------------------------+
                                       |
                                       v
+-----------------------------------------------------------------------------+
|                   SIDE-BY-SIDE REVIEW MODAL (TicketReviewModal.tsx)         |
+--------------------------------------+--------------------------------------+
|  LEFT COLUMN (Submission Proof)      |  RIGHT COLUMN (HR Decision Controls) |
+--------------------------------------+--------------------------------------+
|  * Employee Profile & Department     |  * Signatory: [ Marcus Brody       ] |
|  * Full Submitted Parameters Table   |  * Payroll Date: [ 2026-08-31      ] |
|  * High-Res Zoomable Receipt Proof   |  * HR Feedback Notes:                |
|    [ Zoom In ] [ Download ]          |    "Receipt verified with clinic."   |
|                                      |  [ Reject ]   [ Approve ]            |
+--------------------------------------+--------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Side-by-side verification drawer with employee receipt proof on the left and HR decision controls on the right

Figure 6: Side-by-side verification drawer showing employee submission receipt on the left and HR decision controls on the right.


Step 19: EdgeOne Pages Blob Consistency Model: Eventual vs. Strong Consistency

When building approval state machines, status queues, or counters on edge storage, understanding the Consistency Model is critical.

Tencent EdgeOne Pages Blob Storage (@edgeone/pages-blob) supports two consistency modes:

Comparison diagram of eventual versus strong consistency modes in EdgeOne Pages Blob Storage

Enabling Strong Consistency by Default (edge-functions/_blob.ts)

To eliminate read replication lag and ensure that status changes (e.g. PENDING -> APPROVED -> REJECTED) reflect immediately on the very first read, we configure getBlobStore() with { consistency: 'strong' }:

// edge-functions/_blob.ts
import { getStore as getEdgeOneStore } from '@edgeone/pages-blob';

export interface UnifiedBlobStore {
  set(key: string, data: Buffer | ArrayBuffer | string): Promise<void>;
  setJSON(key: string, data: unknown): Promise<void>;
  get(key: string, options?: { type?: 'text' | 'json'; consistency?: 'eventual' | 'strong' }): Promise<any>;
  delete(key: string): Promise<void>;
  list(options?: { prefix?: string; consistency?: 'eventual' | 'strong' }): Promise<{ blobs: Array<{ key: string }> }>;
}

export function getBlobStore(
  storeName = process.env.BLOB_STORE_NAME || 'uploads',
  options: { consistency?: 'eventual' | 'strong' } = { consistency: 'strong' }
): UnifiedBlobStore {
  return (getEdgeOneStore as any)(storeName, { consistency: 'strong', ...options }) as any;
}
Enter fullscreen mode Exit fullscreen mode

Step 20: Complete End-to-End Architecture Sequence

End-to-end architecture sequence diagram covering upload, retrieval, agent tool calls, and approval flows


Phase 6: Executive Decision Intelligence, AI Analytics & Master Ledger Export

In Phase 6, we finalize our enterprise autonomous copilot with real-time operational analytics, structured CPO-level AI intelligence, and client-side corporate ledger exports.


Step 21: Structured AI Executive Intelligence (cloud-functions/hr-analytics.ts)

Located on /admin/analytics, the Executive Decision Intelligence dashboard aggregates organization-wide ticket metrics and synthesizes CPO-level strategic insights:

  1. KPI Aggregation: Calculates live total submissions, approved spend (IDR), operational approval rate %, and active organizational divisions.
  2. EdgeOne AI Gateway Integration: Dispatches live organizational metrics to DeepSeek via the EdgeOne AI Gateway to generate a structured JSON intelligence schema:
    • executiveSummary: High-level operational throughput & financial exposure briefing.
    • keyTakeaways: Array of key metrics, SLA diagnostics, and impact ratings (positive, neutral, warning).
    • departmentInsights: Department spend patterns and trend indicators.
    • recommendations: Strategic recommendations with priority classifications (HIGH, MEDIUM, OPTIMIZATION), strategic rationales, and concrete next action steps.
  3. Resilient Deterministic Fallback: Includes a deterministic analytics engine to guarantee immediate, reliable rendering even when external API limits are reached.
// cloud-functions/hr-analytics.ts
// Note: Executive reporting handler (see cloud-functions/hr-analytics.ts for deterministic aggregation functions)
import { getAllTickets } from './_tickets';
import { aggregateOrgMetrics, synthesizeExecutiveReport } from './_analytics';

export async function handleHrAnalytics(context: any): Promise<Response> {
  const tickets = await getAllTickets(); // Pure Pages Blob with Strong Consistency

  // 1. Aggregate organizational spend and approval throughput
  const statsSummary = aggregateOrgMetrics(tickets);

  // 2. Synthesize strategic briefing via EdgeOne AI Gateway (DeepSeek)
  const report = await synthesizeExecutiveReport(context, statsSummary);

  return new Response(JSON.stringify({ success: true, stats: statsSummary, report }), {
    headers: { 'Content-Type': 'application/json' },
  });
}
Enter fullscreen mode Exit fullscreen mode

Step 22: Executive UI Dashboard & Master Ledger Export (AnalyticsReport.tsx)

Inside src/components/hr/AnalyticsReport.tsx:

  1. Department & Category Visualizers: Dynamic proportion bars illustrating claim distribution across Engineering, Marketing, and Operations.
  2. Executive UI Dashboard:
    • Chief People Officer Briefing Banner: Polished executive card with gradient accents.
    • 3-Column Organizational Takeaways Grid: Individual cards with colored impact badges and key numerical metrics.
    • Actionable Recommendations Matrix: Structured priority cards with highlighted Action Step callouts and strategic rationales.
  3. Master Ledger Export ([ Export Master Ledger (.xlsx) ]):
    • Generates and downloads a complete, itemized Excel workbook (Acme_HR_Submissions_Ledger.xlsx) using SheetJS for finance reconciliation.

Acme HR Copilot EdgeOne tutorial screenshot

Figure 7: Executive Decision Intelligence analytics dashboard featuring CPO briefing, organizational takeaways, and Master Ledger Excel export.


Step 23: Deploying to Tencent EdgeOne Makers

Deploying your full-stack enterprise HR Copilot to Tencent Cloud's Anycast Edge Network requires just a single Git push:

1. Push to GitHub

git add .
git commit -m "feat: complete Phase 1 through Phase 6 production HR Copilot suite"
git push origin main
Enter fullscreen mode Exit fullscreen mode

2. Connect Repository in Tencent EdgeOne Console

  1. Navigate to the Tencent EdgeOne Makers Console.
  2. Click Create Project and select your GitHub repository (hr-rag-edgeone).
  3. Set your environment variables:
    • QDRANT_URL & QDRANT_API_KEY: Your Qdrant Cloud cluster endpoint.
    • VOYAGE_API_KEY: Your Voyage AI API key for embeddings and rerank-2.
    • AI_GATEWAY_BASE_URL & AI_GATEWAY_API_KEY: EdgeOne Makers AI Gateway.
  4. Click Deploy. EdgeOne builds your Next.js application, deploys the stateful Agent node, registers Cloud Functions, and provisions your Pages Blob Storage bucket automatically across 3,200+ edge locations.

Frequently Asked Questions

How long can a single agent conversation run on EdgeOne?

The stateful loop in Step 12 holds a concurrency lock and streams Server-Sent Events across up to four model rounds per turn, with no platform invocation cap to plan around. Mainstream serverless does not offer this: AWS Lambda caps a single invocation at 900 seconds, which pushes long tool loops into queued fragments or durable-function workarounds.

When should you choose strong consistency over eventual consistency?

Choose strong consistency whenever the first read after a write must reflect it: approval state machines, status queues, counters. This project enables strong mode by default in _blob.ts (Step 19), so a move from pending to approved appears instantly for HR reviewers. Eventual reads remain acceptable for read-heavy catalog listings where brief replication lag harms nobody.

Can I swap Qdrant, Voyage AI, or Firecrawl for other providers?

Yes. Each external dependency sits behind a thin wrapper client (_qdrant.ts, _embeddings.ts, _reranker.ts, _parser.ts). Swap the implementation inside the wrapper and nothing in agents/, the cloud functions, or the UI changes. The reranker also degrades gracefully to raw vector scores when no API key is configured.

Do HR form schemas require manual maintenance?

No. At ingestion time the AI Gateway extracts a structured form schema from each policy document and stores it in Blob metadata (Step 14). Upload a revised policy DOCX and the in-chat form updates itself, with no frontend redeploy required.

Conclusion & Key Production Takeaways

By combining Tencent EdgeOne Edge Workers, Pages Blob Storage, Qdrant Cloud, and Voyage AI, we built a stateful, enterprise-grade HR Copilot that eliminates common serverless bottlenecks:

  • Zero-Timeout Agent Execution: Stateful SSE connections with native concurrency locks allow multi-turn reasoning and tool calling without connection drops.
  • Section-Aware Knowledge Grounding: Standalone parent-child chunking preserves tabular policy data and numerical tier quotas for high RAG accuracy.
  • Immediate State Consistency: Strong Consistency mode on Pages Blob Storage ensures approval decisions, status changes, and tickets reflect instantly without replication lag.
  • Document-Driven Dynamic Workflows: Form schemas and spreadsheet templates are extracted directly from corporate SOPs at ingestion time, eliminating manual form maintenance.

Top comments (0)