fix(profiles): follow the jobs API
Four POST routes became one collection with the operation in the body, so the three route pages collapse into one. samples became sample. Creates send an Idempotency-Key and return a status_url to poll, which the client follows rather than building the path. Full rebuild is closed: regenerate_profiles answers 501 not_yet_available and the docs say so instead of teaching a daily cadence that cannot run.
This commit is contained in:
@@ -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/
|
||||
---
|
||||
@@ -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}/
|
||||
---
|
||||
@@ -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/
|
||||
---
|
||||
@@ -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/
|
||||
---
|
||||
@@ -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/
|
||||
---
|
||||
+2
-3
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+2
-3
@@ -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.
|
||||
|
||||
+153
-117
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,23 +204,33 @@ for (const row of result.results) {
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
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:
|
||||
|
||||
<CodeGroup>
|
||||
```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);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
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`.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
## 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.
|
||||
|
||||
+40
-12
@@ -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<string, Promise<ClientIdentity>>();
|
||||
|
||||
@@ -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<ProfileSamplesResponse> {
|
||||
}): Promise<ProfileJobResponse> {
|
||||
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<ProfileRegenerateResponse> {
|
||||
async regenerateProfiles(): Promise<ProfileJobResponse> {
|
||||
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<ProfileJobStatus> {
|
||||
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 }> {
|
||||
|
||||
@@ -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<ProfileSampleResult>;
|
||||
}
|
||||
|
||||
/** @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<ProfileSampleResult>;
|
||||
}
|
||||
/** @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<ProfileSampleResult>;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, { status: number; body: unknown }>();
|
||||
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<string, { status: number; body: unknown }>();
|
||||
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<string, { status: number; body: unknown }>();
|
||||
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<string, { status: number; body: unknown }>();
|
||||
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/");
|
||||
});
|
||||
});
|
||||
|
||||
+90
-20
@@ -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.
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user