fix(profiles): correct the settings shape, job entity_type and status type
Four bugs that made the profile SDK unusable against the live API, each
found by running the demo notebook end to end rather than by reading.
1. `update_profile_settings()` sent a flat body:
{"enabled": ..., "schema": ..., "custom_instructions": ...}
The API takes only `enabled` and `entities` at the top level and
answers 400 "Unsupported settings: custom_instructions, schema." to
anything else, so every call passing a schema failed.
`get_profile_settings()` already returned the nested shape, so the read
and the write disagreed and the method could not round-trip its own
settings. Both SDKs now nest `schema` and `custom_instructions` under
`entities.<entity_type>` while keeping the flat call signature;
`entity_type` is a new optional argument defaulting to "user".
2. `sample_profiles()` and `regenerate_profiles()` never sent
`entity_type`. Every profile job must name an entity kind, so both
failed with "entity_type must be one of: user, agent."
3. The TypeScript read path rewrote the customer's schema property names.
Once the schema moved under `entities`, `snakeToCamelKeys` camel-cased
the keys inside it, because only a top-level schema was restored
verbatim. A field named `favorite_topics` came back as `favoriteTopics`.
4. `ProfileStatus` declared `notEnabled` and `insufficientData`, but a
status is a value, not a key, so it is never camel-cased. tsc rejected
`status === "insufficient_data"`, which is true at runtime, and accepted
`status === "insufficientData"`, which can never fire. Branching on
status is the documented way to use a profile, so the type steered
every TypeScript caller into a dead branch.
Response-shape corrections found alongside them: sample returns
`entity_ids` on create and a richer `results` array on the job, so the TS
`results` field on the create response is marked deprecated and never set;
regenerate answers 409, not the 501 the docstrings claimed.
Verified against a live environment, from both SDKs: a settings write
followed by a read returns the schema property for property, a partial
update no longer blanks it, sample returns 202 with the entities it
picked, regenerate reaches the server and answers its real
not_yet_available, and a user with no profile returns "insufficient_data".
Adds a user-profiles demo notebook covering the whole loop: schema,
ingestion, generation, a before/after diff of a profile rewriting itself,
schema sampling, and a failure-scenario section for each way the API says
no. It polls the add event to a terminal status instead of sleeping, waits
for the extracted memory count to settle rather than trusting the first
page, and reports plainly when generation cannot finish instead of
presenting an empty profile as a result. Executed end to end: 0 failing
cells.
- python: 20 passed (tests/test_client_profiles.py)
- typescript: 203 passed (src/client/tests/)
This commit is contained in:
@@ -0,0 +1,833 @@
|
||||
{
|
||||
"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": "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\n",
|
||||
"import os\n",
|
||||
"import time\n",
|
||||
"import uuid\n",
|
||||
"\n",
|
||||
"import mem0\n",
|
||||
"from mem0 import MemoryClient\n",
|
||||
"\n",
|
||||
"API_KEY = os.environ.get(\"MEM0_API_KEY\")\n",
|
||||
"if not API_KEY:\n",
|
||||
" import getpass\n",
|
||||
"\n",
|
||||
" API_KEY = getpass.getpass(\"API key: \")\n",
|
||||
"\n",
|
||||
"client = 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.\n",
|
||||
"USER_ID = f\"demo_{uuid.uuid4().hex[:8]}\"\n",
|
||||
"\n",
|
||||
"print(\"sdk :\", mem0.__file__) # must be this worktree\n",
|
||||
"print(\"host :\", client.host)\n",
|
||||
"print(\"demo user:\", USER_ID)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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` belong to one entity kind\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",
|
||||
"assert event_status != \"FAILED\", \"the add itself failed — nothing downstream can work\"\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",
|
||||
"Every call is sent with a fresh `Idempotency-Key`, so a retry after a dropped connection\n",
|
||||
"returns the same job instead of paying twice.\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",
|
||||
" 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",
|
||||
" 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\", \"FAILED\", \"COMPLETED\", \"CANCELLED\"}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def 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.\"\"\"\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",
|
||||
" return status\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"if job is None:\n",
|
||||
" print(\"no sample job to poll\")\n",
|
||||
"else:\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])\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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.\n",
|
||||
"\n",
|
||||
"Rebuilding a whole project in one call is **not available yet**. Check\n",
|
||||
"`capabilities.full_rebuild` before offering it in your own UI — the cell below expects to\n",
|
||||
"be refused, and that refusal is the feature working.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"caps = client.get_profile_settings().get(\"capabilities\", {})\n",
|
||||
"print(\"full_rebuild available:\", caps.get(\"full_rebuild\"))\n",
|
||||
"\n",
|
||||
"if caps.get(\"full_rebuild\"):\n",
|
||||
" wait_for_job(client.regenerate_profiles())\n",
|
||||
"else:\n",
|
||||
" try:\n",
|
||||
" client.regenerate_profiles()\n",
|
||||
" print(\"ACCEPTED (unexpected)\")\n",
|
||||
" except Exception as e:\n",
|
||||
" # 409 not_yet_available. It creates nothing, so nothing was charged.\n",
|
||||
" print(\"refused as expected:\", str(e)[:200])\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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: both entity kinds live in one JSONB column alongside\n",
|
||||
"the envelope that separates them, 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": [
|
||||
"## 8. Cleanup\n",
|
||||
"\n",
|
||||
"Removes the demo users. The profile row cascades with the entity.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"for entity_id in (USER_ID, fresh):\n",
|
||||
" r = client.client.delete(f\"/v2/entities/user/{entity_id}/\")\n",
|
||||
" print(entity_id, \"->\", r.status_code)\n",
|
||||
"\n",
|
||||
"# Leave the project's own profile settings alone — other people may share this env.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"| everyone | `regenerate_profiles()` | **not available yet** — 409 |\n",
|
||||
"| poll a job | `get_profile_job(status_url)` | free |\n",
|
||||
"\n",
|
||||
"**Settings are per entity kind.** 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
|
||||
}
|
||||
Reference in New Issue
Block a user