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
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.
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
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_basetool. - 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
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:
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
Architecture
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
}
}
]
}
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.
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')"
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\"]}')
"
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.
- SyntaxError — Make sure you copied the entire
vector_store.pyfile, including the imports at the top. - ModuleNotFoundError: No module named 'vector_store' — You are running Python from the wrong directory.
cdinto yourcatalog-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]}...')
"
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
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.
- AuthenticationError — Your
ANTHROPIC_API_KEYis missing or invalid. Runecho $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, andagent.pyare 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
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
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);
}
}
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
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();
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.
Testing Guide
| Type | Scenario | Expected 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 |
| EDGE | SKU 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
Troubleshooting
Common errors and how to fix them:
| Error | Cause | Fix |
|---|---|---|
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
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
customeris 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.
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.
- [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.
- [OPTIONAL] Price calculator — Add a tool that computes total cost for a given SKU + quantity + customer, automatically selecting the right tier.
- [OPTIONAL] Contract expiration alerts — Flag contracts expiring within 30 days when a rep queries that customer’s pricing.
- [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.
- [OPTIONAL] Competitor price comparison — Ingest competitor catalog data and add a comparison mode (only for internal use by sales, never shared with customers).
- [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?