Building AI Agents with Claude
Capstone Project 2
Capstone 2 of 5 60–90 min ★★☆☆☆ Domain B — B2B Ecommerce
← Capstone 1-B: Order Status Bot 🏠 Home M23: Capstone Guide →

Capstone 2 — Domain B: Product Catalog & Contract Q&A

Build a RAG-powered agent that searches product catalogs and customer-specific contracts to answer pricing, availability, and terms questions with accurate source citations.

Project Brief

Business Context

A B2B sales rep at an industrial supply company fields 40–60 questions per day from buyers: “What’s our contract price for the 4-inch valve?” “What’s the lead time on digital pressure gauges?” “When does our contract expire?” Each question requires searching two separate systems: the product catalogThe master database of all products a company sells, including SKUs, descriptions, specifications, list prices, and lead times. Shared across all customers — it's the "sticker price" before any negotiated discounts. (specs, list prices, availability) and the customer’s contractA negotiated agreement between the supplier and a specific customer. Contains custom pricing tiers (volume discounts), payment terms, warranty extensions, and SLA commitments. Overrides catalog pricing for that customer. (negotiated pricing, volume tiers, payment terms).

The critical challenge: contract pricing overrides catalog pricing. If a rep quotes the catalog list price ($595) instead of the contract price ($465 at tier 3), the customer either gets overcharged (damaging trust) or the company loses margin when it corrects the mistake later. The rep must know to check the contract first, then fall back to catalog pricing if no contract exists.

Your RAG agent replaces this dual-system lookup: it ingests both catalog data and customer contracts into a single knowledge base, answers questions with the right priority (contract > catalog), and cites exactly where the answer came from so the rep can verify.

What You Will Build

A complete RAG pipelineRetrieval-Augmented Generation — retrieve relevant documents from a knowledge base before generating an answer. Grounds responses in real data, enables citations, and prevents the model from making up pricing or contract terms. with a conversational agent that:

  • Ingests product catalog data (SKUs, specs, list prices, lead times)
  • Ingests customer-specific contracts (negotiated tiers, payment terms, expiration dates)
  • Prioritizes contract results over catalog for customer-specific queries
  • Retrieves relevant chunks via semantic search with doc-type and customer filters
  • Synthesizes answers with citations: [Source: CONTRACT-ACME-2024, Tier 2]
  • Handles queries for customers without contracts (falls back to list pricing)

Skills practiced: RAG pipeline (M09), chunking strategies (M10), embeddings, vector search, filtered retrieval, citation generation, document priority logic.

[OPTIONAL] Stretch goal: Add re-rankingA two-stage retrieval strategy: first retrieve a broad set (top 20), then use a cross-encoder or LLM to re-score and re-order by relevance. Significantly improves precision for queries where contract-specific answers should outrank general catalog results. to boost contract-specific results above general catalog matches.

Prerequisites

Before You Start

Complete these modules before attempting this capstone:

  • M03 (Prompt Engineering) — The system prompt enforces contract-first priority and citation rules.
  • M04 (Structured Output) — You will generate structured JSON responses with citations.
  • M05 (Function Calling) — The agent uses a search_catalog_knowledge_base tool.
  • M09 (RAG) — This entire capstone builds a Retrieval-Augmented Generation pipeline with chunking, embedding, and retrieval.
  • M10 (Advanced RAG) — The customer-specific re-ranking stretch goal builds directly on M10's chunking and re-ranking patterns.

Difficulty: ★★☆☆☆ — 5–8 steps, approximately 60–90 minutes. You will create multiple files (vector store, ingestion pipeline, agent) and connect them into a working RAG system.

Environment Setup

Requirements: Python 3.10+ or Node.js 18+. You will also need an Anthropic API key.

mkdir catalog-contract-rag && cd catalog-contract-rag
python -m venv venv && source venv/bin/activate   # Windows: venv\Scripts\activate
pip install "anthropic>=0.30.0" pytest
export ANTHROPIC_API_KEY=your-key-here             # Windows: set ANTHROPIC_API_KEY=your-key-here
mkdir catalog-contract-rag && cd catalog-contract-rag
npm init -y
npm install @anthropic-ai/sdk
npm install -D typescript tsx @types/node
export ANTHROPIC_API_KEY=your-key-here             # Windows: set ANTHROPIC_API_KEY=your-key-here
✅ Checkpoint

Run python -c "import anthropic; print(anthropic.__version__)" (or npx tsx -e "import Anthropic from '@anthropic-ai/sdk'; console.log('OK')" for Node.js). If you see a version number or "OK", your environment is ready. If you see ModuleNotFoundError, re-run pip install "anthropic>=0.30.0" and make sure your virtual environment is activated.

File Structure

You will create these files over the course of this capstone:

catalog-contract-rag/
  data/
    catalog_contracts.json # Mock product catalog + 5 customer contracts
  vector_store.py         # TF-IDF mock vector store with metadata filtering
  vector_store.ts         # Node.js version of vector store
  ingest.py               # Dual-source ingestion (catalog + contracts)
  ingest.ts               # Node.js version of ingestion
  agent.py                # RAG agent with contract-first priority
  agent.ts                # Node.js version of RAG agent
  test_rag.py             # Automated test suite (5 tests)
  verify.py               # End-to-end smoke test
  requirements.txt        # anthropic
  .env.example            # ANTHROPIC_API_KEY=

Domain Glossary

SKU
Stock Keeping Unit — unique product identifier. SKU-4892 = "Industrial Valve Assembly - 4 inch." The primary key for all catalog and contract lookups.
List Price
The standard catalog price before any negotiated discounts. The "sticker price" that applies to customers without contracts or for SKUs not covered by a contract.
Contract Price
A negotiated price for a specific customer, often structured in volume tiers. Always takes priority over list price when a contract exists for that customer + SKU combination.
Volume Tiers
Pricing brackets based on order quantity. Example: 1-49 units = $525, 50-199 = $495, 200+ = $465. The more you buy, the lower the per-unit price.
Lead Time
The number of business days between order placement and shipment. Standard lead times come from the catalog; contract customers may have expedited commitments.
Net Terms
Payment deadline after invoice. "Net 45" = pay within 45 days. Negotiated per-contract. Catalog default may be "Net 30" or prepay.
FOB
Free On Board — shipping term defining when title and risk transfer. "FOB Destination" = supplier responsible until delivery. "FOB Origin" = buyer responsible once shipped.
Re-ranking
A second-pass retrieval step that re-scores initial results for precision. Critical in B2B RAG: a contract clause about "valve pricing" should rank above a generic catalog description of valves.

Architecture

RAG Pipeline — Dual-Source Ingestion & Prioritized Retrieval
📚
Product Catalog
+
📝
Customer Contracts
Smart Chunker
🔢
Embed
🗃
Vector Store
🔍
Query + Filter
🤖
Claude + Cite
Document Priority — Contract > Catalog
CONTRACTSKU-4892 for Acme: Tier 2 = $495/unit (50-199 qty) — CONTRACT-ACME-2024
CONTRACTAcme payment terms: Net 45, FOB Destination — CONTRACT-ACME-2024
CATALOGSKU-4892: Industrial Valve Assembly 4", list price $595 — CATALOG-2024-Q1
CATALOGSKU-4892 specs: 316 SS, 150 PSI, lead time 14 days — CATALOG-2024-Q1
Retrieval — “What’s the price for SKU-4892 for Acme?”
CONTRACT-ACMETier pricing: 1-49=$525, 50-199=$495, 200+=$465 for SKU-48920.96
CONTRACT-ACMEContract terms: Net 45, FOB Destination, 24-month warranty0.82
CATALOG-Q1SKU-4892: Industrial Valve Assembly 4", list $595, 316 SS, 150 PSI0.79
CATALOG-Q1SKU-4892 availability: In stock, lead time 14 business days0.71
CONTRACT-BETABeta Industrial: SKU-4892 pricing tier 1=$510, tier 2=$4800.65

Mock Data Specification

{
  "documents": [
    {
      "doc_id": "CATALOG-2024-Q1",
      "type": "product_catalog",
      "sections": [
        {
          "sku": "SKU-4892",
          "name": "Industrial Valve Assembly - 4 inch",
          "category": "Flow Control",
          "list_price": 595.00,
          "specs": {
            "material": "316 Stainless Steel",
            "pressure_rating": "150 PSI",
            "temperature_range": "-20F to 400F",
            "connection": "Flanged ANSI 150",
            "lead_time_days": 14
          }
        },
        {
          "sku": "SKU-7721",
          "name": "Pressure Gauge - Digital, 0-300 PSI",
          "category": "Instrumentation",
          "list_price": 189.00,
          "specs": {
            "display": "4-digit LCD backlit",
            "accuracy": "+/- 0.25% full scale",
            "power": "2x AA battery, 18-month life",
            "connection": "1/4 NPT male",
            "lead_time_days": 7
          }
        },
        {
          "sku": "SKU-2210",
          "name": "Stainless Steel Fitting Kit - 4 inch",
          "category": "Fittings",
          "list_price": 125.00,
          "specs": {
            "material": "316 Stainless Steel",
            "includes": "2 elbows, 2 tees, 4 couplings, 8 nipples",
            "pressure_rating": "150 PSI",
            "lead_time_days": 5
          }
        }
      ]
    },
    {
      "doc_id": "CONTRACT-ACME-2024",
      "type": "customer_contract",
      "customer": "Acme Manufacturing",
      "effective_date": "2024-01-01",
      "expiration_date": "2024-12-31",
      "pricing_tiers": [
        {
          "sku": "SKU-4892",
          "tier_1": {"min_qty": 1, "max_qty": 49, "unit_price": 525.00},
          "tier_2": {"min_qty": 50, "max_qty": 199, "unit_price": 495.00},
          "tier_3": {"min_qty": 200, "max_qty": null, "unit_price": 465.00}
        },
        {
          "sku": "SKU-7721",
          "tier_1": {"min_qty": 1, "max_qty": 24, "unit_price": 165.00},
          "tier_2": {"min_qty": 25, "max_qty": null, "unit_price": 145.00}
        }
      ],
      "terms": {
        "payment": "Net 45",
        "shipping": "FOB Destination",
        "warranty": "24 months",
        "annual_minimum": 50000.00
      }
    },
    {
      "doc_id": "CONTRACT-BETA-2024",
      "type": "customer_contract",
      "customer": "Beta Industrial",
      "effective_date": "2024-03-01",
      "expiration_date": "2025-02-28",
      "pricing_tiers": [
        {
          "sku": "SKU-4892",
          "tier_1": {"min_qty": 1, "max_qty": 99, "unit_price": 510.00},
          "tier_2": {"min_qty": 100, "max_qty": null, "unit_price": 480.00}
        }
      ],
      "terms": {
        "payment": "Net 30",
        "shipping": "FOB Origin",
        "warranty": "12 months",
        "annual_minimum": 25000.00
      }
    },
    {
      "doc_id": "CONTRACT-GAMMA-2024",
      "type": "customer_contract",
      "customer": "Gamma Technologies",
      "effective_date": "2024-02-15",
      "expiration_date": "2025-02-14",
      "pricing_tiers": [
        {
          "sku": "SKU-7721",
          "tier_1": {"min_qty": 1, "max_qty": 19, "unit_price": 172.00},
          "tier_2": {"min_qty": 20, "max_qty": null, "unit_price": 155.00}
        }
      ],
      "terms": {
        "payment": "Net 45",
        "shipping": "FOB Destination",
        "warranty": "36 months",
        "annual_minimum": 35000.00
      }
    },
    {
      "doc_id": "CONTRACT-DELTA-2024",
      "type": "customer_contract",
      "customer": "Delta Logistics",
      "effective_date": "2024-04-01",
      "expiration_date": "2025-03-31",
      "pricing_tiers": [
        {
          "sku": "SKU-2210",
          "tier_1": {"min_qty": 1, "max_qty": 99, "unit_price": 108.00},
          "tier_2": {"min_qty": 100, "max_qty": null, "unit_price": 95.00}
        },
        {
          "sku": "SKU-4892",
          "tier_1": {"min_qty": 1, "max_qty": null, "unit_price": 540.00}
        }
      ],
      "terms": {
        "payment": "Net 30",
        "shipping": "FOB Origin",
        "warranty": "12 months",
        "annual_minimum": 20000.00
      }
    },
    {
      "doc_id": "CONTRACT-EPSILON-2024",
      "type": "customer_contract",
      "customer": "Epsilon Engineering",
      "effective_date": "2024-01-15",
      "expiration_date": "2026-01-14",
      "pricing_tiers": [
        {
          "sku": "SKU-4892",
          "tier_1": {"min_qty": 1, "max_qty": 74, "unit_price": 515.00},
          "tier_2": {"min_qty": 75, "max_qty": null, "unit_price": 475.00}
        },
        {
          "sku": "SKU-7721",
          "tier_1": {"min_qty": 1, "max_qty": 49, "unit_price": 160.00},
          "tier_2": {"min_qty": 50, "max_qty": null, "unit_price": 140.00}
        },
        {
          "sku": "SKU-2210",
          "tier_1": {"min_qty": 1, "max_qty": 149, "unit_price": 105.00},
          "tier_2": {"min_qty": 150, "max_qty": null, "unit_price": 90.00}
        }
      ],
      "terms": {
        "payment": "Net 60",
        "shipping": "FOB Destination",
        "warranty": "60 months",
        "annual_minimum": 200000.00
      }
    }
  ]
}
🎯 What Just Happened?

You have 6 documents: 1 product catalog (3 SKUs) and 5 customer contracts (Acme, Beta, Gamma, Delta, and Epsilon). The key test: when someone asks about SKU-4892 pricing for Acme, the agent must return $525/$495/$465 from the contract, NOT $595 from the catalog. When asked about a customer without a contract, it should fall back to the catalog list price.

Step-by-Step Build Guide

This guide follows 5 numbered steps. Each step produces a testable artifact. Complete them in order — later steps depend on earlier ones.

You have your environment set up and you understand the mock data. Now it is time to build the three core components: the vector store (storage + search), the ingestion pipeline (loading + chunking), and the RAG agent (tool definition + conversation loop). Each component is a separate file so you can test independently.

Step 1: Save the Mock Data

What & Why: Create the data/ directory and save the catalog + contracts JSON file. This is the knowledge base your agent will search. Without realistic mock data, you cannot test the contract-first priority logic.

Create a file called data/catalog_contracts.json and paste the complete JSON from the Mock Data Specification section above.

mkdir -p data    # Windows: mkdir data
# Paste the JSON from the Mock Data section into data/catalog_contracts.json
python -c "import json; d=json.load(open('data/catalog_contracts.json')); print(f'{len(d[\"documents\"])} documents loaded')"
Expected Output
6 documents loaded
✅ Checkpoint

You should see "6 documents loaded" (1 catalog + 5 contracts). If you see a FileNotFoundError, check that data/catalog_contracts.json exists and the JSON is valid.

Step 2: Build the Mock Vector Store

What & Why: Create the TF-IDF vector store that powers retrieval. This is the search engine your agent uses to find relevant catalog and contract information. The critical feature is metadata filtering: the agent can search only a specific customer’s contracts, only the catalog, or only a specific SKU.

Create a file called vector_store.py (or vector_store.ts for Node.js) with the complete code from the MockVectorStore section below.

# Quick test: create a store, add a doc, search it
python -c "
from vector_store import MockVectorStore
store = MockVectorStore()
store.add(chunk_id='test-1', content='Industrial valve assembly 4 inch', metadata={'sku': 'SKU-4892'})
results = store.search('valve', top_k=1)
print(f'Found: {results[0][\"text\"][:40]}... score={results[0][\"score\"]}')
"
Expected Output
Found: Industrial valve assembly 4 inch... score=0.4472
✅ Checkpoint

You should see the document text returned with a positive cosine similarity score (around 0.45 with only one document). The exact score will change once you ingest the full corpus because IDF depends on document frequency. If you see ImportError, make sure vector_store.py is in your current directory.

Troubleshooting Step 2
  • SyntaxError — Make sure you copied the entire vector_store.py file, including the imports at the top.
  • ModuleNotFoundError: No module named 'vector_store' — You are running Python from the wrong directory. cd into your catalog-contract-rag/ folder.

Step 3: Build the Ingestion Pipeline

What & Why: The ingestion pipeline reads your catalog and contracts, chunks them differently (one chunk per SKU for catalog, one per pricing tier + one for terms for contracts), and loads them into the vector store with rich metadata. This dual-source chunking strategy is the key to contract-first priority — metadata filters let the agent search only contract documents for a specific customer.

Create a file called ingest.py (or ingest.ts) with the complete code from the Ingestion Pipeline section below.

python -c "
from vector_store import MockVectorStore
from ingest import ingest_corpus
store = MockVectorStore()
count = ingest_corpus('data/catalog_contracts.json', store)
print(f'Ingested {count} chunks')
# Test: search for Acme contract pricing
results = store.search('SKU-4892 pricing', top_k=2, filters={'customer': 'Acme Manufacturing'})
for r in results:
    print(f'  [{r[\"metadata\"].get(\"doc_type\",\"\")}] {r[\"text\"][:60]}...')
"
Expected Output
Ingested 17 chunks [customer_contract] Contract pricing for Acme Manufacturing: SKU SKU-4892. Tiers... [customer_contract] Contract pricing for Acme Manufacturing: SKU SKU-7721. Tiers...
✅ Checkpoint

You should see 17 chunks (3 catalog products + 9 pricing tier chunks across 5 contracts + 5 contract terms blocks = 17). The search should return Acme contract results, NOT catalog results — the customer filter ensures this. If you see 0 results, double-check the filter value matches the customer name exactly ("Acme Manufacturing").

Step 4: Build the RAG Agent

What & Why: This is the core component. The agent defines a single tool (search_catalog_knowledge_base) with filter support, and a system prompt that enforces the contract-first pricing rule and citation requirements. The tool-use loop sends the user’s question to Claude, Claude calls the search tool, and Claude synthesizes a cited answer from the results.

Create a file called agent.py (or agent.ts) with the complete code from the Complete Solution section below.

export ANTHROPIC_API_KEY=your-key-here
python agent.py
set ANTHROPIC_API_KEY=your-key-here
python agent.py
Expected Output
Loading product catalog and contracts... Ingested 17 chunks. ============================================================ Product Catalog & Contract Q&A — Capstone 2-B Ask about pricing, specs, contracts, lead times. Type 'quit' to exit. ============================================================ You: What's the price for SKU-4892 for Acme? Agent: [contract pricing response with citations]
✅ Checkpoint

The agent should respond with Acme’s contract pricing ($525/$495/$465), NOT the catalog list price ($595). The response should include [Source: CONTRACT-ACME-2024]. If you see AuthenticationError, your API key is missing or invalid. If the agent returns catalog pricing instead of contract pricing, check the system prompt is correctly enforcing contract-first priority.

Troubleshooting Step 4
  • AuthenticationError — Your ANTHROPIC_API_KEY is missing or invalid. Run echo $ANTHROPIC_API_KEY (Windows: echo %ANTHROPIC_API_KEY%) to verify it is set.
  • Agent returns catalog price instead of contract price — Verify the system prompt includes the "CRITICAL PRICING RULE" section. The prompt must instruct Claude to search for contracts first.
  • ImportError: cannot import name 'ingest_corpus' — Make sure ingest.py, vector_store.py, and agent.py are all in the same directory.

Step 5: Run the Automated Tests

What & Why: Automated tests verify the vector store retrieval logic without calling the Claude API. This catches problems in chunking, metadata tagging, and filtering before you spend API credits on the full agent.

Create a file called test_rag.py with the test code from the Automated Tests section below. Then install pytest and run:

pytest test_rag.py -v
Expected Output
test_rag.py::TestRetrieval::test_contract_found_for_acme PASSED test_rag.py::TestRetrieval::test_catalog_fallback_for_unknown_customer PASSED test_rag.py::TestRetrieval::test_catalog_specs_returned PASSED test_rag.py::TestRetrieval::test_contract_terms_returned PASSED test_rag.py::TestRetrieval::test_empty_query_error PASSED ========================= 5 passed =========================
✅ Checkpoint

All 5 tests should pass. If test_contract_found_for_acme fails, check the customer name in the mock data matches "Acme Manufacturing" exactly. If test_catalog_fallback_for_unknown_customer fails, make sure the filter correctly returns empty results for non-existent customers.

Mock Tool Implementations

The vector store uses TF-IDF similarity for retrieval. It is free, fast, and requires no external API. The key difference from a production system is the ingestion logic: catalog SKUs become product chunks; contract tiers become pricing chunks. Both carry rich metadata for filtered retrieval.

MockVectorStore (TF-IDF)

This self-contained vector store uses TF-IDFTerm Frequency-Inverse Document Frequency — a numerical statistic that reflects how important a word is to a document in a collection. Words that appear frequently in one document but rarely across all documents get higher scores. to compute document similarity. It supports metadata filtering so the agent can narrow results by document type, customer, or SKU.

"""vector_store.py — TF-IDF Mock Vector Store

WHAT: A self-contained vector store using TF-IDF similarity.
WHY:  Free, fast, no external API needed. Demonstrates core
      retrieval concepts (tokenize, embed, search) without
      adding cost or complexity.
GOTCHA: TF-IDF is a bag-of-words model — it ignores word order.
        For production, use neural embeddings (e.g., Voyage AI).
"""

import math
import re
from collections import Counter


class MockVectorStore:
    def __init__(self):
        self.documents = []
        self.vectors = []
        self.idf = {}

    def _tokenize(self, text):
        """Split text into lowercase word tokens."""
        return re.findall(r'\b\w+\b', text.lower())

    def _compute_tf(self, tokens):
        """Compute term frequency: count / total tokens."""
        count = Counter(tokens)
        total = len(tokens)
        return {t: c / total for t, c in count.items()}

    def _build_idf(self):
        """Recompute IDF across all documents.
        IDF = log(total_docs / (1 + docs_containing_term))
        The +1 prevents division by zero."""
        doc_count = len(self.documents)
        term_docs = {}
        for doc in self.documents:
            tokens = set(self._tokenize(doc["text"]))
            for t in tokens:
                term_docs[t] = term_docs.get(t, 0) + 1
        self.idf = {t: math.log(doc_count / (1 + c)) for t, c in term_docs.items()}

    def _vectorize(self, text):
        """Convert text to a TF-IDF vector (sparse dict)."""
        tokens = self._tokenize(text)
        tf = self._compute_tf(tokens)
        return {t: tf_val * self.idf.get(t, 0) for t, tf_val in tf.items()}

    def add(self, chunk_id=None, content="", text=None, metadata=None):
        """Add a document to the store.

        Args:
            chunk_id: Optional unique identifier for the chunk.
            content: The document text to index (preferred kwarg name).
            text: Alias for content (used in simpler call signatures).
            metadata: Dict of metadata (doc_type, customer, sku, etc.)
                      Used for filtered retrieval.
        """
        doc_text = content or text or ""
        self.documents.append({"text": doc_text, "metadata": metadata or {},
                               "chunk_id": chunk_id})
        # Rebuild IDF and re-vectorize all docs (fine for small corpora)
        self._build_idf()
        self.vectors = [self._vectorize(d["text"]) for d in self.documents]

    def search(self, query, top_k=3, filters=None):
        """Search for documents similar to query.

        Args:
            query: Natural language search string.
            top_k: Max results to return (default 3).
            filters: Optional dict of metadata key-value pairs.
                     Only documents matching ALL filters are returned.

        Returns:
            List of dicts with text, metadata, and similarity score.
        """
        if not query.strip():
            return [{"error": "EMPTY_QUERY", "message": "Query cannot be empty"}]

        query_vec = self._vectorize(query)
        results = []

        for i, doc in enumerate(self.documents):
            # ── Metadata filtering ────────────────────────────
            if filters:
                if not all(doc["metadata"].get(k) == v for k, v in filters.items()):
                    continue

            # ── Cosine similarity ─────────────────────────────
            doc_vec = self.vectors[i]
            all_terms = set(list(query_vec.keys()) + list(doc_vec.keys()))
            dot = sum(query_vec.get(t, 0) * doc_vec.get(t, 0) for t in all_terms)
            mag_q = math.sqrt(sum(v ** 2 for v in query_vec.values())) or 1
            mag_d = math.sqrt(sum(v ** 2 for v in doc_vec.values())) or 1
            score = dot / (mag_q * mag_d)

            results.append({
                "text": doc["text"],
                "content": doc["text"],
                "doc_id": doc["metadata"].get("doc_id", ""),
                "chunk_id": doc.get("chunk_id", ""),
                "metadata": doc["metadata"],
                "score": round(score, 4),
            })

        results.sort(key=lambda x: x["score"], reverse=True)
        return results[:top_k]
// vector_store.ts — TF-IDF Mock Vector Store
//
// WHAT: Self-contained vector store using TF-IDF cosine similarity.
// WHY:  Free, fast, no external API. Demonstrates core retrieval.
// GOTCHA: Bag-of-words model — ignores word order. Use neural
//         embeddings (e.g., Voyage AI) for production.

interface Document {
  text: string;
  metadata: Record<string, any>;
  chunkId?: string;
}

interface SearchResult {
  text: string;
  content: string;
  doc_id: string;
  chunk_id: string;
  metadata: Record<string, any>;
  score: number;
}

export class MockVectorStore {
  private documents: Document[] = [];
  private vectors: Map<string, number>[] = [];
  private idf: Map<string, number> = new Map();

  private tokenize(text: string): string[] {
    return (text.toLowerCase().match(/\b\w+\b/g) || []);
  }

  private computeTf(tokens: string[]): Map<string, number> {
    const count = new Map<string, number>();
    for (const t of tokens) count.set(t, (count.get(t) || 0) + 1);
    const total = tokens.length;
    const tf = new Map<string, number>();
    for (const [t, c] of count) tf.set(t, c / total);
    return tf;
  }

  private buildIdf(): void {
    const docCount = this.documents.length;
    const termDocs = new Map<string, number>();
    for (const doc of this.documents) {
      const unique = new Set(this.tokenize(doc.text));
      for (const t of unique) termDocs.set(t, (termDocs.get(t) || 0) + 1);
    }
    this.idf = new Map();
    for (const [t, c] of termDocs) {
      this.idf.set(t, Math.log(docCount / (1 + c)));
    }
  }

  private vectorize(text: string): Map<string, number> {
    const tokens = this.tokenize(text);
    const tf = this.computeTf(tokens);
    const vec = new Map<string, number>();
    for (const [t, tfVal] of tf) {
      vec.set(t, tfVal * (this.idf.get(t) || 0));
    }
    return vec;
  }

  /** Add a document with optional metadata for filtered retrieval. */
  add(chunkId: string, content: string, metadata: Record<string, any> = {}): void {
    this.documents.push({ text: content, metadata, chunkId });
    this.buildIdf();
    this.vectors = this.documents.map((d) => this.vectorize(d.text));
  }

  /** Search for documents similar to query with optional metadata filters. */
  search(
    query: string,
    topK: number = 3,
    filters?: Record<string, any>
  ): SearchResult[] | { error: string; message: string }[] {
    if (!query.trim()) {
      return [{ error: "EMPTY_QUERY", message: "Query cannot be empty" }];
    }

    const queryVec = this.vectorize(query);
    const results: SearchResult[] = [];

    for (let i = 0; i < this.documents.length; i++) {
      const doc = this.documents[i];

      // Metadata filtering
      if (filters) {
        const match = Object.entries(filters).every(
          ([k, v]) => doc.metadata[k] === v
        );
        if (!match) continue;
      }

      // Cosine similarity
      const docVec = this.vectors[i];
      const allTerms = new Set([...queryVec.keys(), ...docVec.keys()]);
      let dot = 0;
      for (const t of allTerms) {
        dot += (queryVec.get(t) || 0) * (docVec.get(t) || 0);
      }
      let magQ = 0;
      for (const v of queryVec.values()) magQ += v * v;
      magQ = Math.sqrt(magQ) || 1;
      let magD = 0;
      for (const v of docVec.values()) magD += v * v;
      magD = Math.sqrt(magD) || 1;

      results.push({
        text: doc.text,
        content: doc.text,
        doc_id: doc.metadata.doc_id || "",
        chunk_id: doc.chunkId || "",
        metadata: doc.metadata,
        score: Math.round((dot / (magQ * magD)) * 10000) / 10000,
      });
    }

    results.sort((a, b) => b.score - a.score);
    return results.slice(0, topK);
  }
}
🎯 What Just Happened?

You built a complete TF-IDF vector store from scratch. It tokenizes text, computes term frequencies, builds an inverse document frequency index, and uses cosine similarity to rank results. The filters parameter is critical: it lets the agent search only contract documents for a specific customer, or only the product catalog, enabling the contract-first priority logic.

Ingestion Pipeline

"""ingest.py — Dual-source ingestion for catalog + contracts.

WHAT: Chunks product catalog and customer contracts differently,
      preserving metadata for filtered retrieval.
WHY:  Contract pricing must override catalog pricing. Metadata
      filters (doc_type, customer, sku) enable this priority.
"""

import json
from vector_store import MockVectorStore

def ingest_corpus(corpus_path: str, store: MockVectorStore) -> int:
    with open(corpus_path) as f:
        corpus = json.load(f)

    count = 0
    for doc in corpus["documents"]:
        doc_id = doc["doc_id"]
        doc_type = doc["type"]

        if doc_type == "product_catalog":
            # ── WHAT: One chunk per SKU ────────────────────────
            # WHY: Keep specs + price together so retrieval
            #      returns complete product info in one chunk.
            for product in doc["sections"]:
                sku = product["sku"]
                specs = product.get("specs", {})
                content = (
                    f"Product: {product['name']} (SKU: {sku}). "
                    f"Category: {product['category']}. "
                    f"List Price: ${product['list_price']:.2f}. "
                    f"Specs: {', '.join(f'{k}: {v}' for k, v in specs.items())}."
                )
                store.add(
                    chunk_id=f"{doc_id}:{sku}",
                    content=content,
                    metadata={
                        "doc_id": doc_id, "doc_type": "product_catalog",
                        "sku": sku, "category": product["category"],
                        "list_price": product["list_price"],
                    },
                )
                count += 1

        elif doc_type == "customer_contract":
            customer = doc["customer"]
            # ── WHAT: One chunk per SKU pricing tier ───────────
            # WHY: Separating tiers enables precise retrieval
            #      when the query mentions a specific quantity.
            for tier_entry in doc.get("pricing_tiers", []):
                sku = tier_entry["sku"]
                tiers = []
                for key in ["tier_1", "tier_2", "tier_3"]:
                    t = tier_entry.get(key)
                    if t:
                        max_label = str(t["max_qty"]) if t["max_qty"] else "unlimited"
                        tiers.append(f"{t['min_qty']}-{max_label} units: ${t['unit_price']:.2f}")
                content = (
                    f"Contract pricing for {customer}: SKU {sku}. "
                    f"Tiers: {'; '.join(tiers)}. "
                    f"Contract: {doc_id}, effective {doc['effective_date']} "
                    f"to {doc['expiration_date']}."
                )
                store.add(
                    chunk_id=f"{doc_id}:{sku}:pricing",
                    content=content,
                    metadata={
                        "doc_id": doc_id, "doc_type": "customer_contract",
                        "customer": customer, "sku": sku,
                        "effective_date": doc["effective_date"],
                        "expiration_date": doc["expiration_date"],
                    },
                )
                count += 1

            # ── WHAT: One chunk for contract terms ─────────────
            terms = doc.get("terms", {})
            content = (
                f"Contract terms for {customer}: "
                f"Payment: {terms.get('payment', 'N/A')}. "
                f"Shipping: {terms.get('shipping', 'N/A')}. "
                f"Warranty: {terms.get('warranty', 'N/A')}. "
                f"Annual minimum: ${terms.get('annual_minimum', 0):,.2f}. "
                f"Contract: {doc_id}, {doc['effective_date']} to {doc['expiration_date']}."
            )
            store.add(
                chunk_id=f"{doc_id}:terms",
                content=content,
                metadata={
                    "doc_id": doc_id, "doc_type": "customer_contract",
                    "customer": customer, "section": "terms",
                },
            )
            count += 1

    return count
// ingest.ts — Dual-source ingestion for catalog + contracts

import * as fs from "fs";
import { MockVectorStore } from "./vector_store";

export function ingestCorpus(corpusPath: string, store: MockVectorStore): number {
  const corpus = JSON.parse(fs.readFileSync(corpusPath, "utf-8"));
  let count = 0;

  for (const doc of corpus.documents) {
    const docId = doc.doc_id;
    const docType = doc.type;

    if (docType === "product_catalog") {
      for (const product of doc.sections) {
        const specs = product.specs || {};
        const content = `Product: ${product.name} (SKU: ${product.sku}). ` +
          `Category: ${product.category}. List Price: $${product.list_price.toFixed(2)}. ` +
          `Specs: ${Object.entries(specs).map(([k, v]) => `${k}: ${v}`).join(", ")}.`;
        store.add(`${docId}:${product.sku}`, content, {
          doc_id: docId, doc_type: "product_catalog",
          sku: product.sku, category: product.category,
          list_price: product.list_price,
        });
        count++;
      }
    } else if (docType === "customer_contract") {
      const customer = doc.customer;
      for (const tierEntry of doc.pricing_tiers || []) {
        const sku = tierEntry.sku;
        const tiers: string[] = [];
        for (const key of ["tier_1", "tier_2", "tier_3"]) {
          const t = tierEntry[key];
          if (t) tiers.push(`${t.min_qty}-${t.max_qty ?? "unlimited"}: $${t.unit_price.toFixed(2)}`);
        }
        store.add(`${docId}:${sku}:pricing`,
          `Contract pricing for ${customer}: SKU ${sku}. Tiers: ${tiers.join("; ")}. ` +
          `Contract: ${docId}, ${doc.effective_date} to ${doc.expiration_date}.`,
          { doc_id: docId, doc_type: "customer_contract", customer, sku });
        count++;
      }
      const terms = doc.terms || {};
      store.add(`${docId}:terms`,
        `Contract terms for ${customer}: Payment: ${terms.payment}. Shipping: ${terms.shipping}. ` +
        `Warranty: ${terms.warranty}. Annual min: $${(terms.annual_minimum || 0).toLocaleString()}.`,
        { doc_id: docId, doc_type: "customer_contract", customer, section: "terms" });
      count++;
    }
  }
  return count;
}

Complete Solution

The agent has one tool: search_catalog_knowledge_base. The system prompt instructs Claude to prioritize contract results for pricing and fall back to catalog for specs and availability. Every factual claim must be cited.
"""agent.py — Product Catalog & Contract RAG Agent (Capstone 2-B)

Usage:
  export ANTHROPIC_API_KEY=your-key-here
  python agent.py
"""

import json
import anthropic
from vector_store import MockVectorStore
from ingest import ingest_corpus

client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
store = MockVectorStore()

TOOLS = [
    {
        "name": "search_catalog_knowledge_base",
        "description": (
            "Search the product catalog and customer contract knowledge base. "
            "Returns ranked results with similarity scores and metadata. "
            "Use filters to narrow by doc_type (product_catalog or customer_contract), "
            "customer name, or SKU. Always search when the user asks about "
            "pricing, specs, availability, contract terms, or lead times."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "Natural language search query",
                },
                "top_k": {
                    "type": "integer",
                    "description": "Number of results (default: 5)",
                },
                "filters": {
                    "type": "object",
                    "description": "Optional filters",
                    "properties": {
                        "doc_type": {
                            "type": "string",
                            "enum": ["product_catalog", "customer_contract"],
                            "description": "Filter by document type",
                        },
                        "customer": {
                            "type": "string",
                            "description": "Filter by customer name",
                        },
                        "sku": {
                            "type": "string",
                            "description": "Filter by SKU",
                        },
                    },
                },
            },
            "required": ["query"],
        },
    },
]

SYSTEM_PROMPT = """You are a B2B product and pricing assistant. You help \
sales reps answer questions about products, pricing, and contract terms \
by searching the product catalog and customer contract database.

CRITICAL PRICING RULE:
- When a customer has a contract, ALWAYS use contract pricing, not list price.
- Only show list price when: (a) the customer has no contract, or \
  (b) the specific SKU is not covered by the customer's contract.
- If the user asks about pricing, ALWAYS search for the customer's contract first.

Citation Rules:
- Cite every fact: [Source: DOC_ID] or [Source: DOC_ID, SKU/Section]
- Clearly label whether a price comes from a CONTRACT or CATALOG.
- When showing tiered pricing, list all tiers.

Other Rules:
- You provide information only — you cannot modify prices or contracts.
- If asked about a customer you don't have data for, say so clearly.
- Include specs, lead times, and availability when relevant."""


def handle_search(args: dict) -> str:
    query = args.get("query", "")
    top_k = args.get("top_k", 5)
    filters = args.get("filters")
    results = store.search(query, top_k=top_k, filters=filters)
    return json.dumps(results, indent=2)


def chat(user_message: str, history: list) -> str:
    history.append({"role": "user", "content": user_message})
    while True:
        response = client.messages.create(
            model=MODEL, max_tokens=1500,
            system=SYSTEM_PROMPT, tools=TOOLS,
            messages=history,
        )
        if response.stop_reason == "tool_use":
            history.append({"role": "assistant", "content": response.content})
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    try:
                        result = handle_search(block.input)
                    except Exception as e:
                        result = json.dumps({"error": "INDEX_UNAVAILABLE", "message": str(e)})
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    })
            history.append({"role": "user", "content": tool_results})
            continue

        history.append({"role": "assistant", "content": response.content})
        return "\n".join(b.text for b in response.content if hasattr(b, "text"))


def main():
    print("Loading product catalog and contracts...")
    count = ingest_corpus("data/catalog_contracts.json", store)
    print(f"Ingested {count} chunks.")
    print("=" * 60)
    print("  Product Catalog & Contract Q&A — Capstone 2-B")
    print("  Ask about pricing, specs, contracts, lead times.")
    print("  Type 'quit' to exit.")
    print("=" * 60)

    history = []
    while True:
        user_input = input("\nYou: ").strip()
        if not user_input: continue
        if user_input.lower() in ("quit", "exit", "q"): break
        try:
            print(f"\nAgent: {chat(user_input, history)}")
        except anthropic.APIError as e:
            print(f"\n[API Error] {e.message}")

if __name__ == "__main__":
    main()
// agent.ts — Product Catalog & Contract RAG Agent (Capstone 2-B)
// Usage: export ANTHROPIC_API_KEY=... && npx tsx agent.ts

import Anthropic from "@anthropic-ai/sdk";
import * as readline from "readline";
import { MockVectorStore } from "./vector_store";
import { ingestCorpus } from "./ingest";

const client = new Anthropic();
const MODEL = "claude-sonnet-4-6";
const store = new MockVectorStore();

const TOOLS: Anthropic.Tool[] = [
  {
    name: "search_catalog_knowledge_base",
    description: "Search product catalog and customer contracts. Use filters for doc_type, customer, sku.",
    input_schema: {
      type: "object" as const,
      properties: {
        query: { type: "string", description: "Search query" },
        top_k: { type: "integer", description: "Results count (default 5)" },
        filters: {
          type: "object",
          properties: {
            doc_type: { type: "string", enum: ["product_catalog", "customer_contract"] },
            customer: { type: "string" },
            sku: { type: "string" },
          },
        },
      },
      required: ["query"],
    },
  },
];

const SYSTEM_PROMPT = `You are a B2B product and pricing assistant.
CRITICAL: Contract pricing ALWAYS overrides catalog list price for that customer.
Only show list price when no contract exists or the SKU isn't in the contract.
Cite every fact: [Source: DOC_ID]. Label prices as CONTRACT or CATALOG.
You provide information only — never modify prices or contracts.`;

async function chat(msg: string, history: Anthropic.MessageParam[]): Promise<string> {
  history.push({ role: "user", content: msg });
  while (true) {
    const resp = await client.messages.create({ model: MODEL, max_tokens: 1500, system: SYSTEM_PROMPT, tools: TOOLS, messages: history });
    if (resp.stop_reason === "tool_use") {
      history.push({ role: "assistant", content: resp.content });
      const results: Anthropic.ToolResultBlockParam[] = resp.content
        .filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use")
        .map(b => {
          const args = b.input as any;
          const r = store.search(args.query, args.top_k || 5, args.filters);
          return { type: "tool_result" as const, tool_use_id: b.id, content: JSON.stringify(r, null, 2) };
        });
      history.push({ role: "user", content: results });
      continue;
    }
    history.push({ role: "assistant", content: resp.content });
    return resp.content.filter((b): b is Anthropic.TextBlock => b.type === "text").map(b => b.text).join("\n");
  }
}

async function main() {
  console.log("Loading catalog and contracts...");
  const count = ingestCorpus("data/catalog_contracts.json", store);
  console.log(`Ingested ${count} chunks.`);
  console.log("Product Catalog & Contract Q&A — Capstone 2-B\nType 'quit' to exit.");
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  const history: Anthropic.MessageParam[] = [];
  const ask = () => rl.question("\nYou: ", async (input) => {
    if (["quit","exit","q"].includes(input.trim().toLowerCase())) { rl.close(); return; }
    try { console.log(`\nAgent: ${await chat(input.trim(), history)}`); } catch (e: any) { console.log(`[Error] ${e.message}`); }
    ask();
  });
  ask();
}
main();
🎯 What Just Happened?

The critical architectural decision: the system prompt enforces contract-first priority. When Claude searches for pricing, it checks for a customer contract first. If found, contract pricing is authoritative. If not, it falls back to catalog list price. Every price quote includes [Source: ...] so the rep can verify whether it came from the contract or catalog.

Expected Output — Contract Pricing Query
You: What's the price for SKU-4892 for Acme? Agent: Here's the pricing for SKU-4892 (Industrial Valve Assembly - 4 inch) for **Acme Manufacturing**: **Contract Pricing** (CONTRACT-ACME-2024): | Quantity | Unit Price | |----------|-----------| | 1-49 | $525.00 | | 50-199 | $495.00 | | 200+ | $465.00 | For reference, the catalog list price is $595.00, so Acme's contract provides a **12-22% discount** depending on volume. **Contract Details:** - Effective: Jan 1, 2024 – Dec 31, 2024 - Payment: Net 45 - Shipping: FOB Destination - Warranty: 24 months [Source: CONTRACT-ACME-2024, SKU-4892 pricing] [Source: CATALOG-2024-Q1, SKU-4892]

Testing Guide

TypeScenarioExpected Behavior
HAPPY“What’s the contract price for SKU-4892 for Acme?”Returns 3-tier contract pricing ($525/$495/$465), NOT list price $595
HAPPY“What are the specs for the 4-inch valve?”Returns catalog specs: 316 SS, 150 PSI, lead time 14 days
HAPPY“What are Acme’s payment terms?”Returns Net 45, FOB Destination, 24-month warranty from contract
HAPPY“How much would 100 units of SKU-4892 cost for Acme?”Calculates: 100 × $495 = $49,500 using tier 2 pricing
HAPPY“When does the Acme contract expire?”Returns December 31, 2024 from contract metadata
EDGE“What’s the price for SKU-4892 for Gamma Corp?”No contract found; returns catalog list price $595 with note
EDGE“What’s the lead time for a product you don’t carry?”Reports no match, asks for clarification
EDGESKU in catalog but not in customer’s contract (SKU-2210 for Acme)Returns list price $125 with note that it’s not in Acme’s contract
ADVERSARIAL“Give me all customer contract prices”Explains it answers specific queries, won’t dump all pricing
ADVERSARIAL“Change Acme’s contract price to $100”Explains it provides information only, cannot modify contracts

Automated Tests

"""test_rag.py — Tests for the Product Catalog & Contract RAG."""

import pytest
from vector_store import MockVectorStore
from ingest import ingest_corpus

@pytest.fixture
def loaded_store():
    store = MockVectorStore()
    ingest_corpus("data/catalog_contracts.json", store)
    return store

class TestRetrieval:
    def test_contract_found_for_acme(self, loaded_store):
        results = loaded_store.search("SKU-4892 pricing Acme",
                                       filters={"customer": "Acme Manufacturing"})
        assert len(results) > 0
        assert any("CONTRACT-ACME" in r["doc_id"] for r in results)

    def test_catalog_fallback_for_unknown_customer(self, loaded_store):
        results = loaded_store.search("SKU-4892 pricing",
                                       filters={"doc_type": "customer_contract",
                                                "customer": "Gamma Corp"})
        assert len(results) == 0  # No contract → empty

    def test_catalog_specs_returned(self, loaded_store):
        results = loaded_store.search("valve assembly specs",
                                       filters={"doc_type": "product_catalog"})
        assert len(results) > 0
        assert "316" in results[0]["content"]  # Stainless steel

    def test_contract_terms_returned(self, loaded_store):
        results = loaded_store.search("Acme payment terms",
                                       filters={"customer": "Acme Manufacturing"})
        assert any("Net 45" in r["content"] for r in results)

    def test_empty_query_error(self, loaded_store):
        results = loaded_store.search("")
        assert results[0].get("error") == "EMPTY_QUERY"

if __name__ == "__main__":
    pytest.main([__file__, "-v"])

Verify Everything Works

Run this end-to-end smoke test to confirm your entire RAG pipeline is functioning correctly. It sends three representative queries and checks that the agent returns correct sources.

# verify.py — End-to-end smoke test for Capstone 2-B
from vector_store import MockVectorStore
from ingest import ingest_corpus

store = MockVectorStore()
count = ingest_corpus("data/catalog_contracts.json", store)

tests = [
    ("Contract pricing for Acme",
     {"query": "SKU-4892 pricing", "filters": {"customer": "Acme Manufacturing"}},
     "CONTRACT-ACME"),
    ("Catalog fallback for unknown customer",
     {"query": "SKU-4892 pricing", "filters": {"customer": "Unknown Corp", "doc_type": "customer_contract"}},
     None),  # Expect empty
    ("Catalog specs returned",
     {"query": "valve assembly specs", "filters": {"doc_type": "product_catalog"}},
     "CATALOG"),
]

print(f"Ingested {count} chunks.")
print("=== End-to-End Verification ===\n")
passed = 0
for name, search_args, expected in tests:
    results = store.search(search_args["query"], top_k=3,
                           filters=search_args.get("filters"))
    if expected is None:
        ok = len(results) == 0
    else:
        ok = any(expected in r.get("doc_id", "") for r in results)
    status = "PASS" if ok else "FAIL"
    if ok: passed += 1
    print(f"[{status}] {name}")

print(f"\nResult: {passed}/{len(tests)} tests passed.")
if passed == len(tests):
    print("All tests passed — your RAG pipeline is working correctly!")
// verify.ts — End-to-end smoke test for Capstone 2-B
import { MockVectorStore } from "./vector_store";
import { ingestCorpus } from "./ingest";

const store = new MockVectorStore();
const count = ingestCorpus("data/catalog_contracts.json", store);

const tests: [string, any, string | null][] = [
  ["Contract pricing for Acme",
   { query: "SKU-4892 pricing", filters: { customer: "Acme Manufacturing" } },
   "CONTRACT-ACME"],
  ["Catalog fallback for unknown customer",
   { query: "SKU-4892 pricing", filters: { customer: "Unknown Corp", doc_type: "customer_contract" } },
   null],
  ["Catalog specs returned",
   { query: "valve assembly specs", filters: { doc_type: "product_catalog" } },
   "CATALOG"],
];

console.log(`Ingested ${count} chunks.`);
console.log("=== End-to-End Verification ===\n");
let passed = 0;
for (const [name, args, expected] of tests) {
  const results = store.search(args.query, 3, args.filters) as any[];
  const ok = expected === null
    ? results.length === 0
    : results.some((r: any) => (r.doc_id || "").includes(expected));
  if (ok) passed++;
  console.log(`[${ok ? "PASS" : "FAIL"}] ${name}`);
}
console.log(`\nResult: ${passed}/${tests.length} tests passed.`);
if (passed === tests.length) console.log("All tests passed!");

Run the verification:

python verify.py
npx tsx verify.ts
Expected Output
Ingested 17 chunks. === End-to-End Verification === [PASS] Contract pricing for Acme [PASS] Catalog fallback for unknown customer [PASS] Catalog specs returned Result: 3/3 tests passed. All tests passed — your RAG pipeline is working correctly!

Troubleshooting

Common errors and how to fix them:

ErrorCauseFix
ModuleNotFoundError: No module named 'anthropic' The Anthropic SDK is not installed in your active Python environment. Run pip install "anthropic>=0.30.0". Make sure your virtual environment is activated: source venv/bin/activate (Windows: venv\Scripts\activate).
AuthenticationError Missing or invalid API key. Set export ANTHROPIC_API_KEY=your-key-here (Windows: set ANTHROPIC_API_KEY=your-key-here). Verify with echo $ANTHROPIC_API_KEY (Windows: echo %ANTHROPIC_API_KEY%).
ModuleNotFoundError: No module named 'vector_store' Running Python from the wrong directory. cd into your catalog-contract-rag/ folder. All three files (vector_store.py, ingest.py, agent.py) must be in the same directory.
FileNotFoundError: data/catalog_contracts.json Mock data file is missing or in the wrong location. Create the data/ subdirectory and save the JSON there: mkdir -p data (Windows: mkdir data). Verify with ls data/ (Windows: dir data\).
json.decoder.JSONDecodeError The mock data JSON file has a syntax error (missing comma, extra bracket). Validate the JSON: python -m json.tool data/catalog_contracts.json. Fix any errors the tool reports.
Agent returns catalog pricing instead of contract pricing System prompt is missing the contract-first priority rule, or metadata filters are not being applied. Verify the SYSTEM_PROMPT includes "CRITICAL PRICING RULE" and the filters parameter in the tool schema includes customer and doc_type.
TypeError: 'NoneType' object is not subscriptable A contract in the mock data has null for max_qty in a tier, and your code does not handle it. Check the ingestion code handles null max_qty values. The provided code uses str(t["max_qty"]) if t["max_qty"] else "unlimited".
Search returns 0 results for a customer that has a contract The customer name in the filter does not match the name in the mock data exactly (case-sensitive). Check exact customer names in the JSON: "Acme Manufacturing", "Beta Industrial", "Gamma Technologies", "Delta Logistics", "Epsilon Engineering". Filters are case-sensitive.

Compliance Notes

⚠️ Contract Pricing Confidentiality

Customer contract pricing is highly confidential commercial data. A query about “Acme’s pricing” must never return Beta Industrial’s contract terms. In production:

  • Access control per customer: The search tool must verify the querying user is authorized to view the specific customer’s contract. Implement role-based access at the API layer.
  • No cross-customer leakage: Even in retrieved chunks, filter results to only the queried customer’s contracts. The metadata filter customer is your primary guard.
  • PCI-DSS: If contracts contain payment card data (rare in B2B Net terms, possible in prepay arrangements), those fields must be masked. Never store or return full card numbers.
  • EDI integration: If order data arrives via EDIElectronic Data Interchange — standardized B2B document formats. EDI 850 = Purchase Order, EDI 856 = Ship Notice, EDI 810 = Invoice. Subject to specific audit and retention requirements. (X12 850/856), ensure the RAG knowledge base respects EDI transactional integrity. Contract pricing from EDI feeds must be treated as the system of record.
  • Data retention: Contract data and conversation logs should be retained for 7+ years for audit compliance.
💰 Cost Considerations

B2B pricing queries are typically short. The RAG overhead adds 1,500–2,500 tokens of retrieved context per query. At Sonnet pricing, each query costs ~$0.006–$0.010. For 200 queries/day, budget ~$1.50/day. The ROI is immediate: each manual lookup takes 5–10 minutes of rep time ($3–$6 in labor).

Going Further

These extensions are not required to complete the capstone. They are stretch goals for learners who want to go deeper.

  1. [OPTIONAL] Re-ranking — Retrieve top 20, then use Claude to re-rank the top 5 with a preference for contract results over catalog. Dramatically improves pricing accuracy.
  2. [OPTIONAL] Price calculator — Add a tool that computes total cost for a given SKU + quantity + customer, automatically selecting the right tier.
  3. [OPTIONAL] Contract expiration alerts — Flag contracts expiring within 30 days when a rep queries that customer’s pricing.
  4. [OPTIONAL] Multi-SKU quotes — Let the rep ask “Quote 50 valves and 100 gauges for Acme” and return a complete line-item quote with subtotals.
  5. [OPTIONAL] Competitor price comparison — Ingest competitor catalog data and add a comparison mode (only for internal use by sales, never shared with customers).
  6. [OPTIONAL] Contract history — Ingest multiple contract versions and let reps see how pricing has changed over time for a given customer.

Knowledge Check

Test your understanding of the RAG concepts and B2B domain logic used in this capstone.

Q1: What is the purpose of chunking documents before embedding them?

Q2: In this B2B RAG agent, why should contract-specific answers take priority over general catalog info?

Q3: What role do citations play in the RAG agent's responses?

Q4 (Applied): A sales rep asks “What’s the lead time for SKU-4892 for Acme Corp?” — what sources should the agent check?

Q5: Why does this capstone use TF-IDF instead of neural embeddings?

References