OS Capstone C3 — Entity Resolution Agent
Build a ReAct agent that reasons through multi-step entity resolution — searching UCC filings, computing fuzzy matches, cross-referencing registries, and merging entity profiles. Powered by Mistral-7B running locally via Ollama.
Complete M05 (Function Calling), M06 (Multi-Tool Orchestration), M12 (ReAct Agent Loop), and M13 (Planning & Task Decomposition) in the OS Track before starting this capstone. You should be comfortable defining OpenAI-format tool schemas, handling finish_reason == "tool_calls" message flows, and building agentic loops. Also complete M00 (Dev Setup) โ Ollama must be running with mistral pulled.
What Changed vs. the Original Capstone
Before diving in, here is the complete diff between the original Anthropic-SDK version and this OS version. Everything not in this table is identical.
| Original (Anthropic SDK) | OS Version (OpenAI SDK + Ollama) | File |
|---|---|---|
| import anthropic | from openai import OpenAI | entity_agent.py |
| client = anthropic.Anthropic() | client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") | entity_agent.py |
| model="claude-sonnet-4-6" | model="mistral" | entity_agent.py |
| client.messages.create(system=..., tools=TOOLS) | client.chat.completions.create(messages=[system_msg]+messages, tools=TOOLS) | entity_agent.py |
| input_schema: {"type":"object",...} | parameters: {"type":"object",...} inside {"type":"function","function":{...}} | entity_agent.py |
| response.stop_reason == "tool_use" | response.choices[0].finish_reason == "tool_calls" | entity_agent.py |
| response.stop_reason == "end_turn" | response.choices[0].finish_reason == "stop" | entity_agent.py |
| block.type == "tool_use" / block.input | tc.function.name / json.loads(tc.function.arguments) | entity_agent.py |
| {"type":"tool_result","tool_use_id":...} | {"role":"tool","tool_call_id":...,"content":...} | entity_agent.py |
| pip install anthropic + ANTHROPIC_API_KEY | pip install openai + ollama serve (no key) | setup |
| entity_tools.py | Unchanged โ zero SDK dependency | entity_tools.py |
"input_schema": {"type":"object"...}
stop_reason == "tool_use"
block.type == "tool_use"
args = block.input
{"type": "tool_result",
"tool_use_id": block.id}
stop_reason == "end_turn"
{"type":"function","function":{
"parameters":...}}
finish_reason == "tool_calls"
tc = choice.message.tool_calls[0]
args = json.loads(tc.function.arguments)
{"role": "tool",
"tool_call_id": tc.id}
finish_reason == "stop"
The tools file contains pure Python functions: dict lookups, regex normalization, arithmetic. None of it calls an LLM. The agent is the only place the LLM is invoked โ and that is the only file that needed an SDK swap. This is the separation of concerns pattern you should apply to all your agents: keep your domain logic (tools) independent of your inference provider.
Project Brief
Business names in UCC filings are notoriously inconsistent. The same company appears as "Acme Logistics LLC", "ACME LOGISTICS, L.L.C.", "Acme Logistics Company", and "Acme Logistics Inc." across different states. A human analyst looking at these four names must decide: are these all the same company, or four different companies?
The pain is scale and subtlety. A commercial data provider processes millions of filings. Manual entity resolution costs $2–5 per entity. At 100,000 entities per month, that is $200K–$500K annually in analyst time. Worse, humans are inconsistent — one analyst merges two entities, another keeps them separate, and the database has conflicting records.
Your agent automates this: given a business name, it searches filings across states, computes fuzzy match scoresNumerical similarity scores between two text strings computed using algorithms like Levenshtein distance, Jaro-Winkler, and token sort ratio. Higher scores indicate more similar strings. between candidates, cross-references official business registries, and produces a merged entity profile with a confidence scoreA 0.0โ1.0 number indicating how certain the agent is that two name variants refer to the same real-world entity. Above 0.9 = definite match (auto-merge). 0.7โ0.9 = likely match (verify with registry). Below 0.7 = needs more evidence or flag for human review..
A ReAct agentAn agent that follows the Reason-Act-Observe loop: it thinks about what to do next (Thought), takes an action by calling a tool (Action), observes the result (Observation), then reasons again. with 5 tools implementing the Thought-Action-Observation cycle:
search_filings_by_name— find filing candidates across statesfuzzy_match_score— compute similarity between name pairsget_filing_details— retrieve full filing data for comparisonget_business_registry_data— cross-reference official SOS registrationsmerge_entity_profile— create a unified canonical profileA canonical record is the single "official" version of an entity chosen as the authoritative reference. It merges data from all confirmed duplicates under one name โ usually matching the SOS business registration โ eliminating redundant records from the dataset. from matched entities
Environment Setup
If you completed M00 (Dev Setup), openai is already installed and Ollama is running. Just create your project folder:
mkdir capstone-c3-entity-resolution && cd capstone-c3-entity-resolution
# Activate your course venv (from M00 setup)
# Windows: .\venv\Scripts\Activate.ps1
# Mac/Linux: source venv/bin/activate
# openai is already installed โ just verify
python -c "import openai; print('openai', openai.__version__)"
# Verify Ollama + mistral are running
curl http://localhost:11434/api/tags # should include "mistral"
File Structure
capstone-c3-entity-resolution/
โโโ entity_tools.py # Tools โ IDENTICAL to original capstone
โโโ entity_agent.py # Agent โ OpenAI SDK version (the only changed file)
โโโ e2e_test.py # End-to-end verification script
Domain Glossary
ReAct Architecture
The agent follows a Thought → Action → Observation loop. Each cycle, it reasons about what it knows, what it needs, and which tool to call next. Here is a typical resolution trace with Mistral-7B:
Mock Data Specification
{
"request_id": "ER-2024-0078",
"input_name": "Acme Logistics LLC",
"input_state": "DE",
"candidates": [
{"name": "ACME LOGISTICS, L.L.C.", "state": "DE", "filing_count": 3},
{"name": "Acme Logistics Company", "state": "DE", "filing_count": 1},
{"name": "Acme Logistics Inc.", "state": "NY", "filing_count": 2}
]
}
{
"entity_a": "Acme Logistics LLC",
"entity_b": "ACME LOGISTICS, L.L.C.",
"scores": {
"exact": 0.45,
"normalized": 0.95,
"token_sort_ratio": 0.98
},
"recommendation": "likely_match"
}
Implementation Phases
- Phase 1 — Mock Tools (60 min): Copy entity_tools.py as-is โ it needs no changes for this track.
- Phase 2 — System Prompt (30 min): Write a ReAct-style system prompt encoding decision criteria (merge thresholds, registry verification rules).
- Phase 3 — ReAct Loop (45 min): Implement the OpenAI-SDK agentic loop with
finish_reason == "tool_calls"checking. - Phase 4 — Branching Logic (30 min): High confidence → merge. Ambiguous → gather more evidence. Low → mark distinct.
- Phase 5 — Error Recovery (30 min): Handle registry unavailable, timeout on filing search โ agent proceeds with partial data.
- Phase 6 — Testing (45 min): Run 10 test cases covering correct merges, separations, and ambiguous/adversarial inputs.
Step 1: Create entity_tools.py
This file is identical to the original capstone. It uses only the Python standard library (re, dicts). Copy it verbatim โ no SDK, no Ollama, no API key. This is intentional: good tool design means your domain logic never touches the inference layer.
What: 5 mock tool functions that simulate searching UCC filings, computing fuzzy match scores, retrieving filing details, querying business registries, and merging entity profiles.
Why: Mock tools let you develop and test the agent's reasoning without real APIs. Every tool returns structured data with explicit error handling so the agent can always adapt its strategy based on what succeeded or failed.
# entity_tools.py โ Mock tools for entity resolution
# UNCHANGED from original capstone โ zero SDK dependency
import re
# --- Tool 1: Search filings by name ---
MOCK_CANDIDATES = {
"acme logistics llc": [
{"name": "ACME LOGISTICS, L.L.C.", "state": "DE", "filing_count": 3, "most_recent": "2024-01-15"},
{"name": "Acme Logistics Company", "state": "DE", "filing_count": 1, "most_recent": "2023-08-20"},
{"name": "Acme Logistics Inc.", "state": "NY", "filing_count": 2, "most_recent": "2023-12-01"},
],
"buildright construction": [
{"name": "BuildRight Construction LLC", "state": "NY", "filing_count": 1, "most_recent": "2024-01-10"},
{"name": "Build Right Construction", "state": "NY", "filing_count": 1, "most_recent": "2022-03-15"},
],
}
def search_filings_by_name(business_name: str, state: str | None = None, match_type: str = "fuzzy") -> dict:
try:
key = business_name.lower().strip()
candidates = MOCK_CANDIDATES.get(key, [])
if state:
candidates = [c for c in candidates if c["state"] == state.upper()]
if not candidates:
return {"is_error": True, "error_category": "NO_RESULTS", "is_retryable": False,
"context": f"No filings found for '{business_name}'"}
return {"is_error": False, "candidates": candidates, "total": len(candidates)}
except Exception as e:
return {"is_error": True, "error_category": "INTERNAL_ERROR", "is_retryable": True, "context": str(e)}
# --- Tool 2: Fuzzy match score ---
_SUFFIX_RE = re.compile(r'\b(llc|l l c|inc|incorporated|corp|corporation|company|co|ltd)\b')
def _normalize(name: str) -> str:
name = name.lower().strip()
name = re.sub(r'[,.\-]', ' ', name)
name = re.sub(r'\s+', ' ', name)
name = _SUFFIX_RE.sub('', name)
return re.sub(r'\s+', ' ', name).strip()
def fuzzy_match_score(entity_a: str, entity_b: str) -> dict:
try:
if not entity_a or not entity_b:
return {"is_error": True, "error_category": "EMPTY_INPUT", "is_retryable": False,
"context": "Both entities required"}
norm_a, norm_b = _normalize(entity_a), _normalize(entity_b)
exact = 1.0 if entity_a.lower() == entity_b.lower() else round(
len(set(entity_a.lower()) & set(entity_b.lower())) / max(len(set(entity_a.lower())), 1), 2)
normalized = 1.0 if norm_a == norm_b else round(
len(set(norm_a.split()) & set(norm_b.split())) / max(len(set(norm_a.split()) | set(norm_b.split())), 1), 2)
token_sort = min(round(normalized * 1.03, 2) if normalized > 0.8 else normalized, 1.0)
avg = round((exact + normalized + token_sort) / 3, 2)
rec = "likely_match" if avg >= 0.85 else ("possible_match" if avg >= 0.65 else "unlikely_match")
return {"is_error": False, "entity_a": entity_a, "entity_b": entity_b,
"scores": {"exact": exact, "normalized": normalized, "token_sort_ratio": token_sort},
"recommendation": rec}
except Exception as e:
return {"is_error": True, "error_category": "INTERNAL_ERROR", "is_retryable": True, "context": str(e)}
# --- Tool 3: Get filing details ---
MOCK_FILINGS = {
("acme logistics, l.l.c.", "DE"): {"filings": [
{"filing_number": "2023-1234567", "secured_party": "First National Bank",
"collateral": "All inventory and equipment", "status": "active", "estimated_amount": 750_000},
{"filing_number": "2022-9876543", "secured_party": "Delaware Capital Partners",
"collateral": "All vehicles", "status": "active", "estimated_amount": 350_000},
{"filing_number": "2024-0011223", "secured_party": "First National Bank",
"collateral": "Accounts receivable", "status": "active", "estimated_amount": 1_200_000},
]},
}
def get_filing_details(business_name: str, state: str) -> dict:
try:
key = (business_name.lower().strip(), state.upper())
result = MOCK_FILINGS.get(key)
return {"is_error": False, **result} if result else {"is_error": False, "filings": []}
except Exception as e:
return {"is_error": True, "error_category": "INTERNAL_ERROR", "is_retryable": True, "context": str(e)}
# --- Tool 4: Business registry ---
MOCK_REGISTRY = {
("acme logistics llc", "DE"): {
"entity_name": "Acme Logistics LLC", "state": "DE", "entity_type": "LLC",
"file_number": "DE-LLC-2019-4567890", "formation_date": "2019-03-15",
"status": "active", "principal_address": "456 Commerce Blvd, Dover, DE 19901",
},
("acme logistics inc.", "NY"): {
"entity_name": "Acme Logistics Inc.", "state": "NY", "entity_type": "Corporation",
"file_number": "NY-CORP-2020-1234567", "formation_date": "2020-07-01",
"status": "active", "principal_address": "100 Broadway, New York, NY 10001",
},
}
def get_business_registry_data(business_name: str, state: str) -> dict:
try:
key = (business_name.lower().strip(), state.upper())
result = MOCK_REGISTRY.get(key)
if result:
return {"is_error": False, **result}
return {"is_error": True, "error_category": "NOT_FOUND", "is_retryable": False,
"context": f"No registry entry for '{business_name}' in {state}"}
except Exception as e:
return {"is_error": True, "error_category": "INTERNAL_ERROR", "is_retryable": True, "context": str(e)}
# --- Tool 5: Merge entity profile ---
def merge_entity_profile(primary_entity: dict, merge_candidates: list, confidence: float) -> dict:
try:
if confidence < 0.5:
return {"is_error": True, "error_category": "INSUFFICIENT_EVIDENCE", "is_retryable": False,
"context": f"Confidence {confidence} below 0.5 threshold"}
total_filings = sum(c.get("filing_count", 0) for c in merge_candidates) + primary_entity.get("filing_count", 0)
total_lien = sum(c.get("estimated_amount", 0) for c in merge_candidates) + primary_entity.get("estimated_amount", 0)
return {"is_error": False,
"merged_profile_id": f"MP-{primary_entity.get('name','unknown')[:10]}-{confidence:.0%}",
"canonical_name": primary_entity.get("name", "Unknown"),
"total_filings": total_filings,
"total_lien_exposure": total_lien,
"states": list(set([primary_entity.get("state", "")] + [c.get("state", "") for c in merge_candidates])),
"confidence": confidence,
"merge_log": [f"Merged '{c.get('name','')}' (score: {c.get('match_score','N/A')})" for c in merge_candidates]}
except Exception as e:
return {"is_error": True, "error_category": "INTERNAL_ERROR", "is_retryable": True, "context": str(e)}
// entityTools.js โ Mock tools for entity resolution
// UNCHANGED logic from Python version โ zero SDK dependency
const SUFFIX_RE = /\b(llc|l l c|inc|incorporated|corp|corporation|company|co|ltd)\b/gi;
function normalize(name) {
return name.toLowerCase().trim().replace(/[,.\-]/g,' ').replace(/\s+/g,' ')
.replace(SUFFIX_RE,'').replace(/\s+/g,' ').trim();
}
const MOCK_CANDIDATES = {
'acme logistics llc': [
{name:'ACME LOGISTICS, L.L.C.',state:'DE',filing_count:3,most_recent:'2024-01-15'},
{name:'Acme Logistics Company',state:'DE',filing_count:1,most_recent:'2023-08-20'},
{name:'Acme Logistics Inc.',state:'NY',filing_count:2,most_recent:'2023-12-01'},
],
'buildright construction': [
{name:'BuildRight Construction LLC',state:'NY',filing_count:1,most_recent:'2024-01-10'},
{name:'Build Right Construction',state:'NY',filing_count:1,most_recent:'2022-03-15'},
],
};
function searchFilingsByName(businessName, state=null) {
try {
const key = businessName.toLowerCase().trim();
let candidates = (MOCK_CANDIDATES[key] || []);
if (state) candidates = candidates.filter(c => c.state === state.toUpperCase());
if (!candidates.length) return {is_error:true, error_category:'NO_RESULTS', is_retryable:false,
context:`No filings found for '${businessName}'`};
return {is_error:false, candidates, total:candidates.length};
} catch(e) { return {is_error:true, error_category:'INTERNAL_ERROR', is_retryable:true, context:e.message}; }
}
function fuzzyMatchScore(entityA, entityB) {
try {
if (!entityA || !entityB) return {is_error:true, error_category:'EMPTY_INPUT', is_retryable:false};
const normA = normalize(entityA), normB = normalize(entityB);
const exact = entityA.toLowerCase() === entityB.toLowerCase() ? 1.0 : 0.7;
const tokA = new Set(normA.split(' ')), tokB = new Set(normB.split(' '));
const inter = [...tokA].filter(t => tokB.has(t)).length;
const normalized = normA === normB ? 1.0 : parseFloat((inter / Math.max(new Set([...tokA,...tokB]).size,1)).toFixed(2));
const tokenSort = Math.min(normalized > 0.8 ? parseFloat((normalized*1.03).toFixed(2)) : normalized, 1.0);
const avg = parseFloat(((exact+normalized+tokenSort)/3).toFixed(2));
const rec = avg >= 0.85 ? 'likely_match' : avg >= 0.65 ? 'possible_match' : 'unlikely_match';
return {is_error:false, entity_a:entityA, entity_b:entityB,
scores:{exact, normalized, token_sort_ratio:tokenSort}, recommendation:rec};
} catch(e) { return {is_error:true, error_category:'INTERNAL_ERROR', is_retryable:true, context:e.message}; }
}
const MOCK_FILINGS = {
'acme logistics, l.l.c.|DE': {filings:[
{filing_number:'2023-1234567',secured_party:'First National Bank',status:'active',estimated_amount:750000},
{filing_number:'2022-9876543',secured_party:'Delaware Capital Partners',status:'active',estimated_amount:350000},
{filing_number:'2024-0011223',secured_party:'First National Bank',status:'active',estimated_amount:1200000},
]},
};
function getFilingDetails(businessName, state) {
try {
const key = `${businessName.toLowerCase().trim()}|${state.toUpperCase()}`;
const result = MOCK_FILINGS[key];
return result ? {is_error:false,...result} : {is_error:false, filings:[]};
} catch(e) { return {is_error:true, error_category:'INTERNAL_ERROR', is_retryable:true, context:e.message}; }
}
const MOCK_REGISTRY = {
'acme logistics llc|DE': {entity_name:'Acme Logistics LLC',state:'DE',entity_type:'LLC',file_number:'DE-LLC-2019-4567890',formation_date:'2019-03-15',status:'active'},
'acme logistics inc.|NY': {entity_name:'Acme Logistics Inc.',state:'NY',entity_type:'Corporation',file_number:'NY-CORP-2020-1234567',formation_date:'2020-07-01',status:'active'},
};
function getBusinessRegistryData(businessName, state) {
try {
const key = `${businessName.toLowerCase().trim()}|${state.toUpperCase()}`;
const result = MOCK_REGISTRY[key];
return result ? {is_error:false,...result} : {is_error:true, error_category:'NOT_FOUND', is_retryable:false};
} catch(e) { return {is_error:true, error_category:'INTERNAL_ERROR', is_retryable:true, context:e.message}; }
}
function mergeEntityProfile(primaryEntity, mergeCandidates, confidence) {
try {
if (confidence < 0.5) return {is_error:true, error_category:'INSUFFICIENT_EVIDENCE', is_retryable:false};
const totalFilings = mergeCandidates.reduce((s,c)=>s+(c.filing_count||0),0) + (primaryEntity.filing_count||0);
const totalLien = mergeCandidates.reduce((s,c)=>s+(c.estimated_amount||0),0) + (primaryEntity.estimated_amount||0);
const states = [...new Set([primaryEntity.state||'', ...mergeCandidates.map(c=>c.state||'')])];
return {is_error:false,
merged_profile_id:`MP-${(primaryEntity.name||'unknown').slice(0,10)}-${Math.round(confidence*100)}pct`,
canonical_name:primaryEntity.name||'Unknown', total_filings:totalFilings, total_lien_exposure:totalLien,
states, confidence, merge_log:mergeCandidates.map(c=>`Merged '${c.name||''}' (score: ${c.match_score||'N/A'})`)};
} catch(e) { return {is_error:true, error_category:'INTERNAL_ERROR', is_retryable:true, context:e.message}; }
}
module.exports = {searchFilingsByName, fuzzyMatchScore, getFilingDetails, getBusinessRegistryData, mergeEntityProfile};
Quick verify โ run this in your terminal:
python -c "from entity_tools import search_filings_by_name; print(search_filings_by_name('Acme Logistics LLC'))"
node -e "const {searchFilingsByName} = require('./entityTools'); console.log(JSON.stringify(searchFilingsByName('Acme Logistics LLC'), null, 2))"
All 5 tools work and return structured data. The search found 3 candidates. This file will not change again โ all remaining work is in entity_agent.py.
Step 2: Create entity_agent.py
What: The ReAct agent using the OpenAI SDK pointing at Ollama. Defines tool schemas in OpenAI format ({"type":"function","function":{...}}), adds the system prompt as the first message in the messages array (instead of a top-level parameter), and parses tool calls from choice.message.tool_calls.
Why these changes matter: The OpenAI Chat Completions protocol passes tool calls back differently than the Anthropic Messages API. Instead of a content array with typed blocks, you get a tool_calls array on the assistant message. The loop structure is the same โ reason, act, observe โ but the plumbing is different. This file shows the exact translation line by line.
# entity_agent.py โ ReAct Entity Resolution Agent (OpenAI SDK + Ollama)
import json
from openai import OpenAI
from entity_tools import (search_filings_by_name, fuzzy_match_score,
get_filing_details, get_business_registry_data, merge_entity_profile)
# WHAT: Connect to Ollama running locally โ no API key required
# WHY: base_url points to Ollama's OpenAI-compatible endpoint; api_key is a placeholder
client = OpenAI(
base_url="http://localhost:11434/v1",
api_key="ollama"
)
MODEL = "mistral" # swap to "mixtral" or "llama3" for stronger reasoning
SYSTEM_PROMPT = """You are an Entity Resolution Agent for a commercial data provider.
Given a business entity name, you determine whether UCC filing records
under different name variations refer to the same real-world company.
## Your Reasoning Process (ReAct)
For each resolution request, think step by step:
1. THINK: What do I know? What do I need to find out?
2. ACT: Call the appropriate tool to gather evidence.
3. OBSERVE: What did the tool return? What does it tell me?
4. REPEAT until you have enough evidence to make a decision.
## Decision Criteria
- token_sort_ratio >= 0.90 AND same state AND same address -> MERGE (high confidence)
- token_sort_ratio >= 0.80 AND same state -> LIKELY MERGE (verify with registry)
- token_sort_ratio >= 0.70 AND different state -> INVESTIGATE (check registry)
- token_sort_ratio < 0.70 -> DISTINCT ENTITY
## Output Format
After resolution, call merge_entity_profile with your findings.
Include a confidence score (0.0-1.0) based on evidence strength.
Explain your reasoning for each merge/separate decision.
## Rules
- Always check the business registry for ambiguous matches.
- If registry data is unavailable, lower your confidence accordingly.
- Never force a merge โ flag conflicts for human review.
- If there are 10+ candidates, filter by state before matching."""
# WHAT: Tool schemas in OpenAI format
# WHY: OpenAI uses {"type":"function","function":{"name":...,"parameters":...}}
# vs Anthropic's {"name":...,"input_schema":...}
TOOLS = [
{"type": "function", "function": {
"name": "search_filings_by_name",
"description": "Search UCC filings by business name across states. Returns candidate entities with filing counts.",
"parameters": {"type": "object",
"properties": {
"business_name": {"type": "string"},
"state": {"type": "string"},
"match_type": {"type": "string", "enum": ["exact", "fuzzy"]}},
"required": ["business_name"]}}},
{"type": "function", "function": {
"name": "fuzzy_match_score",
"description": "Compute similarity scores between two entity names using normalization and token sort ratio.",
"parameters": {"type": "object",
"properties": {
"entity_a": {"type": "string"},
"entity_b": {"type": "string"}},
"required": ["entity_a", "entity_b"]}}},
{"type": "function", "function": {
"name": "get_filing_details",
"description": "Get full filing details (secured parties, collateral, amounts) for an entity in a state.",
"parameters": {"type": "object",
"properties": {
"business_name": {"type": "string"},
"state": {"type": "string"}},
"required": ["business_name", "state"]}}},
{"type": "function", "function": {
"name": "get_business_registry_data",
"description": "Cross-reference entity against official SOS business registry for address and status.",
"parameters": {"type": "object",
"properties": {
"business_name": {"type": "string"},
"state": {"type": "string"}},
"required": ["business_name", "state"]}}},
{"type": "function", "function": {
"name": "merge_entity_profile",
"description": "Create a merged entity profile from confirmed matches with a confidence score.",
"parameters": {"type": "object",
"properties": {
"primary_entity": {"type": "object"},
"merge_candidates": {"type": "array"},
"confidence": {"type": "number"}},
"required": ["primary_entity", "merge_candidates", "confidence"]}}},
]
HANDLERS = {
"search_filings_by_name": lambda a: search_filings_by_name(
a["business_name"], a.get("state"), a.get("match_type", "fuzzy")),
"fuzzy_match_score": lambda a: fuzzy_match_score(a["entity_a"], a["entity_b"]),
"get_filing_details": lambda a: get_filing_details(a["business_name"], a["state"]),
"get_business_registry_data": lambda a: get_business_registry_data(a["business_name"], a["state"]),
"merge_entity_profile": lambda a: merge_entity_profile(
a["primary_entity"], a["merge_candidates"], a["confidence"]),
}
def run_entity_agent(query: str) -> str:
# WHAT: System prompt is the first message in the array (not a top-level param)
# WHY: OpenAI protocol uses role="system" instead of a separate system= parameter
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": query},
]
for _ in range(15): # cap at 15 iterations โ entity resolution may need 8-12 tool calls
try:
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
)
except Exception as e:
return f"Error calling model: {e}"
choice = response.choices[0]
# WHAT: Check finish_reason instead of stop_reason
# WHY: OpenAI protocol uses "tool_calls" (not "tool_use") and "stop" (not "end_turn")
if choice.finish_reason == "tool_calls":
tool_calls = choice.message.tool_calls or []
# Append the assistant's message with its tool_calls intact
messages.append({
"role": "assistant",
"content": choice.message.content,
"tool_calls": [
{"id": tc.id, "type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments}}
for tc in tool_calls
],
})
# Execute each tool call and append results
for tc in tool_calls:
handler = HANDLERS.get(tc.function.name)
try:
args = json.loads(tc.function.arguments)
except json.JSONDecodeError:
args = {}
result = handler(args) if handler else {"is_error": True, "error_category": "UNKNOWN_TOOL"}
# WHAT: Tool results use role="tool" with tool_call_id
# WHY: OpenAI protocol (vs Anthropic's "tool_result" type inside a user message)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result),
})
elif choice.finish_reason == "stop":
return choice.message.content or "Resolution complete (no text output)."
return "Resolution exceeded maximum iterations (15)."
if __name__ == "__main__":
result = run_entity_agent(
"Resolve entity: Acme Logistics LLC in Delaware. "
"Check for name variations across states and produce a merged profile."
)
print(result)
// entity_agent.js โ ReAct Entity Resolution Agent (OpenAI SDK + Ollama)
const { OpenAI } = require("openai");
const { searchFilingsByName, fuzzyMatchScore, getFilingDetails,
getBusinessRegistryData, mergeEntityProfile } = require("./entity_tools.js");
const client = new OpenAI({
baseURL: "http://localhost:11434/v1",
apiKey: "ollama",
});
const MODEL = "mistral";
const SYSTEM_PROMPT = `You are an Entity Resolution Agent for a commercial data provider.
...
(same system prompt as Python version)`;
const TOOLS = [
{ type: "function", function: {
name: "search_filings_by_name",
description: "Search UCC filings by name across states.",
parameters: { type: "object",
properties: { business_name: { type: "string" }, state: { type: "string" },
match_type: { type: "string", enum: ["exact","fuzzy"] } },
required: ["business_name"] }}},
{ type: "function", function: {
name: "fuzzy_match_score",
description: "Compute similarity between two entity names.",
parameters: { type: "object",
properties: { entity_a: { type: "string" }, entity_b: { type: "string" } },
required: ["entity_a", "entity_b"] }}},
{ type: "function", function: {
name: "get_filing_details",
description: "Get full filing details for an entity in a state.",
parameters: { type: "object",
properties: { business_name: { type: "string" }, state: { type: "string" } },
required: ["business_name","state"] }}},
{ type: "function", function: {
name: "get_business_registry_data",
description: "Cross-reference entity against official SOS registry.",
parameters: { type: "object",
properties: { business_name: { type: "string" }, state: { type: "string" } },
required: ["business_name","state"] }}},
{ type: "function", function: {
name: "merge_entity_profile",
description: "Create merged profile from confirmed matches.",
parameters: { type: "object",
properties: { primary_entity: { type: "object" }, merge_candidates: { type: "array" },
confidence: { type: "number" } },
required: ["primary_entity","merge_candidates","confidence"] }}},
];
const HANDLERS = {
search_filings_by_name: (a) => searchFilingsByName(a.business_name, a.state, a.match_type),
fuzzy_match_score: (a) => fuzzyMatchScore(a.entity_a, a.entity_b),
get_filing_details: (a) => getFilingDetails(a.business_name, a.state),
get_business_registry_data: (a) => getBusinessRegistryData(a.business_name, a.state),
merge_entity_profile: (a) => mergeEntityProfile(a.primary_entity, a.merge_candidates, a.confidence),
};
async function runEntityAgent(query) {
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: query },
];
for (let i = 0; i < 15; i++) {
let response;
try {
response = await client.chat.completions.create({ model: MODEL, messages, tools: TOOLS });
} catch (e) { return `Error: ${e}`; }
const choice = response.choices[0];
if (choice.finish_reason === "tool_calls") {
const toolCalls = choice.message.tool_calls || [];
messages.push({
role: "assistant",
content: choice.message.content,
tool_calls: toolCalls.map(tc => ({
id: tc.id, type: "function",
function: { name: tc.function.name, arguments: tc.function.arguments }
})),
});
for (const tc of toolCalls) {
const handler = HANDLERS[tc.function.name];
const args = JSON.parse(tc.function.arguments || "{}");
const result = handler ? handler(args) : { is_error: true, error_category: "UNKNOWN_TOOL" };
messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(result) });
}
} else if (choice.finish_reason === "stop") {
return choice.message.content || "Resolution complete.";
}
}
return "Resolution exceeded maximum iterations.";
}
runEntityAgent(
"Resolve entity: Acme Logistics LLC in Delaware. Check for name variations across states."
).then(console.log).catch(console.error);
You built the OS-adapted ReAct agent. The key differences from the Anthropic version: (1) system prompt is the first message in the array, (2) tool schemas use parameters inside {"type":"function","function":{...}}, (3) tool calls come from choice.message.tool_calls, (4) tool results use role:"tool" with tool_call_id, (5) the finish check uses finish_reason == "tool_calls" and "stop".
Step 3: Test the Agent
Run the agent end-to-end with Mistral-7B to see the full ReAct trace. Mistral-7B handles 5-tool sequential reasoning well for this task โ the fuzzy matching thresholds give it clear decision rules so it doesn't need to guess.
Make sure Ollama is running, then:
python entity_agent.py
Expected output (exact wording varies โ the model reasons in natural language):
If Mistral-7B stops early without calling all necessary tools, it may need clearer prompting. Try adding "Think step by step and use all available tools before concluding." to the query. For more reliable multi-step reasoning, swap to MODEL = "mixtral" (requires ollama pull mixtral, 26 GB) or use Groq's llama-3.1-70b-versatile for the strongest open source reasoning.
The agent correctly merged the two Delaware entities (high fuzzy match + same registry address) and kept the New York entity separate (different address, different registry entry). The confidence score reflects the strength of the evidence. If you see fewer tool calls than expected, see the troubleshooting section.
Verify Everything Works
# e2e_test.py โ Verify tools work before running the agent
from entity_tools import (search_filings_by_name, fuzzy_match_score,
get_filing_details, get_business_registry_data, merge_entity_profile)
result = search_filings_by_name("Acme Logistics LLC")
assert not result["is_error"], "Search failed"
assert result["total"] == 3, f"Expected 3 candidates, got {result['total']}"
print("Search: OK -", result["total"], "candidates found")
score = fuzzy_match_score("Acme Logistics LLC", "ACME LOGISTICS, L.L.C.")
assert not score["is_error"], "Fuzzy match failed"
assert score["recommendation"] == "likely_match", f"Got: {score['recommendation']}"
print(f"Fuzzy: OK - {score['recommendation']} (token_sort: {score['scores']['token_sort_ratio']})")
reg = get_business_registry_data("Acme Logistics LLC", "DE")
assert not reg["is_error"], "Registry lookup failed"
print(f"Registry: OK - {reg['entity_name']} in {reg['state']}")
merged = merge_entity_profile(
{"name": "Acme Logistics LLC", "state": "DE", "filing_count": 3},
[{"name": "ACME LOGISTICS, L.L.C.", "state": "DE", "match_score": 0.98, "filing_count": 1}],
confidence=0.94)
assert not merged["is_error"], "Merge failed"
print(f"Merge: OK - {merged['canonical_name']}, confidence: {merged['confidence']}")
empty = search_filings_by_name("Nonexistent Corp XYZ")
assert empty["is_error"], "Expected error for unknown entity"
print("Errors: OK - graceful handling for unknown entities")
print("\nAll tool checks passed. Run 'python entity_agent.py' for the full agent test.")
// e2e_test.js โ Verify tools work before running the agent
const {searchFilingsByName, fuzzyMatchScore, getFilingDetails,
getBusinessRegistryData, mergeEntityProfile} = require('./entityTools');
let pass = true;
const result = searchFilingsByName("Acme Logistics LLC");
console.assert(!result.is_error, "Search failed");
console.assert(result.total === 3, `Expected 3 candidates, got ${result.total}`);
console.log("Search: OK -", result.total, "candidates found");
const score = fuzzyMatchScore("Acme Logistics LLC", "ACME LOGISTICS, L.L.C.");
console.assert(!score.is_error, "Fuzzy match failed");
console.assert(score.recommendation === "likely_match", `Got: ${score.recommendation}`);
console.log(`Fuzzy: OK - ${score.recommendation} (token_sort: ${score.scores.token_sort_ratio})`);
const reg = getBusinessRegistryData("Acme Logistics LLC", "DE");
console.assert(!reg.is_error, "Registry lookup failed");
console.log(`Registry: OK - ${reg.entity_name} in ${reg.state}`);
const merged = mergeEntityProfile(
{name: "Acme Logistics LLC", state: "DE", filing_count: 3},
[{name: "ACME LOGISTICS, L.L.C.", state: "DE", match_score: 0.98, filing_count: 1}],
0.94);
console.assert(!merged.is_error, "Merge failed");
console.log(`Merge: OK - ${merged.canonical_name}, confidence: ${merged.confidence}`);
const empty = searchFilingsByName("Nonexistent Corp XYZ");
console.assert(empty.is_error, "Expected error for unknown entity");
console.log("Errors: OK - graceful handling for unknown entities");
console.log("\nAll tool checks passed. Run 'node entityAgent.js' for the full agent test.");
Testing Guide
| Type | Scenario | Expected Behavior |
|---|---|---|
| Happy | Two DE name variants (LLC vs L.L.C.) | Merges with high confidence (>0.9) |
| Happy | Three candidates: 2 match, 1 distinct | Merges 2 DE entities, keeps NY separate |
| Happy | Legal suffix difference only | High match score, quick merge |
| Happy | Entity with filings in multiple states | Cross-references registry to confirm single entity |
| Edge | Similar names, different companies in same state | Distinguishes using address and registry |
| Edge | Registry returns NOT_FOUND for one candidate | Proceeds with lower confidence |
| Edge | "possible_match" fuzzy score | Gathers additional evidence before deciding |
| Adversarial | Common name with many candidates | Filters by state, limits scope |
| Adversarial | Same name, same state, different file numbers | Flags conflict for human review, does not force merge |
Troubleshooting
ConnectionRefusedError or Connection refused on localhost:11434
Ollama isn't running. Start it: ollama serve (Mac/Linux) or open the Ollama app (Windows). Then verify: curl http://localhost:11434/api/tags should return JSON with a list of pulled models.
model "mistral" not found
You haven't pulled the model. Run ollama pull mistral (one-time 4.1 GB download). If you want a lighter model, try ollama pull phi3 (2.2 GB) and change MODEL = "phi3" in the agent.
ModuleNotFoundError: No module named 'openai'
Your virtual environment isn't active. Run .\venv\Scripts\Activate.ps1 (Windows) or source venv/bin/activate (Mac/Linux). Then pip install openai.
ImportError: cannot import name 'search_filings_by_name'
Both entity_tools.py and entity_agent.py must be in the same directory. Check for typos in the filename (underscores not hyphens).
Agent finishes after 1-2 tool calls without resolving
Mistral-7B may stop early on short-context prompts. Try: (1) add "Think step by step and use all available tools before concluding." to the user query, (2) swap to MODEL = "mixtral" for stronger multi-step reasoning, (3) lower the iteration cap to 5 during debugging to fail faster. The system prompt decision criteria (score thresholds) are critical โ make sure they're present.
Agent loops without resolving (hits 15 iterations)
Check: (1) MOCK_CANDIDATES contains the entity you're querying (lowercase key), (2) merge_entity_profile is in the TOOLS list, (3) finish_reason handling is correct โ add print(f"finish_reason: {choice.finish_reason}") inside the loop to debug.
Compliance & Regulatory Notes
False positive risk: Merging two distinct entities creates incorrect risk profiles. A lender relying on a false merge might deny a loan to a clean company because it was conflated with a heavily-liened entity. Always prefer conservative merges with clear evidence.
FCRA implications: If merged profiles are used for credit decisions, accuracy requirements under the Fair Credit Reporting Act apply. Incorrect merges could trigger disputes and regulatory action.
Audit trail: Every merge decision must be logged with the evidence that supported it. The merge_log in the output serves this purpose โ preserve it.
Going Further
- Stronger model โ swap
MODEL = "mistral"to"mixtral"(local) or Groq's"llama-3.1-70b-versatile"for more reliable multi-step reasoning chains. - Address matching tool โ add a 6th tool that normalizes and compares addresses ("456 Commerce Blvd" vs "456 Commerce Boulevard"). Improve merge confidence when registry addresses match.
- Multi-agent pipeline โ split into coordinator + subagents: one for filing search, one for fuzzy matching, one for registry verification. This is Capstone C4 with CrewAI.
- Confidence calibration โ track merge decisions over time and calibrate scores against human reviewer corrections.
rapidfuzzintegration โpip install rapidfuzzand add Levenshtein and Jaro-Winkler scores tofuzzy_match_scorefor more accurate matching on short names.
Knowledge Check
Q1: In the OS version, where does the system prompt go โ and why is it different from the Anthropic SDK?
Q2: Why does entity_tools.py need zero changes for the OS track?
Q3: What does finish_reason == "tool_calls" mean in the OpenAI protocol?
Q4: Mistral-7B stops after 2 tool calls without finishing the resolution. What is the most effective fix?
Q5: Why does the agent search multiple states rather than just one?
References & Resources
| Resource | Why It Matters | Link |
|---|---|---|
| UCC Article 9 โ Secured Transactions | The law that governs all UCC filings, lien priority, and perfection rules in the United States. Understanding it gives context to why entity resolution matters in commercial lending. | law.cornell.edu/ucc/9 |
| OpenAI Function Calling โ official docs | Complete reference for the tools parameter format, tool_calls response parsing, and tool-role message structure used in entity_agent.py. |
platform.openai.com/docs/guides/function-calling |
| Ollama API โ OpenAI compatibility | Explains how Ollama exposes the OpenAI-compatible endpoint at localhost:11434/v1, which models support tool use, and how to pull/serve models locally. |
ollama.com/blog/openai-compatibility |
| rapidfuzz โ Python fuzzy string matching | Production-grade library for entity name matching (Levenshtein, Jaro-Winkler, token-sort). The OS capstone implements a simplified version of what rapidfuzz provides. Install: pip install rapidfuzz. |
github.com/rapidfuzz/RapidFuzz |
| OpenAI Node.js SDK โ npm package | The openai npm package used in entityAgent.js. Same interface as Python but with async/await. Install: npm install openai. |
github.com/openai/openai-node |