diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000000..a38712f463 --- /dev/null +++ b/samples/README.md @@ -0,0 +1,11 @@ +# Dozer samples + +Sample applications showing how to use Dozer together with other tools. + +## LLM vector LangChain sample + +The [`llm-vector-langchain`](llm-vector-langchain/README.md) sample implements +the use case from the [Dozer + LLM article](https://getdozer.io/blog/llm-chatbot): +Dozer sources customer profiles, transactions, and credit-card products, the +data is exposed through the Dozer API, and a LangChain application indexes it in +Chroma to answer personalized credit-card recommendation questions. \ No newline at end of file diff --git a/samples/llm-vector-langchain/README.md b/samples/llm-vector-langchain/README.md new file mode 100644 index 0000000000..4b4d49308d --- /dev/null +++ b/samples/llm-vector-langchain/README.md @@ -0,0 +1,99 @@ +# Dozer + LangChain credit-card recommendation sample + +This sample is a working implementation of the use case described in the +[Dozer + LLM chatbot article](https://getdozer.io/blog/llm-chatbot): a bank +builds an LLM-powered assistant that uses Dozer to assemble a unified customer +picture (profile, transactions, products) and passes it to an LLM as context to +hyper-personalize credit-card recommendations. + +It shows three pieces working together: + +- **Dozer** is configured to source from three datasets at once: customer + profiles, transactions, and credit-card products. +- The datasets are exposed through the Dozer API. +- A **LangChain** application reads the Dozer API, indexes the data in a + **vector database** (Chroma), and answers a personalized product + recommendation question with an LLM. + +## Repository layout + +``` +llm-vector-langchain/ +├── dozer-config.yaml # Dozer app configuration +├── data/ +│ ├── customer_profiles/ # customer_profiles.csv +│ ├── transactions/ # transactions.csv +│ └── credit_card_products/ # credit_card_products.csv +├── app.py # LangChain entry point +├── dozer_client.py # reads datasets from the Dozer API +├── recommender.py # context building + offline ranking helpers +├── requirements.txt +└── tests/ + ├── test_recommender.py # offline tests for recommender.py + └── test_dozer_config.py # checks dozer-config.yaml + CSV data +``` + +## How it works + +`dozer-config.yaml` declares a single `LocalStorage` connection that reads three +CSV datasets, three sources that map those tables into Dozer, and a `Dummy` +sink for each one so every dataset is materialized and available through the +Dozer API: + +| Endpoint | Contents | +| -------------------------- | --------------------------------------------------- | +| `/customer_profiles` | customer segment, income band, and stated goals | +| `/transactions` | per-customer transaction history | +| `/credit_card_products` | available card products and eligibility metadata | + +`app.py` fetches the three datasets from the Dozer API, builds one document per +customer profile and one per product, indexes them in Chroma, and answers a +question such as *"Which credit card should this customer be offered and why?"*. + +If `OPENAI_API_KEY` is set, embeddings and the final answer use OpenAI through +LangChain. Without a key the sample uses a deterministic local embedding and a +local answer, so it stays runnable offline and in CI. + +If the Dozer API is not reachable, the application falls back to reading the +same CSV files under `data/`, so the recommendation flow can be exercised even +without a running Dozer instance. + +## Run the sample + +Prerequisites: a Rust toolchain for Dozer and Python 3.10+ for the application. + +Start Dozer from this directory: + +```bash +dozer run +``` + +In another terminal, install dependencies and run the app: + +```bash +python -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +python app.py --customer-id cust_001 +``` + +By default the app reads the Dozer API at `http://localhost:8080`. Point it at a +different host with `DOZER_BASE_URL`: + +```bash +DOZER_BASE_URL=http://localhost:8080 python app.py --customer-id cust_003 +``` + +Ask for a different customer or a different question: + +```bash +python app.py --customer-id cust_004 --question "Which card fits a frequent traveler?" +``` + +## Run the tests + +The tests do not need Dozer, a vector database, or an LLM: + +```bash +python -m unittest discover -s tests +``` \ No newline at end of file diff --git a/samples/llm-vector-langchain/app.py b/samples/llm-vector-langchain/app.py new file mode 100644 index 0000000000..b4bee26aa3 --- /dev/null +++ b/samples/llm-vector-langchain/app.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import argparse +import hashlib +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from langchain_chroma import Chroma +from langchain_core.documents import Document +from langchain_core.prompts import ChatPromptTemplate +from langchain_openai import ChatOpenAI, OpenAIEmbeddings +from langchain_text_splitters import RecursiveCharacterTextSplitter + +from dozer_client import DEFAULT_BASE_URL, load_datasets +from recommender import build_customer_context, build_product_context, rank_products + + +@dataclass(frozen=True) +class RetrievalBundle: + documents: list[Document] + customer_context: str + ranked_products: list[dict[str, Any]] + + +class HashEmbedding: + """Deterministic local embedding used when no OpenAI key is configured. + + Good enough for the sample's retrieval flow and keeps the sample runnable + offline and in CI. + """ + + def __init__(self, dimensions: int = 64) -> None: + self.dimensions = dimensions + + def embed_documents(self, texts: list[str]) -> list[list[float]]: + return [self._embed(text) for text in texts] + + def embed_query(self, text: str) -> list[float]: + return self._embed(text) + + def _embed(self, text: str) -> list[float]: + vector = [0.0] * self.dimensions + for token in text.lower().split(): + digest = hashlib.sha256(token.encode("utf-8")).digest() + index = int.from_bytes(digest[:4], byteorder="big") % self.dimensions + vector[index] += 1.0 + magnitude = sum(value * value for value in vector) ** 0.5 or 1.0 + return [value / magnitude for value in vector] + + +def load_bundle(datasets: dict[str, list[dict[str, Any]]], customer_id: str) -> RetrievalBundle: + customers = datasets["customer_profiles"] + transactions = datasets["transactions"] + products = datasets["credit_card_products"] + + customer = next( + (row for row in customers if row.get("customer_id") == customer_id), + None, + ) + if customer is None: + raise ValueError(f"Customer {customer_id!r} was not found in the datasets") + + customer_transactions = [ + row for row in transactions if row.get("customer_id") == customer_id + ] + customer_context = build_customer_context(customer, customer_transactions) + documents = [ + Document( + page_content=customer_context, + metadata={"kind": "customer", "customer_id": customer_id}, + ) + ] + documents.extend( + Document( + page_content=build_product_context(product), + metadata={"kind": "product", "product_id": product["product_id"]}, + ) + for product in products + ) + + return RetrievalBundle( + documents=documents, + customer_context=customer_context, + ranked_products=rank_products(customer, customer_transactions, products), + ) + + +def build_vector_store(documents: list[Document]) -> Chroma: + splitter = RecursiveCharacterTextSplitter(chunk_size=700, chunk_overlap=80) + chunks = splitter.split_documents(documents) + embeddings = ( + OpenAIEmbeddings() + if os.getenv("OPENAI_API_KEY") + else HashEmbedding() + ) + return Chroma.from_documents(chunks, embedding=embeddings) + + +def answer(bundle: RetrievalBundle, question: str) -> str: + vector_store = build_vector_store(bundle.documents) + retrieved = vector_store.as_retriever(search_kwargs={"k": 4}).invoke(question) + retrieved_context = "\n\n".join(doc.page_content for doc in retrieved) + ranked_context = "\n".join( + f"- {product['name']}: score={product['score']}, reason={product['reason']}" + for product in bundle.ranked_products + ) + + if not os.getenv("OPENAI_API_KEY"): + best = bundle.ranked_products[0] + return ( + f"Recommended card: {best['name']}.\n" + f"Reason: {best['reason']}.\n\n" + f"Retrieved context:\n{retrieved_context}" + ) + + prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + "You recommend credit-card products using only the supplied " + "Dozer context. Mention the card, why it fits, and one caveat.", + ), + ( + "human", + "Customer context:\n{customer_context}\n\n" + "Ranked products:\n{ranked_context}\n\n" + "Retrieved context:\n{retrieved_context}\n\n" + "Question: {question}", + ), + ] + ) + model = ChatOpenAI(model="gpt-4o-mini", temperature=0) + message = (prompt | model).invoke( + { + "customer_context": bundle.customer_context, + "ranked_context": ranked_context, + "retrieved_context": retrieved_context, + "question": question, + } + ) + return str(message.content) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Personalized credit-card recommendation using Dozer + LangChain." + ) + parser.add_argument("--customer-id", default="cust_001") + parser.add_argument( + "--question", + default="Which credit card should this customer be offered and why?", + ) + parser.add_argument( + "--base-url", + default=os.getenv("DOZER_BASE_URL", DEFAULT_BASE_URL), + help="Base URL of the Dozer REST API.", + ) + parser.add_argument( + "--data-dir", + default=Path(__file__).resolve().parent / "data", + type=Path, + help="Local CSV datasets used when the Dozer API is not reachable.", + ) + args = parser.parse_args() + + datasets = load_datasets(args.base_url, args.data_dir) + bundle = load_bundle(datasets, args.customer_id) + print(answer(bundle, args.question)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/samples/llm-vector-langchain/data/credit_card_products/credit_card_products.csv b/samples/llm-vector-langchain/data/credit_card_products/credit_card_products.csv new file mode 100644 index 0000000000..f87deab281 --- /dev/null +++ b/samples/llm-vector-langchain/data/credit_card_products/credit_card_products.csv @@ -0,0 +1,5 @@ +product_id,name,annual_fee_usd,rewards_focus,intro_offer,eligibility,caveat +card_001,Aurora Travel Signature,395,"travel dining lounge","75000 bonus points after qualifying spend","premium income and excellent credit","High annual fee only pays off for frequent travelers" +card_002,Everyday Cashback Plus,0,"groceries streaming cashback","$200 statement credit after qualifying spend","starter or mass market income","Lower earn rate on travel purchases" +card_003,Family Balance Saver,95,"groceries utilities balance transfer","0% intro APR for 15 months","mass affluent income and good credit","Balance transfer fee applies" +card_004,Nomad Rewards Flex,150,"travel dining subscriptions","50000 bonus points after qualifying spend","premium income and good credit","Subscription credits require activation" \ No newline at end of file diff --git a/samples/llm-vector-langchain/data/customer_profiles/customer_profiles.csv b/samples/llm-vector-langchain/data/customer_profiles/customer_profiles.csv new file mode 100644 index 0000000000..421334699e --- /dev/null +++ b/samples/llm-vector-langchain/data/customer_profiles/customer_profiles.csv @@ -0,0 +1,5 @@ +customer_id,age,income_band,segment,goals,risk_tolerance +cust_001,34,premium,frequent_traveler,"travel rewards, lounge access, low foreign transaction fees",medium +cust_002,27,starter,young_professional,"cashback on everyday spend, no annual fee",low +cust_003,42,mass_affluent,family_planner,"groceries, utilities, balance transfer",low +cust_004,31,premium,digital_nomad,"travel rewards, dining, subscription credits",medium \ No newline at end of file diff --git a/samples/llm-vector-langchain/data/transactions/transactions.csv b/samples/llm-vector-langchain/data/transactions/transactions.csv new file mode 100644 index 0000000000..8d43e7889e --- /dev/null +++ b/samples/llm-vector-langchain/data/transactions/transactions.csv @@ -0,0 +1,13 @@ +transaction_id,customer_id,merchant_category,amount_usd,transaction_date +txn_001,cust_001,travel,820.50,2026-01-12 +txn_002,cust_001,dining,188.10,2026-01-15 +txn_003,cust_001,travel,1320.00,2026-02-03 +txn_004,cust_002,groceries,122.23,2026-01-17 +txn_005,cust_002,streaming,18.99,2026-01-19 +txn_006,cust_002,groceries,98.11,2026-02-02 +txn_007,cust_003,groceries,240.42,2026-01-07 +txn_008,cust_003,utilities,210.00,2026-01-10 +txn_009,cust_003,education,510.00,2026-02-05 +txn_010,cust_004,dining,305.45,2026-01-21 +txn_011,cust_004,travel,940.00,2026-02-08 +txn_012,cust_004,subscriptions,64.99,2026-02-11 \ No newline at end of file diff --git a/samples/llm-vector-langchain/dozer-config.yaml b/samples/llm-vector-langchain/dozer-config.yaml new file mode 100644 index 0000000000..0bda0597be --- /dev/null +++ b/samples/llm-vector-langchain/dozer-config.yaml @@ -0,0 +1,57 @@ +app_name: llm-vector-langchain-sample +version: 1 + +api: + rest: + port: 8080 + host: "0.0.0.0" + cors: true + grpc: + port: 50051 + host: "0.0.0.0" + cors: true + web: true + +connections: + - name: local_customer_data + config: !LocalStorage + details: + path: ./data + tables: + - !Table + name: customer_profiles + config: !CSV + path: customer_profiles + extension: .csv + - !Table + name: transactions + config: !CSV + path: transactions + extension: .csv + - !Table + name: credit_card_products + config: !CSV + path: credit_card_products + extension: .csv + +sources: + - name: customer_profiles + table_name: customer_profiles + connection: local_customer_data + - name: transactions + table_name: transactions + connection: local_customer_data + - name: credit_card_products + table_name: credit_card_products + connection: local_customer_data + +sinks: + - name: customer_profiles + config: !Dummy + table_name: customer_profiles + - name: transactions + config: !Dummy + table_name: transactions + - name: credit_card_products + config: !Dummy + table_name: credit_card_products \ No newline at end of file diff --git a/samples/llm-vector-langchain/dozer_client.py b/samples/llm-vector-langchain/dozer_client.py new file mode 100644 index 0000000000..62fc238d06 --- /dev/null +++ b/samples/llm-vector-langchain/dozer_client.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import csv +from pathlib import Path +from typing import Any + +import requests + +DEFAULT_BASE_URL = "http://localhost:8080" + +DATASETS = ("customer_profiles", "transactions", "credit_card_products") + + +class DozerClient: + """Reads records from the REST endpoints exposed by a running Dozer app.""" + + def __init__(self, base_url: str = DEFAULT_BASE_URL) -> None: + self.base_url = base_url.rstrip("/") + + def fetch_endpoint(self, endpoint: str) -> list[dict[str, Any]]: + url = f"{self.base_url}/{endpoint.lstrip('/')}" + response = requests.get(url, timeout=15) + response.raise_for_status() + payload = response.json() + if isinstance(payload, list): + return [dict(row) for row in payload] + if isinstance(payload, dict): + for key in ("records", "data", "items"): + value = payload.get(key) + if isinstance(value, list): + return [dict(row) for row in value] + raise ValueError(f"Unexpected Dozer response shape from {url}: {payload!r}") + + +def load_csv_datasets(data_dir: Path | str) -> dict[str, list[dict[str, Any]]]: + """Read the sample CSV datasets that Dozer is configured to source from.""" + data_dir = Path(data_dir) + datasets: dict[str, list[dict[str, Any]]] = {} + for dataset in DATASETS: + path = data_dir / dataset / f"{dataset}.csv" + with path.open(newline="", encoding="utf-8") as handle: + datasets[dataset] = [dict(row) for row in csv.DictReader(handle)] + return datasets + + +def load_datasets(base_url: str, data_dir: Path) -> dict[str, list[dict[str, Any]]]: + """Fetch datasets from Dozer, falling back to the local CSV files.""" + try: + client = DozerClient(base_url) + return { + dataset: client.fetch_endpoint(dataset) for dataset in DATASETS + } + except requests.RequestException: + return load_csv_datasets(data_dir) \ No newline at end of file diff --git a/samples/llm-vector-langchain/recommender.py b/samples/llm-vector-langchain/recommender.py new file mode 100644 index 0000000000..40e2048760 --- /dev/null +++ b/samples/llm-vector-langchain/recommender.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from collections import Counter +from typing import Any + +# Goal keywords that can be matched against both a customer's goals and a +# product's rewards focus. +GOAL_KEYWORDS = ("travel", "cashback", "groceries", "balance transfer") + + +def build_customer_context( + customer: dict[str, Any], + transactions: list[dict[str, Any]], +) -> str: + """Turn a customer profile and their transactions into a single context string.""" + total_spend = sum(float(row["amount_usd"]) for row in transactions) + categories = Counter(row["merchant_category"] for row in transactions) + top_categories = ", ".join( + f"{category} ({count})" for category, count in categories.most_common(3) + ) + return ( + f"Customer {customer['customer_id']} is a {customer['segment']} with " + f"income band {customer['income_band']}. Goals: {customer['goals']}. " + f"Risk tolerance: {customer['risk_tolerance']}. Recent spend: " + f"${total_spend:.2f} across {len(transactions)} transactions. " + f"Top merchant categories: {top_categories}." + ) + + +def build_product_context(product: dict[str, Any]) -> str: + """Turn a credit card product row into a context string.""" + return ( + f"Card {product['name']} charges an annual fee of " + f"${float(product['annual_fee_usd']):.0f}. Rewards focus: " + f"{product['rewards_focus']}. Intro offer: {product['intro_offer']}. " + f"Eligibility: {product['eligibility']}. Caveat: {product['caveat']}." + ) + + +def rank_products( + customer: dict[str, Any], + transactions: list[dict[str, Any]], + products: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Score every product against the customer and return them sorted best first.""" + spend_by_category = Counter( + row["merchant_category"].lower() for row in transactions + ) + goals = str(customer.get("goals", "")).lower() + income_band = str(customer.get("income_band", "")).lower() + + ranked: list[dict[str, Any]] = [] + for product in products: + focus = str(product.get("rewards_focus", "")).lower() + eligibility = str(product.get("eligibility", "")).lower() + fee = float(product.get("annual_fee_usd", 0) or 0) + + score = 0.0 + reasons: list[str] = [] + + # Reward categories that match the customer's actual spending habits. + for category, count in spend_by_category.items(): + if category in focus: + score += count * 3 + reasons.append(f"rewards match {category} spend") + + # Stated goals that the product directly supports. + for goal in GOAL_KEYWORDS: + if goal in goals and goal in focus: + score += 4 + reasons.append(f"supports {goal} goal") + + # Income band fit with the annual fee. + if "premium" in income_band: + if fee > 0: + score += 1 + reasons.append("premium income can absorb the annual fee") + elif fee == 0: + score += 2 + reasons.append("no annual fee fits a starter income band") + + # Eligibility requirements that are likely out of reach. + if "excellent credit" in eligibility and income_band in ("starter", "mass market"): + score -= 2 + reasons.append("eligibility requirements may be out of reach") + + ranked.append( + { + **product, + "score": score, + "reason": "; ".join(reasons) if reasons else "general fit", + } + ) + + return sorted(ranked, key=lambda row: row["score"], reverse=True) \ No newline at end of file diff --git a/samples/llm-vector-langchain/requirements.txt b/samples/llm-vector-langchain/requirements.txt new file mode 100644 index 0000000000..39cbcbdaf9 --- /dev/null +++ b/samples/llm-vector-langchain/requirements.txt @@ -0,0 +1,7 @@ +chromadb>=0.5.0 +langchain>=0.3.0 +langchain-chroma>=0.1.4 +langchain-openai>=0.2.0 +langchain-text-splitters>=0.3.0 +requests>=2.32.0 +PyYAML>=6.0.1 \ No newline at end of file diff --git a/samples/llm-vector-langchain/tests/test_dozer_config.py b/samples/llm-vector-langchain/tests/test_dozer_config.py new file mode 100644 index 0000000000..4e091bfd75 --- /dev/null +++ b/samples/llm-vector-langchain/tests/test_dozer_config.py @@ -0,0 +1,78 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import yaml # noqa: E402 + +from dozer_client import load_csv_datasets # noqa: E402 + +SAMPLE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CONFIG_PATH = os.path.join(SAMPLE_ROOT, "dozer-config.yaml") +DATA_DIR = os.path.join(SAMPLE_ROOT, "data") + + +class TagIgnoreLoader(yaml.SafeLoader): + """SafeLoader that treats serde_yaml tags like `!LocalStorage` as plain mappings.""" + + +def _construct_untagged(loader: yaml.SafeLoader, _suffix: str, node: yaml.Node): + return loader.construct_mapping(node, deep=True) + + +TagIgnoreLoader.add_multi_constructor("!", _construct_untagged) + + +def load_config() -> dict: + with open(CONFIG_PATH, encoding="utf-8") as handle: + return yaml.load(handle, Loader=TagIgnoreLoader) + + +class DozerConfigTest(unittest.TestCase): + def test_config_parses(self) -> None: + config = load_config() + self.assertEqual(config["app_name"], "llm-vector-langchain-sample") + self.assertEqual(config["version"], 1) + + def test_config_sources_three_datasets(self) -> None: + config = load_config() + sources = {source["name"] for source in config["sources"]} + self.assertEqual( + sources, + {"customer_profiles", "transactions", "credit_card_products"}, + ) + + def test_config_has_sink_for_every_source(self) -> None: + config = load_config() + sinks = {sink["name"] for sink in config["sinks"]} + self.assertEqual( + sinks, + {"customer_profiles", "transactions", "credit_card_products"}, + ) + + def test_connection_uses_local_storage_with_csv_tables(self) -> None: + config = load_config() + self.assertEqual(len(config["connections"]), 1) + connection = config["connections"][0] + self.assertEqual(connection["name"], "local_customer_data") + self.assertIn("details", connection["config"]) + self.assertEqual(connection["config"]["details"]["path"], "./data") + tables = {table["name"]: table["config"] for table in connection["config"]["tables"]} + self.assertEqual(set(tables), {"customer_profiles", "transactions", "credit_card_products"}) + for table_config in tables.values(): + self.assertEqual(table_config["extension"], ".csv") + + def test_csv_datasets_load(self) -> None: + datasets = load_csv_datasets(os.path.join(SAMPLE_ROOT, "data")) + self.assertEqual(len(datasets["customer_profiles"]), 4) + self.assertEqual(len(datasets["transactions"]), 12) + self.assertEqual(len(datasets["credit_card_products"]), 4) + self.assertEqual( + {row["customer_id"] for row in datasets["customer_profiles"]}, + {"cust_001", "cust_002", "cust_003", "cust_004"}, + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/samples/llm-vector-langchain/tests/test_recommender.py b/samples/llm-vector-langchain/tests/test_recommender.py new file mode 100644 index 0000000000..55c6ea62ad --- /dev/null +++ b/samples/llm-vector-langchain/tests/test_recommender.py @@ -0,0 +1,110 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from recommender import build_customer_context, rank_products # noqa: E402 + + +class RecommenderTest(unittest.TestCase): + def test_frequent_traveler_gets_travel_card(self) -> None: + customer = { + "customer_id": "cust_001", + "income_band": "premium", + "segment": "frequent_traveler", + "goals": "travel rewards and lounge access", + "risk_tolerance": "medium", + } + transactions = [ + {"merchant_category": "travel", "amount_usd": "500"}, + {"merchant_category": "travel", "amount_usd": "300"}, + {"merchant_category": "dining", "amount_usd": "120"}, + ] + products = [ + { + "product_id": "card_001", + "name": "Aurora Travel Signature", + "annual_fee_usd": "395", + "rewards_focus": "travel dining lounge", + "intro_offer": "bonus points", + "eligibility": "premium income and excellent credit", + "caveat": "high fee", + }, + { + "product_id": "card_002", + "name": "Everyday Cashback Plus", + "annual_fee_usd": "0", + "rewards_focus": "groceries streaming cashback", + "intro_offer": "statement credit", + "eligibility": "starter or mass market income", + "caveat": "lower travel earn", + }, + ] + + ranked = rank_products(customer, transactions, products) + + self.assertEqual(ranked[0]["product_id"], "card_001") + self.assertIn("rewards match travel spend", ranked[0]["reason"]) + + def test_cashback_customer_prefers_no_fee_card(self) -> None: + customer = { + "customer_id": "cust_002", + "income_band": "starter", + "segment": "young_professional", + "goals": "cashback on everyday spend, no annual fee", + "risk_tolerance": "low", + } + transactions = [ + {"merchant_category": "groceries", "amount_usd": "122.23"}, + {"merchant_category": "groceries", "amount_usd": "98.11"}, + {"merchant_category": "streaming", "amount_usd": "18.99"}, + ] + products = [ + { + "product_id": "card_001", + "name": "Aurora Travel Signature", + "annual_fee_usd": "395", + "rewards_focus": "travel dining lounge", + "intro_offer": "bonus points", + "eligibility": "premium income and excellent credit", + "caveat": "high fee", + }, + { + "product_id": "card_002", + "name": "Everyday Cashback Plus", + "annual_fee_usd": "0", + "rewards_focus": "groceries streaming cashback", + "intro_offer": "statement credit", + "eligibility": "starter or mass market income", + "caveat": "lower travel earn", + }, + ] + + ranked = rank_products(customer, transactions, products) + + self.assertEqual(ranked[0]["product_id"], "card_002") + self.assertIn("no annual fee fits a starter income band", ranked[0]["reason"]) + + def test_customer_context_summarizes_spend(self) -> None: + customer = { + "customer_id": "cust_002", + "income_band": "starter", + "segment": "young_professional", + "goals": "cashback", + "risk_tolerance": "low", + } + transactions = [ + {"merchant_category": "groceries", "amount_usd": "10.50"}, + {"merchant_category": "groceries", "amount_usd": "12.25"}, + {"merchant_category": "streaming", "amount_usd": "8.00"}, + ] + + context = build_customer_context(customer, transactions) + + self.assertIn("Recent spend: $30.75", context) + self.assertIn("groceries (2)", context) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file