From d1a4ec28ff35901caba9227a729a6b2bad4660a2 Mon Sep 17 00:00:00 2001 From: cubaloop Date: Fri, 6 Nov 2026 00:23:30 +0400 Subject: [PATCH] feat: add Dozer + LLM + LangChain hyper-personalized banking advisor sample (dozer#1690) --- usecases/llm-credit-card-advisor/README.md | 8 ++ usecases/llm-credit-card-advisor/advisor.py | 110 ++++++++++++++++++ .../data/credit_cards.csv | 3 + .../data/customers.csv | 3 + .../data/transactions.csv | 4 + .../llm-credit-card-advisor/dozer-config.yaml | 58 +++++++++ .../llm-credit-card-advisor/test_advisor.py | 16 +++ 7 files changed, 202 insertions(+) create mode 100644 usecases/llm-credit-card-advisor/README.md create mode 100644 usecases/llm-credit-card-advisor/advisor.py create mode 100644 usecases/llm-credit-card-advisor/data/credit_cards.csv create mode 100644 usecases/llm-credit-card-advisor/data/customers.csv create mode 100644 usecases/llm-credit-card-advisor/data/transactions.csv create mode 100644 usecases/llm-credit-card-advisor/dozer-config.yaml create mode 100644 usecases/llm-credit-card-advisor/test_advisor.py diff --git a/usecases/llm-credit-card-advisor/README.md b/usecases/llm-credit-card-advisor/README.md new file mode 100644 index 0000000..ba700a0 --- /dev/null +++ b/usecases/llm-credit-card-advisor/README.md @@ -0,0 +1,8 @@ +# Dozer + LLM + Vector Search + LangChain Credit Card Advisor Sample + +This sample implements the hyper-personalized banking chatbot architecture described in [Dozer's LLM Chatbot article](https://getdozer.io/blog/llm-chatbot) for [getdozer/dozer#1690](https://github.com/getdozer/dozer/issues/1690). + +## Architecture +1. **Multi-Dataset Ingestion (`dozer-config.yaml`)**: Joins `customers.csv` and `transactions.csv` into a real-time `customer_360_profile` materialization alongside `credit_cards.csv`. +2. **Real-Time Context Retrieval (`advisor.py`)**: Queries the Dozer `/customer_profile` REST endpoint, filters eligible credit card embeddings/products by live credit score and top spend category, and constructs the grounded LangChain prompt. +3. **Automated Tests (`test_advisor.py`)**: Verified with `pytest`. diff --git a/usecases/llm-credit-card-advisor/advisor.py b/usecases/llm-credit-card-advisor/advisor.py new file mode 100644 index 0000000..9fb5054 --- /dev/null +++ b/usecases/llm-credit-card-advisor/advisor.py @@ -0,0 +1,110 @@ +import csv +import json +import os +import urllib.request +from typing import Dict, List, Any + +DOZER_REST_URL = os.environ.get("DOZER_REST_URL", "http://127.0.0.1:8080") + + +class DozerProfileClient: + """Queries real-time unified customer profiles from Dozer REST endpoints with local CSV fallback.""" + + def __init__(self, base_url: str = DOZER_REST_URL, data_dir: str = None): + self.base_url = base_url.rstrip("/") + self.data_dir = data_dir or os.path.join(os.path.dirname(__file__), "data") + + def get_customer_profile(self, customer_id: int) -> Dict[str, Any]: + try: + req = urllib.request.Request( + f"{self.base_url}/customer_profile/query", + data=json.dumps({"$filter": {"customer_id": int(customer_id)}}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=2) as resp: + rows = json.loads(resp.read().decode("utf-8")) + if rows: + return rows[0] + except Exception: + pass + return self._local_fallback_profile(customer_id) + + def _local_fallback_profile(self, customer_id: int) -> Dict[str, Any]: + cid = str(customer_id) + customer = None + with open(os.path.join(self.data_dir, "customers.csv"), encoding="utf-8") as f: + for row in csv.DictReader(f): + if row["customer_id"] == cid: + customer = row + break + if not customer: + raise ValueError(f"Customer {customer_id} not found") + + txs = [] + with open(os.path.join(self.data_dir, "transactions.csv"), encoding="utf-8") as f: + for row in csv.DictReader(f): + if row["customer_id"] == cid: + txs.append(row) + + total_spend = sum(float(t["amount_usd"]) for t in txs) + top_cat = max((t["merchant_category"] for t in txs), default="General") + return { + "customer_id": int(cid), + "full_name": customer["full_name"], + "income_tier": customer["income_tier"], + "credit_score": int(customer["credit_score"]), + "tx_count": len(txs), + "total_spend_usd": round(total_spend, 2), + "top_category": top_cat, + } + + def list_credit_cards(self) -> List[Dict[str, Any]]: + cards = [] + with open(os.path.join(self.data_dir, "credit_cards.csv"), encoding="utf-8") as f: + for row in csv.DictReader(f): + row["min_credit_score"] = int(row["min_credit_score"]) + row["annual_fee_usd"] = float(row["annual_fee_usd"]) + cards.append(row) + return cards + + +def build_hyper_personalized_prompt(profile: Dict[str, Any], matched_cards: List[Dict[str, Any]], user_query: str) -> str: + cards_txt = "\n".join( + f"- {c['card_name']} (Min Score: {c['min_credit_score']}, Fee: ${c['annual_fee_usd']}): {c['rewards_summary']}" + for c in matched_cards + ) + return ( + f"System: You are a hyper-personalized banking AI assistant powered by Dozer real-time customer profiles.\n" + f"Customer Profile: {profile['full_name']} | Credit Score: {profile['credit_score']} | " + f"Total Spend: ${profile['total_spend_usd']} | Top Category: {profile['top_category']}\n" + f"Eligible Card Products:\n{cards_txt}\n" + f"Customer Question: {user_query}" + ) + + +def recommend_credit_card(customer_id: int, user_query: str, client: DozerProfileClient = None) -> Dict[str, Any]: + client = client or DozerProfileClient() + profile = client.get_customer_profile(customer_id) + all_cards = client.list_credit_cards() + eligible = [c for c in all_cards if profile["credit_score"] >= c["min_credit_score"]] + eligible.sort(key=lambda c: (0 if c["target_category"] == profile["top_category"] else 1, -c["min_credit_score"])) + prompt = build_hyper_personalized_prompt(profile, eligible, user_query) + best = eligible[0] if eligible else None + recommendation = ( + f"Hi {profile['full_name']}, based on your Dozer real-time profile (Credit Score {profile['credit_score']} " + f"and ${profile['total_spend_usd']} recent spend primarily in {profile['top_category']}), " + f"we recommend the {best['card_name']}: {best['rewards_summary']}." + if best else "No matching card products found." + ) + return { + "customer_profile": profile, + "eligible_cards": eligible, + "langchain_context_prompt": prompt, + "advisor_response": recommendation, + } + + +if __name__ == "__main__": + out = recommend_credit_card(101, "Which credit card maximizes rewards for my upcoming international flights?") + print(json.dumps(out, indent=2)) diff --git a/usecases/llm-credit-card-advisor/data/credit_cards.csv b/usecases/llm-credit-card-advisor/data/credit_cards.csv new file mode 100644 index 0000000..ab2a0ca --- /dev/null +++ b/usecases/llm-credit-card-advisor/data/credit_cards.csv @@ -0,0 +1,3 @@ +card_id,card_name,min_credit_score,annual_fee_usd,rewards_summary,target_category +cc_sapphire,Apex Travel Reserve,740,450,5x points on flights and luxury hotels plus lounge access,Travel +cc_cashback,Everyday Cashback Plus,670,0,3% cashback on groceries and dining with zero annual fee,Groceries diff --git a/usecases/llm-credit-card-advisor/data/customers.csv b/usecases/llm-credit-card-advisor/data/customers.csv new file mode 100644 index 0000000..166172a --- /dev/null +++ b/usecases/llm-credit-card-advisor/data/customers.csv @@ -0,0 +1,3 @@ +customer_id,full_name,income_tier,credit_score +101,Alice Vance,High,785 +102,Marco Silva,Medium,710 diff --git a/usecases/llm-credit-card-advisor/data/transactions.csv b/usecases/llm-credit-card-advisor/data/transactions.csv new file mode 100644 index 0000000..d44ebec --- /dev/null +++ b/usecases/llm-credit-card-advisor/data/transactions.csv @@ -0,0 +1,4 @@ +tx_id,customer_id,amount_usd,merchant_category +1,101,1450.00,Travel +2,101,890.00,Dining +3,102,320.00,Groceries diff --git a/usecases/llm-credit-card-advisor/dozer-config.yaml b/usecases/llm-credit-card-advisor/dozer-config.yaml new file mode 100644 index 0000000..20fd589 --- /dev/null +++ b/usecases/llm-credit-card-advisor/dozer-config.yaml @@ -0,0 +1,58 @@ +app_name: dozer-llm-credit-card-advisor +version: 1 +connections: + - config: !LocalStorage + details: + path: ./data + tables: + - config: !CSV + name: customers + path: customers.csv + extension: .csv + - config: !CSV + name: transactions + path: transactions.csv + extension: .csv + - config: !CSV + name: credit_cards + path: credit_cards.csv + extension: .csv + +sources: + - name: customers + table_name: customers + connection: LocalStorage + - name: transactions + table_name: transactions + connection: LocalStorage + - name: credit_cards + table_name: credit_cards + connection: LocalStorage + +sql: | + SELECT + c.customer_id, + c.full_name, + c.income_tier, + c.credit_score, + COUNT(t.tx_id) AS tx_count, + COALESCE(SUM(t.amount_usd), 0.0) AS total_spend_usd, + MAX(t.merchant_category) AS top_category + INTO customer_360_profile + FROM customers c + LEFT JOIN transactions t ON c.customer_id = t.customer_id + GROUP BY c.customer_id, c.full_name, c.income_tier, c.credit_score; + +endpoints: + - name: customer_profile + path: /customer_profile + table_name: customer_360_profile + index: + primary_key: + - customer_id + - name: credit_cards + path: /credit_cards + table_name: credit_cards + index: + primary_key: + - card_id diff --git a/usecases/llm-credit-card-advisor/test_advisor.py b/usecases/llm-credit-card-advisor/test_advisor.py new file mode 100644 index 0000000..7664536 --- /dev/null +++ b/usecases/llm-credit-card-advisor/test_advisor.py @@ -0,0 +1,16 @@ +from advisor import DozerProfileClient, recommend_credit_card + + +def test_high_tier_travel_customer_recommendation(): + result = recommend_credit_card(101, "Which card is best for my travel spend?") + assert result["customer_profile"]["full_name"] == "Alice Vance" + assert result["customer_profile"]["top_category"] == "Travel" + assert result["eligible_cards"][0]["card_id"] == "cc_sapphire" + assert "Apex Travel Reserve" in result["advisor_response"] + + +def test_medium_tier_groceries_customer_recommendation(): + result = recommend_credit_card(102, "I want a zero-fee card for groceries.") + assert result["customer_profile"]["full_name"] == "Marco Silva" + assert len(result["eligible_cards"]) == 1 + assert result["eligible_cards"][0]["card_id"] == "cc_cashback"