fix(profiles): address SDK/docs review for user profiles v1

Addresses @kartik-mem0's review on mem0#7340, verified against the live
staging profiles API on a neuron:

- generate_profile / sample_profiles accept a caller-supplied idempotency_key,
  so retrying a lost request reuses the job instead of creating a second
  billable one (Python sync+async and TS)
- TS uses the uuid dependency instead of the global crypto.randomUUID(), which
  throws on the supported Node 18 target
- update_profile_settings distinguishes an omitted argument from an explicit
  None, so schema / custom_instructions can be cleared (Python sentinel)
- export ProfileJobResponse and ProfileJobStatus; drop the deprecated
  ProfileTriggerResponse / ProfileSamplesResponse aliases and the unpopulated
  results field; correct usageUnits -> entityCountReserved; add error to
  ProfileResponse
- openapi: nest schema / custom_instructions under entities in the settings
  request and response, add capabilities, and mark entity_type required on the
  job body
- docs sample example polls to a terminal job status with a timeout, then reads
  the create response's entity_ids (status.results raised KeyError)
- notebook: include PARTIALLY_SUCCEEDED in terminal states, raise on timeout,
  and snapshot/restore project settings so a shared env is left as found
- tests: real job_id create shape, idempotency-key reuse, and clear-with-None

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
karthik
2026-09-23 22:56:48 +05:30
parent f29820886a
commit be1ded5793
9 changed files with 322 additions and 163 deletions
+4 -69
View File
@@ -39,30 +39,7 @@
"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"
]
"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",
@@ -527,43 +504,7 @@
"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"
]
"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",
@@ -735,13 +676,7 @@
"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"
]
"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# Restore the project's profile settings to the start-of-run snapshot, so a shared\n# env is left exactly as we found it. Passing the original values (including None)\n# clears anything this notebook set — the SDK distinguishes an explicit None from an\n# omitted argument.\n_user = ORIGINAL_SETTINGS.get(\"entities\", {}).get(\"user\", {})\nclient.update_profile_settings(\n enabled=ORIGINAL_SETTINGS.get(\"enabled\", False),\n schema=_user.get(\"schema\"),\n custom_instructions=_user.get(\"custom_instructions\"),\n)\nprint(\"profile settings restored to the pre-notebook snapshot\")"
},
{
"cell_type": "markdown",
@@ -805,4 +740,4 @@
},
"nbformat": 4,
"nbformat_minor": 4
}
}