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:
co-authored by
Claude Opus 4.8
parent
f29820886a
commit
be1ded5793
+133
-31
@@ -8175,18 +8175,63 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Whether profile generation runs for this project."
|
"description": "Whether profile generation runs for this project. Project-wide."
|
||||||
},
|
},
|
||||||
"schema": {
|
"entities": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true,
|
"description": "Per-entity-type settings. Only `user` is available in this release.",
|
||||||
"nullable": true,
|
"properties": {
|
||||||
"description": "JSON Schema describing the profile. Every property needs a description."
|
"user": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"nullable": true,
|
||||||
|
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||||
|
},
|
||||||
|
"custom_instructions": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Extra guidance for the extraction step."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"nullable": true,
|
||||||
|
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||||
|
},
|
||||||
|
"custom_instructions": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Extra guidance for the extraction step."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"custom_instructions": {
|
"capabilities": {
|
||||||
"type": "string",
|
"type": "object",
|
||||||
"nullable": true,
|
"properties": {
|
||||||
"description": "Extra guidance for the extraction step."
|
"jobs": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"estimates": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"samples": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"full_rebuild": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8208,21 +8253,33 @@
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"description": "Only the fields present are written. `schema` and `custom_instructions` nest under `entities.user`; a flat body is rejected.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Whether profile generation runs for this project."
|
"description": "Whether profile generation runs for this project. Project-wide."
|
||||||
},
|
},
|
||||||
"schema": {
|
"entities": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true,
|
"description": "Per-entity-type settings. Only `user` is available in this release.",
|
||||||
"nullable": true,
|
"properties": {
|
||||||
"description": "JSON Schema describing the profile. Every property needs a description."
|
"user": {
|
||||||
},
|
"type": "object",
|
||||||
"custom_instructions": {
|
"properties": {
|
||||||
"type": "string",
|
"schema": {
|
||||||
"nullable": true,
|
"type": "object",
|
||||||
"description": "Extra guidance for the extraction step."
|
"additionalProperties": true,
|
||||||
|
"nullable": true,
|
||||||
|
"description": "JSON Schema describing the profile. Every property needs a description. Send null to clear it."
|
||||||
|
},
|
||||||
|
"custom_instructions": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Extra guidance for the extraction step. Send null to clear it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8239,18 +8296,63 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Whether profile generation runs for this project."
|
"description": "Whether profile generation runs for this project. Project-wide."
|
||||||
},
|
},
|
||||||
"schema": {
|
"entities": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true,
|
"description": "Per-entity-type settings. Only `user` is available in this release.",
|
||||||
"nullable": true,
|
"properties": {
|
||||||
"description": "JSON Schema describing the profile. Every property needs a description."
|
"user": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"nullable": true,
|
||||||
|
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||||
|
},
|
||||||
|
"custom_instructions": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Extra guidance for the extraction step."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"nullable": true,
|
||||||
|
"description": "JSON Schema describing the profile. Every property needs a description."
|
||||||
|
},
|
||||||
|
"custom_instructions": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Extra guidance for the extraction step."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"custom_instructions": {
|
"capabilities": {
|
||||||
"type": "string",
|
"type": "object",
|
||||||
"nullable": true,
|
"properties": {
|
||||||
"description": "Extra guidance for the extraction step."
|
"jobs": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"estimates": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"samples": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"full_rebuild": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether a project-wide rebuild (regenerate/backfill) is available. Currently false."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8291,7 +8393,8 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"operation"
|
"operation",
|
||||||
|
"entity_type"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"operation": {
|
"operation": {
|
||||||
@@ -8306,8 +8409,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"user"
|
"user"
|
||||||
],
|
]
|
||||||
"default": "user"
|
|
||||||
},
|
},
|
||||||
"entity_id": {
|
"entity_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -182,27 +182,48 @@ Sampling is asynchronous: the call returns a job as soon as it is queued. Poll `
|
|||||||
|
|
||||||
<CodeGroup>
|
<CodeGroup>
|
||||||
```python Python
|
```python Python
|
||||||
|
import time
|
||||||
|
|
||||||
job = client.sample_profiles(limit=5)
|
job = client.sample_profiles(limit=5)
|
||||||
|
|
||||||
# Poll until the sample job finishes.
|
# Poll until the sample job reaches a terminal state (job status is UPPERCASE).
|
||||||
status = client.get_profile_job(job["status_url"])["job"]
|
TERMINAL = {"SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"}
|
||||||
|
deadline = time.time() + 120
|
||||||
|
while True:
|
||||||
|
status = client.get_profile_job(job["status_url"])["job"]
|
||||||
|
if status["status"] in TERMINAL:
|
||||||
|
break
|
||||||
|
if time.time() > deadline:
|
||||||
|
raise TimeoutError("Sample job did not finish in time")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
print(status["status"], status["succeeded"], "of", status["total"])
|
print(status["status"], status["succeeded"], "of", status["total"])
|
||||||
|
|
||||||
# Each result names one sampled entity; read its saved profile.
|
# The create response lists the sampled entities; read each one's saved profile.
|
||||||
for row in status["results"]:
|
for entity_id in job["entity_ids"]:
|
||||||
print(client.get_profile(row["entity_id"]))
|
print(client.get_profile(entity_id))
|
||||||
```
|
```
|
||||||
|
|
||||||
```typescript TypeScript
|
```typescript TypeScript
|
||||||
const job = await client.sampleProfiles({ limit: 5 });
|
const job = await client.sampleProfiles({ limit: 5 });
|
||||||
|
|
||||||
// Poll until the sample job finishes.
|
// Poll until the sample job reaches a terminal state (job status is UPPERCASE).
|
||||||
const { job: status } = await client.getProfileJob(job.statusUrl);
|
const TERMINAL = ["SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED"];
|
||||||
|
const deadline = Date.now() + 120_000;
|
||||||
|
let status;
|
||||||
|
while (true) {
|
||||||
|
status = (await client.getProfileJob(job.statusUrl)).job;
|
||||||
|
if (TERMINAL.includes(status.status)) break;
|
||||||
|
if (Date.now() > deadline)
|
||||||
|
throw new Error("Sample job did not finish in time");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
}
|
||||||
|
|
||||||
console.log(status.status, status.succeeded, "of", status.total);
|
console.log(status.status, status.succeeded, "of", status.total);
|
||||||
|
|
||||||
// Each result names one sampled entity; read its saved profile.
|
// The create response lists the sampled entities; read each one's saved profile.
|
||||||
for (const row of status.results ?? []) {
|
for (const entityId of job.entityIds ?? []) {
|
||||||
console.log(await client.getProfile({ entityId: row.entityId }));
|
console.log(await client.getProfile({ entityId }));
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|||||||
@@ -39,30 +39,7 @@
|
|||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"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)"
|
||||||
"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",
|
"cell_type": "markdown",
|
||||||
@@ -527,43 +504,7 @@
|
|||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"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])"
|
||||||
"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",
|
"cell_type": "markdown",
|
||||||
@@ -735,13 +676,7 @@
|
|||||||
"execution_count": null,
|
"execution_count": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
"source": [
|
"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\")"
|
||||||
"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",
|
"cell_type": "markdown",
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ export type {
|
|||||||
ProfileEntityType,
|
ProfileEntityType,
|
||||||
ProfileStatus,
|
ProfileStatus,
|
||||||
ProfileResponse,
|
ProfileResponse,
|
||||||
ProfileTriggerResponse,
|
ProfileJobResponse,
|
||||||
|
ProfileJobStatus,
|
||||||
ProfileSettings,
|
ProfileSettings,
|
||||||
ProfileSettingsResponse,
|
ProfileSettingsResponse,
|
||||||
EntityProfileSettings,
|
EntityProfileSettings,
|
||||||
ProfileSampleResult,
|
ProfileSampleResult,
|
||||||
ProfileSamplesResponse,
|
|
||||||
} from "./mem0.types";
|
} from "./mem0.types";
|
||||||
|
|
||||||
// Re-export enums as values (not type-only)
|
// Re-export enums as values (not type-only)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import {
|
import {
|
||||||
AllUsers,
|
AllUsers,
|
||||||
PaginatedMemories,
|
PaginatedMemories,
|
||||||
@@ -24,10 +25,8 @@ import {
|
|||||||
ProfileResponse,
|
ProfileResponse,
|
||||||
ProfileJobResponse,
|
ProfileJobResponse,
|
||||||
ProfileJobStatus,
|
ProfileJobStatus,
|
||||||
ProfileTriggerResponse,
|
|
||||||
ProfileSettings,
|
ProfileSettings,
|
||||||
ProfileSettingsResponse,
|
ProfileSettingsResponse,
|
||||||
ProfileSamplesResponse,
|
|
||||||
} from "./mem0.types";
|
} from "./mem0.types";
|
||||||
import {
|
import {
|
||||||
captureClientEvent,
|
captureClientEvent,
|
||||||
@@ -795,10 +794,14 @@ export default class MemoryClient {
|
|||||||
* Profiles are otherwise built once a user crosses an internal message
|
* Profiles are otherwise built once a user crosses an internal message
|
||||||
* threshold, so a new user has none for its first few memories. Returns as
|
* threshold, so a new user has none for its first few memories. Returns as
|
||||||
* soon as the work is queued: poll {@link getProfile} and branch on `status`.
|
* soon as the work is queued: poll {@link getProfile} and branch on `status`.
|
||||||
|
*
|
||||||
|
* Pass `idempotencyKey` and reuse it to retry a lost request without starting
|
||||||
|
* (and being billed for) a second job.
|
||||||
*/
|
*/
|
||||||
async generateProfile(data: {
|
async generateProfile(data: {
|
||||||
entityId: string;
|
entityId: string;
|
||||||
}): Promise<ProfileTriggerResponse> {
|
idempotencyKey?: string;
|
||||||
|
}): Promise<ProfileJobResponse> {
|
||||||
this._captureEvent("generate_profile", []);
|
this._captureEvent("generate_profile", []);
|
||||||
await this._awaitIdentity();
|
await this._awaitIdentity();
|
||||||
|
|
||||||
@@ -806,7 +809,10 @@ export default class MemoryClient {
|
|||||||
`${this.host}${PROFILE_JOBS_PATH}`,
|
`${this.host}${PROFILE_JOBS_PATH}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() },
|
headers: {
|
||||||
|
...this.headers,
|
||||||
|
"Idempotency-Key": data.idempotencyKey ?? uuidv4(),
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
operation: "trigger",
|
operation: "trigger",
|
||||||
entity_type: "user",
|
entity_type: "user",
|
||||||
@@ -911,7 +917,10 @@ export default class MemoryClient {
|
|||||||
* Real generations against real memories, and the results are kept: the
|
* Real generations against real memories, and the results are kept: the
|
||||||
* profiles are written to those users and count toward usage.
|
* profiles are written to those users and count toward usage.
|
||||||
*/
|
*/
|
||||||
async sampleProfiles(data?: { limit?: number }): Promise<ProfileJobResponse> {
|
async sampleProfiles(data?: {
|
||||||
|
limit?: number;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}): Promise<ProfileJobResponse> {
|
||||||
this._captureEvent("sample_profiles", []);
|
this._captureEvent("sample_profiles", []);
|
||||||
await this._awaitIdentity();
|
await this._awaitIdentity();
|
||||||
|
|
||||||
@@ -919,7 +928,10 @@ export default class MemoryClient {
|
|||||||
`${this.host}${PROFILE_JOBS_PATH}`,
|
`${this.host}${PROFILE_JOBS_PATH}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() },
|
headers: {
|
||||||
|
...this.headers,
|
||||||
|
"Idempotency-Key": data?.idempotencyKey ?? uuidv4(),
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
operation: "sample",
|
operation: "sample",
|
||||||
// Required: the API refuses a job that does not name an entity kind.
|
// Required: the API refuses a job that does not name an entity kind.
|
||||||
|
|||||||
@@ -263,6 +263,8 @@ export interface ProfileResponse {
|
|||||||
entityId: string;
|
entityId: string;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
generationCount: number;
|
generationCount: number;
|
||||||
|
/** The last generation's failure reason, when `status` is `failed`. */
|
||||||
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every accepted generation. `statusUrl` is the server's own poll path. */
|
/** Every accepted generation. `statusUrl` is the server's own poll path. */
|
||||||
@@ -272,20 +274,16 @@ export interface ProfileJobResponse {
|
|||||||
statusUrl: string;
|
statusUrl: string;
|
||||||
operation: string;
|
operation: string;
|
||||||
entityType: ProfileEntityType;
|
entityType: ProfileEntityType;
|
||||||
usageUnits?: number;
|
/** Entities reserved against usage for this job. */
|
||||||
|
entityCountReserved?: number;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
replayed?: boolean;
|
replayed?: boolean;
|
||||||
/** Sample runs only: how many entities were picked. */
|
/** Sample runs only: how many entities were picked. */
|
||||||
sampled?: number;
|
sampled?: number;
|
||||||
/** Sample runs only: the entity ids picked. Read each one with `getProfile`. */
|
/** Sample runs only: the entity ids picked. Read each one with `getProfile`. */
|
||||||
entityIds?: string[];
|
entityIds?: string[];
|
||||||
/** @deprecated The API returns `entityIds`; this is never populated. */
|
|
||||||
results?: Array<ProfileSampleResult>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use {@link ProfileJobResponse}. */
|
|
||||||
export type ProfileTriggerResponse = ProfileJobResponse;
|
|
||||||
|
|
||||||
/** The settings to write. `schema` and `customInstructions` apply to user profiles. */
|
/** The settings to write. `schema` and `customInstructions` apply to user profiles. */
|
||||||
export interface ProfileSettings {
|
export interface ProfileSettings {
|
||||||
/** Turn profile generation on or off. Project-wide. */
|
/** Turn profile generation on or off. Project-wide. */
|
||||||
@@ -321,9 +319,6 @@ export interface ProfileSampleResult {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use {@link ProfileJobResponse}. */
|
|
||||||
export type ProfileSamplesResponse = ProfileJobResponse;
|
|
||||||
|
|
||||||
/** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */
|
/** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */
|
||||||
export interface ProfileJobStatus {
|
export interface ProfileJobStatus {
|
||||||
job: {
|
job: {
|
||||||
|
|||||||
@@ -98,11 +98,14 @@ describe("MemoryClient - generateProfile()", () => {
|
|||||||
extra.set("/v2/profiles/jobs/", {
|
extra.set("/v2/profiles/jobs/", {
|
||||||
status: 202,
|
status: 202,
|
||||||
body: {
|
body: {
|
||||||
message: "Profile generation started.",
|
job_id: "01a0ceb6-97c5-7cb2-80c4-b81a97876ef1",
|
||||||
|
status: "QUEUED",
|
||||||
|
status_url: "/v2/profiles/jobs/01a0ceb6-97c5-7cb2-80c4-b81a97876ef1/",
|
||||||
|
operation: "trigger",
|
||||||
entity_type: "user",
|
entity_type: "user",
|
||||||
entity_id: "alice",
|
entity_count_reserved: 1,
|
||||||
profile_id: "p_1",
|
event_id: "01a0ceb6-9865-7841-9b25-d280511382ff",
|
||||||
status: "PENDING",
|
replayed: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const mock = setupMockFetch(extra);
|
const mock = setupMockFetch(extra);
|
||||||
@@ -116,7 +119,39 @@ describe("MemoryClient - generateProfile()", () => {
|
|||||||
expect(body.operation).toBe("trigger");
|
expect(body.operation).toBe("trigger");
|
||||||
expect(body.entity_type).toBe("user");
|
expect(body.entity_type).toBe("user");
|
||||||
expect(body.entity_id).toBe("alice");
|
expect(body.entity_id).toBe("alice");
|
||||||
expect(result.profileId).toBe("p_1");
|
// A generated Idempotency-Key is always sent so the create can be retried safely.
|
||||||
|
const headers = call![1].headers as Record<string, string>;
|
||||||
|
expect(headers["Idempotency-Key"]).toBeTruthy();
|
||||||
|
expect(result.jobId).toBe("01a0ceb6-97c5-7cb2-80c4-b81a97876ef1");
|
||||||
|
expect(result.statusUrl).toBe(
|
||||||
|
"/v2/profiles/jobs/01a0ceb6-97c5-7cb2-80c4-b81a97876ef1/",
|
||||||
|
);
|
||||||
|
expect(result.replayed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reuses a caller-supplied idempotency key", async () => {
|
||||||
|
const extra = new Map<string, { status: number; body: unknown }>();
|
||||||
|
extra.set("/v2/profiles/jobs/", {
|
||||||
|
status: 202,
|
||||||
|
body: {
|
||||||
|
job_id: "j1",
|
||||||
|
status: "QUEUED",
|
||||||
|
status_url: "/v2/profiles/jobs/j1/",
|
||||||
|
operation: "trigger",
|
||||||
|
entity_type: "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mock = setupMockFetch(extra);
|
||||||
|
|
||||||
|
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||||
|
await client.generateProfile({
|
||||||
|
entityId: "alice",
|
||||||
|
idempotencyKey: "retry-key-123",
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST");
|
||||||
|
const headers = call![1].headers as Record<string, string>;
|
||||||
|
expect(headers["Idempotency-Key"]).toBe("retry-key-123");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+58
-29
@@ -44,11 +44,14 @@ ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"})
|
|||||||
PROFILE_JOBS_PATH = "/v2/profiles/jobs/"
|
PROFILE_JOBS_PATH = "/v2/profiles/jobs/"
|
||||||
PROFILE_SETTINGS_PATH = "/v2/profiles/settings/"
|
PROFILE_SETTINGS_PATH = "/v2/profiles/settings/"
|
||||||
|
|
||||||
|
# Distinguishes an omitted argument from an explicit ``None`` that clears a field.
|
||||||
|
_UNSET: Any = object()
|
||||||
|
|
||||||
|
|
||||||
def _profile_settings_payload(
|
def _profile_settings_payload(
|
||||||
enabled: Optional[bool],
|
enabled: Optional[bool],
|
||||||
schema: Optional[Dict[str, Any]],
|
schema: Any = _UNSET,
|
||||||
custom_instructions: Optional[str],
|
custom_instructions: Any = _UNSET,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Build the settings body the API accepts.
|
"""Build the settings body the API accepts.
|
||||||
|
|
||||||
@@ -57,7 +60,7 @@ def _profile_settings_payload(
|
|||||||
``get_profile_settings`` returns, so the two round-trip.
|
``get_profile_settings`` returns, so the two round-trip.
|
||||||
|
|
||||||
Sending them flat is rejected with ``Unsupported settings``, so this shape is
|
Sending them flat is rejected with ``Unsupported settings``, so this shape is
|
||||||
not cosmetic.
|
not cosmetic. ``_UNSET`` leaves a field unchanged; an explicit ``None`` clears it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
payload: Dict[str, Any] = {}
|
payload: Dict[str, Any] = {}
|
||||||
@@ -65,9 +68,9 @@ def _profile_settings_payload(
|
|||||||
payload["enabled"] = enabled
|
payload["enabled"] = enabled
|
||||||
|
|
||||||
entity_settings: Dict[str, Any] = {}
|
entity_settings: Dict[str, Any] = {}
|
||||||
if schema is not None:
|
if schema is not _UNSET:
|
||||||
entity_settings["schema"] = schema
|
entity_settings["schema"] = schema
|
||||||
if custom_instructions is not None:
|
if custom_instructions is not _UNSET:
|
||||||
entity_settings["custom_instructions"] = custom_instructions
|
entity_settings["custom_instructions"] = custom_instructions
|
||||||
|
|
||||||
if entity_settings:
|
if entity_settings:
|
||||||
@@ -406,7 +409,9 @@ class MemoryClient:
|
|||||||
payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"}
|
payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"}
|
||||||
|
|
||||||
if not payload:
|
if not payload:
|
||||||
raise ValueError("At least one of text, metadata, timestamp, or expiration_date must be provided for update.")
|
raise ValueError(
|
||||||
|
"At least one of text, metadata, timestamp, or expiration_date must be provided for update."
|
||||||
|
)
|
||||||
|
|
||||||
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "sync"})
|
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "sync"})
|
||||||
params = self._prepare_params()
|
params = self._prepare_params()
|
||||||
@@ -743,7 +748,7 @@ class MemoryClient:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
@api_error_handler
|
@api_error_handler
|
||||||
def generate_profile(self, entity_id: str) -> Dict[str, Any]:
|
def generate_profile(self, entity_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""Generate or refresh the profile for a single user, now.
|
"""Generate or refresh the profile for a single user, now.
|
||||||
|
|
||||||
Profiles are otherwise built once a user crosses an internal message
|
Profiles are otherwise built once a user crosses an internal message
|
||||||
@@ -752,10 +757,15 @@ class MemoryClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
entity_id: The user's id, as you supplied it on ``add``.
|
entity_id: The user's id, as you supplied it on ``add``.
|
||||||
|
idempotency_key: Optional key that makes the create idempotent. Reuse
|
||||||
|
the same value to safely retry a lost request without starting
|
||||||
|
(and being billed for) a second job. A fresh key is generated when
|
||||||
|
omitted.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing ``profile_id``, ``entity_type``, ``entity_id`` and
|
Dict containing ``job_id``, ``status``, ``status_url``, ``operation``,
|
||||||
``status``.
|
``entity_type``, ``entity_count_reserved``, ``event_id`` and
|
||||||
|
``replayed``. Poll :meth:`get_profile` and branch on ``status``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If profiles are not enabled and configured for the
|
ValidationError: If profiles are not enabled and configured for the
|
||||||
@@ -766,7 +776,7 @@ class MemoryClient:
|
|||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
PROFILE_JOBS_PATH,
|
PROFILE_JOBS_PATH,
|
||||||
json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id},
|
json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id},
|
||||||
headers={"Idempotency-Key": uuid.uuid4().hex},
|
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
capture_client_event("client.generate_profile", self, {"sync_type": "sync"})
|
capture_client_event("client.generate_profile", self, {"sync_type": "sync"})
|
||||||
@@ -791,8 +801,8 @@ class MemoryClient:
|
|||||||
def update_profile_settings(
|
def update_profile_settings(
|
||||||
self,
|
self,
|
||||||
enabled: Optional[bool] = None,
|
enabled: Optional[bool] = None,
|
||||||
schema: Optional[Dict[str, Any]] = None,
|
schema: Any = _UNSET,
|
||||||
custom_instructions: Optional[str] = None,
|
custom_instructions: Any = _UNSET,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Update the profile settings for the current project.
|
"""Update the profile settings for the current project.
|
||||||
|
|
||||||
@@ -801,9 +811,11 @@ class MemoryClient:
|
|||||||
Args:
|
Args:
|
||||||
enabled: Turn profile generation on or off. Project-wide.
|
enabled: Turn profile generation on or off. Project-wide.
|
||||||
schema: JSON Schema for the profile. Every property needs a
|
schema: JSON Schema for the profile. Every property needs a
|
||||||
``description``. Applies to user profiles.
|
``description``. Pass ``None`` to clear it; omit to leave it
|
||||||
custom_instructions: Extra guidance for the extraction step.
|
unchanged. Applies to user profiles.
|
||||||
Applies to user profiles.
|
custom_instructions: Extra guidance for the extraction step. Pass
|
||||||
|
``None`` to clear it; omit to leave it unchanged. Applies to user
|
||||||
|
profiles.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with the settings as stored after the update, in the same
|
Dict with the settings as stored after the update, in the same
|
||||||
@@ -824,7 +836,7 @@ class MemoryClient:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
@api_error_handler
|
@api_error_handler
|
||||||
def sample_profiles(self, limit: Optional[int] = None) -> Dict[str, Any]:
|
def sample_profiles(self, limit: Optional[int] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""Generate profiles for a few real users, to check a schema.
|
"""Generate profiles for a few real users, to check a schema.
|
||||||
|
|
||||||
Real generations against real memories, and the results are kept. The
|
Real generations against real memories, and the results are kept. The
|
||||||
@@ -832,6 +844,9 @@ class MemoryClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
limit: How many users to sample, 1-10. Defaults to the server value.
|
limit: How many users to sample, 1-10. Defaults to the server value.
|
||||||
|
idempotency_key: Optional key that makes the create idempotent. Reuse
|
||||||
|
the same value to safely retry without starting a second sample
|
||||||
|
run. A fresh key is generated when omitted.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing ``job_id``, ``status``, ``status_url``, ``sampled``
|
Dict containing ``job_id``, ``status``, ``status_url``, ``sampled``
|
||||||
@@ -849,7 +864,7 @@ class MemoryClient:
|
|||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
PROFILE_JOBS_PATH,
|
PROFILE_JOBS_PATH,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Idempotency-Key": uuid.uuid4().hex},
|
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
capture_client_event("client.sample_profiles", self, {"sync_type": "sync"})
|
capture_client_event("client.sample_profiles", self, {"sync_type": "sync"})
|
||||||
@@ -1484,7 +1499,9 @@ class AsyncMemoryClient:
|
|||||||
payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"}
|
payload = {k: v for k, v in payload.items() if v is not None or k == "expiration_date"}
|
||||||
|
|
||||||
if not payload:
|
if not payload:
|
||||||
raise ValueError("At least one of text, metadata, timestamp, or expiration_date must be provided for update.")
|
raise ValueError(
|
||||||
|
"At least one of text, metadata, timestamp, or expiration_date must be provided for update."
|
||||||
|
)
|
||||||
|
|
||||||
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "async"})
|
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "async"})
|
||||||
params = self._prepare_params()
|
params = self._prepare_params()
|
||||||
@@ -1807,7 +1824,7 @@ class AsyncMemoryClient:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
@api_error_handler
|
@api_error_handler
|
||||||
async def generate_profile(self, entity_id: str) -> Dict[str, Any]:
|
async def generate_profile(self, entity_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""Generate or refresh the profile for a single user, now.
|
"""Generate or refresh the profile for a single user, now.
|
||||||
|
|
||||||
Profiles are otherwise built once a user crosses an internal message
|
Profiles are otherwise built once a user crosses an internal message
|
||||||
@@ -1816,10 +1833,15 @@ class AsyncMemoryClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
entity_id: The user's id, as you supplied it on ``add``.
|
entity_id: The user's id, as you supplied it on ``add``.
|
||||||
|
idempotency_key: Optional key that makes the create idempotent. Reuse
|
||||||
|
the same value to safely retry a lost request without starting
|
||||||
|
(and being billed for) a second job. A fresh key is generated when
|
||||||
|
omitted.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing ``profile_id``, ``entity_type``, ``entity_id`` and
|
Dict containing ``job_id``, ``status``, ``status_url``, ``operation``,
|
||||||
``status``.
|
``entity_type``, ``entity_count_reserved``, ``event_id`` and
|
||||||
|
``replayed``. Poll :meth:`get_profile` and branch on ``status``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If profiles are not enabled and configured for the
|
ValidationError: If profiles are not enabled and configured for the
|
||||||
@@ -1830,7 +1852,7 @@ class AsyncMemoryClient:
|
|||||||
response = await self.async_client.post(
|
response = await self.async_client.post(
|
||||||
PROFILE_JOBS_PATH,
|
PROFILE_JOBS_PATH,
|
||||||
json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id},
|
json={"operation": "trigger", "entity_type": "user", "entity_id": entity_id},
|
||||||
headers={"Idempotency-Key": uuid.uuid4().hex},
|
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
capture_client_event("client.generate_profile", self, {"sync_type": "async"})
|
capture_client_event("client.generate_profile", self, {"sync_type": "async"})
|
||||||
@@ -1855,8 +1877,8 @@ class AsyncMemoryClient:
|
|||||||
async def update_profile_settings(
|
async def update_profile_settings(
|
||||||
self,
|
self,
|
||||||
enabled: Optional[bool] = None,
|
enabled: Optional[bool] = None,
|
||||||
schema: Optional[Dict[str, Any]] = None,
|
schema: Any = _UNSET,
|
||||||
custom_instructions: Optional[str] = None,
|
custom_instructions: Any = _UNSET,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Update the profile settings for the current project.
|
"""Update the profile settings for the current project.
|
||||||
|
|
||||||
@@ -1865,9 +1887,11 @@ class AsyncMemoryClient:
|
|||||||
Args:
|
Args:
|
||||||
enabled: Turn profile generation on or off. Project-wide.
|
enabled: Turn profile generation on or off. Project-wide.
|
||||||
schema: JSON Schema for the profile. Every property needs a
|
schema: JSON Schema for the profile. Every property needs a
|
||||||
``description``. Applies to user profiles.
|
``description``. Pass ``None`` to clear it; omit to leave it
|
||||||
custom_instructions: Extra guidance for the extraction step.
|
unchanged. Applies to user profiles.
|
||||||
Applies to user profiles.
|
custom_instructions: Extra guidance for the extraction step. Pass
|
||||||
|
``None`` to clear it; omit to leave it unchanged. Applies to user
|
||||||
|
profiles.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with the settings as stored after the update, in the same
|
Dict with the settings as stored after the update, in the same
|
||||||
@@ -1888,7 +1912,9 @@ class AsyncMemoryClient:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
@api_error_handler
|
@api_error_handler
|
||||||
async def sample_profiles(self, limit: Optional[int] = None) -> Dict[str, Any]:
|
async def sample_profiles(
|
||||||
|
self, limit: Optional[int] = None, idempotency_key: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Generate profiles for a few real users, to check a schema.
|
"""Generate profiles for a few real users, to check a schema.
|
||||||
|
|
||||||
Real generations against real memories, and the results are kept. The
|
Real generations against real memories, and the results are kept. The
|
||||||
@@ -1896,6 +1922,9 @@ class AsyncMemoryClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
limit: How many users to sample, 1-10. Defaults to the server value.
|
limit: How many users to sample, 1-10. Defaults to the server value.
|
||||||
|
idempotency_key: Optional key that makes the create idempotent. Reuse
|
||||||
|
the same value to safely retry without starting a second sample
|
||||||
|
run. A fresh key is generated when omitted.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing ``job_id``, ``status``, ``status_url``, ``sampled``
|
Dict containing ``job_id``, ``status``, ``status_url``, ``sampled``
|
||||||
@@ -1913,7 +1942,7 @@ class AsyncMemoryClient:
|
|||||||
response = await self.async_client.post(
|
response = await self.async_client.post(
|
||||||
PROFILE_JOBS_PATH,
|
PROFILE_JOBS_PATH,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Idempotency-Key": uuid.uuid4().hex},
|
headers={"Idempotency-Key": idempotency_key or uuid.uuid4().hex},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
capture_client_event("client.sample_profiles", self, {"sync_type": "async"})
|
capture_client_event("client.sample_profiles", self, {"sync_type": "async"})
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ class TestGetProfile:
|
|||||||
|
|
||||||
class TestGenerateProfile:
|
class TestGenerateProfile:
|
||||||
def test_posts_entity_type_and_id(self, mock_memory_client):
|
def test_posts_entity_type_and_id(self, mock_memory_client):
|
||||||
mock_memory_client.client.post.return_value = _mock_response({"profile_id": "p_1", "status": "PENDING"})
|
mock_memory_client.client.post.return_value = _mock_response(
|
||||||
|
{"job_id": "j1", "status": "QUEUED", "status_url": "/v2/profiles/jobs/j1/"}
|
||||||
|
)
|
||||||
|
|
||||||
mock_memory_client.generate_profile("alice")
|
mock_memory_client.generate_profile("alice")
|
||||||
|
|
||||||
@@ -91,6 +93,15 @@ class TestGenerateProfile:
|
|||||||
{"operation": "trigger", "entity_type": "user", "entity_id": "alice"},
|
{"operation": "trigger", "entity_type": "user", "entity_id": "alice"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_reuses_caller_idempotency_key(self, mock_memory_client):
|
||||||
|
"""A caller-supplied key lets a retry hit the same job instead of billing twice."""
|
||||||
|
mock_memory_client.client.post.return_value = _mock_response({"job_id": "j1", "status": "QUEUED"})
|
||||||
|
|
||||||
|
mock_memory_client.generate_profile("alice", idempotency_key="retry-key-123")
|
||||||
|
|
||||||
|
_, kwargs = mock_memory_client.client.post.call_args
|
||||||
|
assert kwargs["headers"]["Idempotency-Key"] == "retry-key-123"
|
||||||
|
|
||||||
|
|
||||||
class TestProfileSettings:
|
class TestProfileSettings:
|
||||||
def test_get_reads_v2(self, mock_memory_client):
|
def test_get_reads_v2(self, mock_memory_client):
|
||||||
@@ -160,6 +171,15 @@ class TestProfileSettings:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_update_clears_fields_with_explicit_none(self, mock_memory_client):
|
||||||
|
"""Explicit ``None`` clears a field; the sentinel default leaves it untouched."""
|
||||||
|
mock_memory_client.client.post.return_value = _mock_response({"enabled": True})
|
||||||
|
|
||||||
|
mock_memory_client.update_profile_settings(schema=None, custom_instructions=None)
|
||||||
|
|
||||||
|
_, kwargs = mock_memory_client.client.post.call_args
|
||||||
|
assert kwargs["json"] == {"entities": {"user": {"schema": None, "custom_instructions": None}}}
|
||||||
|
|
||||||
|
|
||||||
class TestSampleProfiles:
|
class TestSampleProfiles:
|
||||||
def test_sample_without_limit(self, mock_memory_client):
|
def test_sample_without_limit(self, mock_memory_client):
|
||||||
@@ -210,7 +230,7 @@ class TestAsyncClientParity:
|
|||||||
async_client.async_client.get.assert_called_once_with("/v2/entities/user/alice/profile/")
|
async_client.async_client.get.assert_called_once_with("/v2/entities/user/alice/profile/")
|
||||||
|
|
||||||
def test_generate_profile(self, async_client):
|
def test_generate_profile(self, async_client):
|
||||||
async_client.async_client.post = AsyncMock(return_value=_mock_response({"profile_id": "p_1"}))
|
async_client.async_client.post = AsyncMock(return_value=_mock_response({"job_id": "j1", "status": "QUEUED"}))
|
||||||
|
|
||||||
asyncio.run(async_client.generate_profile("alice"))
|
asyncio.run(async_client.generate_profile("alice"))
|
||||||
|
|
||||||
@@ -240,3 +260,13 @@ class TestAsyncClientParity:
|
|||||||
"/v2/profiles/settings/",
|
"/v2/profiles/settings/",
|
||||||
json={"enabled": True, "entities": {"user": {"schema": schema}}},
|
json={"enabled": True, "entities": {"user": {"schema": schema}}},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_update_settings_clears_with_none(self, async_client):
|
||||||
|
async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True}))
|
||||||
|
|
||||||
|
asyncio.run(async_client.update_profile_settings(custom_instructions=None))
|
||||||
|
|
||||||
|
async_client.async_client.post.assert_called_once_with(
|
||||||
|
"/v2/profiles/settings/",
|
||||||
|
json={"entities": {"user": {"custom_instructions": None}}},
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user