Merge user profile: readable, writable and forgettable user model
Both branches gave the main bot its own tools, so the main-only filter now covers delegate_task and update_user_profile, and the main bot's role test expects the profile tool instead of remember. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
728d78001a
@ -201,6 +201,7 @@ import {
|
|||||||
threadHead,
|
threadHead,
|
||||||
threadSnapshot,
|
threadSnapshot,
|
||||||
} from "./thread-target.js";
|
} from "./thread-target.js";
|
||||||
|
import { forgetProfile, getProfile, updateProfile } from "./user-profile.js";
|
||||||
import {
|
import {
|
||||||
disconnectVoiceCredential,
|
disconnectVoiceCredential,
|
||||||
listVoiceCatalog,
|
listVoiceCatalog,
|
||||||
@ -540,6 +541,15 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
return meDto(deps, context.actor);
|
return meDto(deps, context.actor);
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
profile: {
|
||||||
|
get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)),
|
||||||
|
update: authed.profile.update.handler(({ context, input }) =>
|
||||||
|
updateProfile(deps.prisma, context.actor, input),
|
||||||
|
),
|
||||||
|
forget: authed.profile.forget.handler(({ context, input }) =>
|
||||||
|
forgetProfile(deps.prisma, context.actor, input),
|
||||||
|
),
|
||||||
|
},
|
||||||
spaces: {
|
spaces: {
|
||||||
list: authed.spaces.list.handler(async ({ context }) =>
|
list: authed.spaces.list.handler(async ({ context }) =>
|
||||||
spaceNavigationDto(deps, context.actor, repos, groupRepos),
|
spaceNavigationDto(deps, context.actor, repos, groupRepos),
|
||||||
|
|||||||
60
apps/api/src/user-profile.ts
Normal file
60
apps/api/src/user-profile.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import type {
|
||||||
|
Actor,
|
||||||
|
ForgetUserFacts,
|
||||||
|
UpdateUserProfile,
|
||||||
|
UserProfileState,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
|
import {
|
||||||
|
forgetUserFacts,
|
||||||
|
getUserProfileState,
|
||||||
|
InvalidUserFactError,
|
||||||
|
UserFactNotFoundError,
|
||||||
|
updateUserProfile,
|
||||||
|
} from "@rakazo/db";
|
||||||
|
|
||||||
|
function toRpcError(error: unknown): unknown {
|
||||||
|
if (error instanceof UserFactNotFoundError) {
|
||||||
|
return new ORPCError("NOT_FOUND", { message: error.message });
|
||||||
|
}
|
||||||
|
if (error instanceof InvalidUserFactError) {
|
||||||
|
return new ORPCError("BAD_REQUEST", { message: error.message });
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProfile(prisma: PrismaClient, actor: Actor): Promise<UserProfileState> {
|
||||||
|
return getUserProfileState(prisma, actor.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
actor: Actor,
|
||||||
|
input: UpdateUserProfile,
|
||||||
|
): Promise<UserProfileState> {
|
||||||
|
try {
|
||||||
|
await updateUserProfile(prisma, actor.userId, input);
|
||||||
|
} catch (error) {
|
||||||
|
throw toRpcError(error);
|
||||||
|
}
|
||||||
|
return getUserProfileState(prisma, actor.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unknown ids are ignored, so a caller cannot probe another user's facts. */
|
||||||
|
export async function forgetProfile(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
actor: Actor,
|
||||||
|
input: ForgetUserFacts,
|
||||||
|
): Promise<UserProfileState> {
|
||||||
|
await forgetUserFacts(
|
||||||
|
prisma,
|
||||||
|
actor.userId,
|
||||||
|
input.factIds
|
||||||
|
? { factIds: input.factIds }
|
||||||
|
: input.category
|
||||||
|
? { category: input.category }
|
||||||
|
: { all: true },
|
||||||
|
);
|
||||||
|
return getUserProfileState(prisma, actor.userId);
|
||||||
|
}
|
||||||
@ -222,6 +222,7 @@ export async function openUserSettings(
|
|||||||
| "integrations"
|
| "integrations"
|
||||||
| "models"
|
| "models"
|
||||||
| "memory"
|
| "memory"
|
||||||
|
| "profile"
|
||||||
| "voice"
|
| "voice"
|
||||||
| "usage"
|
| "usage"
|
||||||
| "computer"
|
| "computer"
|
||||||
|
|||||||
106
apps/web/e2e/user-profile.spec.ts
Normal file
106
apps/web/e2e/user-profile.spec.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* E2E: onboarding answers land in the user profile, and Settings → About you shows every
|
||||||
|
* fact with its origin, confirms a pending one, and forgets one or all of them.
|
||||||
|
*/
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers";
|
||||||
|
|
||||||
|
test.use({ timezoneId: "Europe/Vienna" });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Walk the full emoji-first flow, including the preferences step.
|
||||||
|
await page.route("**/rpc/integrationSetup/get", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
json: {
|
||||||
|
canConfigure: true,
|
||||||
|
needsSetup: false,
|
||||||
|
webUrl: "https://example.test/integrations/setup",
|
||||||
|
providers: [{ id: "composio", configured: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("onboarding feeds the profile; facts can be confirmed and forgotten", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `profile-${stamp}@rakazo.test`, "password12", "Profile Tester");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Timezone")).toHaveValue("Europe/Vienna");
|
||||||
|
await page.getByPlaceholder("Your name").fill("Pat Example");
|
||||||
|
await page.getByRole("button", { name: "Music", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "Deep-dive focused" }).click();
|
||||||
|
await page.getByRole("button", { name: "Direct & concise" }).click();
|
||||||
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "English", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||||
|
|
||||||
|
await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 });
|
||||||
|
await waitForChatComposer(page);
|
||||||
|
|
||||||
|
// The answers no longer overwrite a bot's instructions.
|
||||||
|
const bots = await rpc<Array<{ instructions: string }>>(page, "bots/list", null);
|
||||||
|
expect(bots.length).toBeGreaterThan(0);
|
||||||
|
for (const bot of bots) expect(bot.instructions).not.toContain("User Context");
|
||||||
|
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Pat Example");
|
||||||
|
|
||||||
|
// Sensitive facts from anywhere but the user wait for confirmation.
|
||||||
|
await rpc(page, "profile/update", {
|
||||||
|
addFacts: [{ category: "health", value: "Allergic to peanuts", source: "onboarding" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = await openUserSettings(page, "profile");
|
||||||
|
await expect(settings).toHaveAttribute("data-settings-section", "profile");
|
||||||
|
const profile = settings.getByTestId("user-profile-settings");
|
||||||
|
await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna");
|
||||||
|
await expect(profile.getByTestId("profile-field-language")).toContainText("English");
|
||||||
|
await expect(profile.getByTestId("profile-field-locale")).toContainText("United States");
|
||||||
|
await expect(profile.getByTestId("profile-field-communicationStyle")).toContainText(
|
||||||
|
"Direct and concise",
|
||||||
|
);
|
||||||
|
|
||||||
|
const music = profile.getByTestId("user-fact").filter({ hasText: "Interested in music" });
|
||||||
|
await expect(music).toContainText("Onboarding");
|
||||||
|
await expect(profile.getByTestId("user-facts-habits")).toContainText(
|
||||||
|
"Prefers focused deep dives",
|
||||||
|
);
|
||||||
|
|
||||||
|
const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" });
|
||||||
|
await expect(allergy).toHaveAttribute("data-status", "pending");
|
||||||
|
await captureScreenshot(page, testInfo, "user-profile-pending");
|
||||||
|
await allergy.getByRole("button", { name: "Confirm" }).click();
|
||||||
|
await expect(allergy).toHaveAttribute("data-status", "active");
|
||||||
|
await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0);
|
||||||
|
|
||||||
|
await music.getByRole("button", { name: "Delete" }).click();
|
||||||
|
await expect(music).toHaveCount(0);
|
||||||
|
await captureScreenshot(page, testInfo, "user-profile-settings");
|
||||||
|
const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>(
|
||||||
|
page,
|
||||||
|
"profile/get",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(afterDelete.facts).toEqual(
|
||||||
|
expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]),
|
||||||
|
);
|
||||||
|
expect(afterDelete.facts).toContainEqual(
|
||||||
|
expect.objectContaining({ value: "Allergic to peanuts", status: "active" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await profile.getByRole("button", { name: "Forget everything" }).click();
|
||||||
|
await page.getByRole("alertdialog").getByRole("button", { name: "Forget", exact: true }).click();
|
||||||
|
await expect(profile).toContainText("Nothing yet");
|
||||||
|
expect(await rpc<{ facts: unknown[] }>(page, "profile/get", null)).toMatchObject({ facts: [] });
|
||||||
|
});
|
||||||
245
apps/web/src/components/UserProfileSettings.tsx
Normal file
245
apps/web/src/components/UserProfileSettings.tsx
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import type { UserFactCategory, UserFactSource, UserProfileState } from "@rakazo/contracts";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
Button,
|
||||||
|
} from "@rakazo/ui-web";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import { getActiveUiLocale } from "../lib/i18n";
|
||||||
|
import { rpc } from "../lib/rpc";
|
||||||
|
import type { ProfileField } from "../lib/user-profile-view";
|
||||||
|
import {
|
||||||
|
CLEARED_PROFILE_FIELD,
|
||||||
|
displayLanguageTag,
|
||||||
|
displayRegionTag,
|
||||||
|
filledProfileFields,
|
||||||
|
groupUserFacts,
|
||||||
|
} from "../lib/user-profile-view";
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
actions,
|
||||||
|
testId,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
actions: (describedBy: string) => ReactNode;
|
||||||
|
testId: string;
|
||||||
|
status?: string;
|
||||||
|
}) {
|
||||||
|
const valueId = useId();
|
||||||
|
return (
|
||||||
|
<li data-testid={testId} data-status={status} className="flex items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p id={valueId} className="text-ui-body break-words text-foreground/75">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
<p className="text-ui-body-sm text-muted-foreground/70">{detail}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">{actions(valueId)}</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the user's bots know about the user, with consent and forget controls. */
|
||||||
|
export function UserProfileSettings() {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [state, setState] = useState<UserProfileState | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [forgetOpen, setForgetOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
rpc.profile
|
||||||
|
.get()
|
||||||
|
.then((next) => {
|
||||||
|
if (!cancelled) setState(next);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : t`Couldn't load`);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
async function apply(change: () => Promise<UserProfileState>) {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setState(await change());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t`Couldn't save`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLabels: Record<UserFactCategory, string> = {
|
||||||
|
identity: t`Identity`,
|
||||||
|
work: t`Work`,
|
||||||
|
goals: t`Goals`,
|
||||||
|
people: t`People`,
|
||||||
|
health: t`Health`,
|
||||||
|
values: t`Values`,
|
||||||
|
habits: t`Habits`,
|
||||||
|
preferences: t`Preferences`,
|
||||||
|
tools: t`Tools`,
|
||||||
|
};
|
||||||
|
const sourceLabels: Record<UserFactSource, string> = {
|
||||||
|
onboarding: t`Onboarding`,
|
||||||
|
user: t`You`,
|
||||||
|
conversation: t`Chat`,
|
||||||
|
consolidation: t`Chat history`,
|
||||||
|
connector: t`Connected app`,
|
||||||
|
};
|
||||||
|
const fieldLabels: Record<ProfileField, string> = {
|
||||||
|
timeZone: t`Time zone`,
|
||||||
|
language: t`Language`,
|
||||||
|
locale: t`Region`,
|
||||||
|
communicationStyle: t`Communication style`,
|
||||||
|
summary: t`Summary`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fields = state ? filledProfileFields(state.profile) : [];
|
||||||
|
const groups = state ? groupUserFacts(state.facts) : [];
|
||||||
|
const uiLocale = getActiveUiLocale();
|
||||||
|
|
||||||
|
const deleteButton = (describedBy: string, onClick: () => void) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t`Delete`}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="user-profile-settings" className="space-y-5">
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-ui-body-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{state && fields.length === 0 && groups.length === 0 ? (
|
||||||
|
<p className="text-ui-body-sm text-muted-foreground">
|
||||||
|
<Trans>Nothing yet</Trans>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{fields.length > 0 ? (
|
||||||
|
<section className="rounded-xl border border-border px-4 py-4">
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{fields.map(({ field, value }) => (
|
||||||
|
<Row
|
||||||
|
key={field}
|
||||||
|
testId={`profile-field-${field}`}
|
||||||
|
value={
|
||||||
|
field === "language"
|
||||||
|
? displayLanguageTag(value, uiLocale)
|
||||||
|
: field === "locale"
|
||||||
|
? displayRegionTag(value, uiLocale)
|
||||||
|
: value
|
||||||
|
}
|
||||||
|
detail={fieldLabels[field]}
|
||||||
|
actions={(describedBy) =>
|
||||||
|
deleteButton(
|
||||||
|
describedBy,
|
||||||
|
() => void apply(() => rpc.profile.update(CLEARED_PROFILE_FIELD[field])),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<section
|
||||||
|
key={group.category}
|
||||||
|
data-testid={`user-facts-${group.category}`}
|
||||||
|
className="rounded-xl border border-border px-4 py-4"
|
||||||
|
>
|
||||||
|
<h3 className="text-ui-body font-medium text-foreground">
|
||||||
|
{categoryLabels[group.category]}
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-3 space-y-3">
|
||||||
|
{group.facts.map((fact) => (
|
||||||
|
<Row
|
||||||
|
key={fact.id}
|
||||||
|
testId="user-fact"
|
||||||
|
status={fact.status}
|
||||||
|
value={fact.value}
|
||||||
|
detail={sourceLabels[fact.source]}
|
||||||
|
actions={(describedBy) => (
|
||||||
|
<>
|
||||||
|
{fact.status === "pending" ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Confirm</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{deleteButton(
|
||||||
|
describedBy,
|
||||||
|
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{fields.length > 0 || groups.length > 0 ? (
|
||||||
|
<Button variant="destructive" disabled={busy} onClick={() => setForgetOpen(true)}>
|
||||||
|
<Trans>Forget everything</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<AlertDialog open={forgetOpen} onOpenChange={setForgetOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
<Trans>Forget everything about you?</Trans>
|
||||||
|
</AlertDialogTitle>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setForgetOpen(false);
|
||||||
|
void apply(() => rpc.profile.forget({ all: true }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Forget</Trans>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/web/src/lib/onboarding-profile.test.ts
Normal file
56
apps/web/src/lib/onboarding-profile.test.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { onboardingProfileUpdate } from "./onboarding-profile";
|
||||||
|
|
||||||
|
const blank = { name: "", timezone: "", hobbies: [], workStyle: "", communication: "" };
|
||||||
|
|
||||||
|
describe("onboardingProfileUpdate", () => {
|
||||||
|
it("turns answers into profile fields and onboarding facts", () => {
|
||||||
|
expect(
|
||||||
|
onboardingProfileUpdate({
|
||||||
|
answers: {
|
||||||
|
name: " Alice ",
|
||||||
|
timezone: "Europe/Vienna",
|
||||||
|
hobbies: ["music", "tech", "none"],
|
||||||
|
workStyle: "deep-dive",
|
||||||
|
communication: "direct",
|
||||||
|
},
|
||||||
|
accountName: "Alice Example",
|
||||||
|
browserTimeZone: "UTC",
|
||||||
|
locale: "de-AT",
|
||||||
|
language: "de",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
name: "Alice",
|
||||||
|
timeZone: "Europe/Vienna",
|
||||||
|
locale: "de-AT",
|
||||||
|
language: "de",
|
||||||
|
communicationStyle: "Direct and concise",
|
||||||
|
addFacts: [
|
||||||
|
{ category: "preferences", value: "Interested in music", source: "onboarding" },
|
||||||
|
{ category: "preferences", value: "Interested in tech and gadgets", source: "onboarding" },
|
||||||
|
{ category: "habits", value: "Prefers focused deep dives", source: "onboarding" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the account name and falls back to the browser time zone", () => {
|
||||||
|
expect(
|
||||||
|
onboardingProfileUpdate({
|
||||||
|
answers: { ...blank, name: "Alice", timezone: "Not/AZone" },
|
||||||
|
accountName: "Alice",
|
||||||
|
browserTimeZone: "America/Toronto",
|
||||||
|
}),
|
||||||
|
).toEqual({ timeZone: "America/Toronto" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves out a language the user did not pick and malformed tags", () => {
|
||||||
|
expect(
|
||||||
|
onboardingProfileUpdate({
|
||||||
|
answers: blank,
|
||||||
|
browserTimeZone: "UTC",
|
||||||
|
locale: "not a tag",
|
||||||
|
language: null,
|
||||||
|
}),
|
||||||
|
).toEqual({ timeZone: "UTC" });
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/web/src/lib/onboarding-profile.ts
Normal file
73
apps/web/src/lib/onboarding-profile.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import type { UserFactCategory } from "@rakazo/contracts";
|
||||||
|
import { isLanguageTag, isTimeZone } from "@rakazo/contracts";
|
||||||
|
|
||||||
|
export interface OnboardingAnswers {
|
||||||
|
name: string;
|
||||||
|
timezone: string;
|
||||||
|
hobbies: string[];
|
||||||
|
workStyle: string;
|
||||||
|
communication: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stored as data for the user's bots, so these stay plain English statements.
|
||||||
|
const HOBBY_FACTS: Record<string, string> = {
|
||||||
|
coding: "Interested in coding",
|
||||||
|
design: "Interested in design",
|
||||||
|
writing: "Interested in writing",
|
||||||
|
music: "Interested in music",
|
||||||
|
gaming: "Interested in gaming",
|
||||||
|
reading: "Interested in reading",
|
||||||
|
sports: "Interested in sports",
|
||||||
|
cooking: "Interested in cooking",
|
||||||
|
tech: "Interested in tech and gadgets",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WORK_STYLE_FACTS: Record<string, string> = {
|
||||||
|
structured: "Works in a structured, planned way",
|
||||||
|
flexible: "Works flexibly and spontaneously",
|
||||||
|
"deep-dive": "Prefers focused deep dives",
|
||||||
|
quick: "Works quickly and pragmatically",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMMUNICATION_STYLES: Record<string, string> = {
|
||||||
|
direct: "Direct and concise",
|
||||||
|
detailed: "Detailed and explanatory",
|
||||||
|
balanced: "Balanced",
|
||||||
|
casual: "Casual and friendly",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Turns onboarding answers into one profile update; empty or unknown answers are left out. */
|
||||||
|
export function onboardingProfileUpdate(input: {
|
||||||
|
answers: OnboardingAnswers;
|
||||||
|
/** The account name, which only changes when the user typed a different one. */
|
||||||
|
accountName?: string;
|
||||||
|
/** Used when the typed time zone is empty or unknown. */
|
||||||
|
browserTimeZone: string;
|
||||||
|
/** Browser locale, e.g. "de-AT". */
|
||||||
|
locale?: string;
|
||||||
|
/** Language the user picked for their Oos, when they picked one. */
|
||||||
|
language?: string | null;
|
||||||
|
}) {
|
||||||
|
const { answers } = input;
|
||||||
|
const name = answers.name.trim();
|
||||||
|
const typedZone = answers.timezone.trim();
|
||||||
|
const workStyle = WORK_STYLE_FACTS[answers.workStyle];
|
||||||
|
const facts: Array<{ category: UserFactCategory; value: string }> = [
|
||||||
|
...answers.hobbies.flatMap((hobby) => {
|
||||||
|
const value = HOBBY_FACTS[hobby];
|
||||||
|
return value ? [{ category: "preferences" as const, value }] : [];
|
||||||
|
}),
|
||||||
|
...(workStyle ? [{ category: "habits" as const, value: workStyle }] : []),
|
||||||
|
];
|
||||||
|
const communicationStyle = COMMUNICATION_STYLES[answers.communication];
|
||||||
|
return {
|
||||||
|
...(name && name !== input.accountName?.trim() ? { name } : {}),
|
||||||
|
timeZone: typedZone && isTimeZone(typedZone) ? typedZone : input.browserTimeZone,
|
||||||
|
...(input.locale && isLanguageTag(input.locale) ? { locale: input.locale } : {}),
|
||||||
|
...(input.language && isLanguageTag(input.language) ? { language: input.language } : {}),
|
||||||
|
...(communicationStyle ? { communicationStyle } : {}),
|
||||||
|
...(facts.length > 0
|
||||||
|
? { addFacts: facts.map((fact) => ({ ...fact, source: "onboarding" as const })) }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
collectLocalProfileSignals,
|
collectLocalProfileSignals,
|
||||||
mergeUserProfileIntoMemory,
|
mergeUserMemorySections,
|
||||||
persistUserProfileHarvest,
|
persistUserProfileHarvest,
|
||||||
} from "./user-profile-harvest";
|
} from "./user-profile-harvest";
|
||||||
|
|
||||||
@ -20,48 +20,13 @@ describe("user-profile-harvest", () => {
|
|||||||
expect(signals.harvestedAt).toMatch(/^\d{4}-/);
|
expect(signals.harvestedAt).toMatch(/^\d{4}-/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges profile block into MEMORY.md", () => {
|
it("keeps existing notes and leaves profile data to the user profile", () => {
|
||||||
const merged = mergeUserProfileIntoMemory(
|
const merged = mergeUserMemorySections("# Space memory\n\nExisting note.\n");
|
||||||
"# Space memory\n\nExisting note.\n",
|
|
||||||
collectLocalProfileSignals({
|
|
||||||
name: "Ingo",
|
|
||||||
email: "a@b.c",
|
|
||||||
defaultProvider: null,
|
|
||||||
defaultModel: null,
|
|
||||||
computerHost: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(merged).toContain("## User profile (auto-harvested)");
|
|
||||||
expect(merged).toContain("**Name:** Ingo");
|
|
||||||
expect(merged).toContain("Existing note.");
|
expect(merged).toContain("Existing note.");
|
||||||
|
expect(merged).not.toContain("## User profile (auto-harvested)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates an existing profile section in place", () => {
|
it("persists help vibe via memory.update on user scope without profile data", async () => {
|
||||||
const base = mergeUserProfileIntoMemory(
|
|
||||||
"# Space memory\n",
|
|
||||||
collectLocalProfileSignals({
|
|
||||||
name: "First",
|
|
||||||
email: "a@b.c",
|
|
||||||
defaultProvider: null,
|
|
||||||
defaultModel: null,
|
|
||||||
computerHost: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const updated = mergeUserProfileIntoMemory(
|
|
||||||
base,
|
|
||||||
collectLocalProfileSignals({
|
|
||||||
name: "Second",
|
|
||||||
email: "a@b.c",
|
|
||||||
defaultProvider: null,
|
|
||||||
defaultModel: null,
|
|
||||||
computerHost: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(updated).toContain("**Name:** Second");
|
|
||||||
expect(updated.match(/## User profile \(auto-harvested\)/g)?.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists profile and help vibe via memory.update on user scope", async () => {
|
|
||||||
const updates: Array<{ documentId: string; content: string }> = [];
|
const updates: Array<{ documentId: string; content: string }> = [];
|
||||||
const hints = await persistUserProfileHarvest(
|
const hints = await persistUserProfileHarvest(
|
||||||
{
|
{
|
||||||
@ -81,8 +46,9 @@ describe("user-profile-harvest", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(updates).toHaveLength(1);
|
expect(updates).toHaveLength(1);
|
||||||
expect(updates[0]?.content).toContain("Ingo");
|
|
||||||
expect(updates[0]?.content).toContain("## Help & vibe (inferred)");
|
expect(updates[0]?.content).toContain("## Help & vibe (inferred)");
|
||||||
|
expect(updates[0]?.content).not.toContain("Ingo");
|
||||||
|
expect(updates[0]?.content).not.toContain("ingo@example.com");
|
||||||
expect(hints.suggestedLanes).toContain("builder");
|
expect(hints.suggestedLanes).toContain("builder");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -48,43 +48,6 @@ export function collectLocalProfileSignals(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROFILE_SECTION = "## User profile (auto-harvested)";
|
|
||||||
|
|
||||||
export function mergeUserProfileIntoMemory(existing: string, signals: UserProfileSignals): string {
|
|
||||||
const block = formatProfileBlock(signals);
|
|
||||||
const marker = PROFILE_SECTION;
|
|
||||||
const start = existing.indexOf(marker);
|
|
||||||
if (start === -1) {
|
|
||||||
const trimmed = existing.trimEnd();
|
|
||||||
const prefix = trimmed.length ? `${trimmed}\n\n` : "";
|
|
||||||
return `${prefix}${marker}\n\n${block}\n`;
|
|
||||||
}
|
|
||||||
const rest = existing.slice(start + marker.length);
|
|
||||||
const nextSection = rest.search(/\n## /);
|
|
||||||
const tail = nextSection === -1 ? "" : rest.slice(nextSection);
|
|
||||||
return `${existing.slice(0, start).trimEnd()}\n\n${marker}\n\n${block}${tail}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatProfileBlock(signals: UserProfileSignals): string {
|
|
||||||
const lines = [
|
|
||||||
`- **Updated:** ${signals.harvestedAt}`,
|
|
||||||
signals.name ? `- **Name:** ${signals.name}` : null,
|
|
||||||
signals.email ? `- **Email:** ${signals.email}` : null,
|
|
||||||
`- **Timezone:** ${signals.timezone}`,
|
|
||||||
`- **Locale:** ${signals.locale}`,
|
|
||||||
signals.uiLocale ? `- **UI locale:** ${signals.uiLocale}` : null,
|
|
||||||
signals.languages.length ? `- **Languages:** ${signals.languages.join(", ")}` : null,
|
|
||||||
signals.platform ? `- **Platform:** ${signals.platform}` : null,
|
|
||||||
signals.defaultProvider || signals.defaultModel
|
|
||||||
? `- **Default model:** ${signals.defaultProvider ?? "—"} / ${signals.defaultModel ?? "—"}`
|
|
||||||
: null,
|
|
||||||
signals.computerHost ? `- **Computer host:** ${signals.computerHost}` : null,
|
|
||||||
"",
|
|
||||||
"_Seeded during onboarding from local/browser signals. Extend manually in Memory settings._",
|
|
||||||
];
|
|
||||||
return lines.filter((line) => line !== null).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MemoryListClient = {
|
export type MemoryListClient = {
|
||||||
memory: {
|
memory: {
|
||||||
list: (input: {
|
list: (input: {
|
||||||
@ -94,20 +57,16 @@ export type MemoryListClient = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Profile + optional help/vibe + Oo relationship product contract → user MEMORY.md. */
|
/**
|
||||||
export function mergeFullUserProfileMemory(
|
* Optional help/vibe + Oo relationship product contract → user MEMORY.md. Name, time zone and
|
||||||
existing: string,
|
* language live in the user profile instead, so they are not repeated here.
|
||||||
signals: UserProfileSignals,
|
*/
|
||||||
helpVibe?: UserHelpVibeHints,
|
export function mergeUserMemorySections(existing: string, helpVibe?: UserHelpVibeHints): string {
|
||||||
): string {
|
const content = helpVibe ? mergeHelpVibeIntoMemory(existing, helpVibe) : existing;
|
||||||
let content = mergeUserProfileIntoMemory(existing, signals);
|
|
||||||
if (helpVibe) {
|
|
||||||
content = mergeHelpVibeIntoMemory(content, helpVibe);
|
|
||||||
}
|
|
||||||
return mergeRelationshipContractIntoMemory(content);
|
return mergeRelationshipContractIntoMemory(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist harvest + help/vibe hints into space user `MEMORY.md` (existing memory system). */
|
/** Persist help/vibe hints into space user `MEMORY.md` (existing memory system). */
|
||||||
export async function persistUserProfileHarvest(
|
export async function persistUserProfileHarvest(
|
||||||
client: MemoryListClient,
|
client: MemoryListClient,
|
||||||
signals: UserProfileSignals,
|
signals: UserProfileSignals,
|
||||||
@ -117,7 +76,7 @@ export async function persistUserProfileHarvest(
|
|||||||
const docs = await client.memory.list({ scope: "user" });
|
const docs = await client.memory.list({ scope: "user" });
|
||||||
const memoryDoc = docs.find((doc) => doc.path === "MEMORY.md") ?? docs[0];
|
const memoryDoc = docs.find((doc) => doc.path === "MEMORY.md") ?? docs[0];
|
||||||
if (!memoryDoc) return helpVibe;
|
if (!memoryDoc) return helpVibe;
|
||||||
const content = mergeFullUserProfileMemory(memoryDoc.content, signals, helpVibe);
|
const content = mergeUserMemorySections(memoryDoc.content, helpVibe);
|
||||||
if (content !== memoryDoc.content) {
|
if (content !== memoryDoc.content) {
|
||||||
await client.memory.update({ documentId: memoryDoc.id, content });
|
await client.memory.update({ documentId: memoryDoc.id, content });
|
||||||
}
|
}
|
||||||
|
|||||||
76
apps/web/src/lib/user-profile-view.test.ts
Normal file
76
apps/web/src/lib/user-profile-view.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import type { UserFact, UserProfile } from "@rakazo/contracts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
displayLanguageTag,
|
||||||
|
displayRegionTag,
|
||||||
|
filledProfileFields,
|
||||||
|
groupUserFacts,
|
||||||
|
} from "./user-profile-view";
|
||||||
|
|
||||||
|
function fact(overrides: Partial<UserFact> & Pick<UserFact, "id">): UserFact {
|
||||||
|
return {
|
||||||
|
category: "work",
|
||||||
|
value: overrides.id,
|
||||||
|
confidence: 1,
|
||||||
|
source: "conversation",
|
||||||
|
status: "active",
|
||||||
|
sensitive: false,
|
||||||
|
allSpaces: true,
|
||||||
|
spaceIds: [],
|
||||||
|
sourceBotId: null,
|
||||||
|
sourceThreadId: null,
|
||||||
|
createdAt: "2026-10-01T10:00:00.000Z",
|
||||||
|
updatedAt: "2026-10-01T10:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("user profile view", () => {
|
||||||
|
it("groups facts in category order with pending facts first, then the newest", () => {
|
||||||
|
const groups = groupUserFacts([
|
||||||
|
fact({ id: "old-work", createdAt: "2026-09-01T00:00:00.000Z" }),
|
||||||
|
fact({ id: "new-work", createdAt: "2026-10-01T00:00:00.000Z" }),
|
||||||
|
fact({ id: "tool", category: "tools" }),
|
||||||
|
fact({ id: "pending-health", category: "health", status: "pending" }),
|
||||||
|
fact({
|
||||||
|
id: "pending-work",
|
||||||
|
status: "pending",
|
||||||
|
createdAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
fact({ id: "identity", category: "identity" }),
|
||||||
|
]);
|
||||||
|
expect(groups.map((group) => [group.category, group.facts.map((item) => item.id)])).toEqual([
|
||||||
|
["identity", ["identity"]],
|
||||||
|
["work", ["pending-work", "new-work", "old-work"]],
|
||||||
|
["health", ["pending-health"]],
|
||||||
|
["tools", ["tool"]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists only filled profile fields", () => {
|
||||||
|
const profile: UserProfile = {
|
||||||
|
timeZone: "Europe/Vienna",
|
||||||
|
locale: null,
|
||||||
|
language: "de",
|
||||||
|
communicationStyle: "",
|
||||||
|
summary: "Nurse in Vienna.",
|
||||||
|
consolidatedAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
expect(filledProfileFields(profile)).toEqual([
|
||||||
|
{ field: "timeZone", value: "Europe/Vienna" },
|
||||||
|
{ field: "language", value: "de" },
|
||||||
|
{ field: "summary", value: "Nurse in Vienna." },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names languages and regions in the reader's language and keeps unknown tags", () => {
|
||||||
|
expect(displayLanguageTag("de", "en")).toBe("German");
|
||||||
|
expect(displayLanguageTag("de", "de")).toBe("Deutsch");
|
||||||
|
expect(displayLanguageTag("not a tag", "en")).toBe("not a tag");
|
||||||
|
expect(displayRegionTag("de-AT", "en")).toBe("Austria");
|
||||||
|
expect(displayRegionTag("en-US", "de")).toBe("Vereinigte Staaten");
|
||||||
|
expect(displayRegionTag("de", "en")).toBe("German");
|
||||||
|
expect(displayRegionTag("not a tag", "en")).toBe("not a tag");
|
||||||
|
});
|
||||||
|
});
|
||||||
77
apps/web/src/lib/user-profile-view.ts
Normal file
77
apps/web/src/lib/user-profile-view.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import type { UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts";
|
||||||
|
import { USER_FACT_CATEGORIES } from "@rakazo/contracts";
|
||||||
|
|
||||||
|
export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary";
|
||||||
|
|
||||||
|
const PROFILE_FIELDS: ProfileField[] = [
|
||||||
|
"timeZone",
|
||||||
|
"language",
|
||||||
|
"locale",
|
||||||
|
"communicationStyle",
|
||||||
|
"summary",
|
||||||
|
];
|
||||||
|
|
||||||
|
type ClearedProfileField = {
|
||||||
|
timeZone?: null;
|
||||||
|
language?: null;
|
||||||
|
locale?: null;
|
||||||
|
communicationStyle?: "";
|
||||||
|
summary?: "";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The `profile.update` input that removes one profile field. */
|
||||||
|
export const CLEARED_PROFILE_FIELD: Record<ProfileField, ClearedProfileField> = {
|
||||||
|
timeZone: { timeZone: null },
|
||||||
|
language: { language: null },
|
||||||
|
locale: { locale: null },
|
||||||
|
communicationStyle: { communicationStyle: "" },
|
||||||
|
summary: { summary: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Profile fields that hold a value, in display order. */
|
||||||
|
export function filledProfileFields(
|
||||||
|
profile: UserProfile,
|
||||||
|
): Array<{ field: ProfileField; value: string }> {
|
||||||
|
return PROFILE_FIELDS.flatMap((field) => {
|
||||||
|
const value = profile[field];
|
||||||
|
return value ? [{ field, value }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Facts grouped in category order; pending facts lead each group, then the newest. */
|
||||||
|
export function groupUserFacts(
|
||||||
|
facts: UserFact[],
|
||||||
|
): Array<{ category: UserFactCategory; facts: UserFact[] }> {
|
||||||
|
return USER_FACT_CATEGORIES.flatMap((category) => {
|
||||||
|
const inCategory = facts
|
||||||
|
.filter((fact) => fact.category === category)
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.status === b.status
|
||||||
|
? b.createdAt.localeCompare(a.createdAt)
|
||||||
|
: a.status === "pending"
|
||||||
|
? -1
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
|
return inCategory.length > 0 ? [{ category, facts: inCategory }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A language tag in the reader's language, e.g. "de" → "German"; the tag itself if unknown. */
|
||||||
|
export function displayLanguageTag(tag: string, uiLocale: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DisplayNames([uiLocale], { type: "language" }).of(tag) ?? tag;
|
||||||
|
} catch {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The region of a locale tag in the reader's language, e.g. "de-AT" → "Austria". */
|
||||||
|
export function displayRegionTag(tag: string, uiLocale: string): string {
|
||||||
|
try {
|
||||||
|
const region = new Intl.Locale(tag).region;
|
||||||
|
if (!region) return displayLanguageTag(tag, uiLocale);
|
||||||
|
return new Intl.DisplayNames([uiLocale], { type: "region" }).of(region) ?? tag;
|
||||||
|
} catch {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { collectLocalProfileSignals, mergeFullUserProfileMemory } from "./user-profile-harvest";
|
import { mergeUserMemorySections } from "./user-profile-harvest";
|
||||||
import {
|
import {
|
||||||
mergeRelationshipContractIntoMemory,
|
mergeRelationshipContractIntoMemory,
|
||||||
RELATIONSHIP_SECTION,
|
RELATIONSHIP_SECTION,
|
||||||
@ -13,17 +13,8 @@ describe("user-relationship-contract", () => {
|
|||||||
expect(mergeRelationshipContractIntoMemory(once)).toBe(once);
|
expect(mergeRelationshipContractIntoMemory(once)).toBe(once);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is included in full profile harvest merge", () => {
|
it("is included in the user memory merge", () => {
|
||||||
const merged = mergeFullUserProfileMemory(
|
const merged = mergeUserMemorySections("# Space memory\n");
|
||||||
"# Space memory\n",
|
|
||||||
collectLocalProfileSignals({
|
|
||||||
name: "Ingo",
|
|
||||||
email: "i@example.com",
|
|
||||||
defaultProvider: null,
|
|
||||||
defaultModel: null,
|
|
||||||
computerHost: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(merged).toContain(RELATIONSHIP_SECTION);
|
expect(merged).toContain(RELATIONSHIP_SECTION);
|
||||||
expect(merged).toContain("Never creepy");
|
expect(merged).toContain("Never creepy");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,12 +14,14 @@ import {
|
|||||||
KOKORO_LOCAL_TOKEN,
|
KOKORO_LOCAL_TOKEN,
|
||||||
OnboardingVoiceStep,
|
OnboardingVoiceStep,
|
||||||
} from "../components/onboarding/OnboardingVoiceStep";
|
} from "../components/onboarding/OnboardingVoiceStep";
|
||||||
|
import { localTimezone } from "../lib/local-timezone";
|
||||||
import {
|
import {
|
||||||
computeDiscoveryItems,
|
computeDiscoveryItems,
|
||||||
countConfiguredIntegrations,
|
countConfiguredIntegrations,
|
||||||
type OnboardingDiscoveryPhase,
|
type OnboardingDiscoveryPhase,
|
||||||
} from "../lib/onboarding-discovery";
|
} from "../lib/onboarding-discovery";
|
||||||
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
|
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
|
||||||
|
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
|
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
|
||||||
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
|
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
|
||||||
@ -135,7 +137,7 @@ const COMMUNICATION_OPTIONS = [
|
|||||||
function defaultPreferences(): UserPreferences {
|
function defaultPreferences(): UserPreferences {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
timezone: "",
|
timezone: localTimezone(),
|
||||||
hobbies: [],
|
hobbies: [],
|
||||||
workStyle: "",
|
workStyle: "",
|
||||||
communication: "",
|
communication: "",
|
||||||
@ -170,6 +172,7 @@ export function OnboardingPage() {
|
|||||||
return "en";
|
return "en";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const [languageChoice, setLanguageChoice] = useState<UiLocale | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -265,15 +268,17 @@ export function OnboardingPage() {
|
|||||||
const bot = await ensureFirstBot(emojiConfig.avatarValue);
|
const bot = await ensureFirstBot(emojiConfig.avatarValue);
|
||||||
setCrewReady(true);
|
setCrewReady(true);
|
||||||
|
|
||||||
const ctxParts: string[] = [];
|
await rpc.profile
|
||||||
if (prefs.name) ctxParts.push(`User: ${prefs.name}`);
|
.update(
|
||||||
if (prefs.timezone) ctxParts.push(`Timezone: ${prefs.timezone}`);
|
onboardingProfileUpdate({
|
||||||
if (prefs.hobbies.length > 0) ctxParts.push(`Hobbies: ${prefs.hobbies.join(", ")}`);
|
answers: prefs,
|
||||||
if (prefs.workStyle) ctxParts.push(`Work style: ${prefs.workStyle}`);
|
accountName: bootstrapMe?.name,
|
||||||
if (prefs.communication) ctxParts.push(`Communication: ${prefs.communication}`);
|
browserTimeZone: localTimezone(),
|
||||||
const userCtx =
|
locale: typeof navigator !== "undefined" ? navigator.language : undefined,
|
||||||
ctxParts.length > 0 ? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}` : "";
|
language: languageChoice,
|
||||||
await rpc.bots.update({ botId: bot.id, instructions: userCtx }).catch(() => null);
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
if (selectedVoiceId && !voiceSkipped) {
|
if (selectedVoiceId && !voiceSkipped) {
|
||||||
await rpc.voice
|
await rpc.voice
|
||||||
@ -496,6 +501,7 @@ export function OnboardingPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
persistUiLocale(code as UiLocale);
|
persistUiLocale(code as UiLocale);
|
||||||
setCurrentLocale(code);
|
setCurrentLocale(code);
|
||||||
|
setLanguageChoice(code as UiLocale);
|
||||||
setStep("voice");
|
setStep("voice");
|
||||||
}}
|
}}
|
||||||
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
|
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
|
||||||
|
|||||||
@ -10,11 +10,13 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
UserRound,
|
||||||
Volume2,
|
Volume2,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ComponentType, useEffect, useRef, useState } from "react";
|
import { type ComponentType, useEffect, useRef, useState } from "react";
|
||||||
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
|
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
|
||||||
|
import { UserProfileSettings } from "../components/UserProfileSettings";
|
||||||
import type { TradingBotUiState } from "../lib/trading-bot-status";
|
import type { TradingBotUiState } from "../lib/trading-bot-status";
|
||||||
import {
|
import {
|
||||||
ComputerSettingsPanel,
|
ComputerSettingsPanel,
|
||||||
@ -33,6 +35,7 @@ export type SettingsSection =
|
|||||||
| "integrations"
|
| "integrations"
|
||||||
| "models"
|
| "models"
|
||||||
| "memory"
|
| "memory"
|
||||||
|
| "profile"
|
||||||
| "voice"
|
| "voice"
|
||||||
| "usage"
|
| "usage"
|
||||||
| "computer"
|
| "computer"
|
||||||
@ -106,6 +109,7 @@ export function SettingsOverlay({
|
|||||||
{ id: "integrations", label: t`Integrations`, icon: LayoutGrid },
|
{ id: "integrations", label: t`Integrations`, icon: LayoutGrid },
|
||||||
{ id: "models", label: t`Models`, icon: Cpu },
|
{ id: "models", label: t`Models`, icon: Cpu },
|
||||||
{ id: "memory", label: t`Memory`, icon: Brain },
|
{ id: "memory", label: t`Memory`, icon: Brain },
|
||||||
|
{ id: "profile", label: t`About you`, icon: UserRound },
|
||||||
{ id: "voice", label: t`Voice`, icon: Volume2 },
|
{ id: "voice", label: t`Voice`, icon: Volume2 },
|
||||||
{ id: "usage", label: t`Usage`, icon: Gauge },
|
{ id: "usage", label: t`Usage`, icon: Gauge },
|
||||||
...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []),
|
...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []),
|
||||||
@ -245,6 +249,7 @@ export function SettingsOverlay({
|
|||||||
onOpenMcp={onOpenMcp ? () => leaveSettings(onOpenMcp) : undefined}
|
onOpenMcp={onOpenMcp ? () => leaveSettings(onOpenMcp) : undefined}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{section === "profile" ? <UserProfileSettings /> : null}
|
||||||
{section === "usage" ? (
|
{section === "usage" ? (
|
||||||
<UsageSettingsPanel usage={usage} panelRef={usageRef} />
|
<UsageSettingsPanel usage={usage} panelRef={usageRef} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
BotSecretName,
|
BotSecretName,
|
||||||
SecretAskPurpose,
|
SecretAskPurpose,
|
||||||
SecretHttpRequest,
|
SecretHttpRequest,
|
||||||
|
USER_FACT_CATEGORIES,
|
||||||
} from "@rakazo/contracts";
|
} from "@rakazo/contracts";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@ -383,6 +384,40 @@ export const builtinAgentTools: ConnectorTool[] = [
|
|||||||
required: ["content"],
|
required: ["content"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "update_user_profile",
|
||||||
|
description:
|
||||||
|
"Save what you learned about the user so all of their bots can use it: short facts and profile settings. Save only what the user said or clearly confirmed, never guesses. Health and values facts stay hidden from bots until the user confirms them.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
facts: {
|
||||||
|
type: "array",
|
||||||
|
maxItems: 20,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
category: { type: "string", enum: [...USER_FACT_CATEGORIES] },
|
||||||
|
value: {
|
||||||
|
type: "string",
|
||||||
|
description: 'One short statement, e.g. "Works night shifts as a nurse".',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["category", "value"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
time_zone: { type: "string", description: "IANA time zone, e.g. Europe/Vienna." },
|
||||||
|
language: {
|
||||||
|
type: "string",
|
||||||
|
description: "Language the user wants replies in, as a BCP-47 tag, e.g. de.",
|
||||||
|
},
|
||||||
|
communication_style: {
|
||||||
|
type: "string",
|
||||||
|
description: 'How the user wants to be talked to, e.g. "brief and informal".',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -316,7 +316,10 @@ describe("run instructions", () => {
|
|||||||
|
|
||||||
expect(request.instructions.startsWith("You are Chief — Chief of Staff.")).toBe(true);
|
expect(request.instructions.startsWith("You are Chief — Chief of Staff.")).toBe(true);
|
||||||
expect(request.instructions).toContain("You are the user's main agent");
|
expect(request.instructions).toContain("You are the user's main agent");
|
||||||
expect(request.instructions).toContain("save lasting facts they share with remember");
|
// The main bot keeps the user model and delegates; its role names both tools.
|
||||||
|
expect(request.instructions).toContain("record it with update_user_profile");
|
||||||
|
expect(request.instructions).toContain("Give a specialist tracked work with delegate_task");
|
||||||
|
expect(request.instructions).not.toContain("save lasting facts they share with remember");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the main bot every specialist it can delegate to, its own space first", async () => {
|
it("shows the main bot every specialist it can delegate to, its own space first", async () => {
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export function selectBuiltinToolsForRun(options: {
|
|||||||
semanticMemoryEnabled: boolean;
|
semanticMemoryEnabled: boolean;
|
||||||
cloudAgentEnabled?: boolean;
|
cloudAgentEnabled?: boolean;
|
||||||
messagingChannelRun: boolean;
|
messagingChannelRun: boolean;
|
||||||
/** Only the user's main bot may delegate tasks. */
|
/** Only the user's main bot delegates tasks and keeps the user model. */
|
||||||
mainBot?: boolean;
|
mainBot?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return selectCloudAgentTools(
|
return selectCloudAgentTools(
|
||||||
@ -48,9 +48,15 @@ export function selectBuiltinToolsForRun(options: {
|
|||||||
Boolean(options.cloudAgentEnabled),
|
Boolean(options.cloudAgentEnabled),
|
||||||
).filter(
|
).filter(
|
||||||
(tool) =>
|
(tool) =>
|
||||||
(options.mainBot || tool.name !== "delegate_task") &&
|
(options.mainBot || !["delegate_task", "update_user_profile"].includes(tool.name)) &&
|
||||||
(!options.messagingChannelRun ||
|
(!options.messagingChannelRun ||
|
||||||
(!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) &&
|
(![
|
||||||
|
"remember",
|
||||||
|
"save_memory",
|
||||||
|
"recall_memory",
|
||||||
|
"forget_memory",
|
||||||
|
"update_user_profile",
|
||||||
|
].includes(tool.name) &&
|
||||||
!tool.name.startsWith("scratchpad_"))),
|
!tool.name.startsWith("scratchpad_"))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -373,6 +373,7 @@ import {
|
|||||||
} from "./thread-artifacts.js";
|
} from "./thread-artifacts.js";
|
||||||
import { advanceToolCallLoopGuard } from "./tool-loop.js";
|
import { advanceToolCallLoopGuard } from "./tool-loop.js";
|
||||||
import { textContentArg } from "./tool-text.js";
|
import { textContentArg } from "./tool-text.js";
|
||||||
|
import { updateUserProfileFromTool } from "./user-profile-tools.js";
|
||||||
import {
|
import {
|
||||||
botMessageOutcomeFromMidTurn,
|
botMessageOutcomeFromMidTurn,
|
||||||
clampUserProgressMessage,
|
clampUserProgressMessage,
|
||||||
@ -2502,6 +2503,20 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
);
|
);
|
||||||
return finish({ ok: true });
|
return finish({ ok: true });
|
||||||
}
|
}
|
||||||
|
if (name === "update_user_profile") {
|
||||||
|
return finish(
|
||||||
|
await updateUserProfileFromTool(
|
||||||
|
deps.prisma,
|
||||||
|
{
|
||||||
|
userId: run.userId,
|
||||||
|
bot,
|
||||||
|
threadId: thread.id,
|
||||||
|
sourceMessageId: run.sourceMessageId,
|
||||||
|
},
|
||||||
|
args,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (name === "web_search") {
|
if (name === "web_search") {
|
||||||
return finish(await webSearchFromTool(web, context, args));
|
return finish(await webSearchFromTool(web, context, args));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,6 +102,28 @@ describe("inferScript request_secret", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("inferScript update_user_profile", () => {
|
||||||
|
const profileScript = (category: string, value: string) => [
|
||||||
|
{
|
||||||
|
assistant: "noted — i will keep that in your profile.",
|
||||||
|
toolCalls: [{ name: "update_user_profile", args: { facts: [{ category, value }] } }],
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("records a categorized fact about the user", () => {
|
||||||
|
expect(inferScript("learn about me: health: Allergic to peanuts")).toEqual(
|
||||||
|
profileScript("health", "Allergic to peanuts"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to preferences and wins over broader intents in the statement", () => {
|
||||||
|
expect(inferScript("Learn about me: I write every note in a file")).toEqual(
|
||||||
|
profileScript("preferences", "I write every note in a file"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ScriptedAgentRuntime executionIds", () => {
|
describe("ScriptedAgentRuntime executionIds", () => {
|
||||||
it("gives repeated tools distinct executionIds within a run", async () => {
|
it("gives repeated tools distinct executionIds within a run", async () => {
|
||||||
const runtime = new ScriptedAgentRuntime();
|
const runtime = new ScriptedAgentRuntime();
|
||||||
|
|||||||
@ -4,10 +4,17 @@ import type {
|
|||||||
AgentRuntime,
|
AgentRuntime,
|
||||||
AgentRuntimeEvent,
|
AgentRuntimeEvent,
|
||||||
} from "@rakazo/adapter-kit";
|
} from "@rakazo/adapter-kit";
|
||||||
|
import { USER_FACT_CATEGORIES } from "@rakazo/contracts";
|
||||||
import { abortableDelay, DELEGATED_TASK_WAKE_CUE, inferHandoffTargetName } from "@rakazo/core";
|
import { abortableDelay, DELEGATED_TASK_WAKE_CUE, inferHandoffTargetName } from "@rakazo/core";
|
||||||
|
|
||||||
const running = new Map<string, AbortController>();
|
const running = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
/** "learn about me: [category:] statement" records one fact in the user's profile. */
|
||||||
|
const LEARN_ABOUT_ME = new RegExp(
|
||||||
|
`learn about me:\\s*(?:(${USER_FACT_CATEGORIES.join("|")})\\s*:\\s*)?([\\s\\S]+)$`,
|
||||||
|
"i",
|
||||||
|
);
|
||||||
|
|
||||||
export class ScriptedAgentRuntime implements AgentRuntime {
|
export class ScriptedAgentRuntime implements AgentRuntime {
|
||||||
describe() {
|
describe() {
|
||||||
return {
|
return {
|
||||||
@ -178,6 +185,28 @@ export function inferScript(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
const learned = LEARN_ABOUT_ME.exec(prompt);
|
||||||
|
if (learned?.[2]?.trim()) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
assistant: "noted — i will keep that in your profile.",
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "update_user_profile",
|
||||||
|
args: {
|
||||||
|
facts: [
|
||||||
|
{
|
||||||
|
category: (learned[1] ?? "preferences").toLowerCase(),
|
||||||
|
value: learned[2].trim(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
if (lower.includes("delegate to the bot named")) {
|
if (lower.includes("delegate to the bot named")) {
|
||||||
const name = namedBot(prompt) ?? "Researcher";
|
const name = namedBot(prompt) ?? "Researcher";
|
||||||
const brief =
|
const brief =
|
||||||
|
|||||||
110
packages/adapters/src/user-profile-tools.test.ts
Normal file
110
packages/adapters/src/user-profile-tools.test.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { selectBuiltinToolsForRun } from "./executor-tools.js";
|
||||||
|
import { updateUserProfileFromTool } from "./user-profile-tools.js";
|
||||||
|
|
||||||
|
function toolNames(options: { mainBot?: boolean; messagingChannelRun?: boolean }) {
|
||||||
|
return selectBuiltinToolsForRun({
|
||||||
|
graphicalToolsAllowed: false,
|
||||||
|
groupId: null,
|
||||||
|
trigger: "user",
|
||||||
|
semanticMemoryEnabled: false,
|
||||||
|
messagingChannelRun: options.messagingChannelRun ?? false,
|
||||||
|
mainBot: options.mainBot,
|
||||||
|
}).map((tool) => tool.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any database access fails the test: refusals and invalid input must not reach it. */
|
||||||
|
const untouchedPrisma = new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, property) {
|
||||||
|
throw new Error(`unexpected database access: ${String(property)}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
) as PrismaClient;
|
||||||
|
|
||||||
|
const context = (kind: string) => ({
|
||||||
|
userId: "user-1",
|
||||||
|
bot: { id: "bot-1", kind },
|
||||||
|
threadId: "thread-1",
|
||||||
|
sourceMessageId: "message-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update_user_profile", () => {
|
||||||
|
it("is offered to the main bot only, and never in shared messaging channels", () => {
|
||||||
|
expect(toolNames({ mainBot: true })).toContain("update_user_profile");
|
||||||
|
expect(toolNames({ mainBot: false })).not.toContain("update_user_profile");
|
||||||
|
expect(toolNames({})).not.toContain("update_user_profile");
|
||||||
|
expect(toolNames({ mainBot: true, messagingChannelRun: true })).not.toContain(
|
||||||
|
"update_user_profile",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses specialists even when they call the tool anyway", async () => {
|
||||||
|
await expect(
|
||||||
|
updateUserProfileFromTool(untouchedPrisma, context("specialist"), {
|
||||||
|
facts: [{ category: "work", value: "Nurse" }],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ error: "Only the user's main bot can update the user profile." });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid or empty updates before writing", async () => {
|
||||||
|
const invalidCategory = await updateUserProfileFromTool(untouchedPrisma, context("main"), {
|
||||||
|
facts: [{ category: "secrets", value: "x" }],
|
||||||
|
});
|
||||||
|
expect(invalidCategory).toEqual({ error: expect.stringContaining("facts.0.category") });
|
||||||
|
|
||||||
|
const unknownZone = await updateUserProfileFromTool(untouchedPrisma, context("main"), {
|
||||||
|
time_zone: "Vienna",
|
||||||
|
});
|
||||||
|
expect(unknownZone).toEqual({ error: expect.stringContaining("Unknown time zone") });
|
||||||
|
|
||||||
|
await expect(updateUserProfileFromTool(untouchedPrisma, context("main"), {})).resolves.toEqual({
|
||||||
|
error: "Nothing to save. Pass facts or a profile field.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records conversation facts with where they were learned", async () => {
|
||||||
|
const created: Array<Record<string, unknown>> = [];
|
||||||
|
const tx = {
|
||||||
|
$queryRaw: vi.fn().mockResolvedValue([]),
|
||||||
|
userProfile: { upsert: vi.fn().mockResolvedValue({}) },
|
||||||
|
userFact: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||||
|
created.push(data);
|
||||||
|
return { id: `fact-${created.length}`, value: data.value, status: data.status };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
$transaction: (run: (client: typeof tx) => Promise<unknown>) => run(tx),
|
||||||
|
} as unknown as PrismaClient;
|
||||||
|
|
||||||
|
const result = await updateUserProfileFromTool(prisma, context("main"), {
|
||||||
|
facts: [
|
||||||
|
{ category: "work", value: "Nurse" },
|
||||||
|
{ category: "health", value: "Asthma" },
|
||||||
|
],
|
||||||
|
time_zone: "europe/vienna",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: true, saved: 1, pendingConfirmation: 1 });
|
||||||
|
expect(tx.userProfile.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ update: { timeZone: "Europe/Vienna" } }),
|
||||||
|
);
|
||||||
|
expect(created).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
category: "work",
|
||||||
|
value: "Nurse",
|
||||||
|
source: "conversation",
|
||||||
|
status: "active",
|
||||||
|
sourceBotId: "bot-1",
|
||||||
|
sourceThreadId: "thread-1",
|
||||||
|
sourceMessageId: "message-1",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ category: "health", status: "pending", sensitive: true }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
packages/adapters/src/user-profile-tools.ts
Normal file
82
packages/adapters/src/user-profile-tools.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
LanguageTagSchema,
|
||||||
|
TimeZoneSchema,
|
||||||
|
USER_COMMUNICATION_STYLE_MAX_LENGTH,
|
||||||
|
UserFactCategorySchema,
|
||||||
|
UserFactValueSchema,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
|
import { InvalidUserFactError, updateUserProfile } from "@rakazo/db";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const UpdateUserProfileArgs = z.object({
|
||||||
|
facts: z
|
||||||
|
.array(z.object({ category: UserFactCategorySchema, value: UserFactValueSchema }))
|
||||||
|
.max(20)
|
||||||
|
.optional(),
|
||||||
|
time_zone: TimeZoneSchema.optional(),
|
||||||
|
language: LanguageTagSchema.optional(),
|
||||||
|
communication_style: z.string().trim().min(1).max(USER_COMMUNICATION_STYLE_MAX_LENGTH).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface UserProfileToolContext {
|
||||||
|
userId: string;
|
||||||
|
bot: { id: string; kind: string };
|
||||||
|
threadId: string;
|
||||||
|
/** The message the run answers, so a fact can be traced to where it was learned. */
|
||||||
|
sourceMessageId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records what the main bot learned about the user, attributed to the conversation. */
|
||||||
|
export async function updateUserProfileFromTool(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
context: UserProfileToolContext,
|
||||||
|
rawArgs: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (context.bot.kind !== "main") {
|
||||||
|
return { error: "Only the user's main bot can update the user profile." };
|
||||||
|
}
|
||||||
|
const parsed = UpdateUserProfileArgs.safeParse(rawArgs);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const issues = parsed.error.issues
|
||||||
|
.map((issue) => `${issue.path.join(".") || "arguments"}: ${issue.message}`)
|
||||||
|
.join("; ");
|
||||||
|
return { error: `Invalid profile update: ${issues}` };
|
||||||
|
}
|
||||||
|
const args = parsed.data;
|
||||||
|
const facts = args.facts ?? [];
|
||||||
|
if (
|
||||||
|
facts.length === 0 &&
|
||||||
|
args.time_zone === undefined &&
|
||||||
|
args.language === undefined &&
|
||||||
|
args.communication_style === undefined
|
||||||
|
) {
|
||||||
|
return { error: "Nothing to save. Pass facts or a profile field." };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const added = await updateUserProfile(prisma, context.userId, {
|
||||||
|
timeZone: args.time_zone,
|
||||||
|
language: args.language,
|
||||||
|
communicationStyle: args.communication_style,
|
||||||
|
addFacts: facts.map((fact) => ({
|
||||||
|
...fact,
|
||||||
|
source: "conversation" as const,
|
||||||
|
sourceBotId: context.bot.id,
|
||||||
|
sourceThreadId: context.threadId,
|
||||||
|
sourceMessageId: context.sourceMessageId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const pending = added.filter((fact) => fact.status === "pending").length;
|
||||||
|
return pending > 0
|
||||||
|
? {
|
||||||
|
ok: true,
|
||||||
|
saved: added.length - pending,
|
||||||
|
pendingConfirmation: pending,
|
||||||
|
note: "Health and values facts stay hidden from bots until the user confirms them in their settings.",
|
||||||
|
}
|
||||||
|
: { ok: true, saved: added.length };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof InvalidUserFactError) return { error: error.message };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,3 +15,4 @@ export * from "./reactions.js";
|
|||||||
export * from "./rpc.js";
|
export * from "./rpc.js";
|
||||||
export * from "./runs.js";
|
export * from "./runs.js";
|
||||||
export * from "./search.js";
|
export * from "./search.js";
|
||||||
|
export * from "./user-profile.js";
|
||||||
|
|||||||
@ -90,6 +90,11 @@ import { ProjectSessionsOutputSchema } from "./project-sessions.js";
|
|||||||
import { MessageReactionSchema } from "./reactions.js";
|
import { MessageReactionSchema } from "./reactions.js";
|
||||||
import { RunsListOutputSchema } from "./runs.js";
|
import { RunsListOutputSchema } from "./runs.js";
|
||||||
import { SearchQueryOutputSchema } from "./search.js";
|
import { SearchQueryOutputSchema } from "./search.js";
|
||||||
|
import {
|
||||||
|
ForgetUserFactsInput,
|
||||||
|
UpdateUserProfileInput,
|
||||||
|
UserProfileStateSchema,
|
||||||
|
} from "./user-profile.js";
|
||||||
|
|
||||||
const botId = z.object({ botId: Id });
|
const botId = z.object({ botId: Id });
|
||||||
const groupId = z.object({ groupId: Id });
|
const groupId = z.object({ groupId: Id });
|
||||||
@ -169,6 +174,12 @@ export const appContract = {
|
|||||||
preferences: {
|
preferences: {
|
||||||
update: oc.input(z.object({ avatarStyle: AvatarStyleSchema })).output(MeSchema),
|
update: oc.input(z.object({ avatarStyle: AvatarStyleSchema })).output(MeSchema),
|
||||||
},
|
},
|
||||||
|
/** What the user's bots know about the user, across spaces. */
|
||||||
|
profile: {
|
||||||
|
get: oc.output(UserProfileStateSchema),
|
||||||
|
update: oc.input(UpdateUserProfileInput).output(UserProfileStateSchema),
|
||||||
|
forget: oc.input(ForgetUserFactsInput).output(UserProfileStateSchema),
|
||||||
|
},
|
||||||
spaces: {
|
spaces: {
|
||||||
list: oc.output(SpaceNavigationSchema),
|
list: oc.output(SpaceNavigationSchema),
|
||||||
create: oc
|
create: oc
|
||||||
|
|||||||
62
packages/contracts/src/user-profile.test.ts
Normal file
62
packages/contracts/src/user-profile.test.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ForgetUserFactsInput,
|
||||||
|
LanguageTagSchema,
|
||||||
|
TimeZoneSchema,
|
||||||
|
UpdateUserProfileInput,
|
||||||
|
} from "./user-profile.js";
|
||||||
|
|
||||||
|
describe("user profile contracts", () => {
|
||||||
|
it("stores IANA time zones in canonical spelling and rejects unknown ones", () => {
|
||||||
|
expect(TimeZoneSchema.parse(" europe/vienna ")).toBe("Europe/Vienna");
|
||||||
|
expect(TimeZoneSchema.parse("UTC")).toBe("UTC");
|
||||||
|
expect(TimeZoneSchema.safeParse("Vienna").success).toBe(false);
|
||||||
|
expect(TimeZoneSchema.safeParse("").success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canonicalizes language tags and rejects malformed ones", () => {
|
||||||
|
expect(LanguageTagSchema.parse("de-at")).toBe("de-AT");
|
||||||
|
expect(LanguageTagSchema.parse("pt-BR")).toBe("pt-BR");
|
||||||
|
expect(LanguageTagSchema.safeParse("not a tag").success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records user-added facts as the user's own unless they come from onboarding", () => {
|
||||||
|
const parsed = UpdateUserProfileInput.parse({
|
||||||
|
timeZone: null,
|
||||||
|
addFacts: [
|
||||||
|
{ category: "work", value: " Nurse " },
|
||||||
|
{ category: "habits", value: "Runs daily", source: "onboarding", spaceIds: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(parsed.timeZone).toBeNull();
|
||||||
|
expect(parsed.addFacts).toEqual([
|
||||||
|
{ category: "work", value: "Nurse", source: "user" },
|
||||||
|
{ category: "habits", value: "Runs daily", source: "onboarding", spaceIds: null },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
UpdateUserProfileInput.safeParse({
|
||||||
|
addFacts: [{ category: "work", value: "x", source: "conversation" }],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
UpdateUserProfileInput.safeParse({ addFacts: [{ category: "secrets", value: "x" }] }).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
UpdateUserProfileInput.safeParse({
|
||||||
|
editFacts: [{ id: "fact-1", spaceIds: [] }],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets by exactly one of ids, category, or everything", () => {
|
||||||
|
expect(ForgetUserFactsInput.safeParse({ factIds: ["fact-1"] }).success).toBe(true);
|
||||||
|
expect(ForgetUserFactsInput.safeParse({ category: "health" }).success).toBe(true);
|
||||||
|
expect(ForgetUserFactsInput.safeParse({ all: true }).success).toBe(true);
|
||||||
|
expect(ForgetUserFactsInput.safeParse({}).success).toBe(false);
|
||||||
|
expect(ForgetUserFactsInput.safeParse({ factIds: [] }).success).toBe(false);
|
||||||
|
expect(
|
||||||
|
ForgetUserFactsInput.safeParse({ factIds: ["fact-1"], category: "health" }).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(ForgetUserFactsInput.safeParse({ all: false }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
106
packages/contracts/src/user-profile.ts
Normal file
106
packages/contracts/src/user-profile.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import * as z from "zod";
|
||||||
|
import { UserFactCategorySchema, UserFactSchema, UserProfileSchema } from "./domain.js";
|
||||||
|
import { Id } from "./ids.js";
|
||||||
|
|
||||||
|
export const USER_FACT_VALUE_MAX_LENGTH = 500;
|
||||||
|
export const USER_COMMUNICATION_STYLE_MAX_LENGTH = 500;
|
||||||
|
export const USER_PROFILE_SUMMARY_MAX_LENGTH = 4_000;
|
||||||
|
|
||||||
|
/** True when `value` is an IANA time zone this runtime can format, e.g. "Europe/Vienna". */
|
||||||
|
export function isTimeZone(value: string): boolean {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `value` is a well-formed BCP-47 language tag, e.g. "de" or "pt-BR". */
|
||||||
|
export function isLanguageTag(value: string): boolean {
|
||||||
|
try {
|
||||||
|
return Intl.getCanonicalLocales(value).length === 1;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An IANA time zone, stored in its canonical spelling. */
|
||||||
|
export const TimeZoneSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.refine(isTimeZone, "Unknown time zone")
|
||||||
|
.transform(
|
||||||
|
(value) => new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** A BCP-47 language tag, stored in its canonical spelling. */
|
||||||
|
export const LanguageTagSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2)
|
||||||
|
.max(35)
|
||||||
|
.refine(isLanguageTag, "Invalid language tag")
|
||||||
|
.transform((value) => Intl.getCanonicalLocales(value)[0] ?? value);
|
||||||
|
|
||||||
|
export const UserFactValueSchema = z.string().trim().min(1).max(USER_FACT_VALUE_MAX_LENGTH);
|
||||||
|
|
||||||
|
/** null shows a fact in every space; a list limits it to those spaces. */
|
||||||
|
const UserFactSpaceIds = z.array(Id).min(1).max(32).nullable();
|
||||||
|
|
||||||
|
export const UserProfileStateSchema = z.object({
|
||||||
|
profile: UserProfileSchema,
|
||||||
|
facts: z.array(UserFactSchema),
|
||||||
|
});
|
||||||
|
export type UserProfileState = z.infer<typeof UserProfileStateSchema>;
|
||||||
|
|
||||||
|
export const NewUserFactInput = z.object({
|
||||||
|
category: UserFactCategorySchema,
|
||||||
|
value: UserFactValueSchema,
|
||||||
|
/** Answers given during onboarding keep that origin; anything else the user adds is "user". */
|
||||||
|
source: z.enum(["user", "onboarding"]).default("user"),
|
||||||
|
spaceIds: UserFactSpaceIds.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EditUserFactInput = z.object({
|
||||||
|
id: Id,
|
||||||
|
category: UserFactCategorySchema.optional(),
|
||||||
|
value: UserFactValueSchema.optional(),
|
||||||
|
spaceIds: UserFactSpaceIds.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateUserProfileInput = z.object({
|
||||||
|
/** The account name every bot calls the user by. */
|
||||||
|
name: z.string().trim().min(1).max(100).optional(),
|
||||||
|
timeZone: TimeZoneSchema.nullable().optional(),
|
||||||
|
locale: LanguageTagSchema.nullable().optional(),
|
||||||
|
language: LanguageTagSchema.nullable().optional(),
|
||||||
|
communicationStyle: z.string().trim().max(USER_COMMUNICATION_STYLE_MAX_LENGTH).optional(),
|
||||||
|
summary: z.string().trim().max(USER_PROFILE_SUMMARY_MAX_LENGTH).optional(),
|
||||||
|
addFacts: z.array(NewUserFactInput).max(50).optional(),
|
||||||
|
editFacts: z.array(EditUserFactInput).max(50).optional(),
|
||||||
|
/** Pending facts the user agrees bots may use. */
|
||||||
|
confirmFactIds: z.array(Id).max(500).optional(),
|
||||||
|
});
|
||||||
|
export type UpdateUserProfile = z.infer<typeof UpdateUserProfileInput>;
|
||||||
|
|
||||||
|
/** Hard-deletes facts by id, by category, or all of them. */
|
||||||
|
export const ForgetUserFactsInput = z
|
||||||
|
.object({
|
||||||
|
factIds: z.array(Id).min(1).max(500).optional(),
|
||||||
|
category: UserFactCategorySchema.optional(),
|
||||||
|
all: z.literal(true).optional(),
|
||||||
|
})
|
||||||
|
.superRefine((input, ctx) => {
|
||||||
|
const targets = [input.factIds !== undefined, input.category !== undefined, input.all === true];
|
||||||
|
if (targets.filter(Boolean).length !== 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Provide exactly one of factIds, category, or all",
|
||||||
|
path: ["factIds"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export type ForgetUserFacts = z.infer<typeof ForgetUserFactsInput>;
|
||||||
@ -17,4 +17,5 @@ export * from "./repos.js";
|
|||||||
export * from "./scope.js";
|
export * from "./scope.js";
|
||||||
export * from "./spaces.js";
|
export * from "./spaces.js";
|
||||||
export * from "./transaction-retry.js";
|
export * from "./transaction-retry.js";
|
||||||
|
export * from "./user-profile.js";
|
||||||
export * from "./voice-credentials.js";
|
export * from "./voice-credentials.js";
|
||||||
|
|||||||
304
packages/db/src/user-profile.postgres.test.ts
Normal file
304
packages/db/src/user-profile.postgres.test.ts
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { bootstrapUserSpace } from "./bootstrap-user.js";
|
||||||
|
import type { PrismaClient } from "./client.js";
|
||||||
|
import { createDb } from "./client.js";
|
||||||
|
import { loadUserContext } from "./main-agent.js";
|
||||||
|
import { createSpaceForMember } from "./spaces.js";
|
||||||
|
import {
|
||||||
|
forgetUserFacts,
|
||||||
|
getUserProfileState,
|
||||||
|
InvalidUserFactError,
|
||||||
|
USER_FACT_LIMIT,
|
||||||
|
UserFactNotFoundError,
|
||||||
|
updateUserProfile,
|
||||||
|
} from "./user-profile.js";
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
const describePostgres =
|
||||||
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||||
|
|
||||||
|
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
|
||||||
|
|
||||||
|
describePostgres("user profile (PostgreSQL)", () => {
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let close: () => Promise<void>;
|
||||||
|
const userIds: string[] = [];
|
||||||
|
|
||||||
|
async function newUser(name = "Ada") {
|
||||||
|
// bootstrapUserSpace derives the organization slug from the first 12 characters.
|
||||||
|
const id = `up${randomUUID().replaceAll("-", "")}`;
|
||||||
|
userIds.push(id);
|
||||||
|
await prisma.user.create({ data: { id, name, email: `${id}@rakazo.test` } });
|
||||||
|
const { spaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, {
|
||||||
|
claimDeploymentOwner: false,
|
||||||
|
});
|
||||||
|
return { userId: id, homeSpaceId: spaceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const db = createDb(databaseUrl!);
|
||||||
|
prisma = db.prisma;
|
||||||
|
close = async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await db.pool.end();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!prisma) return;
|
||||||
|
const memberships = await prisma.member.findMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
await prisma.organization.deleteMany({
|
||||||
|
where: { id: { in: memberships.map((member) => member.organizationId) } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
await close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts empty and upserts profile fields and the account name", async () => {
|
||||||
|
const { userId } = await newUser();
|
||||||
|
expect(await getUserProfileState(prisma, userId)).toEqual({
|
||||||
|
profile: {
|
||||||
|
timeZone: null,
|
||||||
|
locale: null,
|
||||||
|
language: null,
|
||||||
|
communicationStyle: "",
|
||||||
|
summary: "",
|
||||||
|
consolidatedAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
facts: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateUserProfile(prisma, userId, {
|
||||||
|
name: "Ada L.",
|
||||||
|
timeZone: "Europe/Vienna",
|
||||||
|
language: "de",
|
||||||
|
communicationStyle: "Direct and concise",
|
||||||
|
});
|
||||||
|
await updateUserProfile(prisma, userId, { locale: "de-AT", language: null });
|
||||||
|
|
||||||
|
const { profile } = await getUserProfileState(prisma, userId);
|
||||||
|
expect(profile).toMatchObject({
|
||||||
|
timeZone: "Europe/Vienna",
|
||||||
|
locale: "de-AT",
|
||||||
|
language: null,
|
||||||
|
communicationStyle: "Direct and concise",
|
||||||
|
});
|
||||||
|
expect(profile.updatedAt).not.toBeNull();
|
||||||
|
expect((await prisma.user.findUniqueOrThrow({ where: { id: userId } })).name).toBe("Ada L.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds sensitive facts for confirmation unless the user wrote them, and stores a statement once", async () => {
|
||||||
|
const { userId } = await newUser();
|
||||||
|
const added = await updateUserProfile(prisma, userId, {
|
||||||
|
addFacts: [
|
||||||
|
{ category: "work", value: "Works night shifts as a nurse", source: "conversation" },
|
||||||
|
{ category: "health", value: "Allergic to peanuts", source: "conversation" },
|
||||||
|
{ category: "values", value: "Vegetarian for ethical reasons", source: "user" },
|
||||||
|
{ category: "health", value: "Has asthma", source: "onboarding" },
|
||||||
|
{ category: "work", value: "works night shifts as a nurse.", source: "consolidation" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(added.map(({ status, created }) => ({ status, created }))).toEqual([
|
||||||
|
{ status: "active", created: true },
|
||||||
|
{ status: "pending", created: true },
|
||||||
|
{ status: "active", created: true },
|
||||||
|
{ status: "pending", created: true },
|
||||||
|
{ status: "active", created: false },
|
||||||
|
]);
|
||||||
|
expect(added[4]?.id).toBe(added[0]?.id);
|
||||||
|
|
||||||
|
const rows = await prisma.userFact.findMany({ where: { userId }, orderBy: { value: "asc" } });
|
||||||
|
expect(rows).toHaveLength(4);
|
||||||
|
const byValue = new Map(rows.map((row) => [row.value, row]));
|
||||||
|
expect(byValue.get("Allergic to peanuts")).toMatchObject({
|
||||||
|
sensitive: true,
|
||||||
|
status: "pending",
|
||||||
|
confirmedAt: null,
|
||||||
|
});
|
||||||
|
expect(byValue.get("Vegetarian for ethical reasons")).toMatchObject({
|
||||||
|
sensitive: true,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
expect(byValue.get("Vegetarian for ethical reasons")?.confirmedAt).toBeInstanceOf(Date);
|
||||||
|
|
||||||
|
// The user saying a pending fact themselves is consent.
|
||||||
|
const [restated] = await updateUserProfile(prisma, userId, {
|
||||||
|
addFacts: [{ category: "health", value: "allergic to peanuts", source: "user" }],
|
||||||
|
});
|
||||||
|
expect(restated).toEqual({ id: added[1]?.id, status: "active", created: false });
|
||||||
|
|
||||||
|
const context = await loadUserContext(prisma, { userId, spaceId: "any-space" });
|
||||||
|
expect(context.facts.map((fact) => fact.value).sort()).toEqual([
|
||||||
|
"Allergic to peanuts",
|
||||||
|
"Vegetarian for ethical reasons",
|
||||||
|
"Works night shifts as a nurse",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms pending facts and edits them, scoped to their owner", async () => {
|
||||||
|
const owner = await newUser();
|
||||||
|
const other = await newUser("Grace");
|
||||||
|
const [pending] = await updateUserProfile(prisma, owner.userId, {
|
||||||
|
addFacts: [{ category: "health", value: "Migraines", source: "conversation" }],
|
||||||
|
});
|
||||||
|
const factId = pending!.id;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateUserProfile(prisma, other.userId, { confirmFactIds: [factId] }),
|
||||||
|
).rejects.toBeInstanceOf(UserFactNotFoundError);
|
||||||
|
await expect(
|
||||||
|
updateUserProfile(prisma, other.userId, { editFacts: [{ id: factId, value: "Stolen" }] }),
|
||||||
|
).rejects.toBeInstanceOf(UserFactNotFoundError);
|
||||||
|
expect((await getUserProfileState(prisma, other.userId)).facts).toEqual([]);
|
||||||
|
expect(await forgetUserFacts(prisma, other.userId, { factIds: [factId] })).toBe(0);
|
||||||
|
|
||||||
|
await updateUserProfile(prisma, owner.userId, { confirmFactIds: [factId] });
|
||||||
|
let row = await prisma.userFact.findUniqueOrThrow({ where: { id: factId } });
|
||||||
|
expect(row.status).toBe("active");
|
||||||
|
expect(row.confirmedAt).toBeInstanceOf(Date);
|
||||||
|
|
||||||
|
await prisma.userFact.update({
|
||||||
|
where: { id: factId },
|
||||||
|
data: { embedding: [0.1, 0.2], embeddingModel: "test-embedding" },
|
||||||
|
});
|
||||||
|
await updateUserProfile(prisma, owner.userId, {
|
||||||
|
editFacts: [{ id: factId, category: "habits", value: "Avoids screens late at night" }],
|
||||||
|
});
|
||||||
|
row = await prisma.userFact.findUniqueOrThrow({ where: { id: factId } });
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
category: "habits",
|
||||||
|
sensitive: false,
|
||||||
|
value: "Avoids screens late at night",
|
||||||
|
embedding: [],
|
||||||
|
embeddingModel: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits a fact to the user's own spaces", async () => {
|
||||||
|
const owner = await newUser();
|
||||||
|
const other = await newUser("Grace");
|
||||||
|
const work = await createSpaceForMember(prisma, {
|
||||||
|
currentSpaceId: owner.homeSpaceId,
|
||||||
|
userId: owner.userId,
|
||||||
|
name: "Work",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateUserProfile(prisma, owner.userId, {
|
||||||
|
addFacts: [
|
||||||
|
{
|
||||||
|
category: "people",
|
||||||
|
value: "Reports to Lin",
|
||||||
|
source: "user",
|
||||||
|
spaceIds: [other.homeSpaceId],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(InvalidUserFactError);
|
||||||
|
expect(await prisma.userFact.count({ where: { userId: owner.userId } })).toBe(0);
|
||||||
|
|
||||||
|
const [limited] = await updateUserProfile(prisma, owner.userId, {
|
||||||
|
addFacts: [
|
||||||
|
{ category: "people", value: "Reports to Lin", source: "user", spaceIds: [work.id] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const state = await getUserProfileState(prisma, owner.userId);
|
||||||
|
expect(state.facts[0]).toMatchObject({ allSpaces: false, spaceIds: [work.id] });
|
||||||
|
const inWork = await loadUserContext(prisma, { userId: owner.userId, spaceId: work.id });
|
||||||
|
const inHome = await loadUserContext(prisma, {
|
||||||
|
userId: owner.userId,
|
||||||
|
spaceId: owner.homeSpaceId,
|
||||||
|
});
|
||||||
|
expect(inWork.facts.map((fact) => fact.value)).toEqual(["Reports to Lin"]);
|
||||||
|
expect(inHome.facts).toEqual([]);
|
||||||
|
|
||||||
|
await updateUserProfile(prisma, owner.userId, {
|
||||||
|
editFacts: [{ id: limited!.id, spaceIds: null }],
|
||||||
|
});
|
||||||
|
expect((await getUserProfileState(prisma, owner.userId)).facts[0]).toMatchObject({
|
||||||
|
allSpaces: true,
|
||||||
|
spaceIds: [],
|
||||||
|
});
|
||||||
|
expect(await prisma.userFactSpace.count({ where: { factId: limited!.id } })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets by id, by category, and everything, so nothing can come back", async () => {
|
||||||
|
const { userId } = await newUser();
|
||||||
|
const added = await updateUserProfile(prisma, userId, {
|
||||||
|
timeZone: "Europe/Vienna",
|
||||||
|
communicationStyle: "Casual",
|
||||||
|
summary: "Nurse with peanut allergy who likes chess.",
|
||||||
|
addFacts: [
|
||||||
|
{ category: "work", value: "Nurse", source: "user" },
|
||||||
|
{ category: "health", value: "Peanut allergy", source: "user" },
|
||||||
|
{ category: "preferences", value: "Likes chess", source: "onboarding" },
|
||||||
|
{ category: "preferences", value: "Likes jazz", source: "onboarding" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await forgetUserFacts(prisma, userId, { factIds: [added[1]!.id] })).toBe(1);
|
||||||
|
let state = await getUserProfileState(prisma, userId);
|
||||||
|
expect(state.facts.map((fact) => fact.value).sort()).toEqual([
|
||||||
|
"Likes chess",
|
||||||
|
"Likes jazz",
|
||||||
|
"Nurse",
|
||||||
|
]);
|
||||||
|
// The summary may restate what was forgotten; unconsolidated chats must not teach it again.
|
||||||
|
expect(state.profile.summary).toBe("");
|
||||||
|
expect(state.profile.consolidatedAt).not.toBeNull();
|
||||||
|
expect(state.profile.timeZone).toBe("Europe/Vienna");
|
||||||
|
|
||||||
|
expect(await forgetUserFacts(prisma, userId, { category: "preferences" })).toBe(2);
|
||||||
|
state = await getUserProfileState(prisma, userId);
|
||||||
|
expect(state.facts.map((fact) => fact.value)).toEqual(["Nurse"]);
|
||||||
|
|
||||||
|
expect(await forgetUserFacts(prisma, userId, { all: true })).toBe(1);
|
||||||
|
state = await getUserProfileState(prisma, userId);
|
||||||
|
expect(state.facts).toEqual([]);
|
||||||
|
expect(state.profile).toMatchObject({
|
||||||
|
timeZone: null,
|
||||||
|
locale: null,
|
||||||
|
language: null,
|
||||||
|
communicationStyle: "",
|
||||||
|
summary: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the number of facts and serializes concurrent writers", async () => {
|
||||||
|
const { userId } = await newUser();
|
||||||
|
const same = {
|
||||||
|
category: "goals" as const,
|
||||||
|
value: "Run a marathon",
|
||||||
|
source: "conversation" as const,
|
||||||
|
};
|
||||||
|
const results = await Promise.all([
|
||||||
|
updateUserProfile(prisma, userId, { addFacts: [same] }),
|
||||||
|
updateUserProfile(prisma, userId, { addFacts: [same] }),
|
||||||
|
updateUserProfile(prisma, userId, { addFacts: [same] }),
|
||||||
|
]);
|
||||||
|
expect(new Set(results.map(([fact]) => fact?.id)).size).toBe(1);
|
||||||
|
expect(await prisma.userFact.count({ where: { userId } })).toBe(1);
|
||||||
|
|
||||||
|
await prisma.userFact.createMany({
|
||||||
|
data: Array.from({ length: USER_FACT_LIMIT - 1 }, (_, index) => ({
|
||||||
|
userId,
|
||||||
|
category: "tools",
|
||||||
|
value: `Uses tool ${index}`,
|
||||||
|
source: "user",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
updateUserProfile(prisma, userId, {
|
||||||
|
addFacts: [{ category: "tools", value: "Uses one more tool", source: "user" }],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(InvalidUserFactError);
|
||||||
|
// A statement the profile already holds still resolves at the cap.
|
||||||
|
const [known] = await updateUserProfile(prisma, userId, { addFacts: [same] });
|
||||||
|
expect(known?.created).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
packages/db/src/user-profile.test.ts
Normal file
21
packages/db/src/user-profile.test.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isSensitiveUserFactCategory, normalizeUserFactValue } from "./user-profile.js";
|
||||||
|
|
||||||
|
describe("user facts", () => {
|
||||||
|
it("treats the same statement with different case, spacing or end punctuation as one", () => {
|
||||||
|
expect(normalizeUserFactValue(" Works as a Nurse. ")).toBe("works as a nurse");
|
||||||
|
expect(normalizeUserFactValue('"Works as a nurse!"')).toBe("works as a nurse");
|
||||||
|
expect(normalizeUserFactValue("Works as a nurse")).toBe("works as a nurse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps symbols that change the meaning", () => {
|
||||||
|
expect(normalizeUserFactValue("Writes C++")).not.toBe(normalizeUserFactValue("Writes C"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks only health and values as sensitive", () => {
|
||||||
|
expect(isSensitiveUserFactCategory("health")).toBe(true);
|
||||||
|
expect(isSensitiveUserFactCategory("values")).toBe(true);
|
||||||
|
expect(isSensitiveUserFactCategory("work")).toBe(false);
|
||||||
|
expect(isSensitiveUserFactCategory("people")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
383
packages/db/src/user-profile.ts
Normal file
383
packages/db/src/user-profile.ts
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
import type {
|
||||||
|
UserFact,
|
||||||
|
UserFactCategory,
|
||||||
|
UserFactSource,
|
||||||
|
UserFactStatus,
|
||||||
|
UserProfile,
|
||||||
|
UserProfileState,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { SENSITIVE_USER_FACT_CATEGORIES } from "@rakazo/contracts";
|
||||||
|
import type { PrismaClient } from "./client.js";
|
||||||
|
import { Prisma } from "./client.js";
|
||||||
|
|
||||||
|
/** Upper bound on facts one user keeps, so a runaway writer cannot grow the profile without end. */
|
||||||
|
export const USER_FACT_LIMIT = 500;
|
||||||
|
|
||||||
|
export class UserFactNotFoundError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Fact not found");
|
||||||
|
this.name = "UserFactNotFoundError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidUserFactError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "InvalidUserFactError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewUserFact {
|
||||||
|
category: UserFactCategory;
|
||||||
|
value: string;
|
||||||
|
source: UserFactSource;
|
||||||
|
confidence?: number;
|
||||||
|
/** Omitted or null: visible in every space. */
|
||||||
|
spaceIds?: string[] | null;
|
||||||
|
sourceBotId?: string | null;
|
||||||
|
sourceThreadId?: string | null;
|
||||||
|
sourceMessageId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserFactEdit {
|
||||||
|
id: string;
|
||||||
|
category?: UserFactCategory;
|
||||||
|
value?: string;
|
||||||
|
/** null: visible in every space. */
|
||||||
|
spaceIds?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfileUpdate {
|
||||||
|
name?: string;
|
||||||
|
timeZone?: string | null;
|
||||||
|
locale?: string | null;
|
||||||
|
language?: string | null;
|
||||||
|
communicationStyle?: string;
|
||||||
|
summary?: string;
|
||||||
|
addFacts?: NewUserFact[];
|
||||||
|
editFacts?: UserFactEdit[];
|
||||||
|
confirmFactIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome for one added fact, in input order. */
|
||||||
|
export interface AddedUserFact {
|
||||||
|
id: string;
|
||||||
|
status: UserFactStatus;
|
||||||
|
/** False when the statement was already known and the existing fact was kept. */
|
||||||
|
created: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserFactForgetTarget =
|
||||||
|
| { factIds: string[] }
|
||||||
|
| { category: UserFactCategory }
|
||||||
|
| { all: true };
|
||||||
|
|
||||||
|
const SENSITIVE_CATEGORIES = new Set<UserFactCategory>(SENSITIVE_USER_FACT_CATEGORIES);
|
||||||
|
|
||||||
|
export function isSensitiveUserFactCategory(category: UserFactCategory): boolean {
|
||||||
|
return SENSITIVE_CATEGORIES.has(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Comparison key for the same statement written twice: case, spacing, quotes and end punctuation differ. */
|
||||||
|
export function normalizeUserFactValue(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize("NFKC")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/^["'“”„‘’«»\s]+|["'“”„‘’«».!?;:,\s]+$/gu, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const factSelect = {
|
||||||
|
id: true,
|
||||||
|
category: true,
|
||||||
|
value: true,
|
||||||
|
confidence: true,
|
||||||
|
source: true,
|
||||||
|
status: true,
|
||||||
|
sensitive: true,
|
||||||
|
allSpaces: true,
|
||||||
|
sourceBotId: true,
|
||||||
|
sourceThreadId: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
visibleSpaces: { select: { spaceId: true }, orderBy: { spaceId: "asc" } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type FactRow = Prisma.UserFactGetPayload<{ select: typeof factSelect }>;
|
||||||
|
|
||||||
|
type ProfileRow = {
|
||||||
|
timeZone: string | null;
|
||||||
|
locale: string | null;
|
||||||
|
language: string | null;
|
||||||
|
communicationStyle: string;
|
||||||
|
summary: string;
|
||||||
|
consolidatedAt: Date | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function factDto(row: FactRow): UserFact {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
category: row.category as UserFactCategory,
|
||||||
|
value: row.value,
|
||||||
|
confidence: row.confidence,
|
||||||
|
source: row.source as UserFactSource,
|
||||||
|
status: row.status as UserFactStatus,
|
||||||
|
sensitive: row.sensitive,
|
||||||
|
allSpaces: row.allSpaces,
|
||||||
|
spaceIds: row.allSpaces ? [] : row.visibleSpaces.map((space) => space.spaceId),
|
||||||
|
sourceBotId: row.sourceBotId,
|
||||||
|
sourceThreadId: row.sourceThreadId,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileDto(row: ProfileRow | null): UserProfile {
|
||||||
|
return {
|
||||||
|
timeZone: row?.timeZone ?? null,
|
||||||
|
locale: row?.locale ?? null,
|
||||||
|
language: row?.language ?? null,
|
||||||
|
communicationStyle: row?.communicationStyle ?? "",
|
||||||
|
summary: row?.summary ?? "",
|
||||||
|
consolidatedAt: row?.consolidatedAt?.toISOString() ?? null,
|
||||||
|
updatedAt: row?.updatedAt.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's profile and every fact, pending ones included, newest first. */
|
||||||
|
export async function getUserProfileState(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
userId: string,
|
||||||
|
): Promise<UserProfileState> {
|
||||||
|
const [profile, facts] = await Promise.all([
|
||||||
|
prisma.userProfile.findUnique({ where: { userId } }),
|
||||||
|
prisma.userFact.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
|
||||||
|
select: factSelect,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { profile: profileDto(profile), facts: facts.map(factDto) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes one user's profile writes so duplicate checks and the fact limit hold under concurrency. */
|
||||||
|
async function lockUserProfile(tx: Prisma.TransactionClient, userId: string): Promise<void> {
|
||||||
|
const key = `user-profile:${userId}`;
|
||||||
|
await tx.$queryRaw(Prisma.sql`
|
||||||
|
SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))::text AS "lock"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertUserSpaces(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: string,
|
||||||
|
spaceIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const unique = [...new Set(spaceIds)];
|
||||||
|
if (unique.length === 0) return;
|
||||||
|
const memberships = await tx.spaceMember.count({
|
||||||
|
where: { userId, spaceId: { in: unique } },
|
||||||
|
});
|
||||||
|
if (memberships !== unique.length) throw new InvalidUserFactError("Space not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleSpacesOf(spaceIds: string[] | null | undefined): string[] | null {
|
||||||
|
return spaceIds && spaceIds.length > 0 ? [...new Set(spaceIds)] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFacts(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: string,
|
||||||
|
facts: NewUserFact[],
|
||||||
|
): Promise<AddedUserFact[]> {
|
||||||
|
if (facts.length === 0) return [];
|
||||||
|
const existing = await tx.userFact.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true, value: true, status: true },
|
||||||
|
});
|
||||||
|
const known = new Map(existing.map((fact) => [normalizeUserFactValue(fact.value), fact]));
|
||||||
|
let total = existing.length;
|
||||||
|
const results: AddedUserFact[] = [];
|
||||||
|
for (const fact of facts) {
|
||||||
|
const value = fact.value.trim();
|
||||||
|
const key = normalizeUserFactValue(value);
|
||||||
|
const match = known.get(key);
|
||||||
|
if (match) {
|
||||||
|
// The user stating a pending fact themselves is consent.
|
||||||
|
if (match.status === "pending" && fact.source === "user") {
|
||||||
|
await tx.userFact.update({
|
||||||
|
where: { id: match.id },
|
||||||
|
data: { status: "active", confirmedAt: new Date() },
|
||||||
|
});
|
||||||
|
match.status = "active";
|
||||||
|
}
|
||||||
|
results.push({ id: match.id, status: match.status as UserFactStatus, created: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (total >= USER_FACT_LIMIT) {
|
||||||
|
throw new InvalidUserFactError(`A profile keeps at most ${USER_FACT_LIMIT} facts`);
|
||||||
|
}
|
||||||
|
const sensitive = isSensitiveUserFactCategory(fact.category);
|
||||||
|
const userAuthored = fact.source === "user";
|
||||||
|
const status: UserFactStatus = sensitive && !userAuthored ? "pending" : "active";
|
||||||
|
const spaces = visibleSpacesOf(fact.spaceIds);
|
||||||
|
const created = await tx.userFact.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
category: fact.category,
|
||||||
|
value,
|
||||||
|
confidence: fact.confidence ?? 1,
|
||||||
|
source: fact.source,
|
||||||
|
status,
|
||||||
|
sensitive,
|
||||||
|
allSpaces: spaces === null,
|
||||||
|
sourceBotId: fact.sourceBotId ?? null,
|
||||||
|
sourceThreadId: fact.sourceThreadId ?? null,
|
||||||
|
sourceMessageId: fact.sourceMessageId ?? null,
|
||||||
|
confirmedAt: userAuthored ? new Date() : null,
|
||||||
|
...(spaces ? { visibleSpaces: { create: spaces.map((spaceId) => ({ spaceId })) } } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true, value: true, status: true },
|
||||||
|
});
|
||||||
|
known.set(key, created);
|
||||||
|
total += 1;
|
||||||
|
results.push({ id: created.id, status, created: true });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editFact(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: string,
|
||||||
|
edit: UserFactEdit,
|
||||||
|
): Promise<void> {
|
||||||
|
const fact = await tx.userFact.findFirst({
|
||||||
|
where: { id: edit.id, userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!fact) throw new UserFactNotFoundError();
|
||||||
|
const data: Prisma.UserFactUpdateInput = {};
|
||||||
|
if (edit.category !== undefined) {
|
||||||
|
data.category = edit.category;
|
||||||
|
data.sensitive = isSensitiveUserFactCategory(edit.category);
|
||||||
|
}
|
||||||
|
if (edit.value !== undefined) {
|
||||||
|
data.value = edit.value.trim();
|
||||||
|
// The stored embedding described the old wording.
|
||||||
|
data.embedding = [];
|
||||||
|
data.embeddingModel = null;
|
||||||
|
}
|
||||||
|
if (edit.spaceIds !== undefined) {
|
||||||
|
const spaces = visibleSpacesOf(edit.spaceIds);
|
||||||
|
data.allSpaces = spaces === null;
|
||||||
|
data.visibleSpaces = {
|
||||||
|
deleteMany: {},
|
||||||
|
...(spaces ? { create: spaces.map((spaceId) => ({ spaceId })) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await tx.userFact.update({ where: { id: fact.id }, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmFacts(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: string,
|
||||||
|
factIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const unique = [...new Set(factIds)];
|
||||||
|
if (unique.length === 0) return;
|
||||||
|
const owned = await tx.userFact.count({ where: { userId, id: { in: unique } } });
|
||||||
|
if (owned !== unique.length) throw new UserFactNotFoundError();
|
||||||
|
await tx.userFact.updateMany({
|
||||||
|
where: { userId, id: { in: unique }, status: "pending" },
|
||||||
|
data: { status: "active", confirmedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileFields(update: UserProfileUpdate) {
|
||||||
|
const fields = {
|
||||||
|
...(update.timeZone !== undefined ? { timeZone: update.timeZone } : {}),
|
||||||
|
...(update.locale !== undefined ? { locale: update.locale } : {}),
|
||||||
|
...(update.language !== undefined ? { language: update.language } : {}),
|
||||||
|
...(update.communicationStyle !== undefined
|
||||||
|
? { communicationStyle: update.communicationStyle }
|
||||||
|
: {}),
|
||||||
|
...(update.summary !== undefined ? { summary: update.summary } : {}),
|
||||||
|
};
|
||||||
|
return Object.keys(fields).length > 0 ? fields : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies one profile change atomically: account name, profile fields, new facts, fact edits
|
||||||
|
* and confirmations. Facts in sensitive categories wait for the user's confirmation unless the
|
||||||
|
* user wrote them, and a statement the profile already holds is not stored twice.
|
||||||
|
*/
|
||||||
|
export async function updateUserProfile(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
userId: string,
|
||||||
|
update: UserProfileUpdate,
|
||||||
|
): Promise<AddedUserFact[]> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await lockUserProfile(tx, userId);
|
||||||
|
await assertUserSpaces(
|
||||||
|
tx,
|
||||||
|
userId,
|
||||||
|
[...(update.addFacts ?? []), ...(update.editFacts ?? [])].flatMap(
|
||||||
|
(fact) => fact.spaceIds ?? [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (update.name !== undefined) {
|
||||||
|
await tx.user.update({ where: { id: userId }, data: { name: update.name } });
|
||||||
|
}
|
||||||
|
const fields = profileFields(update);
|
||||||
|
if (fields) {
|
||||||
|
await tx.userProfile.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: { userId, ...fields },
|
||||||
|
update: fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const added = await addFacts(tx, userId, update.addFacts ?? []);
|
||||||
|
for (const edit of update.editFacts ?? []) await editFact(tx, userId, edit);
|
||||||
|
await confirmFacts(tx, userId, update.confirmFactIds ?? []);
|
||||||
|
return added;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-deletes facts. Because the summary may restate them and chats not yet consolidated could
|
||||||
|
* teach them again, forgetting also clears the summary and marks earlier chats as consolidated.
|
||||||
|
* Forgetting everything clears the remaining profile fields too.
|
||||||
|
*/
|
||||||
|
export async function forgetUserFacts(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
userId: string,
|
||||||
|
target: UserFactForgetTarget,
|
||||||
|
): Promise<number> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await lockUserProfile(tx, userId);
|
||||||
|
const where: Prisma.UserFactWhereInput =
|
||||||
|
"factIds" in target
|
||||||
|
? { userId, id: { in: target.factIds } }
|
||||||
|
: "category" in target
|
||||||
|
? { userId, category: target.category }
|
||||||
|
: { userId };
|
||||||
|
const { count } = await tx.userFact.deleteMany({ where });
|
||||||
|
const everything = "all" in target;
|
||||||
|
if (count > 0 || everything) {
|
||||||
|
const reset = {
|
||||||
|
summary: "",
|
||||||
|
consolidatedAt: new Date(),
|
||||||
|
...(everything
|
||||||
|
? { timeZone: null, locale: null, language: null, communicationStyle: "" }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
await tx.userProfile.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: { userId, ...reset },
|
||||||
|
update: reset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -60,6 +60,9 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
it("rejects unauthenticated calls to every protected RPC family", async () => {
|
it("rejects unauthenticated calls to every protected RPC family", async () => {
|
||||||
const calls = exhaustiveProtectedCalls([
|
const calls = exhaustiveProtectedCalls([
|
||||||
["me"],
|
["me"],
|
||||||
|
["profile/get"],
|
||||||
|
["profile/update", { timeZone: "UTC" }],
|
||||||
|
["profile/forget", { all: true }],
|
||||||
["deployment/get"],
|
["deployment/get"],
|
||||||
["deployment/update", { signupsEnabled: true }],
|
["deployment/update", { signupsEnabled: true }],
|
||||||
["updater/status"],
|
["updater/status"],
|
||||||
@ -488,6 +491,50 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
await handles.prisma.memoryDocument.findUniqueOrThrow({ where: { id: ownerMemory.id } }),
|
await handles.prisma.memoryDocument.findUniqueOrThrow({ where: { id: ownerMemory.id } }),
|
||||||
).toMatchObject({ content: "owner-only-memory" });
|
).toMatchObject({ content: "owner-only-memory" });
|
||||||
|
|
||||||
|
// The user profile is per user: another user's fact ids and spaces are out of reach.
|
||||||
|
const ownerProfile = await rpc<{ facts: Array<{ id: string; status: string }> }>(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
"profile/update",
|
||||||
|
{
|
||||||
|
addFacts: [
|
||||||
|
{ category: "work", value: "Owner works nights" },
|
||||||
|
{ category: "health", value: "Owner health note", source: "onboarding" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const ownerFactIds = ownerProfile.facts.map((fact) => fact.id);
|
||||||
|
expect(ownerProfile.facts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]);
|
||||||
|
const profileCalls = [
|
||||||
|
["profile/update", { confirmFactIds: ownerFactIds }],
|
||||||
|
["profile/update", { editFacts: [{ id: ownerFactIds[0], value: "Stolen" }] }],
|
||||||
|
[
|
||||||
|
"profile/update",
|
||||||
|
{
|
||||||
|
addFacts: [{ category: "work", value: "Planted fact", spaceIds: [ownerActor.spaceId] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] satisfies Array<[string, unknown]>;
|
||||||
|
await Promise.all(
|
||||||
|
profileCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await rpc<{ facts: unknown[] }>(app, intruder, "profile/forget", { factIds: ownerFactIds }),
|
||||||
|
).toMatchObject({ facts: [] });
|
||||||
|
expect(await rpc<{ facts: unknown[] }>(app, intruder, "profile/get")).toMatchObject({
|
||||||
|
facts: [],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await handles.prisma.userFact.findMany({
|
||||||
|
where: { id: { in: ownerFactIds } },
|
||||||
|
select: { userId: true, value: true, status: true },
|
||||||
|
orderBy: { value: "asc" },
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{ userId: ownerActor.userId, value: "Owner health note", status: "pending" },
|
||||||
|
{ userId: ownerActor.userId, value: "Owner works nights", status: "active" },
|
||||||
|
]);
|
||||||
|
|
||||||
// Destructive bot removal is checked last so an authorization regression is unmistakable.
|
// Destructive bot removal is checked last so an authorization regression is unmistakable.
|
||||||
await expectDenied(app, intruder, "bots/remove", {
|
await expectDenied(app, intruder, "bots/remove", {
|
||||||
botId: ownerBot.id,
|
botId: ownerBot.id,
|
||||||
|
|||||||
@ -104,8 +104,10 @@ async function main() {
|
|||||||
"packages/testkit/src/connections.test.ts",
|
"packages/testkit/src/connections.test.ts",
|
||||||
"packages/testkit/src/bot-secrets.test.ts",
|
"packages/testkit/src/bot-secrets.test.ts",
|
||||||
"packages/testkit/src/delegation.postgres.test.ts",
|
"packages/testkit/src/delegation.postgres.test.ts",
|
||||||
|
"packages/testkit/src/user-profile.postgres.test.ts",
|
||||||
"packages/db/src/space-membership.postgres.test.ts",
|
"packages/db/src/space-membership.postgres.test.ts",
|
||||||
"packages/db/src/main-agent.postgres.test.ts",
|
"packages/db/src/main-agent.postgres.test.ts",
|
||||||
|
"packages/db/src/user-profile.postgres.test.ts",
|
||||||
"packages/db/src/messaging.postgres.test.ts",
|
"packages/db/src/messaging.postgres.test.ts",
|
||||||
"packages/memory/src/commit.postgres.test.ts",
|
"packages/memory/src/commit.postgres.test.ts",
|
||||||
"packages/adapters/src/wakeup.postgres.test.ts",
|
"packages/adapters/src/wakeup.postgres.test.ts",
|
||||||
|
|||||||
196
packages/testkit/src/user-profile.postgres.test.ts
Normal file
196
packages/testkit/src/user-profile.postgres.test.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { ComposioEmulator } from "@rakazo/adapters";
|
||||||
|
import type { UserProfileState } from "@rakazo/contracts";
|
||||||
|
import { ensureMainBot } from "@rakazo/db";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import type { createApp } from "../../../apps/api/src/app.ts";
|
||||||
|
import { sessionCookieHeader } from "./index.js";
|
||||||
|
|
||||||
|
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
|
||||||
|
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
||||||
|
|
||||||
|
process.env.WAKEUP_DRIVER = "memory";
|
||||||
|
process.env.SANDBOX_PROVIDER = "fake";
|
||||||
|
process.env.AGENT_RUNTIME = "scripted";
|
||||||
|
|
||||||
|
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
|
||||||
|
const describeWithDatabase = hasDb ? describe : describe.skip;
|
||||||
|
|
||||||
|
describeWithDatabase("user profile through the API and the main bot", () => {
|
||||||
|
let handles: AppHandles;
|
||||||
|
let app: App;
|
||||||
|
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-profile-"));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { createApp } = await import("../../../apps/api/src/app.ts");
|
||||||
|
handles = await createApp({
|
||||||
|
databaseUrl: process.env.DATABASE_URL!,
|
||||||
|
dataDir,
|
||||||
|
sandboxProvider: "fake",
|
||||||
|
agentRuntime: "scripted",
|
||||||
|
wakeupDriver: "memory",
|
||||||
|
signupsEnabled: "true",
|
||||||
|
composio: new ComposioEmulator(),
|
||||||
|
});
|
||||||
|
app = handles.app;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await handles?.stop();
|
||||||
|
rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sendAndWait(cookie: string, botId: string, text: string) {
|
||||||
|
const { runId } = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId, text });
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < 20_000) {
|
||||||
|
const run = await handles.prisma.run.findUnique({
|
||||||
|
where: { id: runId },
|
||||||
|
select: { id: true, status: true, error: true, sourceMessageId: true },
|
||||||
|
});
|
||||||
|
if (run && ["completed", "failed", "cancelled"].includes(run.status)) {
|
||||||
|
expect(run, run.error ?? "").toMatchObject({ status: "completed" });
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
throw new Error(`run ${runId} did not finish`);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lets the main bot record what it learns, and only the main bot", async () => {
|
||||||
|
const cookie = await signup(app, `profile-tool-${stamp}@rakazo.test`, "Profile Tool");
|
||||||
|
const me = await rpc<{ userId: string }>(app, cookie, "me");
|
||||||
|
const main = await ensureMainBot(handles.prisma, me.userId);
|
||||||
|
const specialist = await rpc<{ id: string }>(app, cookie, "bots/create", {
|
||||||
|
name: "Researcher",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
instructions: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendAndWait(cookie, main.id, "learn about me: work: Works night shifts as a nurse");
|
||||||
|
const healthRun = await sendAndWait(
|
||||||
|
cookie,
|
||||||
|
main.id,
|
||||||
|
"learn about me: health: Allergic to peanuts",
|
||||||
|
);
|
||||||
|
const specialistRun = await sendAndWait(
|
||||||
|
cookie,
|
||||||
|
specialist.id,
|
||||||
|
"learn about me: tools: Uses a paper planner",
|
||||||
|
);
|
||||||
|
const refusal = await handles.prisma.event.findFirstOrThrow({
|
||||||
|
where: { runId: specialistRun.id, type: "agent.tool.completed" },
|
||||||
|
select: { payload: true },
|
||||||
|
});
|
||||||
|
expect(refusal.payload).toMatchObject({
|
||||||
|
name: "update_user_profile",
|
||||||
|
outcome: "error",
|
||||||
|
error: expect.stringContaining("main bot"),
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = await rpc<UserProfileState>(app, cookie, "profile/get");
|
||||||
|
expect(
|
||||||
|
state.facts.map(({ category, value, source, status, sourceBotId }) => ({
|
||||||
|
category,
|
||||||
|
value,
|
||||||
|
source,
|
||||||
|
status,
|
||||||
|
sourceBotId,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
category: "health",
|
||||||
|
value: "Allergic to peanuts",
|
||||||
|
source: "conversation",
|
||||||
|
status: "pending",
|
||||||
|
sourceBotId: main.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "work",
|
||||||
|
value: "Works night shifts as a nurse",
|
||||||
|
source: "conversation",
|
||||||
|
status: "active",
|
||||||
|
sourceBotId: main.id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(state.facts[0]?.sourceThreadId).toBe(main.threadId);
|
||||||
|
expect(healthRun.sourceMessageId).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
await handles.prisma.userFact.findUniqueOrThrow({
|
||||||
|
where: { id: state.facts[0]!.id },
|
||||||
|
select: { sourceMessageId: true },
|
||||||
|
}),
|
||||||
|
).toEqual({ sourceMessageId: healthRun.sourceMessageId });
|
||||||
|
|
||||||
|
state = await rpc<UserProfileState>(app, cookie, "profile/update", {
|
||||||
|
confirmFactIds: [state.facts[0]!.id],
|
||||||
|
});
|
||||||
|
expect(state.facts.map((fact) => fact.status)).toEqual(["active", "active"]);
|
||||||
|
|
||||||
|
state = await rpc<UserProfileState>(app, cookie, "profile/forget", { category: "health" });
|
||||||
|
expect(state.facts.map((fact) => fact.value)).toEqual(["Works night shifts as a nurse"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates profile input and reports unknown facts", async () => {
|
||||||
|
const cookie = await signup(app, `profile-input-${stamp}@rakazo.test`, "Profile Input");
|
||||||
|
const state = await rpc<UserProfileState>(app, cookie, "profile/update", {
|
||||||
|
name: "Renamed",
|
||||||
|
timeZone: "europe/vienna",
|
||||||
|
language: "de",
|
||||||
|
addFacts: [{ category: "goals", value: "Run a marathon" }],
|
||||||
|
});
|
||||||
|
expect(state.profile).toMatchObject({ timeZone: "Europe/Vienna", language: "de" });
|
||||||
|
expect(state.facts).toMatchObject([
|
||||||
|
{ category: "goals", value: "Run a marathon", source: "user", status: "active" },
|
||||||
|
]);
|
||||||
|
expect((await rpc<{ name: string }>(app, cookie, "me")).name).toBe("Renamed");
|
||||||
|
|
||||||
|
expect((await raw(app, cookie, "profile/update", { timeZone: "Vienna" })).status).toBe(400);
|
||||||
|
expect(
|
||||||
|
(await raw(app, cookie, "profile/update", { confirmFactIds: ["missing-fact"] })).status,
|
||||||
|
).toBe(404);
|
||||||
|
|
||||||
|
const forgotten = await rpc<UserProfileState>(app, cookie, "profile/forget", { all: true });
|
||||||
|
expect(forgotten.facts).toEqual([]);
|
||||||
|
expect(forgotten.profile).toMatchObject({ timeZone: null, language: null, summary: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function signup(app: App, email: string, name: string) {
|
||||||
|
const response = await app.request("/api/auth/sign-up/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
|
||||||
|
body: JSON.stringify({ email, password: "password12", name }),
|
||||||
|
});
|
||||||
|
if (response.status >= 400) {
|
||||||
|
throw new Error(`signup failed ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
return sessionCookieHeader(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) {
|
||||||
|
return app.request(`/rpc/${procedure}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie,
|
||||||
|
origin: "http://127.0.0.1:5173",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ json: body }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpc<T>(app: App, cookie: string, procedure: string, body: unknown = {}) {
|
||||||
|
const response = await raw(app, cookie, procedure, body);
|
||||||
|
const text = await response.text();
|
||||||
|
const payload = JSON.parse(text) as { json?: T; error?: { message?: string } };
|
||||||
|
if (response.status >= 400 || payload.error) {
|
||||||
|
throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`);
|
||||||
|
}
|
||||||
|
return payload.json as T;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user