ea9bbcabed
Co-authored-by: Pratik <10096516+pratikgajjar@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
764 lines
34 KiB
Plaintext
764 lines
34 KiB
Plaintext
{
|
|
"cells": [
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"# User Profiles — live demo\n",
|
|
"\n",
|
|
"A **profile** is a structured JSON document about ONE user, filled by an LLM from that\n",
|
|
"user's memories, shaped by a JSON Schema you supply.\n",
|
|
"\n",
|
|
"Search answers *\"what did this user say about X\"*. A profile answers *\"who is this\n",
|
|
"user\"*, in one read, with no query to write — and it is available on the first turn of a\n",
|
|
"session, before the user has said anything.\n",
|
|
"\n",
|
|
"**What this notebook does:** feed a user 12 conversation turns, watch a profile get\n",
|
|
"generated from them, add 6 more turns that contradict the first set, and watch the\n",
|
|
"profile rewrite itself. Then it shows every way the API says no.\n",
|
|
"\n",
|
|
"**You need:** an API key, and a project on the **Pro plan or higher**. Never commit one.\n",
|
|
"\n",
|
|
"> Set `MEM0_API_KEY`, and `MEM0_API_HOST` if you are pointing at a sandbox rather than\n",
|
|
"> production. The cells below read both from the environment.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"> **Use a disposable project.** This notebook overwrites the project's profile settings\n",
|
|
"> (enabled, schema, custom instructions). The last cell restores the values saved at the\n",
|
|
"> start, but only if you reach it: if a cell fails midway, the project keeps the demo\n",
|
|
"> schema until you run the cleanup cell or reset it yourself. Do not point it at a\n",
|
|
"> project other people or production traffic depend on.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# This notebook drives the SDK from this worktree, not the published mem0ai:\n",
|
|
"# the profile fixes below are not released yet.\n",
|
|
"%pip install -q -e ../..\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": "import json\nimport os\nimport time\nimport uuid\n\nimport mem0\nfrom mem0 import MemoryClient\n\nAPI_KEY = os.environ.get(\"MEM0_API_KEY\")\nif not API_KEY:\n import getpass\n\n API_KEY = getpass.getpass(\"API key: \")\n\nclient = MemoryClient(api_key=API_KEY, host=os.environ.get(\"MEM0_API_HOST\") or None)\n\n# Fresh id each run, so nothing below is stale from a previous pass.\nUSER_ID = f\"demo_{uuid.uuid4().hex[:8]}\"\n\n# Snapshot the project's profile settings up front. This notebook overwrites the\n# shared project schema/instructions/enabled below; the cleanup cell restores this.\nORIGINAL_SETTINGS = client.get_profile_settings()\n\nprint(\"sdk :\", mem0.__file__) # must be this worktree\nprint(\"host :\", client.host)\nprint(\"demo user:\", USER_ID)"
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 1. Define the schema\n",
|
|
"\n",
|
|
"The schema is handed to the model as a **tool definition**, and each field's\n",
|
|
"`description` is the only instruction the model gets about what belongs there. An\n",
|
|
"undescribed field is a field the model guesses at.\n",
|
|
"\n",
|
|
"Rules worth knowing:\n",
|
|
"\n",
|
|
"- root `type: object` with a **non-empty** `properties` — an empty one is refused, because\n",
|
|
" it would bill you to extract nothing\n",
|
|
"- the root keys `_profile_config_version` and `entities` are **reserved** and rejected:\n",
|
|
" they name the storage envelope, so a schema using them could not be read back\n",
|
|
" unambiguously\n",
|
|
"- keep it small. The whole schema is sent to the model on every generation\n",
|
|
"\n",
|
|
"Descriptions are **not** enforced on write in this build — a property without one is\n",
|
|
"accepted and then quietly underfilled at generation time. Section F1 demonstrates it.\n",
|
|
"Treat descriptions as your job, not the validator's.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"SCHEMA = {\n",
|
|
" \"type\": \"object\",\n",
|
|
" \"properties\": {\n",
|
|
" \"occupation\": {\n",
|
|
" \"type\": \"string\",\n",
|
|
" \"description\": \"The person's current job title, in one short phrase.\",\n",
|
|
" },\n",
|
|
" \"location\": {\n",
|
|
" \"type\": \"string\",\n",
|
|
" \"description\": \"The city or region the person currently lives in.\",\n",
|
|
" },\n",
|
|
" \"interests\": {\n",
|
|
" \"type\": \"array\",\n",
|
|
" \"items\": {\"type\": \"string\"},\n",
|
|
" \"description\": \"Hobbies and topics they return to, as short lowercase tags.\",\n",
|
|
" },\n",
|
|
" \"dietary_restrictions\": {\n",
|
|
" \"type\": \"array\",\n",
|
|
" \"items\": {\"type\": \"string\"},\n",
|
|
" \"description\": \"Foods the person avoids, and why, if they said.\",\n",
|
|
" },\n",
|
|
" \"communication_style\": {\n",
|
|
" \"type\": \"string\",\n",
|
|
" \"enum\": [\"concise\", \"detailed\", \"casual\", \"formal\"],\n",
|
|
" \"description\": \"How this person prefers to be answered.\",\n",
|
|
" },\n",
|
|
" \"expertise_level\": {\n",
|
|
" \"type\": \"string\",\n",
|
|
" \"enum\": [\"beginner\", \"intermediate\", \"advanced\"],\n",
|
|
" \"description\": \"Their technical depth, judged from how they discuss their work.\",\n",
|
|
" },\n",
|
|
" },\n",
|
|
"}\n",
|
|
"\n",
|
|
"settings = client.update_profile_settings(\n",
|
|
" enabled=True,\n",
|
|
" schema=SCHEMA,\n",
|
|
" custom_instructions=(\n",
|
|
" \"Prefer facts the person stated outright over anything inferred. \"\n",
|
|
" \"Leave a field empty rather than guessing.\"\n",
|
|
" ),\n",
|
|
")\n",
|
|
"\n",
|
|
"# Sorted, because JSONB storage does not preserve the key order you sent.\n",
|
|
"# Compare a stored schema by SET, never by string or by key order.\n",
|
|
"stored = settings[\"entities\"][\"user\"][\"schema\"]\n",
|
|
"print(\"schema fields:\", sorted(stored[\"properties\"]))\n",
|
|
"print(\"enabled :\", settings[\"enabled\"])\n",
|
|
"print(\"capabilities :\", settings[\"capabilities\"])\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"`enabled` is project-wide; `schema` and `custom_instructions` apply to user profiles\n",
|
|
"and are stored under `entities`. The SDK takes them flat and nests them for you, so what\n",
|
|
"you write comes back unchanged from `get_profile_settings()`.\n",
|
|
"\n",
|
|
"Only the arguments you pass are written. To turn the feature off without touching your\n",
|
|
"schema, send `enabled` alone.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 2. The turns\n",
|
|
"\n",
|
|
"Twelve conversation turns for one user. Nothing about `add()` changes — profiles are a\n",
|
|
"side effect of the normal pipeline.\n",
|
|
"\n",
|
|
"Twelve, not five, because generation fires when an entity crosses a **10-message\n",
|
|
"boundary**. Below that it waits for a flush window measured in hours, and this notebook\n",
|
|
"would sit there.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"TURNS = [\n",
|
|
" (\"user\", \"Hey — I just moved to Berlin for a new job.\"),\n",
|
|
" (\"assistant\", \"Congratulations! What's the new role?\"),\n",
|
|
" (\"user\", \"Senior data engineer at a logistics company. Mostly Spark and Airflow.\"),\n",
|
|
" (\"assistant\", \"Nice stack. How are you finding the pipelines there?\"),\n",
|
|
" (\"user\", \"Honestly the DAGs are a mess. I've been rewriting the partitioning to cut shuffle.\"),\n",
|
|
" (\"assistant\", \"That usually pays off fast. Anything blocking you?\"),\n",
|
|
" (\"user\", \"Just time. Keep it short when you answer me, I skim everything.\"),\n",
|
|
" (\"assistant\", \"Understood — short answers from here.\"),\n",
|
|
" (\"user\", \"Outside work I climb most weekends, and I'm learning German.\"),\n",
|
|
" (\"assistant\", \"Bouldering or ropes?\"),\n",
|
|
" (\"user\", \"Bouldering. Also — I'm vegetarian, so skip meat in any recipe suggestions.\"),\n",
|
|
" (\"assistant\", \"Noted, vegetarian only.\"),\n",
|
|
"]\n",
|
|
"\n",
|
|
"response = client.add(\n",
|
|
" [{\"role\": r, \"content\": c} for r, c in TURNS],\n",
|
|
" user_id=USER_ID,\n",
|
|
")\n",
|
|
"print(json.dumps(response, indent=2)[:300])\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"The add is **async** — it returns an `event_id` and the memories do not exist yet. Poll\n",
|
|
"`GET /v1/event/{event_id}/` until it is `SUCCEEDED` or `FAILED`; that, not a sleep, is how\n",
|
|
"you know the add finished. Then let the extracted memories settle.\n",
|
|
"\n",
|
|
"Under load this can take a minute or more, so the cell says plainly whether it ran out of\n",
|
|
"time rather than printing `0 memories` as though that were the answer.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"event_id = response[\"event_id\"]\n",
|
|
"deadline = time.time() + 300\n",
|
|
"\n",
|
|
"# 1. The add itself. Terminal status, not a sleep.\n",
|
|
"event_status = None\n",
|
|
"while time.time() < deadline:\n",
|
|
" event_status = client.client.get(f\"/v1/event/{event_id}/\").json().get(\"status\")\n",
|
|
" if event_status in (\"SUCCEEDED\", \"FAILED\"):\n",
|
|
" break\n",
|
|
" print(f\" add {event_status}\")\n",
|
|
" time.sleep(5)\n",
|
|
"print(f\"add finished: {event_status}\")\n",
|
|
"# Stop here unless the add SUCCEEDED. A failed or unfinished add would otherwise let the\n",
|
|
"# generation below bill for a profile built without these memories.\n",
|
|
"if event_status != \"SUCCEEDED\":\n",
|
|
" raise RuntimeError(f\"add did not succeed (status={event_status}); not generating a profile\")\n",
|
|
"\n",
|
|
"# 2. Extraction lands in batches, so the FIRST non-empty page is not the whole set.\n",
|
|
"# Wait for the count to stop growing instead of breaking on the first result.\n",
|
|
"memories, stable = [], 0\n",
|
|
"while time.time() < deadline:\n",
|
|
" page = client.get_all(filters={\"user_id\": USER_ID}, page_size=50)\n",
|
|
" found = page.get(\"results\", []) if isinstance(page, dict) else page\n",
|
|
" stable = stable + 1 if found and len(found) == len(memories) else 0\n",
|
|
" memories = found\n",
|
|
" if stable >= 2: # two identical polls in a row\n",
|
|
" break\n",
|
|
" print(f\" ... {len(memories)} so far\")\n",
|
|
" time.sleep(5)\n",
|
|
"\n",
|
|
"if memories:\n",
|
|
" print(f\"\\n{len(memories)} memories extracted:\\n\")\n",
|
|
" for m in memories:\n",
|
|
" print(\" \\u2022\", m.get(\"memory\"))\n",
|
|
"else:\n",
|
|
" # Say so. Reporting '0 memories' as a result hides a busy or broken environment\n",
|
|
" # and makes the profile below look like it came from nothing.\n",
|
|
" print(\"\\nNO memories yet — extraction is still catching up, or the ingestion\")\n",
|
|
" print(\"worker is down. Everything below will report insufficient_data.\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 3. Read the profile\n",
|
|
"\n",
|
|
"Crossing the 10-message boundary should already have queued a generation. Read first —\n",
|
|
"and note that a known user with no profile yet is a **200 with a status**, not a 404. That\n",
|
|
"distinction is the whole point of the envelope.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"envelope = client.get_profile(USER_ID)\n",
|
|
"print(json.dumps(envelope, indent=2))\n",
|
|
"\n",
|
|
"print(\"\\nstatus vocabulary:\")\n",
|
|
"print(\" succeeded terminal — a generation ran AND the profile has content\")\n",
|
|
"print(\" pending queued or running\")\n",
|
|
"print(\" failed terminal — the last generation did not complete\")\n",
|
|
"print(\" not_enabled feature off, or plan below Pro\")\n",
|
|
"print(\" insufficient_data no content to show: no row yet, queued, or a\")\n",
|
|
"print(\" generation that legitimately found nothing\")\n",
|
|
"print()\n",
|
|
"print(\"`succeeded` is decided by the profile BODY, not by generation_count: an\")\n",
|
|
"print(\"empty extraction still increments the counter, so counting generations\")\n",
|
|
"print(\"reports 'done' for a profile with nothing in it.\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"### Force it, rather than waiting\n",
|
|
"\n",
|
|
"`generate_profile()` closes the bootstrapping gap: without it a new user has no profile\n",
|
|
"until their tenth message. One entity, a few seconds.\n",
|
|
"\n",
|
|
"Each call sends a new `Idempotency-Key` unless you pass one, and a new key starts a new job.\n",
|
|
"To retry a dropped request safely, generate the key yourself and pass the same\n",
|
|
"`idempotency_key` on every attempt: the server then returns the original job instead of\n",
|
|
"billing a second one.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"TERMINAL = {\"succeeded\", \"failed\", \"not_enabled\"}\n",
|
|
"\n",
|
|
"\n",
|
|
"def wait_for_profile(entity_id, timeout=300, interval=5, since=None):\n",
|
|
" \"\"\"Poll until terminal.\n",
|
|
"\n",
|
|
" `since` waits for a generation_count ABOVE that value, which is how you wait\n",
|
|
" for an UPDATE rather than accepting the profile you already had.\n",
|
|
"\n",
|
|
" `insufficient_data` is NOT terminal by itself — it also covers 'queued', so\n",
|
|
" poll through it and give up on the timeout instead.\n",
|
|
" \"\"\"\n",
|
|
" deadline = time.time() + timeout\n",
|
|
" body = None\n",
|
|
" while time.time() < deadline:\n",
|
|
" body = client.get_profile(entity_id)\n",
|
|
" status = (body.get(\"status\") or \"\").lower()\n",
|
|
" count = body.get(\"generation_count\") or 0\n",
|
|
" fresh = count > since if since is not None else True\n",
|
|
" if status == \"succeeded\" and fresh:\n",
|
|
" return body\n",
|
|
" if status in (\"failed\", \"not_enabled\"):\n",
|
|
" raise RuntimeError(f\"generation stopped: {status}\")\n",
|
|
" print(f\" ... {status} (generation_count={count})\")\n",
|
|
" time.sleep(interval)\n",
|
|
" raise TimeoutError(f\"not ready in {timeout}s: {body}\")\n",
|
|
"\n",
|
|
"\n",
|
|
"print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
|
|
"print(\"\\npolling...\")\n",
|
|
"\n",
|
|
"try:\n",
|
|
" body = wait_for_profile(USER_ID)\n",
|
|
" print(\"\\n=== PROFILE ===\")\n",
|
|
" print(json.dumps(body[\"profile\"], indent=2))\n",
|
|
" print(f\"\\nstatus={body['status']} generations={body['generation_count']} updated={body['updated_at']}\")\n",
|
|
"except TimeoutError as e:\n",
|
|
" # Say so plainly and let the rest of the notebook skip, rather than raising\n",
|
|
" # a NameError in every cell below and burying the real cause.\n",
|
|
" body = None\n",
|
|
" print(f\"\\nNO PROFILE: {e}\")\n",
|
|
" print(\"Generation never finished. Usually the ingestion worker is down, or\")\n",
|
|
" print(\"this project has no memories for the user yet.\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# The model must not invent fields outside your schema — the forced tool call is\n",
|
|
"# what makes that structural rather than a request.\n",
|
|
"if body is None:\n",
|
|
" print(\"skipped — no profile was generated above\")\n",
|
|
"else:\n",
|
|
" extra = set(body[\"profile\"]) - set(SCHEMA[\"properties\"])\n",
|
|
" print(\"fields outside the schema:\", extra or \"none\")\n",
|
|
"\n",
|
|
" # A forced JSON-Schema response makes the model emit SOMETHING for every property,\n",
|
|
" # so 'I found nothing' arrives as a type default: 0, \"\", [].\n",
|
|
" filled = {k: v for k, v in body[\"profile\"].items() if v not in (None, \"\", [], {}, 0)}\n",
|
|
" print(f\"genuinely populated: {len(filled)}/{len(SCHEMA['properties'])} -> {list(filled)}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 4. Now watch it update\n",
|
|
"\n",
|
|
"Six more turns that contradict and extend what we already know: a promotion, a move, a\n",
|
|
"dropped hobby. A profile is a living document, not an append-only log — the model gets the\n",
|
|
"memories and rewrites the whole thing.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"if body is None:\n",
|
|
" print(\"skipped — no profile was generated above\")\n",
|
|
"else:\n",
|
|
" before = body[\"generation_count\"]\n",
|
|
"\n",
|
|
" MORE_TURNS = [\n",
|
|
" (\"user\", \"Update — I got promoted to staff engineer last week.\"),\n",
|
|
" (\"assistant\", \"Congratulations. Same team?\"),\n",
|
|
" (\"user\", \"Same company, but I'm relocating to Munich for it.\"),\n",
|
|
" (\"assistant\", \"Big move. How do you feel about it?\"),\n",
|
|
" (\"user\", \"Good. I've stopped climbing though — knee injury. Picked up cycling instead.\"),\n",
|
|
" (\"assistant\", \"Sorry about the knee. Cycling's kinder on it.\"),\n",
|
|
" ]\n",
|
|
"\n",
|
|
" followup = client.add(\n",
|
|
" [{\"role\": r, \"content\": c} for r, c in MORE_TURNS],\n",
|
|
" user_id=USER_ID,\n",
|
|
" )\n",
|
|
"\n",
|
|
" # Wait for the add to land before triggering: a generation queued before the new\n",
|
|
" # memories exist rewrites the profile from the OLD ones and looks like a no-op.\n",
|
|
" deadline = time.time() + 300\n",
|
|
" status = None\n",
|
|
" while time.time() < deadline:\n",
|
|
" status = client.client.get(f\"/v1/event/{followup['event_id']}/\").json().get(\"status\")\n",
|
|
" if status in (\"SUCCEEDED\", \"FAILED\"):\n",
|
|
" break\n",
|
|
" time.sleep(5)\n",
|
|
" print(\"follow-up add:\", status)\n",
|
|
" # Generating after a FAILED or unfinished add bills for a profile built from the OLD\n",
|
|
" # memories only, so stop instead.\n",
|
|
" if status != \"SUCCEEDED\":\n",
|
|
" raise RuntimeError(f\"follow-up add did not succeed (status={status}); not regenerating\")\n",
|
|
" time.sleep(15) # let extraction settle\n",
|
|
"\n",
|
|
" print(json.dumps(client.generate_profile(USER_ID), indent=2))\n",
|
|
" print(f\"\\npolling for a NEW generation (count must exceed {before})...\")\n",
|
|
" updated = wait_for_profile(USER_ID, since=before)\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"if body is None:\n",
|
|
" print(\"skipped — no profile was generated above\")\n",
|
|
"else:\n",
|
|
" print(f\"{'field':<22} {'before':<34} after\")\n",
|
|
" print(\"-\" * 92)\n",
|
|
" for field in SCHEMA[\"properties\"]:\n",
|
|
" b = json.dumps(body[\"profile\"].get(field))\n",
|
|
" a = json.dumps(updated[\"profile\"].get(field))\n",
|
|
" mark = \" \" if a == b else \"->\"\n",
|
|
" print(f\"{mark} {field:<20} {b[:32]:<34} {a[:32]}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 5. Use it in a prompt\n",
|
|
"\n",
|
|
"The point of the structure is that it drops straight into a prompt — no list of memories\n",
|
|
"to summarize, no query to write.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"def build_system_prompt(entity_id):\n",
|
|
" result = client.get_profile(entity_id)\n",
|
|
" if result[\"status\"] != \"succeeded\":\n",
|
|
" # Branch on status, never on an empty profile: a user whose profile is\n",
|
|
" # still building is not a user you know nothing about.\n",
|
|
" return \"You are a helpful assistant.\"\n",
|
|
"\n",
|
|
" p = result[\"profile\"]\n",
|
|
" return f\"\"\"You are helping {entity_id}.\n",
|
|
"Occupation: {p.get(\"occupation\", \"unknown\")}\n",
|
|
"Location: {p.get(\"location\", \"unknown\")}\n",
|
|
"Interests: {\", \".join(p.get(\"interests\", [])) or \"unknown\"}\n",
|
|
"Dietary restrictions: {\", \".join(p.get(\"dietary_restrictions\", [])) or \"none stated\"}\n",
|
|
"Preferred style: {p.get(\"communication_style\", \"unknown\")}\n",
|
|
"\n",
|
|
"Match their style and do not explain what they already know.\"\"\"\n",
|
|
"\n",
|
|
"\n",
|
|
"print(build_system_prompt(USER_ID))\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 6. Judge a schema before committing to it\n",
|
|
"\n",
|
|
"`sample_profiles()` runs your schema against up to 10 **real** users that have memories.\n",
|
|
"\n",
|
|
"These are real generations and the results are **kept** — a dry run would cost exactly the\n",
|
|
"same and leave those users no better off. It is not a free preview.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# 202, not 200: the sample generations are queued, not finished.\n",
|
|
"#\n",
|
|
"# A 409 `already_running` means a sample from an earlier run is still going.\n",
|
|
"# That is the cooldown working, not an error — reuse that job rather than\n",
|
|
"# failing the notebook.\n",
|
|
"try:\n",
|
|
" job = client.sample_profiles(limit=3)\n",
|
|
" print(json.dumps(job, indent=2)[:400])\n",
|
|
" print(\"\\nsampled\", job.get(\"sampled\"), \"entities:\", job.get(\"entity_ids\"))\n",
|
|
"except Exception as e:\n",
|
|
" detail = str(e)\n",
|
|
" print(\"sample refused:\", detail[:200])\n",
|
|
" running = json.loads(detail).get(\"error\", {}).get(\"job_id\") if detail.startswith(\"{\") else None\n",
|
|
" job = {\"job_id\": running, \"status_url\": f\"/v2/profiles/jobs/{running}/\"} if running else None\n",
|
|
" print(\"reusing the running job:\", running)\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"Poll `status_url` to see how the job went. `total` is `null` until enumeration finishes,\n",
|
|
"so format it defensively rather than assuming a number.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": "JOB_TERMINAL = {\"SUCCEEDED\", \"PARTIALLY_SUCCEEDED\", \"FAILED\", \"CANCELLED\"}\n\n\ndef wait_for_job(job_response, timeout=300, interval=5):\n \"\"\"Poll a generation job. Prefer status_url over a bare job id, so a route\n change needs no client update. Raise on timeout so an unfinished job is never\n mistaken for a finished one.\"\"\"\n handle = job_response.get(\"status_url\") or job_response[\"job_id\"]\n deadline = time.time() + timeout\n status = None\n while time.time() < deadline:\n status = client.get_profile_job(handle)[\"job\"]\n total = status.get(\"total\")\n print(\n f\" {status['status']} \"\n f\"completed={status.get('completed', 0)}/{total if total is not None else '?'} \"\n f\"succeeded={status.get('succeeded', 0)} \"\n f\"failed={status.get('failed', 0)} \"\n f\"skipped={status.get('skipped', 0)}\"\n )\n if str(status.get(\"status\", \"\")).upper() in JOB_TERMINAL:\n return status\n time.sleep(interval)\n raise TimeoutError(\n f\"job not terminal in {timeout}s (last status: {status.get('status') if status else 'none'})\"\n )\n\n\nif job is None:\n print(\"no sample job to poll\")\nelse:\n final = wait_for_job(job)\n\n print(\"\\n--- what the sample produced ---\")\n for entity_id in job.get(\"entity_ids\", []):\n got = client.get_profile(entity_id)\n print(f\"\\n{entity_id} [{got['status']}]\")\n print(\" \", json.dumps(got[\"profile\"])[:220])"
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 7. Apply a new schema to existing users\n",
|
|
"\n",
|
|
"A new schema shapes the **next** generation. Profiles that already exist keep their values\n",
|
|
"until their user is generated again — which happens as that user sends more memories, or\n",
|
|
"when you call `generate_profile()` for them.\n",
|
|
"\n",
|
|
"A field you **remove** stops being maintained: on the next generation, fields your schema\n",
|
|
"no longer defines are pruned. Keep a field for as long as you want its value kept."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"# Failure scenarios\n",
|
|
"\n",
|
|
"Everything above is the path that works. These are the ways it says no, and what each one\n",
|
|
"means. Run this section last: F3 deliberately leaves the project switched off for a moment.\n",
|
|
"\n",
|
|
"> **About the `HTTP error occurred:` lines below.** The SDK logs every 4xx at\n",
|
|
"> ERROR level before raising, so they appear even for the failures these cells\n",
|
|
"> deliberately catch. Read the line printed *after* each one — that is the cell's\n",
|
|
"> own verdict. Nothing here is unhandled.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## F1. Schemas that get rejected\n",
|
|
"\n",
|
|
"Rejections happen on **write**, where you can see and fix them — not silently at\n",
|
|
"generation time, where you would only notice as an empty profile weeks later.\n",
|
|
"\n",
|
|
"The last case matters for storage: the user schema lives in one JSONB column alongside\n",
|
|
"the envelope that separates it, so a schema using the envelope's own reserved keys could\n",
|
|
"not be read back unambiguously. It is refused rather than stored.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"BAD_SCHEMAS = [\n",
|
|
" ({\"type\": \"object\", \"properties\": {}}, \"empty — bills you to extract nothing\"),\n",
|
|
" ({\"type\": \"array\", \"items\": {\"type\": \"string\"}}, \"root must be an object\"),\n",
|
|
" (\n",
|
|
" {\n",
|
|
" \"type\": \"object\",\n",
|
|
" \"properties\": {\"tone\": {\"type\": \"string\", \"description\": \"Preferred tone.\"}},\n",
|
|
" # At the schema ROOT, which is where the envelope's own keys live.\n",
|
|
" \"_profile_config_version\": 1,\n",
|
|
" \"entities\": {\"user\": {}},\n",
|
|
" },\n",
|
|
" \"reserved settings keys at the schema root\",\n",
|
|
" ),\n",
|
|
"]\n",
|
|
"\n",
|
|
"for bad, why in BAD_SCHEMAS:\n",
|
|
" try:\n",
|
|
" client.update_profile_settings(schema=bad)\n",
|
|
" print(f\"ACCEPTED (unexpected): {why}\")\n",
|
|
" except Exception as e:\n",
|
|
" print(f\"rejected [{why}]:\\n {str(e)[:160]}\\n\")\n",
|
|
"\n",
|
|
"# NOT rejected: a property with no description. The validator allows it and the\n",
|
|
"# model then has nothing to go on, so the field comes back empty. Descriptions are\n",
|
|
"# your job, not the validator's.\n",
|
|
"try:\n",
|
|
" client.update_profile_settings(schema={\"type\": \"object\", \"properties\": {\"x\": {\"type\": \"string\"}}})\n",
|
|
" print(\"accepted [no description on 'x'] <- the trap: valid to store, useless to generate\")\n",
|
|
"finally:\n",
|
|
" client.update_profile_settings(schema=SCHEMA) # put the good one back\n",
|
|
"\n",
|
|
"restored = client.get_profile_settings()[\"entities\"][\"user\"][\"schema\"]\n",
|
|
"assert set(restored[\"properties\"]) == set(SCHEMA[\"properties\"])\n",
|
|
"print(\"\\nschema restored:\", sorted(restored[\"properties\"]))\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## F2. A user that does not exist\n",
|
|
"\n",
|
|
"404 means only \"no such user\". A known user with no profile yet is a 200 carrying\n",
|
|
"`insufficient_data`, so an ordinary empty state never looks like an error.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"from mem0.exceptions import MemoryNotFoundError\n",
|
|
"\n",
|
|
"try:\n",
|
|
" client.get_profile(\"user_who_never_existed\")\n",
|
|
" print(\"ACCEPTED (unexpected)\")\n",
|
|
"except MemoryNotFoundError as e:\n",
|
|
" print(\"404 as intended:\", str(e)[:120])\n",
|
|
"\n",
|
|
"# ...versus a real user who simply has no profile row yet.\n",
|
|
"fresh = f\"demo_never_profiled_{uuid.uuid4().hex[:6]}\"\n",
|
|
"client.add([{\"role\": \"user\", \"content\": \"One passing remark.\"}], user_id=fresh)\n",
|
|
"time.sleep(5)\n",
|
|
"print(\"known but unprofiled:\", client.get_profile(fresh)[\"status\"])\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## F3. Profiles turned off\n",
|
|
"\n",
|
|
"`enabled` is the one project-wide switch. Every generation path then refuses.\n",
|
|
"\n",
|
|
"Nothing is deleted. Your schema and every profile you already built are kept, so turning\n",
|
|
"it back on resumes rather than restarts.\n",
|
|
"\n",
|
|
"Note what a read does **not** do — a profile that already exists keeps reporting\n",
|
|
"`succeeded` and keeps returning its content. `not_enabled` is only what you get for a user\n",
|
|
"with no profile yet. Turning the feature off stops new work; it does not hide what has\n",
|
|
"already been built.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"client.update_profile_settings(enabled=False)\n",
|
|
"\n",
|
|
"print(\"read (demo user) :\", client.get_profile(USER_ID)[\"status\"])\n",
|
|
"print(\"read (never profiled) :\", client.get_profile(fresh)[\"status\"])\n",
|
|
"try:\n",
|
|
" client.generate_profile(USER_ID)\n",
|
|
" print(\"trigger: ACCEPTED (unexpected)\")\n",
|
|
"except Exception as e:\n",
|
|
" print(\"trigger:\", str(e)[:160])\n",
|
|
"\n",
|
|
"back = client.update_profile_settings(enabled=True) # put it back\n",
|
|
"print(\"\\nrestored:\", back[\"enabled\"])\n",
|
|
"print(\"schema survived:\", bool(back[\"entities\"][\"user\"][\"schema\"]))\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 7. Cleanup\n",
|
|
"\n",
|
|
"Removes the demo users. The profile row cascades with the entity.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": "# Restore the project's profile settings to the start-of-run snapshot in `finally`, so a\n# failed delete still leaves a shared project as we found it. Passing the original values\n# (including None) clears anything this notebook set: the SDK treats an explicit None as\n# \"clear\" and an omitted argument as \"unchanged\".\ntry:\n # `fresh` only exists if the error-handling section ran.\n for entity_id in (USER_ID, globals().get(\"fresh\")):\n if entity_id is None:\n continue\n r = client.client.delete(f\"/v2/entities/user/{entity_id}/\")\n print(entity_id, \"->\", r.status_code)\nfinally:\n _user = ORIGINAL_SETTINGS.get(\"entities\", {}).get(\"user\", {})\n client.update_profile_settings(\n enabled=ORIGINAL_SETTINGS.get(\"enabled\", False),\n schema=_user.get(\"schema\"),\n custom_instructions=_user.get(\"custom_instructions\"),\n )\n print(\"profile settings restored to the pre-notebook snapshot\")"
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"---\n",
|
|
"\n",
|
|
"## Cheat sheet\n",
|
|
"\n",
|
|
"| Want | Call | Cost |\n",
|
|
"| --- | --- | --- |\n",
|
|
"| configure | `update_profile_settings(...)` | free |\n",
|
|
"| read | `get_profile(user_id)` | free |\n",
|
|
"| one user now | `generate_profile(user_id)` | 1 LLM call |\n",
|
|
"| try a schema | `sample_profiles(limit=n)` | ≤10 real generations, kept |\n",
|
|
"| poll a job | `get_profile_job(status_url)` | free |\n",
|
|
"\n",
|
|
"**Settings apply to user profiles.** The stored shape is:\n",
|
|
"\n",
|
|
"```json\n",
|
|
"{\"enabled\": true,\n",
|
|
" \"entities\": {\"user\": {\"schema\": {...}, \"custom_instructions\": \"...\"}},\n",
|
|
" \"capabilities\": {\"full_rebuild\": false}}\n",
|
|
"```\n",
|
|
"\n",
|
|
"The SDK takes these flat and nests them for you. Only the fields you pass are written;\n",
|
|
"`enabled` is the one project-wide switch.\n",
|
|
"\n",
|
|
"**Left alone, generation fires** on a 10-message boundary, or after a flush window\n",
|
|
"measured in hours. `generate_profile()` is how you skip the wait for one user.\n",
|
|
"\n",
|
|
"**Three traps:**\n",
|
|
"\n",
|
|
"1. `insufficient_data` is not a terminal verdict — it also covers \"queued\", so poll\n",
|
|
" through it and give up on a timeout instead.\n",
|
|
"2. `succeeded` is decided by the profile **body**, not `generation_count`. An empty\n",
|
|
" extraction still increments the counter.\n",
|
|
"3. A forced JSON-Schema response emits something for every property, so \"nothing found\"\n",
|
|
" arrives as a type default — `\"\"`, `[]`, `0` — not as a missing key.\n"
|
|
]
|
|
}
|
|
],
|
|
"metadata": {
|
|
"kernelspec": {
|
|
"display_name": "Python 3 (ipykernel)",
|
|
"language": "python",
|
|
"name": "python3"
|
|
},
|
|
"language_info": {
|
|
"codemirror_mode": {
|
|
"name": "ipython",
|
|
"version": 3
|
|
},
|
|
"file_extension": ".py",
|
|
"mimetype": "text/x-python",
|
|
"name": "python",
|
|
"nbconvert_exporter": "python",
|
|
"pygments_lexer": "ipython3",
|
|
"version": "3.12.4"
|
|
}
|
|
},
|
|
"nbformat": 4,
|
|
"nbformat_minor": 4
|
|
} |