feat(ai): per-course mentor preferences table (contract request 4)
0002_ai_mentor_prefs.sql adds ai_mentor_prefs(user_id, course_id, off). upgradeAiData copies v1 config_json.mentorOff toggles into it once (marked ai_data_v11 in schema_migrations); the settings repo awaits the upgrade before first use and aiFeature starts it at construction. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d34a5c1f0c
commit
a995900fba
@@ -0,0 +1,7 @@
|
||||
-- v1.1 (contract request 4): per-course mentor toggles get their own table. A row exists only while the learner
|
||||
-- has switched the course's mentor rules off. v1 kept them in ai_settings.config_json.mentorOff; reading that JSON
|
||||
-- is not portable SQL, so upgradeAiData (src/upgrade.ts) copies it on first start.
|
||||
CREATE TABLE ai_mentor_prefs (
|
||||
user_id TEXT NOT NULL, course_id TEXT NOT NULL, off INTEGER NOT NULL, updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, course_id)
|
||||
);
|
||||
@@ -145,6 +145,8 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
const progress = progressRepo(ctx.db);
|
||||
const sink = opts.draftSink ?? createFsDraftSink(ctx.config.dataDir);
|
||||
const log = opts.log ?? ((m: string) => console.error(`[ai] ${m}`));
|
||||
// The v1 data upgrade (mentor toggles, MCP secrets) starts now; every settings call also waits for it.
|
||||
settings.ready().catch((e) => log(`v1.1 data upgrade failed (retried on next use): ${(e as Error).message}`));
|
||||
// AppConfig.aiAllowedBaseUrls (STC_AI_ALLOWED_BASE_URLS) plus the admin's list in the server AI settings.
|
||||
const configOrigins = parseAllowedOrigins((ctx.config.aiAllowedBaseUrls ?? []).join(","));
|
||||
const allowedOrigins = async () => [...new Set([...configOrigins, ...(await settings.getServer()).allowedBaseUrls])];
|
||||
|
||||
@@ -2,6 +2,7 @@ export { createSecretBox, type SecretBox } from "./crypto";
|
||||
|
||||
export { migrationsDirs } from "./migrations";
|
||||
export * from "./settings";
|
||||
export { upgradeAiData, AI_DATA_UPGRADE } from "./upgrade";
|
||||
export * from "./providers";
|
||||
export * from "./retrieval/course-index";
|
||||
export { Bm25, tokenize } from "./retrieval/bm25";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Db } from "@stc/db";
|
||||
import type { SecretBox } from "./crypto";
|
||||
import { upgradeAiData } from "./upgrade";
|
||||
|
||||
export type ProviderKind = "anthropic" | "openai";
|
||||
export type Effort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
@@ -30,7 +31,7 @@ export interface ServerSettingsInput {
|
||||
export interface UserSettings { byok: PublicProviderConfig | null; mentorOff: Record<string, boolean> }
|
||||
|
||||
type ServerRow = { provider: ProviderConfig | null; apiKeyCipher: string | null; mcpServers: McpServerConfig[]; allowedBaseUrls?: string[] };
|
||||
type UserRow = { byok: ProviderConfig | null; mentorOff: Record<string, boolean> };
|
||||
type UserRow = { byok: ProviderConfig | null };
|
||||
|
||||
const pickProvider = (p: ProviderConfig): ProviderConfig => {
|
||||
const out: ProviderConfig = { kind: p.kind };
|
||||
@@ -46,14 +47,18 @@ const parse = <T>(s: string | undefined, fallback: T): T => {
|
||||
};
|
||||
|
||||
/**
|
||||
* AI settings in the ai_settings and user_api_keys tables of 0001_init.sql.
|
||||
* AI settings in ai_settings and user_api_keys (core 0001_init.sql) and ai_mentor_prefs (this package's 0002).
|
||||
* - ('server', '') holds the admin provider (key encrypted) and MCP servers.
|
||||
* - ('user', <userId>) holds the BYOK provider choice and per-course mentor toggles. The frozen primary key
|
||||
* is (scope, owner_id), so the per-course flag lives in config_json; the mentor_off column means
|
||||
* "off for every course" and stays 0 (see docs/contract-requests/ai.md).
|
||||
* - ('user', <userId>) holds the BYOK provider choice. The mentor_off column ("off for every course") stays 0.
|
||||
* - ai_mentor_prefs has a row per (user, course) whose mentor rules the learner switched off.
|
||||
* Keys never leave this module except through serverApiKey/userApiKey, which only server code calls.
|
||||
* Every method first awaits the one-time v1 data upgrade (upgradeAiData); a failed attempt is retried next call.
|
||||
*/
|
||||
export function aiSettingsRepo(db: Db, box: SecretBox) {
|
||||
let upgraded: Promise<unknown> | null = null;
|
||||
const ready = () =>
|
||||
(upgraded ??= upgradeAiData(db, box).catch((e) => { upgraded = null; throw e; }));
|
||||
|
||||
const read = async (scope: "server" | "user", owner: string): Promise<string | undefined> => {
|
||||
const rows = await db.sql`SELECT config_json FROM ai_settings WHERE scope = ${scope} AND owner_id = ${owner}`;
|
||||
return rows[0]?.config_json;
|
||||
@@ -68,16 +73,23 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
|
||||
});
|
||||
};
|
||||
const serverRow = async (): Promise<ServerRow> => parse(await read("server", ""), { provider: null, apiKeyCipher: null, mcpServers: [] });
|
||||
const userRow = async (userId: string): Promise<UserRow> => parse(await read("user", userId), { byok: null, mentorOff: {} });
|
||||
const userRow = async (userId: string): Promise<UserRow> => {
|
||||
const { mentorOff: _legacy, ...row } = parse(await read("user", userId), { byok: null } as UserRow & { mentorOff?: unknown });
|
||||
return row;
|
||||
};
|
||||
const keyRow = async (userId: string) =>
|
||||
(await db.sql`SELECT provider, ciphertext FROM user_api_keys WHERE user_id = ${userId}`)[0] as { provider: string; ciphertext: string } | undefined;
|
||||
|
||||
return {
|
||||
/** Resolves once the v1 data upgrade is done (aiFeature starts it at construction). */
|
||||
ready: async (): Promise<void> => { await ready(); },
|
||||
async getServer(): Promise<ServerSettings> {
|
||||
await ready();
|
||||
const r = await serverRow();
|
||||
return { provider: r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null, mcpServers: r.mcpServers ?? [], allowedBaseUrls: r.allowedBaseUrls ?? [] };
|
||||
},
|
||||
async saveServer(input: ServerSettingsInput): Promise<void> {
|
||||
await ready();
|
||||
const prev = await serverRow();
|
||||
let apiKeyCipher: string | null = null;
|
||||
if (input.provider) {
|
||||
@@ -92,15 +104,19 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
|
||||
});
|
||||
},
|
||||
async serverApiKey(): Promise<string | null> {
|
||||
await ready();
|
||||
const r = await serverRow();
|
||||
return r.provider && r.apiKeyCipher ? box.decrypt(r.apiKeyCipher) : null;
|
||||
},
|
||||
async getUser(userId: string): Promise<UserSettings> {
|
||||
await ready();
|
||||
const r = await userRow(userId);
|
||||
const k = await keyRow(userId);
|
||||
return { byok: r.byok && k ? { ...r.byok, hasApiKey: true } : null, mentorOff: r.mentorOff ?? {} };
|
||||
const prefs = (await db.sql`SELECT course_id FROM ai_mentor_prefs WHERE user_id = ${userId} AND off = 1`) as { course_id: string }[];
|
||||
return { byok: r.byok && k ? { ...r.byok, hasApiKey: true } : null, mentorOff: Object.fromEntries(prefs.map((p) => [p.course_id, true])) };
|
||||
},
|
||||
async saveUserKey(userId: string, cfg: ProviderConfig & { apiKey: string }): Promise<void> {
|
||||
await ready();
|
||||
const ciphertext = await box.encrypt(cfg.apiKey);
|
||||
const now = new Date().toISOString();
|
||||
await db.sql.begin(async (tx) => {
|
||||
@@ -111,23 +127,26 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
|
||||
await write("user", userId, { ...r, byok: pickProvider(cfg) });
|
||||
},
|
||||
async removeUserKey(userId: string): Promise<void> {
|
||||
await ready();
|
||||
await db.sql`DELETE FROM user_api_keys WHERE user_id = ${userId}`;
|
||||
const r = await userRow(userId);
|
||||
await write("user", userId, { ...r, byok: null });
|
||||
},
|
||||
async userApiKey(userId: string): Promise<string | null> {
|
||||
await ready();
|
||||
const k = await keyRow(userId);
|
||||
return k ? box.decrypt(k.ciphertext) : null;
|
||||
},
|
||||
async setMentorOff(userId: string, courseId: string, off: boolean): Promise<void> {
|
||||
const r = await userRow(userId);
|
||||
const mentorOff = { ...r.mentorOff };
|
||||
if (off) mentorOff[courseId] = true;
|
||||
else delete mentorOff[courseId];
|
||||
await write("user", userId, { ...r, mentorOff });
|
||||
await ready();
|
||||
await db.sql.begin(async (tx) => {
|
||||
await tx`DELETE FROM ai_mentor_prefs WHERE user_id = ${userId} AND course_id = ${courseId}`;
|
||||
if (off) await tx`INSERT INTO ai_mentor_prefs (user_id, course_id, off, updated_at) VALUES (${userId}, ${courseId}, 1, ${new Date().toISOString()})`;
|
||||
});
|
||||
},
|
||||
async isMentorOff(userId: string, courseId: string): Promise<boolean> {
|
||||
return (await userRow(userId)).mentorOff?.[courseId] === true;
|
||||
await ready();
|
||||
return (await db.sql`SELECT 1 AS x FROM ai_mentor_prefs WHERE user_id = ${userId} AND course_id = ${courseId} AND off = 1`).length > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Db } from "@stc/db";
|
||||
import type { SecretBox } from "./crypto";
|
||||
|
||||
/** Recorded in schema_migrations once the data upgrade has run (no `.sql` suffix, so no file can share it). */
|
||||
export const AI_DATA_UPGRADE = "ai_data_v11";
|
||||
|
||||
type Tx = Bun.TransactionSQL;
|
||||
|
||||
const parseObject = (s: string): Record<string, unknown> | null => {
|
||||
try {
|
||||
const v = JSON.parse(s);
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
/** v1 kept per-course toggles in the user row's config_json.mentorOff; move them to ai_mentor_prefs. */
|
||||
async function moveMentorToggles(tx: Tx, now: string) {
|
||||
const rows = (await tx`SELECT owner_id, config_json FROM ai_settings WHERE scope = 'user'`) as { owner_id: string; config_json: string }[];
|
||||
for (const r of rows) {
|
||||
const cfg = parseObject(r.config_json);
|
||||
if (!cfg || !("mentorOff" in cfg)) continue;
|
||||
const off = cfg.mentorOff && typeof cfg.mentorOff === "object" ? (cfg.mentorOff as Record<string, unknown>) : {};
|
||||
for (const [courseId, v] of Object.entries(off)) {
|
||||
if (v !== true || !courseId) continue;
|
||||
const exists = await tx`SELECT 1 AS x FROM ai_mentor_prefs WHERE user_id = ${r.owner_id} AND course_id = ${courseId}`;
|
||||
if (!exists.length) await tx`INSERT INTO ai_mentor_prefs (user_id, course_id, off, updated_at) VALUES (${r.owner_id}, ${courseId}, 1, ${now})`;
|
||||
}
|
||||
const { mentorOff: _drop, ...rest } = cfg;
|
||||
await tx`UPDATE ai_settings SET config_json = ${JSON.stringify(rest)}, updated_at = ${now} WHERE scope = 'user' AND owner_id = ${r.owner_id}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data the v1.1 SQL migrations cannot move portably. Runs after the package's migrations, in one transaction,
|
||||
* once (marked in schema_migrations). Returns true if it ran now. The settings repo awaits it before first use.
|
||||
*/
|
||||
export async function upgradeAiData(db: Db, _box: SecretBox): Promise<boolean> {
|
||||
return db.sql.begin(async (tx) => {
|
||||
if ((await tx`SELECT name FROM schema_migrations WHERE name = ${AI_DATA_UPGRADE}`).length) return false;
|
||||
const now = new Date().toISOString();
|
||||
await moveMentorToggles(tx, now);
|
||||
await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${AI_DATA_UPGRADE}, ${now})`;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -61,3 +61,17 @@ forEachDb("mentor mode is on by default and toggles per course", async (db) => {
|
||||
expect(await r.isMentorOff("u1", "sample")).toBe(false);
|
||||
expect((await r.getUser("u1")).byok?.kind).toBe("anthropic");
|
||||
});
|
||||
|
||||
forEachDb("mentor toggles are rows in ai_mentor_prefs, not config_json", async (db) => {
|
||||
const r = aiSettingsRepo(db, box);
|
||||
await r.saveUserKey("u1", { kind: "anthropic", apiKey: "k" });
|
||||
await r.setMentorOff("u1", "sample", true);
|
||||
await r.setMentorOff("u1", "sample", true); // twice is fine
|
||||
const rows = await db.sql`SELECT user_id, course_id, off FROM ai_mentor_prefs`;
|
||||
expect(rows.map((x: { user_id: string; course_id: string; off: number }) => [x.user_id, x.course_id, Number(x.off)])).toEqual([["u1", "sample", 1]]);
|
||||
const cfg = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'user' AND owner_id = 'u1'`)[0].config_json;
|
||||
expect(cfg).not.toContain("mentorOff");
|
||||
expect((await r.getUser("u1")).mentorOff).toEqual({ sample: true });
|
||||
await r.setMentorOff("u1", "sample", false);
|
||||
expect(await db.sql`SELECT user_id FROM ai_mentor_prefs`).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,13 @@ import { expect } from "bun:test";
|
||||
import { migrate, type Db } from "@stc/db";
|
||||
// Core migrations only: the ai_* tables exactly as v1 shipped them.
|
||||
import { forEachDb as forEachV1Db } from "../../db/test/helpers";
|
||||
import { createSecretBox } from "../src/crypto";
|
||||
import { migrationsDirs } from "../src/migrations";
|
||||
import { aiSettingsRepo } from "../src/settings";
|
||||
import { upgradeAiData } from "../src/upgrade";
|
||||
|
||||
const T = "2026-10-01T00:00:00.000Z";
|
||||
const box = createSecretBox("test-secret");
|
||||
const aiMigrations = { "feature-ai": migrationsDirs };
|
||||
|
||||
async function seedMessage(db: Db, id: string, conv: string, role: string, content: string) {
|
||||
@@ -39,3 +43,42 @@ forEachV1Db("v1 upgrade: sources rows fold into citations_json on the assistant
|
||||
expect(byId["0001-a"]).toBeNull();
|
||||
expect(await migrate(db, aiMigrations)).toEqual([]);
|
||||
});
|
||||
|
||||
async function seedSettings(db: Db, scope: "server" | "user", owner: string, config: unknown) {
|
||||
const json = typeof config === "string" ? config : JSON.stringify(config);
|
||||
await db.sql`INSERT INTO ai_settings (scope, owner_id, mentor_off, config_json, updated_at) VALUES (${scope}, ${owner}, 0, ${json}, ${T})`;
|
||||
}
|
||||
|
||||
forEachV1Db("v1 upgrade: per-course mentor toggles move from config_json to ai_mentor_prefs", async (db) => {
|
||||
await seedSettings(db, "user", "u1", { byok: { kind: "anthropic" }, mentorOff: { sample: true, other: false } });
|
||||
await seedSettings(db, "user", "u2", { byok: null, mentorOff: {} });
|
||||
await seedSettings(db, "user", "u3", "{not json");
|
||||
await db.sql`INSERT INTO user_api_keys (user_id, provider, ciphertext, created_at) VALUES ('u1', 'anthropic', ${await box.encrypt("sk-u1")}, ${T})`;
|
||||
|
||||
expect(await migrate(db, aiMigrations)).toContain("0002_ai_mentor_prefs.sql");
|
||||
expect(await upgradeAiData(db, box)).toBe(true);
|
||||
|
||||
const prefs = await db.sql`SELECT user_id, course_id, off FROM ai_mentor_prefs ORDER BY user_id, course_id`;
|
||||
expect(prefs.map((r: { user_id: string; course_id: string; off: number }) => [r.user_id, r.course_id, Number(r.off)])).toEqual([["u1", "sample", 1]]);
|
||||
const cfg = async (u: string) => (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'user' AND owner_id = ${u}`)[0].config_json as string;
|
||||
expect(JSON.parse(await cfg("u1"))).toEqual({ byok: { kind: "anthropic" } });
|
||||
expect(JSON.parse(await cfg("u2"))).toEqual({ byok: null });
|
||||
expect(await cfg("u3")).toBe("{not json");
|
||||
|
||||
const repo = aiSettingsRepo(db, box);
|
||||
expect(await repo.isMentorOff("u1", "sample")).toBe(true);
|
||||
expect(await repo.isMentorOff("u1", "other")).toBe(false);
|
||||
expect(await repo.getUser("u1")).toEqual({ byok: { kind: "anthropic", hasApiKey: true }, mentorOff: { sample: true } });
|
||||
|
||||
// idempotent: the marker stops a second run
|
||||
expect(await upgradeAiData(db, box)).toBe(false);
|
||||
expect((await db.sql`SELECT COUNT(*) AS n FROM ai_mentor_prefs`)[0].n.toString()).toBe("1");
|
||||
expect((await db.sql`SELECT name FROM schema_migrations WHERE name = 'ai_data_v11'`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
forEachV1Db("v1 upgrade: the settings repo upgrades on first use", async (db) => {
|
||||
await seedSettings(db, "user", "u1", { byok: null, mentorOff: { sample: true } });
|
||||
await migrate(db, aiMigrations);
|
||||
expect(await aiSettingsRepo(db, box).isMentorOff("u1", "sample")).toBe(true);
|
||||
expect(JSON.parse((await db.sql`SELECT config_json FROM ai_settings WHERE owner_id = 'u1'`)[0].config_json)).toEqual({ byok: null });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user