{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Fraud Review Agent with MongoDB Atlas and Claude Managed Agents\n", "\n", "This cookbook shows how to bring **MongoDB Atlas** to an agent running on\n", "[Claude Managed Agents](README.md) (CMA) — as its retrieval engine, its graph store, and its\n", "system of record — using only the standard CMA patterns (custom tools and MCP toolsets), with\n", "**no platform-level MongoDB integration required**. The runnable helpers live beside this\n", "notebook in [`mongodb_on_cma/`](mongodb_on_cma/). Each section links the full file so the\n", "narrative stays focused on *where MongoDB plugs in* and *where the agent adds value*.\n", "\n", "**Where MongoDB Plugs In:** Most agent stacks bolt together three or four systems: a vector\n", "database for semantic search, a separate engine for keywords, a graph store for relationships,\n", "and an operational database for the records themselves. Every seam is another integration,\n", "another credential, another place the agent's view of the world can drift. MongoDB collapses\n", "that into one engine: the same documents are searchable by **vector** (`$vectorSearch`), by\n", "**full-text** (`$search`), as a **hybrid** of the two fused with reciprocal rank fusion, or RRF\n", "(`$rankFusion`), and traversable as a **graph** (`$graphLookup`) — and they are the same\n", "documents the agent reads, writes, and persists its decisions to. One query language, one\n", "cluster, one connection (`pymongo`).\n", "\n", "**Where the Agent Adds Value:** CMA runs the agent loop and sandbox on Anthropic's side. Your\n", "application owns the **data path** to MongoDB. The agent reasons over what MongoDB retrieves,\n", "pauses for a human on risky calls through CMA's native `requires_action` gate, and writes its\n", "verdict back — all without the database credential ever entering the agent context or its\n", "sandbox.\n", "\n", "> MongoDB here is your **operational data store and retrieval engine** — your system of record,\n", "> controlled by your application. That's distinct from the memory features CMA manages natively\n", "> on the platform. The two are complementary.\n", "\n", "**By the end you will be able to:**\n", "\n", "- Connect a credential-safe MongoDB Atlas data path into a Claude Managed Agent in three ways.\n", "- Lift vector, full-text, hybrid, and graph retrieval into an agent's custom tools.\n", "- Gate risky agent decisions behind CMA's native human-in-the-loop pause.\n", "- Make one MongoDB Atlas cluster the agent's system of record and audit backbone.\n", "\n", "The worked example is a **human-in-the-loop fraud-review agent**, but the patterns are\n", "vertical-agnostic: swap the collection and the tools, and the same shape serves a support,\n", "research, or operations agent.\n", "\n", "## What this guide covers\n", "\n", "1. [Connect MongoDB to a managed agent](#1-connect-mongodb-to-a-managed-agent)\n", "2. [Retrieve: four patterns, one engine](#2-retrieve-four-patterns-one-engine)\n", "3. [The end-to-end agent: human-in-the-loop fraud review](#3-the-end-to-end-agent-human-in-the-loop-fraud-review)\n", "4. [MongoDB Atlas as the system of record and audit backbone](#4-mongodb-atlas-as-the-system-of-record-and-audit-backbone)\n", "5. [Recap](#5-recap)" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "%%capture\n", "# Third-party dependencies: the Anthropic SDK (drives Claude Managed Agents via client.beta.*),\n", "# pymongo (the host-side data path), python-dotenv, and cryptography + pyjwt (sign/verify the\n", "# AP2 mandates). This installs the PyPI packages only — the notebook also imports the adjacent\n", "# `mongodb_on_cma/` package and `utilities.py`, so run it from a clone of this repo (see Setup).\n", "%pip install -q \"anthropic>=0.109.0\" pymongo python-dotenv cryptography pyjwt" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Prerequisites\n", "\n", "**Required knowledge:** Python and `pymongo` basics, plus passing familiarity with the Claude\n", "API. New to MongoDB + Claude? The\n", "[library-RAG notebook](../third_party/MongoDB/rag_using_mongodb.ipynb) covers `$vectorSearch`\n", "first. New to the custom-tool gate?\n", "[`CMA_gate_human_in_the_loop.ipynb`](CMA_gate_human_in_the_loop.ipynb) teaches the round-trip.\n", "\n", "**Required tools:** Python 3.11+, an [Anthropic API key](https://console.anthropic.com), and a\n", "MongoDB Atlas cluster.\n", "\n", "## Setup\n", "\n", "**Required:** `MONGO_URI` (an Atlas SRV connection string) plus Anthropic API access. A **free\n", "M0 cluster runs everything in this cookbook** — vector, full-text, hybrid `$rankFusion`, and\n", "graph traversal — so there is no paid-tier requirement. Hybrid search uses the native\n", "`$rankFusion` stage, which needs MongoDB 8.0+; every current Atlas cluster (M0 included) is on\n", "8.0 or later, so this holds by default.\n", "\n", "```bash\n", "uv sync --all-extras # from the repo root\n", "cp .env.example .env # then add MONGO_URI (and Anthropic auth, if not already configured)\n", "```\n", "\n", "**Optional embeddings/rerank provider:** The seed fixture ships precomputed embeddings, so the\n", "cookbook runs without one. To enable the live-embedding and reranker paths, set **one** of\n", "`MDB_ATLAS_API_KEY` (the MongoDB Atlas AI endpoint, serving both `/v1/embeddings` and\n", "`/v1/rerank`) or `VOYAGE_API_KEY` (the `voyageai` SDK). `ENABLE_RERANK=1` adds the reranker\n", "second stage. `AUTO_APPROVE=1` resolves the human gate deterministically for CI. `COOKBOOK_MODEL`\n", "overrides the agent model (default `claude-haiku-4-5`).\n", "\n", "**No Atlas cluster yet?** The partner notebook [`rag_using_mongodb.ipynb`](../third_party/MongoDB/rag_using_mongodb.ipynb) walks through creating a free cluster and getting your `MONGO_URI`; the [Atlas Search index docs](https://www.mongodb.com/docs/atlas/atlas-search/create-index/) cover index setup." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The teaching code lives **inline in this notebook**: the four retrieval pipeline builders\n", "(Section 2) and the custom-tool handlers plus the `requires_action` gate loop (Section 3). The\n", "[`mongodb_on_cma/`](mongodb_on_cma/) package next to the notebook holds only setup boilerplate\n", "you import rather than read: [`config.py`](mongodb_on_cma/config.py) (index names + tunables),\n", "[`embeddings.py`](mongodb_on_cma/embeddings.py) (the Atlas/Voyage client),\n", "[`tools.py`](mongodb_on_cma/tools.py) (Atlas seed/index setup + shared decision/audit doc\n", "shapers), [`ap2_mandates.py`](mongodb_on_cma/ap2_mandates.py) (AP2 signing/verification — a\n", "crypto black box you call through `verify_mandates`), and\n", "[`seed.py`](mongodb_on_cma/seed.py) (the example-data loader). Import them, connect the clients,\n", "and resolve the run configuration." ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "warning: no ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_PROFILE found; relying on the SDK / `ant` CLI to resolve credentials.\n", "model=claude-haiku-4-5 rerank=on provider=yes\n" ] } ], "source": [ "import hashlib\n", "import json\n", "import logging\n", "import os\n", "from datetime import UTC, datetime\n", "from typing import Any\n", "\n", "import dotenv\n", "from anthropic import Anthropic\n", "from mongodb_on_cma import (\n", " EMBED_DIM,\n", " build_audit_event,\n", " build_decision_doc,\n", " ensure_indexes,\n", " has_anthropic_auth,\n", " load_seed,\n", " make_embedding_client,\n", " missing_required_env,\n", " preflight,\n", " prepare_seed,\n", " rerank,\n", " resolve_model,\n", " seed_collection,\n", " server_version,\n", " supports_rank_fusion,\n", ")\n", "from mongodb_on_cma.ap2_mandates import (\n", " attach_mandates,\n", " store_mandate_receipt,\n", " tool_verify_mandates,\n", ")\n", "from pymongo import MongoClient\n", "from utilities import wait_for_idle_status\n", "\n", "dotenv.load_dotenv()\n", "\n", "missing = missing_required_env()\n", "assert not missing, f\"Set these in your environment / .env: {missing}\"\n", "# Anthropic auth is a soft check: an API key, an auth token, or a profile (e.g. the `ant`\n", "# CLI's workload-identity federation) all work, so warn rather than block if none is set.\n", "if not has_anthropic_auth():\n", " print(\n", " \"warning: no ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_PROFILE found; \"\n", " \"relying on the SDK / `ant` CLI to resolve credentials.\"\n", " )\n", "\n", "MODEL = resolve_model()\n", "ENABLE_RERANK = os.getenv(\"ENABLE_RERANK\", \"\").lower() in (\"1\", \"true\")\n", "AUTO_APPROVE = os.getenv(\"AUTO_APPROVE\", \"\").lower() in (\"1\", \"true\")\n", "\n", "# Quiet the SDK's one-shot notice that ANTHROPIC_API_KEY shadows profile/federation\n", "# auto-discovery — expected when an API key is set; harmless under `ant` / WIF auth.\n", "logging.getLogger(\"anthropic.lib.credentials._auth\").setLevel(logging.ERROR)\n", "client = Anthropic()\n", "mongo = MongoClient(os.environ[\"MONGO_URI\"])\n", "db = mongo[\"fraud_review_demo\"]\n", "coll = db[\"transactions\"]\n", "ai_client = make_embedding_client() # Atlas / Voyage / None (seed ships precomputed vectors)\n", "\n", "print(\n", " f\"model={MODEL} rerank={'on' if ENABLE_RERANK else 'off'} provider={'yes' if ai_client else 'none'}\"\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1. Connect MongoDB to a managed agent\n", "\n", "The agent loop and its sandbox are **Anthropic-hosted** — that is what \"Managed\" means. The\n", "only thing you host is the **data path** to MongoDB. On every path, the MongoDB credential\n", "lives on *your* side of the boundary: it never enters the agent context, a cloud sandbox's\n", "environment, or a file the agent can read.\n", "\n", "> **Why this cookbook talks to CMA through `client.beta.*` and not the Claude Agent SDK.**\n", "> Managed Agents (CMA) is a [hosted REST API](https://platform.claude.com/docs/en/managed-agents/overview):\n", "> Anthropic runs the agent loop and the sandbox, and you drive it by creating agents/sessions\n", "> and streaming events through the standard Anthropic SDK's beta surface\n", "> (`client.beta.agents`, `client.beta.sessions`, `client.beta.sessions.events`). There is no\n", "> separate \"CMA SDK\" — this *is* how you use CMA. The\n", "> [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview)\n", "> (`claude-agent-sdk`, showcased in the repo's\n", "> [`claude_agent_sdk/`](../claude_agent_sdk/) cookbooks) is a different product: a library that\n", "> runs the agent loop **inside your own process**, on your own infrastructure. The two are\n", "> complementary — you'd prototype locally with the Agent SDK and run in production on CMA — but\n", "> a CMA integration like this one is built on the beta sessions/events API. That choice is also\n", "> what makes the credential boundary below work: because *your* process handles each custom-tool\n", "> call, the MongoDB secret stays host-side (see Path A).\n", "\n", "There are three ways to wire the data path. **This cookbook uses Path A throughout** — it is\n", "the recommended, lightest pattern and the one that keeps the credential fully host-side. Paths\n", "B and C are summarized after it for completeness.\n", "\n", "### Path A: host-side custom tool (recommended)\n", "\n", "Your application defines a `custom` tool. When the agent calls it, the session pauses with\n", "`requires_action`, **your** process runs the query with `pymongo`, and you post back a\n", "`user.custom_tool_result`. The secret never enters the sandbox, MongoDB can sit in a private\n", "VPC, and the agent is constrained to a fixed, audited set of queries. It is the lightest path —\n", "no standing server — and Anthropic's recommended pattern for any secret-bearing data source.\n", "\n", "To see the wiring unobscured, here is the whole round-trip with one tiny `find_notes` tool over\n", "a throwaway `notes` collection. **This is the core integration: a few lines of `pymongo`, run\n", "host-side, behind a custom tool.** Section 3 scales this exact shape to five tools and a real\n", "workload." ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "seeded 3 notes; session: sesn_016ufm58BVJ8aZeWjUt39bsq\n" ] } ], "source": [ "notes = mongo[\"cookbook_mongodb_on_cma\"][\"notes\"]\n", "notes.delete_many({})\n", "notes.insert_many(\n", " [\n", " {\"note_id\": \"n1\", \"tag\": \"todo\", \"text\": \"Renew the SSL certificate before it expires.\"},\n", " {\"note_id\": \"n2\", \"tag\": \"idea\", \"text\": \"Add a dark-mode toggle to the dashboard.\"},\n", " {\"note_id\": \"n3\", \"tag\": \"todo\", \"text\": \"Email the quarterly report to the finance team.\"},\n", " ]\n", ")\n", "\n", "notes_agent = client.beta.agents.create(\n", " name=\"cookbook-mongodb-notes\",\n", " model=MODEL,\n", " system=(\n", " \"You answer questions about the user's notes. Use the find_notes tool to fetch notes \"\n", " \"(optionally filtered by `tag`) before answering, then give a concise final answer.\"\n", " ),\n", " tools=[\n", " {\n", " \"type\": \"agent_toolset_20260401\",\n", " \"default_config\": {\"enabled\": True, \"permission_policy\": {\"type\": \"always_allow\"}},\n", " },\n", " {\n", " \"type\": \"custom\",\n", " \"name\": \"find_notes\",\n", " \"description\": \"Return stored notes, optionally filtered by tag (e.g. 'todo', 'idea').\",\n", " \"input_schema\": {\n", " \"type\": \"object\",\n", " \"properties\": {\"tag\": {\"type\": \"string\", \"description\": \"optional tag filter\"}},\n", " \"required\": [],\n", " },\n", " },\n", " ],\n", ")\n", "notes_env = client.beta.environments.create(\n", " name=\"cookbook-mongodb-notes-env\", config={\"type\": \"cloud\", \"networking\": {\"type\": \"limited\"}}\n", ")\n", "notes_session = client.beta.sessions.create(\n", " environment_id=notes_env.id,\n", " agent={\"type\": \"agent\", \"id\": notes_agent.id, \"version\": notes_agent.version},\n", " title=\"MongoDB notes (host-side custom tool)\",\n", ")\n", "print(f\"seeded {notes.count_documents({})} notes; session: {notes_session.id}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Open the event stream, ask a question, and drive the round-trip: on each\n", "`agent.custom_tool_use`, run the query **host-side** with `pymongo` and post back a\n", "`user.custom_tool_result`. Results are projected with `{\"_id\": 0}` because a raw MongoDB\n", "`ObjectId` is not JSON-serializable." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "host-side find_notes round-trips:\n", " find_notes({'tag': 'todo'}) -> 2 notes\n", "archived the demo agent / environment / session; dropped the notes collection\n" ] } ], "source": [ "def run_find_notes(args: dict) -> dict:\n", " \"\"\"Host-side handler: pymongo runs here; _id (ObjectId) is projected out so the result is JSON-able.\"\"\"\n", " flt = {\"tag\": args[\"tag\"]} if args.get(\"tag\") else {}\n", " return {\"notes\": list(notes.find(flt, {\"_id\": 0}))}\n", "\n", "\n", "tool_use_events, responded, round_trips = {}, set(), []\n", "question = \"Using find_notes, list every note tagged 'todo', then tell me how many there are.\"\n", "\n", "with client.beta.sessions.events.stream(notes_session.id) as stream:\n", " client.beta.sessions.events.send(\n", " session_id=notes_session.id,\n", " events=[{\"type\": \"user.message\", \"content\": [{\"type\": \"text\", \"text\": question}]}],\n", " )\n", " for ev in stream:\n", " if ev.type == \"agent.custom_tool_use\":\n", " tool_use_events[ev.id] = ev\n", " elif ev.type == \"session.status_idle\" and ev.stop_reason:\n", " if ev.stop_reason.type == \"requires_action\":\n", " for event_id in ev.stop_reason.event_ids:\n", " if event_id in responded:\n", " continue\n", " tev = tool_use_events[event_id]\n", " result = (\n", " run_find_notes(tev.input)\n", " if tev.name == \"find_notes\"\n", " else {\"error\": \"unknown\"}\n", " )\n", " if tev.name == \"find_notes\":\n", " round_trips.append(\n", " {\"tool_input\": tev.input, \"returned\": len(result[\"notes\"])}\n", " )\n", " client.beta.sessions.events.send(\n", " session_id=notes_session.id,\n", " events=[\n", " {\n", " \"type\": \"user.custom_tool_result\",\n", " \"custom_tool_use_id\": event_id,\n", " \"content\": [{\"type\": \"text\", \"text\": json.dumps(result)}],\n", " }\n", " ],\n", " )\n", " responded.add(event_id)\n", " elif ev.stop_reason.type == \"end_turn\":\n", " break\n", " elif ev.type == \"session.status_terminated\":\n", " break\n", "\n", "wait_for_idle_status(client, notes_session.id)\n", "print(\"host-side find_notes round-trips:\")\n", "for rt in round_trips:\n", " print(f\" find_notes({rt['tool_input']}) -> {rt['returned']} notes\")\n", "\n", "client.beta.sessions.archive(notes_session.id)\n", "client.beta.environments.archive(notes_env.id)\n", "client.beta.agents.archive(notes_agent.id)\n", "notes.drop()\n", "print(\"archived the demo agent / environment / session; dropped the notes collection\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Other options (brief)\n", "\n", "Path A is the default and the rest of this cookbook uses it. Two alternatives exist for cases\n", "it doesn't fit — you don't need them to follow along:\n", "\n", "- **Path B — in-sandbox `pymongo` (self-hosted sandbox).** If you run the sandbox on your own\n", " infrastructure, the MongoDB client can live *inside* it and the agent queries MongoDB straight\n", " from its `bash` tool, with `MONGO_URI` as an ordinary container env var. Pick this only for\n", " self-hosted sandboxes (on a cloud sandbox the connection string would land in session history).\n", " Runnable image: [`self_hosted_sandboxes/docker/`](self_hosted_sandboxes/docker/).\n", "- **Path C — self-hosted MongoDB MCP server.** Host the official MongoDB MCP server behind HTTPS\n", " and register it as an `mcp_toolset` so the agent can query the full surface (`find`,\n", " `aggregate`, `$vectorSearch`) rather than a fixed query set. Standard CMA MCP + vault wiring.\n", "\n", "The trade-off in one line: **Path A** keeps the query set fixed and the secret in your backend\n", "(the safest default); **Path B** hands the sandbox direct access; **Path C** gives the agent the\n", "broadest query surface at the cost of running a server. When in doubt, stay on Path A." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2. Retrieve: four patterns, one engine\n", "\n", "An agent is only as well-grounded as its retrieval. These are the four MongoDB retrieval\n", "patterns shown **in isolation** — here's the builder, here's what it returns — so you can lift a\n", "single pattern into your own agent's tools. Each is a plain aggregation-pipeline builder; the\n", "Section 3 agent calls these same functions through its tools.\n", "\n", "| Pattern | Builder | MongoDB stage |\n", "| --- | --- | --- |\n", "| Vector search | `build_vector_pipeline` | `$vectorSearch` |\n", "| Full-text search | `build_lexical_pipeline` | `$search` |\n", "| Hybrid (reciprocal rank fusion) | `build_rank_fusion_pipeline` | `$rankFusion` (8.0+) |\n", "| Graph traversal | `build_graph_pipeline` | `$graphLookup` |\n", "\n", "The builders are defined inline in the next cell — each returns a list of aggregation stages you\n", "can lift into your own collection. Index names and the projected fields come from\n", "[`config.py`](mongodb_on_cma/config.py); `EMBED_DIM` and the index constants are imported in the\n", "setup cell above.\n", "\n", "First, load the seed fixture from\n", "[`example_data/mongodb_on_cma/`](example_data/mongodb_on_cma/seed_transactions.jsonl) and build\n", "the indexes. The fixture ships precomputed embeddings, so this runs with no provider key." ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "# The four retrieval builders — each returns a plain aggregation pipeline you can lift into your\n", "# own collection. These are the functions Section 3's agent calls through its custom tools.\n", "from mongodb_on_cma.config import (\n", " DECIDED_STATUSES,\n", " LEXICAL_PATHS,\n", " PROJECT_FIELDS,\n", " SEARCH_INDEX_NAME,\n", " VECTOR_INDEX_NAME,\n", ")\n", "\n", "\n", "def _project_stage(with_score: bool = False) -> dict:\n", " proj: dict[str, Any] = {f: 1 for f in PROJECT_FIELDS}\n", " proj[\"_id\"] = 0\n", " if with_score:\n", " proj[\"score\"] = {\"$meta\": \"score\"}\n", " return {\"$project\": proj}\n", "\n", "\n", "def build_vector_pipeline(\n", " qvec, *, limit, candidates=None, vector_index=VECTOR_INDEX_NAME, status_in=DECIDED_STATUSES\n", ") -> list[dict]:\n", " return [\n", " {\n", " \"$vectorSearch\": {\n", " \"index\": vector_index,\n", " \"path\": \"embedding\",\n", " \"queryVector\": qvec,\n", " \"numCandidates\": candidates or max(50, limit * 10),\n", " \"limit\": limit,\n", " \"filter\": {\"status\": {\"$in\": list(status_in)}},\n", " }\n", " },\n", " _project_stage(),\n", " ]\n", "\n", "\n", "def build_lexical_pipeline(query, *, limit, search_index=SEARCH_INDEX_NAME) -> list[dict]:\n", " return [\n", " {\"$search\": {\"index\": search_index, \"text\": {\"query\": query, \"path\": LEXICAL_PATHS}}},\n", " {\"$limit\": limit},\n", " _project_stage(),\n", " ]\n", "\n", "\n", "def build_rank_fusion_pipeline(\n", " qvec,\n", " query,\n", " *,\n", " k,\n", " vector_index=VECTOR_INDEX_NAME,\n", " search_index=SEARCH_INDEX_NAME,\n", " status_in=DECIDED_STATUSES,\n", ") -> list[dict]:\n", " # $rankFusion (MongoDB 8.0+) runs both input pipelines and fuses them by reciprocal rank —\n", " # one aggregation, server-side. Each input pipeline gets weight 1 (uniform). To bias toward\n", " # semantic vs. lexical, add \"combination\": {\"weights\": {\"vector\": w, \"lexical\": w}}.\n", " candidates = max(50, k * 10)\n", " per_branch = max(k * 4, 20)\n", " return [\n", " {\n", " \"$rankFusion\": {\n", " \"input\": {\n", " \"pipelines\": {\n", " \"vector\": [\n", " {\n", " \"$vectorSearch\": {\n", " \"index\": vector_index,\n", " \"path\": \"embedding\",\n", " \"queryVector\": qvec,\n", " \"numCandidates\": candidates,\n", " \"limit\": per_branch,\n", " \"filter\": {\"status\": {\"$in\": list(status_in)}},\n", " }\n", " }\n", " ],\n", " \"lexical\": [\n", " {\n", " \"$search\": {\n", " \"index\": search_index,\n", " \"text\": {\"query\": query, \"path\": LEXICAL_PATHS},\n", " }\n", " },\n", " {\"$limit\": per_branch},\n", " ],\n", " }\n", " },\n", " }\n", " },\n", " {\"$limit\": k},\n", " _project_stage(with_score=True),\n", " ]\n", "\n", "\n", "def build_graph_pipeline(\n", " account_id: str, *, max_depth: int = 4, collection: str = \"transactions\"\n", ") -> list[dict]:\n", " return [\n", " {\"$match\": {\"sender.account_number\": account_id}},\n", " {\n", " \"$graphLookup\": {\n", " \"from\": collection,\n", " \"startWith\": \"$recipient.account_number\",\n", " \"connectFromField\": \"recipient.account_number\",\n", " \"connectToField\": \"sender.account_number\",\n", " \"as\": \"chain\",\n", " \"maxDepth\": max_depth,\n", " \"depthField\": \"depth\",\n", " }\n", " },\n", " ]\n", "\n", "\n", "def summarize_ring(graph_doc: dict, *, seed_account: str) -> dict:\n", " # Turn a $graphLookup chain into fraud-ring signals: circular flow back to the seed account,\n", " # layering (many small transfers), and overall network size.\n", " chain: list[dict] = list(graph_doc.get(\"chain\", []))\n", " accounts: set[str] = set()\n", " small_transfers = 0\n", " circular_flow = False\n", " for edge in chain:\n", " sender = (edge.get(\"sender\") or {}).get(\"account_number\")\n", " recipient = (edge.get(\"recipient\") or {}).get(\"account_number\")\n", " accounts.update(a for a in (sender, recipient) if a)\n", " if recipient == seed_account:\n", " circular_flow = True\n", " if float(edge.get(\"amount\", 0) or 0) < 1000:\n", " small_transfers += 1\n", " network_size = len(chain)\n", " layering = small_transfers >= 5\n", " return {\n", " \"network_size\": network_size,\n", " \"unique_accounts\": len(accounts),\n", " \"circular_flow\": circular_flow,\n", " \"layering\": layering,\n", " \"suspicious_patterns\": circular_flow or layering or network_size >= 3,\n", " }" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "seeded 20 transactions\n", "server=8.0.27 hybrid search=$rankFusion (native)\n" ] } ], "source": [ "docs = prepare_seed(load_seed(), now=datetime.now(UTC))\n", "count = seed_collection(coll, docs)\n", "print(f\"seeded {count} transactions\")\n", "\n", "# Create the vector + Atlas Search indexes if absent, then block until both are queryable AND\n", "# actually reflect the freshly-seeded docs (Atlas Search is eventually consistent).\n", "ensure_indexes(coll, dim=EMBED_DIM)\n", "\n", "check = preflight(coll)\n", "for issue in check[\"issues\"]:\n", " print(\"PREFLIGHT:\", issue)\n", "assert check[\"ok\"], \"Fix the preflight issues above before continuing.\"\n", "\n", "# This cookbook uses the native `$rankFusion` stage for hybrid search, which needs MongoDB 8.0+.\n", "# Every current Atlas cluster — including the free M0 tier — runs 8.0 or later, so this holds by\n", "# default; the check just fails loudly if you point MONGO_URI at an older self-hosted server.\n", "version = server_version(coll)\n", "assert supports_rank_fusion(version), (\n", " f\"MongoDB {version} predates $rankFusion (needs 8.0+). Use an Atlas cluster (any tier) or a \"\n", " f\"self-hosted MongoDB 8.0+.\"\n", ")\n", "print(f\"server={version} hybrid search=$rankFusion (native)\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Pattern 1: Vector search (`$vectorSearch`)\n", "\n", "Semantic recall: find documents whose `embedding` is nearest the query vector. This example\n", "reuses an existing document's embedding as the query (a pending case looking for decided\n", "precedent), so no embedding API call is needed. The builder filters to decided cases via the `status` field." ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "nearest decided precedents:\n", " txn-struct-01: Cash deposit of 4950 USD, just under the 5000 reporting threshold. Third\n", " txn-struct-02: Cash deposit of 4900 USD just below the 5000 CTR threshold, same account\n", " txn-struct-03: Transfer of 4999 USD deliberately under 5000 to avoid reporting. Pattern\n" ] } ], "source": [ "query_doc = coll.find_one({\"transaction_id\": \"txn-review-struct\"}) # a pending case\n", "hits = list(coll.aggregate(build_vector_pipeline(query_doc[\"embedding\"], limit=3)))\n", "print(\"nearest decided precedents:\")\n", "for h in hits:\n", " print(f\" {h['transaction_id']}: {h['text'][:72]}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Pattern 2: Full-text search (`$search`, BM25)\n", "\n", "Keyword and phrase matching over text fields — this is what catches the exact names, IDs, and\n", "codes that embeddings blur." ] }, { "cell_type": "code", "execution_count": 8, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "full-text matches:\n", " txn-struct-01: Cash deposit of 4950 USD, just under the 5000 reporting threshold. Third\n", " txn-review-struct: Cash deposit of 4950 USD, just under the 5000 reporting threshold, follo\n", " txn-struct-02: Cash deposit of 4900 USD just below the 5000 CTR threshold, same account\n" ] } ], "source": [ "hits = list(\n", " coll.aggregate(\n", " build_lexical_pipeline(\"cash deposit just under the reporting threshold\", limit=3)\n", " )\n", ")\n", "print(\"full-text matches:\")\n", "for h in hits:\n", " print(f\" {h['transaction_id']}: {h['text'][:72]}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Pattern 3: Hybrid (reciprocal rank fusion)\n", "\n", "Fuse the vector and full-text rankings into one result set. `$rankFusion` (MongoDB 8.0+) runs\n", "both input pipelines server-side and combines them by reciprocal rank — no client-side merging,\n", "no second round-trip." ] }, { "cell_type": "code", "execution_count": 9, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "hybrid via $rankFusion (server-side):\n", " txn-struct-01: Cash deposit of 4950 USD, just under the 5000 reporting threshold. Third\n", " txn-struct-02: Cash deposit of 4900 USD just below the 5000 CTR threshold, same account\n", " txn-struct-03: Transfer of 4999 USD deliberately under 5000 to avoid reporting. Pattern\n" ] } ], "source": [ "qvec, query = query_doc[\"embedding\"], \"structuring: cash deposit just under the threshold\"\n", "hits = list(coll.aggregate(build_rank_fusion_pipeline(qvec, query, k=3)))\n", "print(\"hybrid via $rankFusion (server-side):\")\n", "for h in hits:\n", " print(f\" {h['transaction_id']}: {h['text'][:72]}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Pattern 4: Graph traversal (`$graphLookup`)\n", "\n", "Follow `sender.account_number -> recipient.account_number` links to surface a network (here, a\n", "circular money-flow ring). This is a *relationship* signal, deliberately separate from the\n", "similarity ranking above." ] }, { "cell_type": "code", "execution_count": 10, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "graph traversal from ACC-RING-A: network_size=4 circular_flow=True suspicious=True\n" ] } ], "source": [ "graph_doc = next(\n", " iter(coll.aggregate(build_graph_pipeline(\"ACC-RING-A\", collection=coll.name))), {\"chain\": []}\n", ")\n", "ring = summarize_ring(graph_doc, seed_account=\"ACC-RING-A\")\n", "print(\n", " f\"graph traversal from ACC-RING-A: network_size={ring['network_size']} \"\n", " f\"circular_flow={ring['circular_flow']} suspicious={ring['suspicious_patterns']}\"\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Adapt these for your domain.** Each builder is a generic shape: embed your text with any\n", "model and point `build_vector_pipeline` at your field; set the searchable paths for full-text;\n", "tune the `$rankFusion` input weights to bias semantic vs. lexical; change the graph\n", "`connectFromField`/`connectToField` to your relationship (citations, org charts, supply chains).\n", "All four are defined in the cell above — lift them straight into your own project." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3. The end-to-end agent: human-in-the-loop fraud review\n", "\n", "Now everything works together. A Managed Agent reviews flagged transactions, grounding each\n", "recommendation in the MongoDB signals from Section 2 (exposed as **custom tools** over the\n", "Path A round-trip), and pauses for a human on the risky cases. Decisions and an append-only\n", "audit trail are written back to the same cluster.\n", "\n", "
\n",
"
\n",
"