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
@@ -26,12 +26,12 @@ export type {
|
||||
ProfileEntityType,
|
||||
ProfileStatus,
|
||||
ProfileResponse,
|
||||
ProfileTriggerResponse,
|
||||
ProfileJobResponse,
|
||||
ProfileJobStatus,
|
||||
ProfileSettings,
|
||||
ProfileSettingsResponse,
|
||||
EntityProfileSettings,
|
||||
ProfileSampleResult,
|
||||
ProfileSamplesResponse,
|
||||
} from "./mem0.types";
|
||||
|
||||
// Re-export enums as values (not type-only)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
AllUsers,
|
||||
PaginatedMemories,
|
||||
@@ -24,10 +25,8 @@ import {
|
||||
ProfileResponse,
|
||||
ProfileJobResponse,
|
||||
ProfileJobStatus,
|
||||
ProfileTriggerResponse,
|
||||
ProfileSettings,
|
||||
ProfileSettingsResponse,
|
||||
ProfileSamplesResponse,
|
||||
} from "./mem0.types";
|
||||
import {
|
||||
captureClientEvent,
|
||||
@@ -795,10 +794,14 @@ export default class MemoryClient {
|
||||
* 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
|
||||
* 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: {
|
||||
entityId: string;
|
||||
}): Promise<ProfileTriggerResponse> {
|
||||
idempotencyKey?: string;
|
||||
}): Promise<ProfileJobResponse> {
|
||||
this._captureEvent("generate_profile", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
@@ -806,7 +809,10 @@ export default class MemoryClient {
|
||||
`${this.host}${PROFILE_JOBS_PATH}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() },
|
||||
headers: {
|
||||
...this.headers,
|
||||
"Idempotency-Key": data.idempotencyKey ?? uuidv4(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: "trigger",
|
||||
entity_type: "user",
|
||||
@@ -911,7 +917,10 @@ export default class MemoryClient {
|
||||
* Real generations against real memories, and the results are kept: the
|
||||
* 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", []);
|
||||
await this._awaitIdentity();
|
||||
|
||||
@@ -919,7 +928,10 @@ export default class MemoryClient {
|
||||
`${this.host}${PROFILE_JOBS_PATH}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...this.headers, "Idempotency-Key": crypto.randomUUID() },
|
||||
headers: {
|
||||
...this.headers,
|
||||
"Idempotency-Key": data?.idempotencyKey ?? uuidv4(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: "sample",
|
||||
// Required: the API refuses a job that does not name an entity kind.
|
||||
|
||||
@@ -263,6 +263,8 @@ export interface ProfileResponse {
|
||||
entityId: string;
|
||||
updatedAt: string | null;
|
||||
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. */
|
||||
@@ -272,20 +274,16 @@ export interface ProfileJobResponse {
|
||||
statusUrl: string;
|
||||
operation: string;
|
||||
entityType: ProfileEntityType;
|
||||
usageUnits?: number;
|
||||
/** Entities reserved against usage for this job. */
|
||||
entityCountReserved?: number;
|
||||
eventId?: string;
|
||||
replayed?: boolean;
|
||||
/** Sample runs only: how many entities were picked. */
|
||||
sampled?: number;
|
||||
/** Sample runs only: the entity ids picked. Read each one with `getProfile`. */
|
||||
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. */
|
||||
export interface ProfileSettings {
|
||||
/** Turn profile generation on or off. Project-wide. */
|
||||
@@ -321,9 +319,6 @@ export interface ProfileSampleResult {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link ProfileJobResponse}. */
|
||||
export type ProfileSamplesResponse = ProfileJobResponse;
|
||||
|
||||
/** `GET /v2/profiles/jobs/{id}/`. The job nests under `job`. */
|
||||
export interface ProfileJobStatus {
|
||||
job: {
|
||||
|
||||
@@ -98,11 +98,14 @@ describe("MemoryClient - generateProfile()", () => {
|
||||
extra.set("/v2/profiles/jobs/", {
|
||||
status: 202,
|
||||
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_id: "alice",
|
||||
profile_id: "p_1",
|
||||
status: "PENDING",
|
||||
entity_count_reserved: 1,
|
||||
event_id: "01a0ceb6-9865-7841-9b25-d280511382ff",
|
||||
replayed: false,
|
||||
},
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
@@ -116,7 +119,39 @@ describe("MemoryClient - generateProfile()", () => {
|
||||
expect(body.operation).toBe("trigger");
|
||||
expect(body.entity_type).toBe("user");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user