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 <noreply@anthropic.com>
This commit is contained in:
1 parent
fbfe16534a
commit
d34a5c1f0c
9 files changed
+94
-22
No files matched your search
@@ -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")];
|
||||
Reference in new issue
Block a user