feat(ai): citations column on ai_messages (contract request 5)
New packages/feature-ai/migrations/0001_ai_message_citations.sql adds ai_messages.citations_json and folds v1 'sources' rows into the assistant row before them. The feature writes and reads the column directly. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fbfe16534a
commit
d34a5c1f0c
@@ -0,0 +1,10 @@
|
||||
-- v1.1 (contract request 5): an assistant message's citations live in its own column.
|
||||
-- v1 stored them as an extra row with role 'sources' right after the assistant row (ids sort in write order).
|
||||
-- Fold each such row into the assistant row before it, then drop every 'sources' row.
|
||||
ALTER TABLE ai_messages ADD COLUMN citations_json TEXT;
|
||||
UPDATE ai_messages SET citations_json = (
|
||||
SELECT s.content FROM ai_messages s
|
||||
WHERE s.conversation_id = ai_messages.conversation_id AND s.role = 'sources'
|
||||
AND s.id = (SELECT MIN(n.id) FROM ai_messages n WHERE n.conversation_id = ai_messages.conversation_id AND n.id > ai_messages.id)
|
||||
) WHERE role = 'assistant';
|
||||
DELETE FROM ai_messages WHERE role = 'sources';
|
||||
@@ -7,6 +7,7 @@ import { createCourseRepository } from "@stc/course-format";
|
||||
import { connect, migrate } from "@stc/db";
|
||||
import indexHtml from "../../../apps/web/index.html";
|
||||
import { aiFeature } from "../src/feature";
|
||||
import { migrationsDirs } from "../src/migrations";
|
||||
|
||||
const config = loadConfig();
|
||||
const who = process.env.STC_AI_DEV_USER ?? "owner";
|
||||
@@ -32,7 +33,7 @@ const fake: AiProvider = {
|
||||
};
|
||||
|
||||
const db = await connect(config.databaseUrl);
|
||||
await migrate(db);
|
||||
await migrate(db, { "feature-ai": migrationsDirs });
|
||||
const courses = await createCourseRepository(config.courseDirs);
|
||||
const ctx = { config, db, courses, auth } as ServerContext;
|
||||
const feature = aiFeature(ctx, process.env.STC_AI_DEV_FAKE ? { providerFactory: () => fake } : {});
|
||||
|
||||
@@ -198,22 +198,23 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
const rows = await ctx.db.sql`SELECT id, user_id, course_id, title, created_at FROM ai_conversations WHERE id = ${id} AND user_id = ${userId}`;
|
||||
return rows[0] as { id: string; user_id: string; course_id: string; title: string | null; created_at: string } | undefined;
|
||||
};
|
||||
// Roles: "user", "assistant", and "sources" (JSON citations for the assistant message before it).
|
||||
const addMessage = async (conversationId: string, role: "user" | "assistant" | "sources", content: string) => {
|
||||
await ctx.db.sql`INSERT INTO ai_messages (id, conversation_id, role, content, created_at) VALUES (${Bun.randomUUIDv7()}, ${conversationId}, ${role}, ${content}, ${new Date().toISOString()})`;
|
||||
/** Roles "user" and "assistant"; an assistant message's citations go in `citations_json` (contract request 5). */
|
||||
const addMessage = async (conversationId: string, role: "user" | "assistant", content: string, citations?: unknown[]) => {
|
||||
const cites = citations ? JSON.stringify(citations) : null;
|
||||
await ctx.db.sql`INSERT INTO ai_messages (id, conversation_id, role, content, citations_json, created_at) VALUES (${Bun.randomUUIDv7()}, ${conversationId}, ${role}, ${content}, ${cites}, ${new Date().toISOString()})`;
|
||||
};
|
||||
const messagesOf = async (conversationId: string) =>
|
||||
(await ctx.db.sql`SELECT id, role, content, created_at FROM ai_messages WHERE conversation_id = ${conversationId}`)
|
||||
.map((r: { id: string; role: string; content: string; created_at: string }) => ({ id: r.id, role: r.role, content: r.content, createdAt: r.created_at }))
|
||||
.sort((a: { id: string }, b: { id: string }) => (a.id < b.id ? -1 : 1)) as { id: string; role: "user" | "assistant" | "sources"; content: string; createdAt: string }[];
|
||||
/** Chat turns with each assistant message's stored citations attached. */
|
||||
/** Chat turns in write order (UUIDv7 ids), each assistant message with its stored citations. */
|
||||
const turnsOf = async (conversationId: string) => {
|
||||
const rows = (await ctx.db.sql`SELECT id, role, content, citations_json, created_at FROM ai_messages WHERE conversation_id = ${conversationId}`) as
|
||||
{ id: string; role: string; content: string; citations_json: string | null; created_at: string }[];
|
||||
const out: { id: string; role: "user" | "assistant"; content: string; createdAt: string; citations?: unknown[] }[] = [];
|
||||
for (const m of await messagesOf(conversationId)) {
|
||||
if (m.role === "sources") {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.role === "assistant") { try { last.citations = JSON.parse(m.content); } catch {} }
|
||||
} else out.push({ ...m, role: m.role });
|
||||
for (const r of rows.sort((a, b) => (a.id < b.id ? -1 : 1))) {
|
||||
if (r.role !== "user" && r.role !== "assistant") continue;
|
||||
const turn: (typeof out)[number] = { id: r.id, role: r.role, content: r.content, createdAt: r.created_at };
|
||||
if (r.role === "assistant" && r.citations_json) {
|
||||
try { const c = JSON.parse(r.citations_json); if (Array.isArray(c)) turn.citations = c; } catch {}
|
||||
}
|
||||
out.push(turn);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -282,10 +283,7 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
send({ type: "done", text: ev.text, citations: cited });
|
||||
} else send(ev);
|
||||
}
|
||||
if (answer) {
|
||||
await addMessage(convId, "assistant", answer);
|
||||
await addMessage(convId, "sources", JSON.stringify(cited));
|
||||
}
|
||||
if (answer) await addMessage(convId, "assistant", answer, cited);
|
||||
} catch (e) {
|
||||
const m = (e as Error).message;
|
||||
if (resolved.source === "byok") log(`chat error for user ${user.id} (own key): ${m}`);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { createSecretBox, type SecretBox } from "./crypto";
|
||||
|
||||
/** Migration folders for this package. Empty: the AI package uses the ai_* tables from 0001_init.sql. */
|
||||
export const migrationsDirs: string[] = [];
|
||||
export { migrationsDirs } from "./migrations";
|
||||
export * from "./settings";
|
||||
export * from "./providers";
|
||||
export * from "./retrieval/course-index";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Migration folders for this package; the server passes them to core `migrate()`. The files upgrade the v1 ai_*
|
||||
* tables of core's 0001_init.sql. Data that portable SQL cannot move runs in `upgradeAiData` (src/upgrade.ts).
|
||||
*/
|
||||
export const migrationsDirs: string[] = [join(import.meta.dir, "..", "migrations")];
|
||||
@@ -6,7 +6,7 @@ import type { AppConfig, AuthUser, JobRecord, ServerContext } from "@stc/contrac
|
||||
import { createApp, createJobQueue, resolveConfig } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import type { Db } from "@stc/db";
|
||||
import { forEachDb } from "../../db/test/helpers";
|
||||
import { forEachAiDb as forEachDb } from "./helpers";
|
||||
import { aiFeature, type AiFeatureOptions } from "../src/feature";
|
||||
import { fakeProvider } from "./helpers";
|
||||
|
||||
@@ -104,6 +104,11 @@ forEachDb("chat cites course files, applies mentor rules, and stores the convers
|
||||
expect(conv.messages.map((m: { role: string }) => m.role)).toEqual(["user", "assistant"]);
|
||||
expect(conv.messages[1].citations.some((c: { path: string }) => c.path === "lessons/04-exercise.md")).toBe(true);
|
||||
expect((await call("GET", `/api/ai/conversations/${convs[0].id}`, "bob")).status).toBe(404);
|
||||
// citations live in the assistant row's column, not in a separate "sources" row
|
||||
const stored = await db.sql`SELECT role, citations_json FROM ai_messages WHERE conversation_id = ${convs[0].id}`;
|
||||
expect(stored.map((m: { role: string }) => m.role).sort()).toEqual(["assistant", "user"]);
|
||||
const assistantRow = stored.find((m: { role: string }) => m.role === "assistant");
|
||||
expect(JSON.parse(assistantRow.citations_json).some((c: { path: string }) => c.path === "lessons/04-exercise.md")).toBe(true);
|
||||
|
||||
// Learner switches mentor mode off for this course: rules leave the prompt.
|
||||
expect((await call("PUT", "/api/ai/courses/sample/mentor", "ann", { off: true })).status).toBe(200);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { AiProvider, ChatMessage, ToolSpec } from "@stc/contracts";
|
||||
import { migrate, type Db } from "@stc/db";
|
||||
import { forEachDb } from "../../db/test/helpers";
|
||||
import { migrationsDirs } from "../src/migrations";
|
||||
|
||||
/** SSE body from a list of [event, data] pairs, split into awkward chunks to exercise buffering. */
|
||||
export function sseBody(events: [string | null, unknown][], chunk = 7): ReadableStream<Uint8Array> {
|
||||
@@ -51,3 +54,11 @@ export function fakeProvider(turns: ((messages: ChatMessage[], tools?: ToolSpec[
|
||||
};
|
||||
return { provider, seen };
|
||||
}
|
||||
|
||||
/** forEachDb with the AI package's migrations applied on top of core (the schema the server runs). */
|
||||
export function forEachAiDb(name: string, fn: (db: Db) => Promise<void>): void {
|
||||
forEachDb(name, async (db) => {
|
||||
await migrate(db, { "feature-ai": migrationsDirs });
|
||||
await fn(db);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from "bun:test";
|
||||
import { forEachDb } from "../../db/test/helpers";
|
||||
import { forEachAiDb as forEachDb } from "./helpers";
|
||||
import { createSecretBox } from "../src/crypto";
|
||||
import { aiSettingsRepo } from "../src/settings";
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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 { migrationsDirs } from "../src/migrations";
|
||||
|
||||
const T = "2026-10-01T00:00:00.000Z";
|
||||
const aiMigrations = { "feature-ai": migrationsDirs };
|
||||
|
||||
async function seedMessage(db: Db, id: string, conv: string, role: string, content: string) {
|
||||
await db.sql`INSERT INTO ai_messages (id, conversation_id, role, content, created_at) VALUES (${id}, ${conv}, ${role}, ${content}, ${T})`;
|
||||
}
|
||||
|
||||
forEachV1Db("v1 upgrade: sources rows fold into citations_json on the assistant row before them", async (db) => {
|
||||
const cites = JSON.stringify([{ path: "lessons/04-exercise.md", title: "Adding numbers", lessonId: "m2-ex-add" }]);
|
||||
await db.sql`INSERT INTO ai_conversations (id, user_id, course_id, title, created_at) VALUES ('c1', 'u1', 'sample', 'Hi', ${T})`;
|
||||
await db.sql`INSERT INTO ai_conversations (id, user_id, course_id, title, created_at) VALUES ('c2', 'u1', 'sample', 'Two', ${T})`;
|
||||
// ids sort in the order v1 wrote them (UUIDv7); c1: user, assistant, sources, user, assistant (no sources)
|
||||
await seedMessage(db, "0001-a", "c1", "user", "How do I add?");
|
||||
await seedMessage(db, "0001-b", "c1", "assistant", "Which operator?");
|
||||
await seedMessage(db, "0001-c", "c1", "sources", cites);
|
||||
await seedMessage(db, "0001-d", "c1", "user", "And then?");
|
||||
await seedMessage(db, "0001-e", "c1", "assistant", "Try it.");
|
||||
// c2: an orphan sources row (no assistant before it) and an assistant whose next row belongs to c1's ids range
|
||||
await seedMessage(db, "0002-a", "c2", "sources", "[]");
|
||||
await seedMessage(db, "0002-b", "c2", "assistant", "Alone.");
|
||||
|
||||
const applied = await migrate(db, aiMigrations);
|
||||
expect(applied).toContain("0001_ai_message_citations.sql");
|
||||
|
||||
const rows = await db.sql`SELECT id, role, content, citations_json FROM ai_messages ORDER BY id`;
|
||||
expect(rows.map((r: { id: string; role: string }) => `${r.id}:${r.role}`)).toEqual([
|
||||
"0001-a:user", "0001-b:assistant", "0001-d:user", "0001-e:assistant", "0002-b:assistant",
|
||||
]);
|
||||
const byId = Object.fromEntries(rows.map((r: { id: string; citations_json: string | null }) => [r.id, r.citations_json]));
|
||||
expect(JSON.parse(byId["0001-b"]!)).toEqual(JSON.parse(cites));
|
||||
expect(byId["0001-e"]).toBeNull();
|
||||
expect(byId["0002-b"]).toBeNull();
|
||||
expect(byId["0001-a"]).toBeNull();
|
||||
expect(await migrate(db, aiMigrations)).toEqual([]);
|
||||
});
|
||||
Reference in New Issue
Block a user