About you could only confirm or delete what the bots know about the user, so a fact that was slightly wrong or filed under the wrong category had to be deleted and taught again, and a fact could not be limited to some spaces from the app even though the profile supports it. - Each fact gets an Edit button that turns its row into an inline editor: wording, category, and (when the user has more than one space, or the fact is already limited) which spaces it shows in. Escape or Cancel leaves it unchanged; only changed fields are sent through profile.update editFacts. - A limited fact names its spaces next to its source. - The repository validates edits like new facts: a wording whose normalized form is empty or equals another fact of the user is refused, all edits of one update apply atomically, and the stored embedding is only dropped when the wording really changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
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. Every writer of facts takes it inside its transaction.
|
||
*/
|
||
export 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;
|
||
}
|
||
|
||
/**
|
||
* Applies the user's fact edits in order. A new wording must still say something and must not
|
||
* repeat another fact, compared the way new facts are.
|
||
*/
|
||
async function editFacts(
|
||
tx: Prisma.TransactionClient,
|
||
userId: string,
|
||
edits: UserFactEdit[],
|
||
): Promise<void> {
|
||
if (edits.length === 0) return;
|
||
const facts = await tx.userFact.findMany({
|
||
where: { userId },
|
||
select: { id: true, value: true },
|
||
});
|
||
const values = new Map(facts.map((fact) => [fact.id, fact.value]));
|
||
for (const edit of edits) {
|
||
const current = values.get(edit.id);
|
||
if (current === undefined) throw new UserFactNotFoundError();
|
||
const data: Prisma.UserFactUpdateInput = {};
|
||
if (edit.category !== undefined) {
|
||
data.category = edit.category;
|
||
data.sensitive = isSensitiveUserFactCategory(edit.category);
|
||
}
|
||
if (edit.value !== undefined && edit.value.trim() !== current) {
|
||
const value = edit.value.trim();
|
||
const key = normalizeUserFactValue(value);
|
||
if (!key) throw new InvalidUserFactError("Fact is empty");
|
||
for (const [id, other] of values) {
|
||
if (id !== edit.id && normalizeUserFactValue(other) === key) {
|
||
throw new InvalidUserFactError("This fact already exists");
|
||
}
|
||
}
|
||
values.set(edit.id, value);
|
||
data.value = value;
|
||
// 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 })) } : {}),
|
||
};
|
||
}
|
||
if (Object.keys(data).length > 0) await tx.userFact.update({ where: { id: edit.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 ?? []);
|
||
await editFacts(tx, userId, update.editFacts ?? []);
|
||
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;
|
||
});
|
||
}
|