diff --git a/docs/api-reference/profiles/generate-profiles.mdx b/docs/api-reference/profiles/generate-profiles.mdx new file mode 100644 index 000000000..4b215ad41 --- /dev/null +++ b/docs/api-reference/profiles/generate-profiles.mdx @@ -0,0 +1,5 @@ +--- +title: 'Generate Profiles' +description: "Start one generation: sample a few entities, build one, or rebuild the project." +openapi: post /v2/profiles/jobs/ +--- diff --git a/docs/api-reference/profiles/get-profile-job.mdx b/docs/api-reference/profiles/get-profile-job.mdx new file mode 100644 index 000000000..c24e86794 --- /dev/null +++ b/docs/api-reference/profiles/get-profile-job.mdx @@ -0,0 +1,5 @@ +--- +title: 'Get Generation Job' +description: "Read the progress of a generation, and whether it finished." +openapi: get /v2/profiles/jobs/{job_id}/ +--- diff --git a/docs/api-reference/profiles/regenerate-profiles.mdx b/docs/api-reference/profiles/regenerate-profiles.mdx deleted file mode 100644 index 694e895fb..000000000 --- a/docs/api-reference/profiles/regenerate-profiles.mdx +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: 'Regenerate Profiles' -description: "Rebuild the profile of every entity in the project, applying the current schema to entities that already have one." -openapi: post /v2/profiles/regenerate/ ---- diff --git a/docs/api-reference/profiles/sample-profiles.mdx b/docs/api-reference/profiles/sample-profiles.mdx deleted file mode 100644 index ff231a586..000000000 --- a/docs/api-reference/profiles/sample-profiles.mdx +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: 'Sample Profiles' -description: "Generate profiles for a small set of real entities to evaluate a schema before applying it across the project." -openapi: post /v2/profiles/samples/ ---- diff --git a/docs/api-reference/profiles/trigger-profile.mdx b/docs/api-reference/profiles/trigger-profile.mdx deleted file mode 100644 index 4dfde7709..000000000 --- a/docs/api-reference/profiles/trigger-profile.mdx +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: 'Generate Profile' -description: "Generate or refresh the profile for a single user or agent immediately, instead of waiting for the message threshold." -openapi: post /v2/profiles/trigger/ ---- diff --git a/docs/docs.json b/docs/docs.json index ed22f8635..b5ca66214 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -517,11 +517,10 @@ "icon": "id-card", "pages": [ "api-reference/profiles/get-profile", - "api-reference/profiles/trigger-profile", "api-reference/profiles/get-profile-settings", "api-reference/profiles/update-profile-settings", - "api-reference/profiles/sample-profiles", - "api-reference/profiles/regenerate-profiles" + "api-reference/profiles/generate-profiles", + "api-reference/profiles/get-profile-job" ] }, { diff --git a/docs/llms.txt b/docs/llms.txt index dfedf13b9..807af9202 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -364,11 +364,10 @@ All API Reference docs describe Mem0 Platform REST endpoints (requires API key). - [Get Users](https://docs.mem0.ai/api-reference/entities/get-users) [Platform]: Use when listing users, agents, or apps known to a project. - [Delete User](https://docs.mem0.ai/api-reference/entities/delete-user) [Platform]: Use when removing an entity and all its memories. - [Get Profile](https://docs.mem0.ai/api-reference/profiles/get-profile) [Platform]: Use when reading a user's or agent's structured profile and branching on its generation status. -- [Generate Profile](https://docs.mem0.ai/api-reference/profiles/trigger-profile) [Platform]: Use when a profile is needed before the entity reaches the automatic message threshold. - [Get Profile Settings](https://docs.mem0.ai/api-reference/profiles/get-profile-settings) [Platform]: Use when checking the project's profile schema, instructions, or enabled flag. - [Update Profile Settings](https://docs.mem0.ai/api-reference/profiles/update-profile-settings) [Platform]: Use when defining or changing the JSON Schema that shapes profiles for a project. -- [Sample Profiles](https://docs.mem0.ai/api-reference/profiles/sample-profiles) [Platform]: Use when validating a profile schema against a few real entities before applying it project-wide. -- [Regenerate Profiles](https://docs.mem0.ai/api-reference/profiles/regenerate-profiles) [Platform]: Use when a new schema must be applied to entities that already have a profile. +- [Generate Profiles](https://docs.mem0.ai/api-reference/profiles/generate-profiles) [Platform]: Use when building profiles now: a sample of ten, one entity, or the whole project. +- [Get Generation Job](https://docs.mem0.ai/api-reference/profiles/get-profile-job) [Platform]: Use when checking how far a generation has got, and whether it finished. ### Organizations - [Create Organization](https://docs.mem0.ai/api-reference/organization/create-org) [Platform]: Use when setting up a new org. diff --git a/docs/openapi.json b/docs/openapi.json index cef11fed4..3ac7284a6 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -8265,14 +8265,27 @@ } } }, - "/v2/profiles/trigger/": { + "/v2/profiles/jobs/": { "post": { "tags": [ "profiles" ], - "operationId": "profiles_trigger", - "summary": "Generate one entity's profile now", - "description": "Generate or refresh the profile for a single entity immediately.\n\nProfiles are otherwise built once an entity crosses an internal message threshold, so a new entity has none for its first few memories. Returns 202: poll the read endpoint and branch on `status`.", + "operationId": "profiles_create_job", + "summary": "Generate profiles", + "description": "Start one generation. `operation` says what to build:\n\n- `sample` — up to 10 real entities, so a schema can be judged before it is used widely. These are real profiles: they are saved to those entities and count toward usage.\n- `trigger` — one entity, named by `entity_id`.\n- `regenerate` — every entity in the project. Not available yet; returns 501 `not_yet_available` and creates nothing. Check `capabilities.full_rebuild` on the settings route first.\n\nSend an `Idempotency-Key` header. Replaying the same key returns the same job instead of charging twice. Poll `status_url` from the response until the status is terminal.", + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "type": "string", + "minLength": 8, + "maxLength": 128 + }, + "description": "Makes a retry safe: the same key returns the same job." + } + ], "requestBody": { "required": true, "content": { @@ -8280,9 +8293,18 @@ "schema": { "type": "object", "required": [ - "entity_id" + "operation" ], "properties": { + "operation": { + "type": "string", + "enum": [ + "sample", + "trigger", + "regenerate" + ], + "description": "What to generate. Optional only when `entity_id` is set, which means `trigger`." + }, "entity_type": { "type": "string", "enum": [ @@ -8292,65 +8314,9 @@ "default": "user" }, "entity_id": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "202": { - "description": "Generation queued.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "entity_type": { - "type": "string" - }, - "entity_id": { - "type": "string" - }, - "profile_id": { - "type": "string" - }, - "status": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Unsupported entity type, or profiles are not enabled and configured." - }, - "404": { - "description": "No such entity in this project." - } - } - } - }, - "/v2/profiles/samples/": { - "post": { - "tags": [ - "profiles" - ], - "operationId": "profiles_samples", - "summary": "Sample profiles to check a schema", - "description": "Generate profiles for a handful of real entities so a schema can be judged before it is applied project-wide.\n\nThese are real generations and the results are kept, so the work counts toward a later regenerate.", - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { + "type": "string", + "description": "One entity, for `trigger`." + }, "limit": { "type": "integer", "minimum": 1, @@ -8364,23 +8330,141 @@ }, "responses": { "202": { - "description": "Sampling queued.", + "description": "Job accepted.", "content": { "application/json": { "schema": { "type": "object", "properties": { - "message": { + "job_id": { "type": "string" }, - "sampled": { + "status": { + "type": "string" + }, + "status_url": { + "type": "string", + "description": "Poll this. Building the path yourself breaks on a route change." + }, + "operation": { + "type": "string" + }, + "entity_type": { + "type": "string" + }, + "usage_units": { "type": "integer" }, + "replayed": { + "type": "boolean", + "description": "True when an Idempotency-Key returned an existing job." + }, + "sampled": { + "type": "integer", + "description": "`sample` only." + }, "results": { "type": "array", "items": { "type": "object", "additionalProperties": true + }, + "description": "`sample` only." + } + } + } + } + } + }, + "400": { + "description": "Unknown or missing `operation`, or profiles are not configured." + }, + "402": { + "description": "Payment required." + }, + "409": { + "description": "A job is already running, or the Idempotency-Key was used for a different request. Branch on `error.code`." + }, + "429": { + "description": "Cooldown. `retry_after_seconds` sits inside `error`." + }, + "501": { + "description": "`not_yet_available` — this operation does not exist yet. Nothing is created or charged." + }, + "503": { + "description": "`jobs_unavailable` — generation is switched off for this project." + } + } + } + }, + "/v2/profiles/jobs/{job_id}/": { + "get": { + "tags": [ + "profiles" + ], + "operationId": "profiles_get_job", + "summary": "Read a generation job", + "description": "The job nests under `job`. `total` is null until `enumeration_complete`, and `completed` is `succeeded + failed + skipped`.", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The job.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "job": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "entity_type": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "PARTIALLY_SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "total": { + "type": "integer", + "nullable": true + }, + "enumeration_complete": { + "type": "boolean" + }, + "completed": { + "type": "integer" + }, + "succeeded": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + } } } } @@ -8388,56 +8472,8 @@ } } }, - "400": { - "description": "Profiles are not enabled and configured." - }, - "429": { - "description": "A sample run was already started for this project very recently." - } - } - } - }, - "/v2/profiles/regenerate/": { - "post": { - "tags": [ - "profiles" - ], - "operationId": "profiles_regenerate", - "summary": "Rebuild every profile in the project", - "description": "Rebuild the profile of every entity in the project. This is how a new schema reaches entities that already have a profile.\n\nReturns 202 immediately; the rebuild runs in the background.", - "responses": { - "202": { - "description": "Regenerate queued.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "message": { - "type": "string" - }, - "project_id": { - "type": "string" - }, - "existing_profile_count": { - "type": "integer" - } - } - } - } - } - }, - "400": { - "description": "Profiles are not enabled and configured." - }, - "409": { - "description": "A regenerate is already running for this project." - }, - "429": { - "description": "A regenerate already ran for this project within the last hour." + "404": { + "description": "No such job in this project." } } } diff --git a/docs/platform/features/user-profiles.mdx b/docs/platform/features/user-profiles.mdx index d91530aba..eca4bd233 100644 --- a/docs/platform/features/user-profiles.mdx +++ b/docs/platform/features/user-profiles.mdx @@ -204,23 +204,33 @@ for (const row of result.results) { ``` -These are real generations against real memories, and the results are kept — sampling is not wasted work. Sampling is limited to 10 entities per call and cannot be repeated immediately. +These are real generations. The profiles are saved to those entities and count toward your usage, so sampling is not wasted work and not a free dry run. A sample covers up to 10 entities and cannot be repeated immediately. -## Apply a new schema to existing entities - -Changing the schema does not retroactively rewrite profiles that already exist. To rebuild every profile in the project: +Both calls return as soon as the job is queued. Poll `status_url` to see how it went: ```python Python -client.regenerate_profiles() +job = client.sample_profiles(limit=5) +status = client.get_profile_job(job["status_url"])["job"] +print(status["status"], status["succeeded"], "of", status["total"]) ``` ```typescript TypeScript -await client.regenerateProfiles(); +const job = await client.sampleProfiles({ limit: 5 }); +const { job: status } = await client.getProfileJob(job.statusUrl); +console.log(status.status, status.succeeded, "of", status.total); ``` -This returns immediately and runs in the background; a large project takes a while. It is limited to one run per project per day, so sample first and regenerate once you are satisfied. +## Apply a new schema to existing entities + +A new schema shapes the next generation. Profiles that already exist keep their values until their entity is generated again. + +Each entity picks the new schema up as it sends more memories, and you can generate one now with `generate_profile`. + + +Rebuilding a whole project in one call is not available yet. `regenerate_profiles()` returns `501 not_yet_available` and creates nothing. Check `capabilities.full_rebuild` in the settings response before you offer it. + ## When profiles update @@ -305,7 +315,7 @@ Generation is asynchronous and needs enough to work with. Branch on `status`: `p No. `sample_profiles` runs real generations against real memories and **keeps** the profiles it produces, so it counts toward your usage like any other generation. It exists to check a schema on a few entities before you commit to it — not as a zero-cost dry run. **Does changing the schema rewrite existing profiles?** -No. A schema change applies to future generations. To apply a new schema to entities that already have a profile, call `regenerate_profiles` (rate-limited to once per project per day). +No. A schema change applies to the next generation. An existing profile keeps its values until its entity is generated again, which happens as that entity sends more memories, or when you call `generate_profile` for it. **What happens to a field I remove from the schema?** It stops being maintained. On an entity's next generation, fields your schema no longer defines are pruned from the stored profile — so keep a field in the schema for as long as you want its value kept. diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 15368cc21..675677b94 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -22,6 +22,8 @@ import { GetMemoryExportPayload, ProfileEntityType, ProfileResponse, + ProfileJobResponse, + ProfileJobStatus, ProfileTriggerResponse, ProfileSettings, ProfileSamplesResponse, @@ -98,6 +100,9 @@ interface ClientIdentity { } // Shares one ping per (host, api key) across clients; FIFO-capped. +// One collection for every generation; the operation is a body field. +const PROFILE_JOBS_PATH = "/v2/profiles/jobs/"; + const IDENTITY_CACHE_MAX_DEFAULT = 50; const identityByCredentials = new Map>(); @@ -803,11 +808,12 @@ export default class MemoryClient { await this._awaitIdentity(); const response = await this._fetchWithErrorHandling( - `${this.host}/v2/profiles/trigger/`, + `${this.host}${PROFILE_JOBS_PATH}`, { method: "POST", - headers: this.headers, + headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ + operation: "trigger", entity_type: data.entityType ?? "user", entity_id: data.entityId, }), @@ -862,20 +868,24 @@ export default class MemoryClient { /** * Generate profiles for a few real entities, to check a schema. * - * Real generations against real memories, and the results are kept. + * Real generations against real memories, and the results are kept: the + * profiles are written to those entities and count toward usage. */ async sampleProfiles(data?: { limit?: number; - }): Promise { + }): Promise { this._captureEvent("sample_profiles", []); await this._awaitIdentity(); const response = await this._fetchWithErrorHandling( - `${this.host}/v2/profiles/samples/`, + `${this.host}${PROFILE_JOBS_PATH}`, { method: "POST", - headers: this.headers, - body: JSON.stringify(this._prepareParams({ limit: data?.limit })), + headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() }, + body: JSON.stringify({ + operation: "sample", + ...this._prepareParams({ limit: data?.limit }), + }), }, ); return response; @@ -884,23 +894,41 @@ export default class MemoryClient { /** * Rebuild the profile of every entity in the project. * - * This is how a new schema reaches entities that already have a profile. + * Not available yet: the server answers 501 `not_yet_available` and creates + * nothing. Use {@link sampleProfiles} or {@link generateProfile} until + * `capabilities.full_rebuild` from {@link getProfileSettings} is true. */ - async regenerateProfiles(): Promise { + async regenerateProfiles(): Promise { this._captureEvent("regenerate_profiles", []); await this._awaitIdentity(); const response = await this._fetchWithErrorHandling( - `${this.host}/v2/profiles/regenerate/`, + `${this.host}${PROFILE_JOBS_PATH}`, { method: "POST", - headers: this.headers, - body: JSON.stringify({}), + headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() }, + body: JSON.stringify({ operation: "regenerate" }), }, ); return response; } + /** + * Read one generation job. Accepts the `statusUrl` from a create call, or a + * bare job id. Prefer `statusUrl` so a route change needs no client update. + */ + async getProfileJob(jobIdOrStatusUrl: string): Promise { + await this._awaitIdentity(); + + const path = jobIdOrStatusUrl.startsWith("/") + ? jobIdOrStatusUrl + : `${PROFILE_JOBS_PATH}${jobIdOrStatusUrl}/`; + return this._fetchWithErrorHandling(`${this.host}${path}`, { + method: "GET", + headers: this.headers, + }); + } + async createMemoryExport( data: CreateMemoryExportPayload, ): Promise<{ message: string; id: string }> { diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts index cdbcd1c4d..4a8daea68 100644 --- a/mem0-ts/src/client/mem0.types.ts +++ b/mem0-ts/src/client/mem0.types.ts @@ -256,14 +256,24 @@ export interface ProfileResponse { generationCount: number; } -export interface ProfileTriggerResponse { - message: string; - entityType: ProfileEntityType; - entityId: string; - profileId: string; +/** Every accepted generation. `statusUrl` is the server's own poll path. */ +export interface ProfileJobResponse { + jobId: string; status: string; + statusUrl: string; + operation: string; + entityType: ProfileEntityType; + usageUnits?: number; + eventId?: string; + replayed?: boolean; + /** Sample runs only. */ + sampled?: number; + results?: Array; } +/** @deprecated Use {@link ProfileJobResponse}. */ +export type ProfileTriggerResponse = ProfileJobResponse; + export interface ProfileSettings { enabled?: boolean; /** JSON Schema for the profile. Every property needs a `description`. */ @@ -278,15 +288,28 @@ export interface ProfileSampleResult { [key: string]: any; } -export interface ProfileSamplesResponse { - message: string; - sampled: number; - results: Array; -} +/** @deprecated Use {@link ProfileJobResponse}. */ +export type ProfileSamplesResponse = ProfileJobResponse; -export interface ProfileRegenerateResponse { - status: string; - message: string; - projectId: string; - existingProfileCount: number; +/** @deprecated Use {@link ProfileJobResponse}. */ +export type ProfileRegenerateResponse = ProfileJobResponse; + +/** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */ +export interface ProfileJobStatus { + job: { + id: string; + operation: string; + entityType: ProfileEntityType; + status: string; + /** Null until `enumerationComplete`. */ + total: number | null; + enumerationComplete: boolean; + /** succeeded + failed + skipped. */ + completed: number; + succeeded: number; + failed: number; + skipped: number; + results?: Array; + [key: string]: any; + }; } diff --git a/mem0-ts/src/client/tests/memoryClient.profiles.test.ts b/mem0-ts/src/client/tests/memoryClient.profiles.test.ts index d09aa0697..e35270f5e 100644 --- a/mem0-ts/src/client/tests/memoryClient.profiles.test.ts +++ b/mem0-ts/src/client/tests/memoryClient.profiles.test.ts @@ -67,9 +67,9 @@ describe("MemoryClient - getProfile()", () => { }); describe("MemoryClient - generateProfile()", () => { - test("posts entity_type and entity_id to the trigger route", async () => { + test("sends operation trigger with the entity to the jobs collection", async () => { const extra = new Map(); - extra.set("/v2/profiles/trigger/", { + extra.set("/v2/profiles/jobs/", { status: 202, body: { message: "Profile generation started.", @@ -84,9 +84,10 @@ describe("MemoryClient - generateProfile()", () => { const client = new MemoryClient({ apiKey: TEST_API_KEY }); const result = await client.generateProfile({ entityId: "alice" }); - const call = findFetchCall(mock, "/v2/profiles/trigger/", "POST"); + const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST"); expect(call).toBeDefined(); const body = getFetchBody(call!); + expect(body.operation).toBe("trigger"); expect(body.entity_type).toBe("user"); expect(body.entity_id).toBe("alice"); expect(result.profileId).toBe("p_1"); @@ -184,44 +185,62 @@ describe("MemoryClient - profile settings", () => { describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => { test("sampleProfiles omits limit when unset", async () => { const extra = new Map(); - extra.set("/v2/profiles/samples/", { + extra.set("/v2/profiles/jobs/", { status: 202, - body: { message: "Sampling 5 users.", sampled: 5, results: [] }, + body: { + job_id: "job_1", + status: "QUEUED", + status_url: "/v2/profiles/jobs/job_1/", + operation: "sample", + entity_type: "user", + sampled: 5, + results: [], + }, }); const mock = setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); await client.sampleProfiles(); - const call = findFetchCall(mock, "/v2/profiles/samples/", "POST"); - expect(getFetchBody(call!)).toEqual({}); + const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST"); + expect(getFetchBody(call!)).toEqual({ operation: "sample" }); }); test("sampleProfiles passes an explicit limit", async () => { const extra = new Map(); - extra.set("/v2/profiles/samples/", { + extra.set("/v2/profiles/jobs/", { status: 202, - body: { message: "Sampling 3 users.", sampled: 3, results: [] }, + body: { + job_id: "job_2", + status: "QUEUED", + status_url: "/v2/profiles/jobs/job_2/", + operation: "sample", + entity_type: "user", + sampled: 3, + results: [], + }, }); const mock = setupMockFetch(extra); const client = new MemoryClient({ apiKey: TEST_API_KEY }); const result = await client.sampleProfiles({ limit: 3 }); - const call = findFetchCall(mock, "/v2/profiles/samples/", "POST"); + const call = findFetchCall(mock, "/v2/profiles/jobs/", "POST"); + expect(getFetchBody(call!).operation).toBe("sample"); expect(getFetchBody(call!).limit).toBe(3); expect(result.sampled).toBe(3); }); - test("regenerateProfiles posts to the regenerate route", async () => { + test("sends operation regenerate to the jobs collection", async () => { const extra = new Map(); - extra.set("/v2/profiles/regenerate/", { + extra.set("/v2/profiles/jobs/", { status: 202, body: { - status: "accepted", - message: "Regenerating profiles.", - project_id: "proj_abc", - existing_profile_count: 12, + job_id: "job_3", + status: "QUEUED", + status_url: "/v2/profiles/jobs/job_3/", + operation: "regenerate", + entity_type: "user", }, }); const mock = setupMockFetch(extra); @@ -230,9 +249,9 @@ describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => { const result = await client.regenerateProfiles(); expect( - findFetchCall(mock, "/v2/profiles/regenerate/", "POST"), + findFetchCall(mock, "/v2/profiles/jobs/", "POST"), ).toBeDefined(); - expect(result.existingProfileCount).toBe(12); - expect(result.projectId).toBe("proj_abc"); + expect(result.jobId).toBe("job_3"); + expect(result.statusUrl).toBe("/v2/profiles/jobs/job_3/"); }); }); diff --git a/mem0/client/main.py b/mem0/client/main.py index 9bce4e3d7..425640c52 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -1,6 +1,7 @@ import hashlib import logging import os +import uuid import warnings from typing import Any, Dict, List, Optional from urllib.parse import quote @@ -39,6 +40,9 @@ setup_config() # Entity parameters that must be passed via filters, not top-level ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"}) +# One collection for every generation; the operation is a body field. +PROFILE_JOBS_PATH = "/v2/profiles/jobs/" + def _validate_and_trim_search_query(query: str) -> str: if not isinstance(query, str): @@ -734,8 +738,9 @@ class MemoryClient: """ response = self.client.post( - "/v2/profiles/trigger/", - json={"entity_type": entity_type, "entity_id": entity_id}, + PROFILE_JOBS_PATH, + json={"operation": "trigger", "entity_type": entity_type, "entity_id": entity_id}, + headers={"Idempotency-Key": uuid.uuid4().hex}, ) response.raise_for_status() capture_client_event("client.generate_profile", self, {"entity_type": entity_type, "sync_type": "sync"}) @@ -792,20 +797,28 @@ class MemoryClient: def sample_profiles(self, limit: Optional[int] = None) -> Dict[str, Any]: """Generate profiles for a few real entities, to check a schema. - Real generations against real memories, and the results are kept. + Real generations against real memories, and the results are kept. The + profiles are written to those entities and count toward usage. Args: limit: How many entities to sample, 1-10. Defaults to the server value. Returns: - Dict containing ``sampled`` and one ``results`` row per entity. + Dict containing ``job_id``, ``status`` and ``status_url``. Poll + :meth:`get_profile_job` with ``status_url`` until the status is terminal. Raises: ValidationError: If profiles are not enabled and configured. RateLimitError: If a sample run was already started very recently. """ - response = self.client.post("/v2/profiles/samples/", json=self._prepare_params({"limit": limit})) + payload = self._prepare_params({"limit": limit}) + payload["operation"] = "sample" + response = self.client.post( + PROFILE_JOBS_PATH, + json=payload, + headers={"Idempotency-Key": uuid.uuid4().hex}, + ) response.raise_for_status() capture_client_event("client.sample_profiles", self, {"sync_type": "sync"}) return response.json() @@ -814,23 +827,47 @@ class MemoryClient: def regenerate_profiles(self) -> Dict[str, Any]: """Rebuild the profile of every entity in the current project. - This is how a new schema reaches entities that already have a profile. - Returns as soon as the work is queued. + Not available yet: the server answers 501 with ``not_yet_available`` and + creates nothing. Use :meth:`sample_profiles` or :meth:`generate_profile` + until ``capabilities.full_rebuild`` in :meth:`get_profile_settings` is true. Returns: - Dict containing ``status``, ``message``, ``project_id`` and - ``existing_profile_count``. + Dict containing ``job_id``, ``status`` and ``status_url``. Raises: ValidationError: If profiles are not enabled and configured. RateLimitError: If a regenerate already ran for this project recently. """ - response = self.client.post("/v2/profiles/regenerate/", json={}) + response = self.client.post( + PROFILE_JOBS_PATH, + json={"operation": "regenerate"}, + headers={"Idempotency-Key": uuid.uuid4().hex}, + ) response.raise_for_status() capture_client_event("client.regenerate_profiles", self, {"sync_type": "sync"}) return response.json() + @api_error_handler + def get_profile_job(self, job_id_or_status_url: str) -> Dict[str, Any]: + """Read one generation job. + + Accepts the ``status_url`` from a create call, or a bare job id. Prefer + passing ``status_url`` so a route change needs no client update. + + Returns: + Dict whose ``job`` key holds the job: ``status``, ``total``, + ``completed``, ``succeeded``, ``failed`` and ``skipped``. ``total`` is + null until ``enumeration_complete``. + """ + + path = job_id_or_status_url + if not path.startswith("/"): + path = f"{PROFILE_JOBS_PATH}{path}/" + response = self.client.get(path) + response.raise_for_status() + return response.json() + @api_error_handler def get_project(self, fields: Optional[List[str]] = None) -> Dict[str, Any]: """Get instructions or categories for the current project. @@ -1789,8 +1826,9 @@ class AsyncMemoryClient: """ response = await self.async_client.post( - "/v2/profiles/trigger/", - json={"entity_type": entity_type, "entity_id": entity_id}, + PROFILE_JOBS_PATH, + json={"operation": "trigger", "entity_type": entity_type, "entity_id": entity_id}, + headers={"Idempotency-Key": uuid.uuid4().hex}, ) response.raise_for_status() capture_client_event("client.generate_profile", self, {"entity_type": entity_type, "sync_type": "async"}) @@ -1847,20 +1885,28 @@ class AsyncMemoryClient: async def sample_profiles(self, limit: Optional[int] = None) -> Dict[str, Any]: """Generate profiles for a few real entities, to check a schema. - Real generations against real memories, and the results are kept. + Real generations against real memories, and the results are kept. The + profiles are written to those entities and count toward usage. Args: limit: How many entities to sample, 1-10. Defaults to the server value. Returns: - Dict containing ``sampled`` and one ``results`` row per entity. + Dict containing ``job_id``, ``status`` and ``status_url``. Poll + :meth:`get_profile_job` with ``status_url`` until the status is terminal. Raises: ValidationError: If profiles are not enabled and configured. RateLimitError: If a sample run was already started very recently. """ - response = await self.async_client.post("/v2/profiles/samples/", json=self._prepare_params({"limit": limit})) + payload = self._prepare_params({"limit": limit}) + payload["operation"] = "sample" + response = await self.async_client.post( + PROFILE_JOBS_PATH, + json=payload, + headers={"Idempotency-Key": uuid.uuid4().hex}, + ) response.raise_for_status() capture_client_event("client.sample_profiles", self, {"sync_type": "async"}) return response.json() @@ -1869,23 +1915,47 @@ class AsyncMemoryClient: async def regenerate_profiles(self) -> Dict[str, Any]: """Rebuild the profile of every entity in the current project. - This is how a new schema reaches entities that already have a profile. - Returns as soon as the work is queued. + Not available yet: the server answers 501 with ``not_yet_available`` and + creates nothing. Use :meth:`sample_profiles` or :meth:`generate_profile` + until ``capabilities.full_rebuild`` in :meth:`get_profile_settings` is true. Returns: - Dict containing ``status``, ``message``, ``project_id`` and - ``existing_profile_count``. + Dict containing ``job_id``, ``status`` and ``status_url``. Raises: ValidationError: If profiles are not enabled and configured. RateLimitError: If a regenerate already ran for this project recently. """ - response = await self.async_client.post("/v2/profiles/regenerate/", json={}) + response = await self.async_client.post( + PROFILE_JOBS_PATH, + json={"operation": "regenerate"}, + headers={"Idempotency-Key": uuid.uuid4().hex}, + ) response.raise_for_status() capture_client_event("client.regenerate_profiles", self, {"sync_type": "async"}) return response.json() + @api_error_handler + async def get_profile_job(self, job_id_or_status_url: str) -> Dict[str, Any]: + """Read one generation job. + + Accepts the ``status_url`` from a create call, or a bare job id. Prefer + passing ``status_url`` so a route change needs no client update. + + Returns: + Dict whose ``job`` key holds the job: ``status``, ``total``, + ``completed``, ``succeeded``, ``failed`` and ``skipped``. ``total`` is + null until ``enumeration_complete``. + """ + + path = job_id_or_status_url + if not path.startswith("/"): + path = f"{PROFILE_JOBS_PATH}{path}/" + response = await self.async_client.get(path) + response.raise_for_status() + return response.json() + @api_error_handler async def get_project(self, fields: Optional[List[str]] = None) -> Dict[str, Any]: """Get instructions or categories for the current project. diff --git a/tests/test_client_profiles.py b/tests/test_client_profiles.py index 352af1ffb..7099c3e4b 100644 --- a/tests/test_client_profiles.py +++ b/tests/test_client_profiles.py @@ -31,6 +31,15 @@ def mock_memory_client(): yield client +def _assert_job_call(post_mock, expected_json): + """One jobs collection, operation in the body, idempotency key per attempt.""" + post_mock.assert_called_once() + args, kwargs = post_mock.call_args + assert args[0] == "/v2/profiles/jobs/" + assert kwargs["json"] == expected_json + assert kwargs["headers"]["Idempotency-Key"] + + def _mock_response(payload): response = MagicMock() response.json.return_value = payload @@ -84,9 +93,9 @@ class TestGenerateProfile: mock_memory_client.generate_profile("alice") - mock_memory_client.client.post.assert_called_once_with( - "/v2/profiles/trigger/", - json={"entity_type": "user", "entity_id": "alice"}, + _assert_job_call( + mock_memory_client.client.post, + {"operation": "trigger", "entity_type": "user", "entity_id": "alice"}, ) def test_agent_entity_type(self, mock_memory_client): @@ -94,9 +103,9 @@ class TestGenerateProfile: mock_memory_client.generate_profile("support-bot", entity_type="agent") - mock_memory_client.client.post.assert_called_once_with( - "/v2/profiles/trigger/", - json={"entity_type": "agent", "entity_id": "support-bot"}, + _assert_job_call( + mock_memory_client.client.post, + {"operation": "trigger", "entity_type": "agent", "entity_id": "support-bot"}, ) @@ -148,14 +157,14 @@ class TestSampleAndRegenerate: mock_memory_client.sample_profiles() - mock_memory_client.client.post.assert_called_once_with("/v2/profiles/samples/", json={}) + _assert_job_call(mock_memory_client.client.post, {"operation": "sample"}) def test_sample_with_limit(self, mock_memory_client): mock_memory_client.client.post.return_value = _mock_response({"sampled": 3, "results": []}) mock_memory_client.sample_profiles(limit=3) - mock_memory_client.client.post.assert_called_once_with("/v2/profiles/samples/", json={"limit": 3}) + _assert_job_call(mock_memory_client.client.post, {"operation": "sample", "limit": 3}) def test_regenerate(self, mock_memory_client): mock_memory_client.client.post.return_value = _mock_response( @@ -164,7 +173,7 @@ class TestSampleAndRegenerate: mock_memory_client.regenerate_profiles() - mock_memory_client.client.post.assert_called_once_with("/v2/profiles/regenerate/", json={}) + _assert_job_call(mock_memory_client.client.post, {"operation": "regenerate"}) class TestAsyncClientParity: @@ -201,9 +210,9 @@ class TestAsyncClientParity: asyncio.run(async_client.generate_profile("alice", entity_type="agent")) - async_client.async_client.post.assert_called_once_with( - "/v2/profiles/trigger/", - json={"entity_type": "agent", "entity_id": "alice"}, + _assert_job_call( + async_client.async_client.post, + {"operation": "trigger", "entity_type": "agent", "entity_id": "alice"}, ) def test_update_settings_partial(self, async_client): @@ -221,4 +230,4 @@ class TestAsyncClientParity: asyncio.run(async_client.regenerate_profiles()) - async_client.async_client.post.assert_called_once_with("/v2/profiles/regenerate/", json={}) + _assert_job_call(async_client.async_client.post, {"operation": "regenerate"})