diff --git a/.changeset/agent-meeting-presets-grounding.md b/.changeset/agent-meeting-presets-grounding.md new file mode 100644 index 000000000..972e54cc3 --- /dev/null +++ b/.changeset/agent-meeting-presets-grounding.md @@ -0,0 +1,52 @@ +--- +"@launchstack/core": minor +--- + +Ground meeting agents in the workspace's documents, and ship a preset startup +team. + +**Turn-level grounding** + +Meeting agents were the one part of the product that reasoned about documents +without ever reading them: `MeetingConfig.context` existed, and nothing +populated it. Retrieval is now a port on the engine — +`TurnGroundingProvider` — consulted before each turn for the persona *about to +speak*. The engine still knows nothing about documents or indexes; the +implementation over the existing ensemble retriever lives in the host. + +**New exports** from `@launchstack/core/collab`: `TurnGroundingProvider`, +`TurnGrounding`, `TurnGroundingRequest`, `GroundingSource`, +`buildGroundingQuery`, `toExcerpt`, `EMPTY_GROUNDING`, `MAX_EXCERPT_CHARS`. +`MeetingOrchestratorOptions` and `CreateMeetingInput` accept +`groundingProvider`; the orchestrator additionally takes `onGroundingError`. + +Retrieval is per-persona because the analyst and the engineer ask the corpus +different questions, capped at four passages because each turn already carries +the whole transcript, and never fatal — a failure yields an ungrounded turn +rather than a dead meeting. + +**Behaviour change: `evaluateMeeting` grounding dimension** + +`scoreGrounding` now builds its haystack from `config.context` *and* the +excerpts recorded on each message, rather than `config.context` alone. A +meeting grounded purely by retrieval previously scored "no context supplied — +dimension not applicable" at weight 0; it is now scored. Meetings with no +grounding at all are unaffected. + +This is why turns record a truncated excerpt of what they read alongside the +citation: it keeps a grounded transcript checkable without the index still +being around, and lets the scorer tell a cited number from an invented one. + +**Behaviour change: `selectNextSpeaker` follows the last mention** + +`findMention` returned the *first* `@handle` in a message. A turn in a working +channel opens by answering whoever spoke last and closes by asking someone +else, so the first mention is a reply-to and the last is the actual handoff — +taking the first sent the floor back to the person who had just spoken and +starved the specialists. Under `reactive` and `moderated`, the last mention now +wins. + +Single-mention messages are unaffected, which is why no existing test moved: +every scripted line in the suite carries exactly one mention. This was found by +running a real meeting. + diff --git a/.changeset/collab-rooms.md b/.changeset/collab-rooms.md new file mode 100644 index 000000000..f21482af8 --- /dev/null +++ b/.changeset/collab-rooms.md @@ -0,0 +1,37 @@ +--- +"@launchstack/core": minor +--- + +Add rooms — ask every member one question at once, each answering from its own +documents. + +A meeting is a conversation: one speaker, elected from the transcript, working +toward a close. A room is a query: one question, every member, concurrently, no +floor to hold. They share a channel log and nothing else. + +**New exports** from `@launchstack/core/collab`: `askRoom`, `summarizeRounds`, +`buildRoomTurnContext`, `extractDecline`, `ROOM_DECLINE_MARKER`, +`DEFAULT_MEMBER_TIMEOUT_MS`, and the `RoomConfig` / `RoomAnswer` / +`AskRoomResult` / `RoomRound` types. + +`askRoom` is a function rather than a method on `MeetingOrchestrator` for three +reasons: the orchestrator marks a whole meeting `failed` when no runtime serves +a persona, which is the ordinary case for a room member that is offline; a room +carries no state between rounds, so there is nothing to persist; and +`maxConsecutiveFailures` is meaningful for a conversation and actively wrong for +a fan-out, where one member failing says nothing about the rest. + +A round has no row of its own. The question carries `meta.round = { id, +expected }`, each answer carries `meta.roundId` and its status, and +`summarizeRounds()` reconstructs the round from the log — the same rule meetings +follow, where the channel is the only transcript. + +**`TurnContext` gains an optional `mode`.** Absent means `meeting`, so a worker +built before rooms existed keeps its current behaviour. `buildSystemPrompt` +branches on it: a room member answers once, alone, and is told that declining is +a useful answer, rather than being given a turn budget and handoff instructions +for a room that does not exist. + +Members are deliberately isolated — each receives the question only, never the +other answers. If they saw each other, the first to finish would anchor the +rest, and the fan-out would collapse into a slower conversation. diff --git a/apps/web/__tests__/api/collab/meetings.test.ts b/apps/web/__tests__/api/collab/meetings.test.ts index 273ef0a32..55228e120 100644 --- a/apps/web/__tests__/api/collab/meetings.test.ts +++ b/apps/web/__tests__/api/collab/meetings.test.ts @@ -42,6 +42,10 @@ function mockBuildRow(config: { status: "scheduled" as const, slackChannelId: null as string | null, slackMirrorEnabled: false, + // Both columns are NOT NULL with defaults, so a real row always carries + // them — the fixture has to as well or it stops representing the row. + groundingEnabled: false, + documentIds: [] as string[], createdAt: new Date("2026-01-01T00:00:00.000Z"), startedAt: null as Date | null, endedAt: null as Date | null, diff --git a/apps/web/__tests__/api/collab/presets.test.ts b/apps/web/__tests__/api/collab/presets.test.ts new file mode 100644 index 000000000..ae594d932 --- /dev/null +++ b/apps/web/__tests__/api/collab/presets.test.ts @@ -0,0 +1,263 @@ +/** + * Preset agent teams. + * + * Two things matter here and neither is cosmetic. Applying a pack must be + * additive — a handle already in use is referenced by past transcripts and by + * the frozen roster on every meeting that used it, so overwriting one rewrites + * history. And the prompts themselves are the product: a preset agent with a + * vague prompt produces a meeting of six agreeable voices, which is the exact + * failure the preset exists to prevent. + */ + +import { partitionByHandle } from "~/server/collab/personas"; +import { PERSONA_PACKS, getPack, listPackSummaries } from "~/server/collab/presets"; + +interface StoredPersona { + dbId: string; + id: string; + archived: boolean; +} + +const mockCtx: { userId: string | null; personas: StoredPersona[] } = { + userId: "user_1", + personas: [], +}; + +jest.mock("~/lib/require-workspace-context", () => ({ + requireWorkspaceContext: () => + mockCtx.userId + ? Promise.resolve({ + success: true, + data: { + clerkUserId: mockCtx.userId, + userPk: 1n, + companyId: 7n, + role: "owner", + status: "verified", + }, + }) + : Promise.resolve({ + success: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }), + }), +})); + +// Keeps the Postgres client from loading — it pulls `~/env`, which is +// ESM-only and cannot be required under Jest. +jest.mock("~/server/db", () => ({ db: {} })); + +// `applyPersonas` is storage; `partitionByHandle` is the rule. The rule stays +// real here — the mock only supplies the rows it would have read and records +// the writes it would have made. +jest.mock("~/server/collab/personas", () => { + const actual: { + partitionByHandle: ( + existing: Iterable, + candidates: Array<{ key: string }>, + ) => { toCreate: Array<{ key: string }>; skipped: string[] }; + } = jest.requireActual("~/server/collab/personas"); + + return { + ...actual, + listPersonas: (_companyId: bigint, includeArchived = false) => + Promise.resolve(mockCtx.personas.filter((p) => (includeArchived ? true : !p.archived))), + applyPersonas: (_companyId: bigint, candidates: Array<{ key: string }>) => { + const { toCreate, skipped } = actual.partitionByHandle( + mockCtx.personas.map((p) => p.id), + candidates, + ); + for (const persona of toCreate) { + mockCtx.personas.push({ dbId: `p_${persona.key}`, id: persona.key, archived: false }); + } + return Promise.resolve({ created: toCreate.map((p) => p.key), skipped }); + }, + }; +}); + +async function loadRoute() { + return import("~/app/api/collab/agents/presets/route"); +} + +function postRequest(body: unknown): Request { + return new Request("http://localhost/api/collab/agents/presets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + mockCtx.userId = "user_1"; + mockCtx.personas = []; + jest.resetModules(); +}); + +describe("preset pack definitions", () => { + const packs = PERSONA_PACKS; + + it("ships the tech startup core team", () => { + const pack = getPack("startup-core"); + expect(pack).not.toBeNull(); + expect(pack!.personas.map((p) => p.key)).toEqual([ + "founder", + "product", + "eng", + "design", + "growth", + "data", + ]); + }); + + it("uses handles that are mention-safe and unique within a pack", () => { + for (const pack of packs) { + const keys = pack.personas.map((p) => p.key); + expect(new Set(keys).size).toBe(keys.length); + for (const key of keys) expect(key).toMatch(/^[a-z0-9][a-z0-9_-]*$/); + } + }); + + it("names a moderator that is actually in the pack", () => { + for (const pack of packs) { + if (pack.suggested.turnPolicy !== "moderated") continue; + expect(pack.suggested.moderatorKey).toBeDefined(); + expect(pack.personas.map((p) => p.key)).toContain(pack.suggested.moderatorKey!); + } + }); + + it("fits inside the meeting participant cap", () => { + // The create-meeting API accepts at most 10 participants; a pack you + // cannot put in one room is not a usable preset. + for (const pack of packs) expect(pack.personas.length).toBeLessThanOrEqual(10); + }); + + it("gives every agent a prompt with a stance, a shape, and a handoff", () => { + for (const pack of packs) { + for (const persona of pack.personas) { + const prompt = persona.systemPrompt; + // Substantial enough to actually steer a model. + expect(prompt.length).toBeGreaterThan(600); + // What this agent refuses to let pass — the source of real disagreement. + expect(prompt).toContain("## What you refuse to let pass"); + // How a good turn is shaped, so "be concrete" is not the instruction. + expect(prompt).toContain("## How you run a turn"); + // Who to hand to, so the room does not stall with nobody addressed. + expect(prompt).toMatch(/@[a-z0-9_-]+/); + // Explicit permission to not know, so gaps do not become inventions. + expect(prompt).toContain("do not support a claim"); + } + } + }); + + it("only hands off to handles that exist in the same pack", () => { + for (const pack of packs) { + const keys = new Set(pack.personas.map((p) => p.key)); + for (const persona of pack.personas) { + const mentioned = [...persona.systemPrompt.matchAll(/@([a-z0-9_-]+)/g)].map((m) => m[1]!); + for (const handle of mentioned) { + // A prompt telling an agent to hand to @finance in a pack with no + // finance seat produces a turn addressed to nobody. + expect(keys.has(handle)).toBe(true); + } + } + } + }); + + it("keeps full prompts out of the pack summaries", () => { + for (const summary of listPackSummaries()) { + for (const persona of summary.personas) { + expect(persona.promptPreview.length).toBeLessThan(300); + expect(persona).not.toHaveProperty("systemPrompt"); + } + } + }); +}); + +describe("partitionByHandle", () => { + const p = (key: string) => ({ key, displayName: key, role: key, systemPrompt: "x" }); + + it("keeps existing handles and creates the rest", () => { + const result = partitionByHandle(["eng"], [p("founder"), p("eng"), p("data")]); + expect(result.toCreate.map((c) => c.key)).toEqual(["founder", "data"]); + expect(result.skipped).toEqual(["eng"]); + }); + + it("creates a repeated handle once rather than tripping the unique index", () => { + const result = partitionByHandle([], [p("eng"), p("eng")]); + expect(result.toCreate).toHaveLength(1); + expect(result.skipped).toEqual(["eng"]); + }); +}); + +describe("POST /api/collab/agents/presets", () => { + it("creates the whole pack on a fresh workspace", async () => { + const { POST } = await loadRoute(); + const response = await POST(postRequest({ packId: "startup-core" })); + const body = (await response.json()) as { created: string[]; skipped: string[] }; + + expect(response.status).toBe(201); + expect(body.created).toHaveLength(6); + expect(body.skipped).toEqual([]); + }); + + it("is idempotent — applying twice creates nothing the second time", async () => { + const { POST } = await loadRoute(); + await POST(postRequest({ packId: "startup-core" })); + const response = await POST(postRequest({ packId: "startup-core" })); + const body = (await response.json()) as { created: string[]; skipped: string[] }; + + expect(response.status).toBe(200); + expect(body.created).toEqual([]); + expect(body.skipped).toHaveLength(6); + expect(mockCtx.personas).toHaveLength(6); + }); + + it("never overwrites a handle the workspace already uses", async () => { + mockCtx.personas.push({ dbId: "mine", id: "eng", archived: false }); + const { POST } = await loadRoute(); + const response = await POST(postRequest({ packId: "startup-core" })); + const body = (await response.json()) as { created: string[]; skipped: string[] }; + + expect(body.skipped).toEqual(["eng"]); + expect(body.created).not.toContain("eng"); + // The original row is untouched — same dbId, not replaced by the preset. + expect(mockCtx.personas.filter((p) => p.id === "eng")).toEqual([ + { dbId: "mine", id: "eng", archived: false }, + ]); + }); + + it("treats an archived handle as taken", async () => { + // The handle still appears in old transcripts, and the unique index does + // not care that the row is archived. + mockCtx.personas.push({ dbId: "old", id: "growth", archived: true }); + const { POST } = await loadRoute(); + const response = await POST(postRequest({ packId: "startup-core" })); + const body = (await response.json()) as { skipped: string[] }; + + expect(body.skipped).toEqual(["growth"]); + }); + + it("404s an unknown pack and 400s a malformed body", async () => { + const { POST } = await loadRoute(); + expect((await POST(postRequest({ packId: "nope" }))).status).toBe(404); + expect((await POST(postRequest({}))).status).toBe(400); + }); + + it("requires a workspace", async () => { + mockCtx.userId = null; + const { GET, POST } = await loadRoute(); + expect((await GET()).status).toBe(401); + expect((await POST(postRequest({ packId: "startup-core" }))).status).toBe(401); + }); +}); + +describe("GET /api/collab/agents/presets", () => { + it("flags handles that would conflict before the user clicks", async () => { + mockCtx.personas.push({ dbId: "mine", id: "product", archived: false }); + const { GET } = await loadRoute(); + const body = (await (await GET()).json()) as { + packs: Array<{ id: string; conflicts: string[] }>; + }; + + expect(body.packs.find((p) => p.id === "startup-core")!.conflicts).toEqual(["product"]); + }); +}); diff --git a/apps/web/__tests__/api/collab/rooms.test.ts b/apps/web/__tests__/api/collab/rooms.test.ts new file mode 100644 index 000000000..4342e3c73 --- /dev/null +++ b/apps/web/__tests__/api/collab/rooms.test.ts @@ -0,0 +1,330 @@ +/** + * The rooms API. + * + * What matters here: a room belongs to one workspace and cannot be reached from + * another; asking runs every member concurrently and settles each one + * independently; and retrieval happens as the *asker*, not the room's creator, + * so a room cannot be used to read documents you could not read yourself. + */ + +import { + askRoom, + InMemoryChannelStore, + ScriptedAgentRuntime, + fixedClock, + sequentialIdFactory, + type AgentPersona, +} from "@launchstack/core/collab"; + +interface StoredRoom { + id: string; + companyId: bigint; + channelId: string; + name: string; + purpose: string | null; + members: Array; + archived: boolean; + createdByUserId: string; + createdAt: Date; +} + +const mockCtx: { + userId: string | null; + companyId: bigint; + rooms: StoredRoom[]; + /** Records who retrieval ran as, per member. */ + retrievalActors: Array<{ memberId: string; actorUserId: string }>; +} = { + userId: "user_1", + companyId: 7n, + rooms: [], + retrievalActors: [], +}; + +const mockStore = new InMemoryChannelStore( + fixedClock(1_700_000_000_000, 1_000), + sequentialIdFactory(), +); +const mockNewId = sequentialIdFactory(); + +jest.mock("~/lib/require-workspace-context", () => ({ + requireWorkspaceContext: () => + mockCtx.userId + ? Promise.resolve({ + success: true, + data: { + clerkUserId: mockCtx.userId, + userPk: 1n, + companyId: mockCtx.companyId, + role: "owner", + status: "verified", + }, + }) + : Promise.resolve({ + success: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }), + }), +})); + +jest.mock("@clerk/nextjs/server", () => ({ + auth: () => Promise.resolve({ sessionClaims: { name: "Priya" } }), +})); + +jest.mock("~/server/collab/store", () => ({ + getChannelStore: () => mockStore, +})); + +// Keeps the Postgres client and the retriever stack from loading — both reach +// `~/env`, which is ESM-only and cannot be required under Jest. Retrieval +// itself is substituted at the runtime level below. +jest.mock("~/server/db", () => ({ db: {} })); +jest.mock("~/lib/tools/rag", () => ({ executeRAGSearch: () => Promise.resolve({ results: [] }) })); +jest.mock("~/server/collab/chat", () => ({ + createCollabChatFn: () => () => Promise.resolve("stubbed"), +})); + +jest.mock("~/server/collab/personas", () => ({ + ensureStarterPersonas: () => + Promise.resolve([ + { id: "web", displayName: "WEB", role: "web specialist", systemPrompt: "x" }, + { id: "mobile", displayName: "MOBILE", role: "mobile specialist", systemPrompt: "x" }, + ]), + listPersonas: () => Promise.resolve([]), +})); + +jest.mock("~/server/collab/rooms", () => { + const actual: { rowToConfig: (row: StoredRoom) => unknown } = + jest.requireActual("~/server/collab/rooms"); + return { + ...actual, + getRoom: (roomId: string, companyId: bigint) => + Promise.resolve( + mockCtx.rooms.find((r) => r.id === roomId && r.companyId === companyId) ?? null, + ), + listRoomsForCompany: (companyId: bigint) => + Promise.resolve(mockCtx.rooms.filter((r) => r.companyId === companyId)), + // Substitutes the model + retrieval, and records the actor each member's + // retrieval ran as — the escalation guard this route exists to hold. + buildRoomRuntimes: (input: { + members: Array<{ id: string }>; + actorUserId: string; + }) => { + // Required inside the factory: jest hoists `jest.mock` above the imports, + // so the module-scope binding does not exist yet when this is defined. + const { ScriptedAgentRuntime: Scripted }: { ScriptedAgentRuntime: typeof ScriptedAgentRuntime } = + jest.requireActual("@launchstack/core/collab"); + const script: Record = {}; + for (const member of input.members) { + mockCtx.retrievalActors.push({ memberId: member.id, actorUserId: input.actorUserId }); + script[member.id] = [`${member.id} answers from its own documents.`]; + } + return [new Scripted(script)]; + }, + }; +}); + +async function loadRoutes() { + return { + collection: await import("~/app/api/collab/rooms/route"), + detail: await import("~/app/api/collab/rooms/[roomId]/route"), + ask: await import("~/app/api/collab/rooms/[roomId]/ask/route"), + }; +} + +function member(id: string, documentIds: string[]): AgentPersona & { documentIds: string[] } { + return { + id, + displayName: id.toUpperCase(), + role: `${id} specialist`, + systemPrompt: "Answer from your own sources.", + documentIds, + }; +} + +/** Monotonic across the file: the store outlives `beforeEach`, the rooms don't. */ +let mockSeed = 0; + +async function seedRoom(overrides: Partial = {}): Promise { + const channelId = `chan_${++mockSeed}`; + await mockStore.createChannel({ + id: channelId, + slug: channelId, + name: "Room", + workspaceId: String(overrides.companyId ?? mockCtx.companyId), + }); + const room: StoredRoom = { + id: `room_${mockSeed}`, + companyId: mockCtx.companyId, + channelId, + name: "API token change", + purpose: "Find out what breaks", + members: [member("web", ["1", "2"]), member("mobile", ["3"])], + archived: false, + createdByUserId: "creator_9", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + ...overrides, + }; + mockCtx.rooms.push(room); + return room; +} + +function askRequest(body: unknown): Request { + return new Request("http://localhost/api/collab/rooms/x/ask", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + mockCtx.userId = "user_1"; + mockCtx.companyId = 7n; + mockCtx.rooms = []; + mockCtx.retrievalActors = []; + jest.resetModules(); +}); + +describe("POST /api/collab/rooms/[roomId]/ask", () => { + it("asks every member and settles each independently", async () => { + const room = await seedRoom(); + const { ask } = await loadRoutes(); + + const response = await ask.POST(askRequest({ text: "What breaks?" }), { + params: Promise.resolve({ roomId: room.id }), + }); + const body = (await response.json()) as { + roundId: string; + answers: Array<{ memberId: string; status: string }>; + }; + + expect(response.status).toBe(201); + expect(body.answers.map((a) => a.memberId)).toEqual(["web", "mobile"]); + expect(body.answers.every((a) => a.status === "answered")).toBe(true); + }); + + it("retrieves as the asker, not the room's creator", async () => { + // Otherwise a room launders access: ask a question, get an answer drawn + // from documents the asker could not open themselves. + const room = await seedRoom({ createdByUserId: "creator_9" }); + mockCtx.userId = "asker_2"; + const { ask } = await loadRoutes(); + + await ask.POST(askRequest({ text: "What breaks?" }), { + params: Promise.resolve({ roomId: room.id }), + }); + + expect(mockCtx.retrievalActors.length).toBeGreaterThan(0); + for (const call of mockCtx.retrievalActors) { + expect(call.actorUserId).toBe("asker_2"); + expect(call.actorUserId).not.toBe(room.createdByUserId); + } + }); + + it("asks only the named subset", async () => { + const room = await seedRoom(); + const { ask } = await loadRoutes(); + + const response = await ask.POST(askRequest({ text: "What breaks?", memberIds: ["mobile"] }), { + params: Promise.resolve({ roomId: room.id }), + }); + const body = (await response.json()) as { answers: Array<{ memberId: string }> }; + + expect(body.answers.map((a) => a.memberId)).toEqual(["mobile"]); + }); + + it("rejects a member that is not in the room", async () => { + const room = await seedRoom(); + const { ask } = await loadRoutes(); + + const response = await ask.POST(askRequest({ text: "hi", memberIds: ["infra"] }), { + params: Promise.resolve({ roomId: room.id }), + }); + + expect(response.status).toBe(400); + expect(((await response.json()) as { error: string }).error).toContain("infra"); + }); + + it("404s a room in another workspace and 400s an empty question", async () => { + const room = await seedRoom({ companyId: 999n }); + const { ask } = await loadRoutes(); + + const foreign = await ask.POST(askRequest({ text: "hi" }), { + params: Promise.resolve({ roomId: room.id }), + }); + expect(foreign.status).toBe(404); + + const mine = await seedRoom(); + const empty = await ask.POST(askRequest({ text: "" }), { + params: Promise.resolve({ roomId: mine.id }), + }); + expect(empty.status).toBe(400); + }); + + it("requires a workspace", async () => { + mockCtx.userId = null; + const { ask } = await loadRoutes(); + const response = await ask.POST(askRequest({ text: "hi" }), { + params: Promise.resolve({ roomId: "room_1" }), + }); + expect(response.status).toBe(401); + }); +}); + +describe("GET /api/collab/rooms/[roomId]", () => { + it("returns the log and the rounds derived from it", async () => { + const room = await seedRoom(); + const { detail } = await loadRoutes(); + + await askRoom({ + store: mockStore, + room: { + id: room.id, + channelId: room.channelId, + workspaceId: "7", + name: room.name, + members: room.members, + }, + runtimes: [new ScriptedAgentRuntime({ web: ["a"], mobile: ["b"] })], + question: { text: "Q?", author: { kind: "human", id: "user_1", displayName: "Priya" } }, + newId: mockNewId, + }); + + const response = await detail.GET(new Request("http://localhost/api/collab/rooms/x"), { + params: Promise.resolve({ roomId: room.id }), + }); + const body = (await response.json()) as { + rounds: Array<{ complete: boolean; settled: unknown[] }>; + messages: unknown[]; + }; + + expect(response.status).toBe(200); + expect(body.rounds).toHaveLength(1); + expect(body.rounds[0]!.complete).toBe(true); + expect(body.rounds[0]!.settled).toHaveLength(2); + expect(body.messages).toHaveLength(3); // question + two answers + }); + + it("404s a room in another workspace", async () => { + const room = await seedRoom({ companyId: 999n }); + const { detail } = await loadRoutes(); + const response = await detail.GET(new Request("http://localhost/api/collab/rooms/x"), { + params: Promise.resolve({ roomId: room.id }), + }); + expect(response.status).toBe(404); + }); +}); + +describe("GET /api/collab/rooms", () => { + it("lists only this workspace's rooms", async () => { + await seedRoom({ name: "Mine" }); + await seedRoom({ name: "Theirs", companyId: 999n }); + const { collection } = await loadRoutes(); + + const body = (await (await collection.GET()).json()) as { + rooms: Array<{ name: string; members: Array<{ documentCount: number }> }>; + }; + + expect(body.rooms.map((r) => r.name)).toEqual(["Mine"]); + expect(body.rooms[0]!.members.map((m) => m.documentCount)).toEqual([2, 1]); + }); +}); diff --git a/apps/web/__tests__/collab/meeting-grounding.test.ts b/apps/web/__tests__/collab/meeting-grounding.test.ts new file mode 100644 index 000000000..e4d32172e --- /dev/null +++ b/apps/web/__tests__/collab/meeting-grounding.test.ts @@ -0,0 +1,293 @@ +/** + * Turn-level grounding — the retrieval port, its failure behaviour, and the + * provenance it leaves on the transcript. + * + * The provider here is a stub, so these assertions are about the *engine*: + * when it retrieves, what it does with what comes back, and what happens when + * retrieval is broken. Whether the real retriever finds good passages is a + * different question, answered by the RAG tests. + */ + +import { + buildGroundingQuery, + createMeeting, + evaluateMeeting, + fixedClock, + InMemoryChannelStore, + MeetingOrchestrator, + ScriptedAgentRuntime, + sequentialIdFactory, + toExcerpt, + type AgentPersona, + type AgentTurnRequest, + type ChannelMessage, + type TurnGrounding, + type TurnGroundingProvider, + type TurnGroundingRequest, +} from "@launchstack/core/collab"; + +const ANALYST: AgentPersona = { + id: "data", + displayName: "Tomas", + role: "Data analyst", + systemPrompt: "Check the numbers.", +}; +const ENG: AgentPersona = { + id: "eng", + displayName: "Marcus", + role: "Engineering lead", + systemPrompt: "Price the work.", +}; + +/** Records what it was asked for, and answers with whatever it was handed. */ +class StubProvider implements TurnGroundingProvider { + readonly calls: TurnGroundingRequest[] = []; + + constructor(private readonly answer: (r: TurnGroundingRequest) => TurnGrounding) {} + + async retrieve(request: TurnGroundingRequest): Promise { + this.calls.push(request); + return this.answer(request); + } +} + +/** Captures the TurnContext each agent actually received. */ +class SpyRuntime extends ScriptedAgentRuntime { + readonly seen: Array<{ personaId: string; context: string[] }> = []; + + async takeTurn(request: AgentTurnRequest) { + this.seen.push({ personaId: request.persona.id, context: [...request.context.context] }); + return super.takeTurn(request); + } +} + +function build(options: { + provider?: TurnGroundingProvider; + context?: string[]; + script?: Record; + maxTurns?: number; +}) { + const clock = fixedClock(1_700_000_000_000, 1_000); + const store = new InMemoryChannelStore(clock, sequentialIdFactory()); + const runtime = new SpyRuntime( + options.script ?? { data: ["Churn is 4%."], eng: ["Two sprints."] }, + ); + + return { + store, + runtime, + meeting: createMeeting({ + store, + workspaceId: "ws_1", + title: "Renewal risk", + objective: "Decide whether to build the retention dashboard", + agenda: ["Churn baseline", "Build cost"], + participants: [ANALYST, ENG], + runtimes: [runtime], + maxTurns: options.maxTurns ?? 2, + context: options.context, + groundingProvider: options.provider, + clock, + newId: sequentialIdFactory(), + }), + }; +} + +function chatOf(transcript: ChannelMessage[]) { + return transcript.filter((m) => m.kind === "chat"); +} + +describe("turn grounding", () => { + it("retrieves once per turn, for the persona about to speak", async () => { + const provider = new StubProvider((r) => ({ + passages: [`passage for ${r.persona.id}`], + sources: [{ label: `doc · ${r.persona.id}` }], + })); + const h = build({ provider }); + const { orchestrator } = await h.meeting; + + await orchestrator.run(); + + expect(provider.calls.map((c) => c.persona.id)).toEqual(["data", "eng"]); + expect(provider.calls.map((c) => c.turnIndex)).toEqual([0, 1]); + // Each agent sees its own passages, not the union of everyone's. + expect(h.runtime.seen).toEqual([ + { personaId: "data", context: ["passage for data"] }, + { personaId: "eng", context: ["passage for eng"] }, + ]); + }); + + it("appends retrieved passages after the meeting's pinned context", async () => { + const provider = new StubProvider(() => ({ passages: ["retrieved"] })); + const h = build({ provider, context: ["pinned"], maxTurns: 1 }); + const { orchestrator } = await h.meeting; + + await orchestrator.step(); + + // Order matters: pinned material is what the human chose, and stays first. + expect(h.runtime.seen[0]!.context).toEqual(["pinned", "retrieved"]); + }); + + it("records provenance on the message the turn produced", async () => { + const provider = new StubProvider(() => ({ + passages: ["Churn was 4.1% in Q2."], + sources: [{ label: "Q2 metrics · p.3", documentId: "12", page: 3, excerpt: "Churn was 4.1%" }], + })); + const h = build({ provider, maxTurns: 1 }); + const { orchestrator, config } = await h.meeting; + + await orchestrator.step(); + + const [message] = chatOf(await h.store.read(config.channelId)); + expect(message!.meta?.grounding).toEqual([ + { label: "Q2 metrics · p.3", documentId: "12", page: 3, excerpt: "Churn was 4.1%" }, + ]); + }); + + it("distinguishes 'searched and found nothing' from 'never searched'", async () => { + const grounded = build({ + provider: new StubProvider(() => ({ passages: [], sources: [] })), + maxTurns: 1, + }); + await (await grounded.meeting).orchestrator.step(); + const [groundedMessage] = chatOf(await grounded.store.read((await grounded.meeting).config.channelId)); + expect(groundedMessage!.meta?.grounding).toEqual([]); + + const ungrounded = build({ maxTurns: 1 }); + await (await ungrounded.meeting).orchestrator.step(); + const [plainMessage] = chatOf( + await ungrounded.store.read((await ungrounded.meeting).config.channelId), + ); + expect(plainMessage!.meta).not.toHaveProperty("grounding"); + }); + + it("keeps the meeting running when retrieval throws, and reports it", async () => { + const failures: Array<{ message: string; personaId: string }> = []; + const clock = fixedClock(1_700_000_000_000, 1_000); + const store = new InMemoryChannelStore(clock, sequentialIdFactory()); + const channel = await store.createChannel({ + id: "chan_1", + slug: "renewal-risk", + name: "Renewal risk", + workspaceId: "ws_1", + }); + + const orchestrator = new MeetingOrchestrator({ + store, + config: { + id: "mtg_1", + channelId: channel.id, + workspaceId: "ws_1", + title: "Renewal risk", + objective: "Decide whether to build the retention dashboard", + agenda: [], + participants: [ANALYST, ENG], + turnPolicy: { kind: "round_robin" }, + maxTurns: 2, + }, + runtimes: [new ScriptedAgentRuntime({ data: ["Churn is 4%."], eng: ["Two sprints."] })], + groundingProvider: { + retrieve: () => Promise.reject(new Error("index unavailable")), + }, + onGroundingError: (error, personaId) => + failures.push({ message: error.message, personaId }), + clock, + }); + + const state = await orchestrator.run(); + + // A broken index degrades the answer. It must not end the meeting. + expect(state.status).toBe("completed"); + expect(chatOf(await store.read(channel.id))).toHaveLength(2); + // Both turns happened, and both were reported as ungrounded rather than + // silently passing off as grounded. + expect(failures).toEqual([ + { message: "index unavailable", personaId: "data" }, + { message: "index unavailable", personaId: "eng" }, + ]); + for (const message of chatOf(await store.read(channel.id))) { + expect(message.meta).not.toHaveProperty("grounding"); + } + }); + + it("scores grounding from retrieved excerpts, not just pinned context", async () => { + const provider = new StubProvider(() => ({ + passages: ["Churn was 4% in Q2 across 1200 accounts."], + sources: [{ label: "Q2 metrics", excerpt: "Churn was 4% in Q2 across 1200 accounts." }], + })); + const h = build({ + provider, + script: { data: ["Churn is 4%, so 1200 accounts are in scope."], eng: ["Noted."] }, + maxTurns: 1, + }); + const { orchestrator, config } = await h.meeting; + await orchestrator.step(); + + const transcript = await h.store.read(config.channelId); + const result = evaluateMeeting(config, orchestrator.getState(), transcript); + const grounding = result.dimensions.find((d) => d.id === "grounding")!; + + // Before retrieval existed this dimension abstained (weight 0) whenever + // config.context was empty — which is every retrieval-grounded meeting. + expect(grounding.weight).toBeGreaterThan(0); + expect(grounding.score).toBe(1); + }); +}); + +describe("buildGroundingQuery", () => { + const base: TurnGroundingRequest = { + meetingId: "mtg_1", + persona: ANALYST, + objective: "Decide whether to build the retention dashboard", + agenda: ["Churn baseline", "Build cost"], + transcript: [], + turnIndex: 0, + }; + + it("uses the agenda only before anything has been said", () => { + const query = buildGroundingQuery(base); + expect(query).toContain("Data analyst"); + expect(query).toContain("retention dashboard"); + expect(query).toContain("Churn baseline"); + }); + + it("prefers the last substantive message once the room is talking", () => { + const message = { + id: "m1", + channelId: "c1", + seq: 2, + ts: "2026-01-01T00:00:00.000Z", + author: { kind: "agent" as const, id: "eng", displayName: "Marcus" }, + text: "What does the indemnity cap actually commit us to?", + kind: "chat" as const, + }; + const query = buildGroundingQuery({ ...base, transcript: [message] }); + + expect(query).toContain("indemnity cap"); + // Querying the agenda too would retrieve the meeting's own framing back. + expect(query).not.toContain("Build cost"); + }); + + it("skips system messages when choosing the last thing said", () => { + const system = { + id: "m1", + channelId: "c1", + seq: 1, + ts: "2026-01-01T00:00:00.000Z", + author: { kind: "agent" as const, id: "system", displayName: "Launchstack" }, + text: "Meeting paused.", + kind: "system" as const, + }; + expect(buildGroundingQuery({ ...base, transcript: [system] })).not.toContain("Meeting paused"); + }); +}); + +describe("toExcerpt", () => { + it("collapses whitespace and truncates long passages", () => { + expect(toExcerpt("a\n\n b c")).toBe("a b c"); + const long = "x".repeat(500); + const excerpt = toExcerpt(long, 20); + expect(excerpt).toHaveLength(20); + expect(excerpt.endsWith("…")).toBe(true); + }); +}); diff --git a/apps/web/__tests__/collab/meeting-orchestration.test.ts b/apps/web/__tests__/collab/meeting-orchestration.test.ts index 9e80e9901..6682c5fd0 100644 --- a/apps/web/__tests__/collab/meeting-orchestration.test.ts +++ b/apps/web/__tests__/collab/meeting-orchestration.test.ts @@ -109,6 +109,76 @@ describe("meeting orchestration", () => { expect(chatOf(await h.store.read(config.channelId))).toHaveLength(4); }); + /** + * Regression, found by `pnpm meeting:live` rather than by this suite. Every + * other scripted line here carries a single mention, which makes + * first-mention and last-mention indistinguishable — a real meeting was the + * only thing that could surface it. + */ + it("hands the floor to the closing ask, not the opening reply-to", () => { + const speaker = selectNextSpeaker({ + participants: [PM, ENG, FIN], + transcript: [ + { + id: "m1", + channelId: "c1", + seq: 1, + ts: "2026-01-01T00:00:00.000Z", + author: { kind: "agent", id: "fin", displayName: "Dana" }, + text: "@pm agreed on the cut. @eng, what does the billing migration cost?", + kind: "chat", + }, + ], + turnIndex: 1, + policy: { kind: "reactive" }, + }); + + expect(speaker?.id).toBe("eng"); + }); + + it("skips a self-mention when picking the closing ask", () => { + const speaker = selectNextSpeaker({ + participants: [PM, ENG, FIN], + transcript: [ + { + id: "m1", + channelId: "c1", + seq: 1, + ts: "2026-01-01T00:00:00.000Z", + author: { kind: "agent", id: "pm", displayName: "Priya" }, + text: "@fin can you confirm? To be clear, @pm owns the rollout.", + kind: "chat", + }, + ], + turnIndex: 1, + policy: { kind: "reactive" }, + }); + + // @pm is last but is the speaker; the floor goes to the real ask. + expect(speaker?.id).toBe("fin"); + }); + + it("routes a moderated nomination to the closing ask too", () => { + const speaker = selectNextSpeaker({ + participants: [PM, ENG, FIN], + transcript: [ + { + id: "m1", + channelId: "c1", + seq: 1, + ts: "2026-01-01T00:00:00.000Z", + author: { kind: "agent", id: "pm", displayName: "Priya" }, + text: "Thanks @fin. @eng, you have the floor.", + kind: "chat", + }, + ], + turnIndex: 1, + policy: { kind: "moderated", moderatorId: "pm" }, + }); + + expect(speaker?.id).toBe("eng"); + }); + it("follows @mentions under the reactive policy", async () => { const h = harness({ turnPolicy: { kind: "reactive" }, diff --git a/apps/web/__tests__/collab/room-qa-participant.test.ts b/apps/web/__tests__/collab/room-qa-participant.test.ts new file mode 100644 index 000000000..121646d08 --- /dev/null +++ b/apps/web/__tests__/collab/room-qa-participant.test.ts @@ -0,0 +1,185 @@ +/** + * Workspace Q&A room members. + * + * The property under test is the one the whole feature rests on: a member + * answers from *its own* documents, retrieved under the *asker's* access, and + * says so plainly when its sources don't cover the question. A member that + * guesses fluently instead is worse than useless in a room, because the reader + * cannot tell which answers were grounded. + */ + +import type { AgentPersona, TurnContext } from "@launchstack/core/collab"; + +interface SearchCall { + query: string; + documentIds: string[]; + userId: string; +} + +const mockCalls: SearchCall[] = []; +const mockResults = new Map>>(); +const mockPrompts: string[] = []; + +jest.mock("~/lib/tools/rag", () => ({ + executeRAGSearch: ( + input: { query: string; documentIds: string[]; topK?: number }, + userId: string, + ) => { + mockCalls.push({ query: input.query, documentIds: input.documentIds, userId }); + const key = input.documentIds.join(","); + return Promise.resolve({ results: mockResults.get(key) ?? [] }); + }, +})); + +jest.mock("~/server/collab/chat", () => ({ + createCollabChatFn: () => (request: { messages: Array<{ role: string; content: string }> }) => { + mockPrompts.push(request.messages.map((m) => m.content).join("\n---\n")); + return Promise.resolve("Clause 11.2 caps liability at 12 months of fees."); + }, +})); + +function persona(id: string): AgentPersona { + return { id, displayName: id, role: `${id} specialist`, systemPrompt: "Answer from sources." }; +} + +function turnContext(question: string): TurnContext { + return { + meetingId: "room_1", + title: "Contract review", + objective: question, + agenda: [], + context: [], + roster: [], + turnIndex: 0, + maxTurns: 1, + completionMarker: "NO_ANSWER", + mode: "room", + }; +} + +function questionTurn(question: string) { + return { + persona: persona("legal"), + context: turnContext(question), + transcript: [ + { + id: "m1", + channelId: "c", + seq: 1, + ts: "2026-01-01T00:00:00.000Z", + author: { kind: "human" as const, id: "u", displayName: "Priya" }, + text: question, + kind: "chat" as const, + }, + ], + }; +} + +async function load() { + return import("~/server/collab/qa-participant"); +} + +beforeEach(() => { + mockCalls.length = 0; + mockPrompts.length = 0; + mockResults.clear(); + jest.resetModules(); +}); + +describe("WorkspaceQaRuntime", () => { + it("serves only personas it has a binding for", async () => { + const { WorkspaceQaRuntime } = await load(); + const runtime = new WorkspaceQaRuntime({ + binding: (p) => (p.id === "legal" ? { documentIds: ["1"] } : null), + actorUserId: "asker", + }); + + expect(runtime.serves(persona("legal"))).toBe(true); + expect(runtime.serves(persona("finance"))).toBe(false); + }); + + it("retrieves from the member's own documents, as the asker", async () => { + mockResults.set("7,9", [ + { content: "Clause 11.2 caps liability at 12 months of fees.", page: 4, documentTitle: "MSA", relevanceScore: 0.8 }, + ]); + const { WorkspaceQaRuntime } = await load(); + const runtime = new WorkspaceQaRuntime({ + binding: () => ({ documentIds: ["7", "9"] }), + actorUserId: "asker_2", + }); + + const result = await runtime.takeTurn(questionTurn("What is the liability cap?")); + + expect(mockCalls).toHaveLength(1); + expect(mockCalls[0]).toMatchObject({ + query: "What is the liability cap?", + documentIds: ["7", "9"], + // Never the room's creator — a room must not read what its asker cannot. + userId: "asker_2", + }); + expect(result.text).toContain("Clause 11.2"); + expect(result.meta).toMatchObject({ adapter: "workspace-qa", passagesUsed: 1 }); + }); + + it("records citable provenance for every passage it used", async () => { + mockResults.set("7", [ + { content: "Churn was 4.1% in Q2.", page: 2, documentTitle: "Q2 review", relevanceScore: 0.9 }, + ]); + const { WorkspaceQaRuntime } = await load(); + const runtime = new WorkspaceQaRuntime({ + binding: () => ({ documentIds: ["7"] }), + actorUserId: "asker", + }); + + const result = await runtime.takeTurn(questionTurn("What was churn?")); + + expect(result.meta!.grounding).toEqual([ + expect.objectContaining({ label: "Q2 review · p.2", page: 2, excerpt: "Churn was 4.1% in Q2." }), + ]); + }); + + it("declines rather than guessing when its sources return nothing", async () => { + const { WorkspaceQaRuntime } = await load(); + const runtime = new WorkspaceQaRuntime({ + binding: () => ({ documentIds: ["7"] }), + actorUserId: "asker", + }); + + const result = await runtime.takeTurn(questionTurn("Anything about pricing?")); + + // A decline is the honest answer, and the model is never consulted — a + // member with no matching passage has nothing to be fluent about. + expect(result.meta).toMatchObject({ declined: true, reason: "no_matches" }); + expect(mockPrompts).toHaveLength(0); + }); + + it("declines when the member has no documents assigned", async () => { + const { WorkspaceQaRuntime } = await load(); + const runtime = new WorkspaceQaRuntime({ + binding: () => ({ documentIds: [] }), + actorUserId: "asker", + }); + + const result = await runtime.takeTurn(questionTurn("Anything?")); + + expect(result.meta).toMatchObject({ declined: true, reason: "no_documents" }); + expect(mockCalls).toHaveLength(0); + }); + + it("tells the member its sources are its own and others differ", async () => { + mockResults.set("7", [{ content: "Some passage.", page: 1, documentTitle: "Doc" }]); + const { WorkspaceQaRuntime } = await load(); + const runtime = new WorkspaceQaRuntime({ + binding: () => ({ documentIds: ["7"] }), + actorUserId: "asker", + }); + + await runtime.takeTurn(questionTurn("Q?")); + + const prompt = mockPrompts[0]!; + expect(prompt).toContain("only material you can see"); + expect(prompt).toContain("different sources"); + // The instruction that keeps a room honest. + expect(prompt).toContain("Being the member with nothing to add is a useful answer"); + }); +}); diff --git a/apps/web/__tests__/collab/room-scatter-gather.test.ts b/apps/web/__tests__/collab/room-scatter-gather.test.ts new file mode 100644 index 000000000..13c4b179e --- /dev/null +++ b/apps/web/__tests__/collab/room-scatter-gather.test.ts @@ -0,0 +1,328 @@ +/** + * Rooms — one question, every member, each from its own context. + * + * The assertions here are about the *primitive*: what lands in the log, in what + * order, and what happens when a member fails, declines, hangs, or has no + * runtime at all. Answer quality is not in scope; the whole point of a room is + * that each member answers from material the others cannot see. + */ + +import { + askRoom, + extractDecline, + fixedClock, + InMemoryChannelStore, + ScriptedAgentRuntime, + sequentialIdFactory, + summarizeRounds, + type AgentPersona, + type AgentRuntime, + type AgentTurnRequest, + type AgentTurnResult, + type ChannelMessage, + type RoomConfig, +} from "@launchstack/core/collab"; + +const WEB: AgentPersona = { + id: "web", + displayName: "Web", + role: "Frontend repo", + systemPrompt: "Answer from the web app.", +}; +const MOBILE: AgentPersona = { + id: "mobile", + displayName: "Mobile", + role: "iOS repo", + systemPrompt: "Answer from the iOS app.", +}; +const INFRA: AgentPersona = { + id: "infra", + displayName: "Infra", + role: "Terraform", + systemPrompt: "Answer from infrastructure.", +}; + +const ASKER = { kind: "human" as const, id: "user_1", displayName: "Priya" }; + +async function harness(members: AgentPersona[] = [WEB, MOBILE, INFRA]) { + const clock = fixedClock(1_700_000_000_000, 1_000); + const store = new InMemoryChannelStore(clock, sequentialIdFactory()); + // One factory for the whole harness: a fresh one per round would hand every + // round the same id, which is only ever true of a sequential test factory. + const newId = sequentialIdFactory(); + const channel = await store.createChannel({ + id: "chan_room", + slug: "api-token-change", + name: "API token change", + workspaceId: "ws_1", + }); + const room: RoomConfig = { + id: "room_1", + channelId: channel.id, + workspaceId: "ws_1", + name: "API token change", + purpose: "Find out what breaks", + members, + }; + return { clock, store, room, newId }; +} + +function ask( + h: Awaited>, + runtimes: AgentRuntime[], + options: { text?: string; memberIds?: string[]; timeoutMs?: number } = {}, +) { + return askRoom({ + store: h.store, + room: h.room, + runtimes, + question: { text: options.text ?? "Changing the auth token to a JWT. What breaks?", author: ASKER }, + memberIds: options.memberIds, + timeoutMs: options.timeoutMs, + clock: h.clock, + newId: h.newId, + }); +} + +/** Records the context each member was handed. */ +class SpyRuntime extends ScriptedAgentRuntime { + readonly seen: Array<{ memberId: string; transcriptLength: number; mode?: string }> = []; + + async takeTurn(request: AgentTurnRequest): Promise { + this.seen.push({ + memberId: request.persona.id, + transcriptLength: request.transcript.length, + mode: request.context.mode, + }); + return super.takeTurn(request); + } +} + +/** A runtime that serves everyone and does whatever the test says. */ +function fakeRuntime( + behaviour: (persona: AgentPersona) => Promise, +): AgentRuntime { + return { nodeId: "local", serves: () => true, takeTurn: (r) => behaviour(r.persona) }; +} + +describe("askRoom", () => { + it("appends the question before every answer and settles all members", async () => { + const h = await harness(); + const runtime = new SpyRuntime({ + web: ["3 call sites assume an opaque token."], + mobile: ["We never decode it, but we pin length at 128."], + infra: ["The ALB caps headers at 4KB."], + }); + + const result = await ask(h, [runtime]); + + expect(result.answers.map((a) => a.memberId)).toEqual(["web", "mobile", "infra"]); + expect(result.answers.every((a) => a.status === "answered")).toBe(true); + + const transcript = await h.store.read(h.room.channelId); + expect(transcript[0]!.id).toBe(result.question.id); + for (const answer of result.answers) { + expect(answer.message.seq).toBeGreaterThan(result.question.seq); + } + }); + + it("gives each member the question only — never the other answers", async () => { + const h = await harness(); + const runtime = new SpyRuntime({ web: ["a"], mobile: ["b"], infra: ["c"] }); + + await ask(h, [runtime]); + + // If a member saw the others' answers, the first to finish would anchor the + // rest and this would stop being a fan-out. + expect(runtime.seen).toHaveLength(3); + for (const seen of runtime.seen) { + expect(seen.transcriptLength).toBe(1); + expect(seen.mode).toBe("room"); + } + }); + + it("asks only the named subset", async () => { + const h = await harness(); + const runtime = new ScriptedAgentRuntime({ web: ["a"], mobile: ["b"], infra: ["c"] }); + + const result = await ask(h, [runtime], { memberIds: ["web", "infra"] }); + + expect(result.answers.map((a) => a.memberId)).toEqual(["web", "infra"]); + expect((result.question.meta!.round as { expected: string[] }).expected).toEqual([ + "web", + "infra", + ]); + }); + + it("keeps the round alive when one member throws", async () => { + const h = await harness([WEB, MOBILE]); + const runtime = fakeRuntime(async (persona) => { + if (persona.id === "web") throw new Error("repo is locked"); + return { text: "Mobile is fine." }; + }); + + const result = await ask(h, [runtime]); + + const web = result.answers.find((a) => a.memberId === "web")!; + const mobile = result.answers.find((a) => a.memberId === "mobile")!; + expect(web.status).toBe("failed"); + expect(web.error).toBe("repo is locked"); + // A failure is not content — the kind split is what stops the minutes + // extractor and the Slack bridge reading an error as an answer. + expect(web.message.kind).toBe("system"); + expect(mobile.status).toBe("answered"); + expect(mobile.message.kind).toBe("chat"); + }); + + it("settles a member with no runtime rather than failing the round", async () => { + const h = await harness([WEB, MOBILE]); + // Serves only `web` — `mobile`'s machine is offline. + const runtime = new ScriptedAgentRuntime({ web: ["Only I am reachable."] }); + + const result = await ask(h, [runtime]); + + expect(result.answers.find((a) => a.memberId === "mobile")!.status).toBe("unserved"); + expect(result.answers.find((a) => a.memberId === "web")!.status).toBe("answered"); + }); + + it("times a member out without stalling the others", async () => { + const h = await harness([WEB, MOBILE]); + const runtime = fakeRuntime(async (persona) => { + if (persona.id === "web") return new Promise(() => undefined); + return { text: "Answered immediately." }; + }); + + const result = await ask(h, [runtime], { timeoutMs: 30 }); + + const web = result.answers.find((a) => a.memberId === "web")!; + expect(web.status).toBe("timeout"); + expect(web.message.kind).toBe("system"); + expect(result.answers.find((a) => a.memberId === "mobile")!.status).toBe("answered"); + }); + + it("treats a decline as an answer, not a failure", async () => { + const h = await harness([WEB, MOBILE]); + const runtime = fakeRuntime(async (persona) => + persona.id === "web" + ? { text: `Nothing here touches auth.\n${"NO_ANSWER"}` } + : { text: "We pin token length at 128." }, + ); + + const result = await ask(h, [runtime]); + + const web = result.answers.find((a) => a.memberId === "web")!; + expect(web.status).toBe("declined"); + expect(web.message.kind).toBe("chat"); + // The marker is control flow and must never reach the transcript. + expect(web.message.text).not.toContain("NO_ANSWER"); + expect(web.message.text).toContain("Nothing here touches auth."); + }); + + it("treats meta.declined from a remote worker as a decline", async () => { + const h = await harness([WEB]); + const runtime = fakeRuntime(async () => ({ + text: "I looked but found nothing.", + meta: { declined: true, servedByNode: "laptop" }, + })); + + const result = await ask(h, [runtime]); + + expect(result.answers[0]!.status).toBe("declined"); + expect(result.answers[0]!.message.meta).toMatchObject({ servedByNode: "laptop" }); + }); + + it("records provenance so an answer is attributable to its member", async () => { + const h = await harness([WEB]); + const runtime = fakeRuntime(async () => ({ text: "x", meta: { adapter: "workspace-qa" } })); + + const result = await ask(h, [runtime]); + + expect(result.answers[0]!.message.meta).toMatchObject({ + roomId: "room_1", + roundId: result.roundId, + memberId: "web", + status: "answered", + adapter: "workspace-qa", + }); + expect(result.answers[0]!.message.author.id).toBe("web"); + }); + + it("rejects a round with no members", async () => { + const h = await harness(); + await expect(ask(h, [], { memberIds: ["nobody"] })).rejects.toThrow(/at least one member/); + }); +}); + +describe("summarizeRounds", () => { + it("derives rounds from the log with no round table", async () => { + const h = await harness([WEB, MOBILE]); + const runtime = new ScriptedAgentRuntime({ web: ["a"], mobile: ["b"] }); + + const first = await ask(h, [runtime], { text: "First question?" }); + const second = await ask(h, [runtime], { text: "Second question?" }); + + const rounds = summarizeRounds(await h.store.read(h.room.channelId)); + + expect(rounds).toHaveLength(2); + expect(rounds[0]!.id).toBe(first.roundId); + expect(rounds[0]!.text).toBe("First question?"); + expect(rounds[0]!.complete).toBe(true); + expect(rounds[0]!.pending).toEqual([]); + expect(rounds[0]!.settled.map((s) => s.memberId).sort()).toEqual(["mobile", "web"]); + expect(rounds[1]!.id).toBe(second.roundId); + // Ordered by question position, not by arrival. + expect(rounds[0]!.questionSeq).toBeLessThan(rounds[1]!.questionSeq); + }); + + it("reports a round still in flight as incomplete", () => { + const question: ChannelMessage = { + id: "m1", + channelId: "c", + seq: 1, + ts: "2026-01-01T00:00:00.000Z", + author: ASKER, + text: "Q?", + kind: "chat", + meta: { round: { id: "round_1", expected: ["web", "mobile"] } }, + }; + const answer: ChannelMessage = { + ...question, + id: "m2", + seq: 2, + author: { kind: "agent", id: "web", displayName: "Web" }, + text: "A", + meta: { roundId: "round_1", memberId: "web", status: "answered" }, + }; + + const [round] = summarizeRounds([question, answer]); + + expect(round!.complete).toBe(false); + expect(round!.pending).toEqual(["mobile"]); + }); + + it("ignores messages that belong to no round", () => { + const stray: ChannelMessage = { + id: "m1", + channelId: "c", + seq: 1, + ts: "2026-01-01T00:00:00.000Z", + author: ASKER, + text: "just chatting", + kind: "chat", + }; + expect(summarizeRounds([stray])).toEqual([]); + }); +}); + +describe("extractDecline", () => { + it("strips the marker and reports it", () => { + expect(extractDecline("nothing to add\nNO_ANSWER")).toEqual({ + text: "nothing to add", + declined: true, + }); + expect(extractDecline("here is the answer")).toEqual({ + text: "here is the answer", + declined: false, + }); + }); +}); diff --git a/apps/web/drizzle/20260815210549_meeting_grounding.sql b/apps/web/drizzle/20260815210549_meeting_grounding.sql new file mode 100644 index 000000000..a43f76cc7 --- /dev/null +++ b/apps/web/drizzle/20260815210549_meeting_grounding.sql @@ -0,0 +1,2 @@ +ALTER TABLE "pdr_ai_v2_collab_meeting" ADD COLUMN "grounding_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "pdr_ai_v2_collab_meeting" ADD COLUMN "document_ids" jsonb DEFAULT '[]'::jsonb NOT NULL; \ No newline at end of file diff --git a/apps/web/drizzle/20260817012254_collab_rooms.sql b/apps/web/drizzle/20260817012254_collab_rooms.sql new file mode 100644 index 000000000..2cc38a9ad --- /dev/null +++ b/apps/web/drizzle/20260817012254_collab_rooms.sql @@ -0,0 +1,17 @@ +CREATE TABLE "pdr_ai_v2_collab_room" ( + "id" varchar(64) PRIMARY KEY NOT NULL, + "company_id" bigint NOT NULL, + "channel_id" varchar(64) NOT NULL, + "name" varchar(256) NOT NULL, + "purpose" text, + "members" jsonb NOT NULL, + "archived" boolean DEFAULT false NOT NULL, + "created_by_user_id" varchar(256), + "created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "pdr_ai_v2_collab_room" ADD CONSTRAINT "pdr_ai_v2_collab_room_company_id_pdr_ai_v2_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."pdr_ai_v2_company"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pdr_ai_v2_collab_room" ADD CONSTRAINT "pdr_ai_v2_collab_room_channel_id_pdr_ai_v2_collab_channel_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."pdr_ai_v2_collab_channel"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "collab_room_company_idx" ON "pdr_ai_v2_collab_room" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX "collab_room_channel_idx" ON "pdr_ai_v2_collab_room" USING btree ("channel_id"); \ No newline at end of file diff --git a/apps/web/drizzle/meta/20260815210549_snapshot.json b/apps/web/drizzle/meta/20260815210549_snapshot.json new file mode 100644 index 000000000..09499b5d9 --- /dev/null +++ b/apps/web/drizzle/meta/20260815210549_snapshot.json @@ -0,0 +1,6082 @@ +{ + "id": "c6aa48c4-8add-4990-a467-4dc45f109775", + "prevId": "e37a7839-78f6-4293-912a-dac5be3fca47", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pdr_ai_v2_agent_ai_chatbot_chat": { + "name": "pdr_ai_v2_agent_ai_chatbot_chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "agent_mode": { + "name": "agent_mode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'interactive'" + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "ai_style": { + "name": "ai_style", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'concise'" + }, + "ai_persona": { + "name": "ai_persona", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'general'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_document": { + "name": "pdr_ai_v2_agent_ai_chatbot_document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_document_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_document_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_document", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_agent_ai_chatbot_document_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_document_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_document", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pdr_ai_v2_agent_ai_chatbot_document_id_created_at_pk": { + "name": "pdr_ai_v2_agent_ai_chatbot_document_id_created_at_pk", + "columns": [ + "id", + "created_at" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_execution_step": { + "name": "pdr_ai_v2_agent_ai_chatbot_execution_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_type": { + "name": "step_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_execution_step_task_step_idx": { + "name": "agent_execution_step_task_step_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_execution_step_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_execution_step_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_execution_step", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_memory": { + "name": "pdr_ai_v2_agent_ai_chatbot_memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "memory_type": { + "name": "memory_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "importance": { + "name": "importance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "accessed_at": { + "name": "accessed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_memory_chat_idx": { + "name": "agent_memory_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_memory_chat_type_idx": { + "name": "agent_memory_chat_type_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "memory_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_memory_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_memory_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_memory", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_message": { + "name": "pdr_ai_v2_agent_ai_chatbot_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "message_type": { + "name": "message_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_message_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_message_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_message", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_suggestion": { + "name": "pdr_ai_v2_agent_ai_chatbot_suggestion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "document_created_at": { + "name": "document_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "original_text": { + "name": "original_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_text": { + "name": "suggested_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_resolved": { + "name": "is_resolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_suggestion_document_id_document_created_at_pdr_ai_v2_agent_ai_chatbot_document_id_created_at_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_suggestion_document_id_document_created_at_pdr_ai_v2_agent_ai_chatbot_document_id_created_at_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_suggestion", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_document", + "columnsFrom": [ + "document_id", + "document_created_at" + ], + "columnsTo": [ + "id", + "created_at" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_task": { + "name": "pdr_ai_v2_agent_ai_chatbot_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_task_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_task_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_task", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_tool_call": { + "name": "pdr_ai_v2_agent_ai_chatbot_tool_call", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "tool_input": { + "name": "tool_input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tool_output": { + "name": "tool_output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_time_ms": { + "name": "execution_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_tool_call_message_id_pdr_ai_v2_agent_ai_chatbot_message_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_tool_call_message_id_pdr_ai_v2_agent_ai_chatbot_message_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_tool_call", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_agent_ai_chatbot_tool_call_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_tool_call_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_tool_call", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_tool_registry": { + "name": "pdr_ai_v2_agent_ai_chatbot_tool_registry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "required_permissions": { + "name": "required_permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rate_limit": { + "name": "rate_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pdr_ai_v2_agent_ai_chatbot_tool_registry_name_unique": { + "name": "pdr_ai_v2_agent_ai_chatbot_tool_registry_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_vote": { + "name": "pdr_ai_v2_agent_ai_chatbot_vote", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "is_upvoted": { + "name": "is_upvoted", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_vote_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_vote_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_vote", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_agent_ai_chatbot_vote_message_id_pdr_ai_v2_agent_ai_chatbot_message_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_vote_message_id_pdr_ai_v2_agent_ai_chatbot_message_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_vote", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pdr_ai_v2_agent_ai_chatbot_vote_chat_id_message_id_pk": { + "name": "pdr_ai_v2_agent_ai_chatbot_vote_chat_id_message_id_pk", + "columns": [ + "chat_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_collab_agent_persona": { + "name": "pdr_ai_v2_collab_agent_persona", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "route": { + "name": "route", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "temperature_x100": { + "name": "temperature_x100", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_turn_chars": { + "name": "max_turn_chars", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "accent": { + "name": "accent", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collab_persona_company_key_idx": { + "name": "collab_persona_company_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_collab_agent_persona_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_collab_agent_persona_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_collab_agent_persona", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_collab_channel": { + "name": "pdr_ai_v2_collab_channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(96)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collab_channel_company_slug_idx": { + "name": "collab_channel_company_slug_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collab_channel_company_idx": { + "name": "collab_channel_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_collab_channel_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_collab_channel_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_collab_channel", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_collab_meeting": { + "name": "pdr_ai_v2_collab_meeting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agenda": { + "name": "agenda", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "participants": { + "name": "participants", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "turn_policy": { + "name": "turn_policy", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'round_robin'" + }, + "moderator_persona_id": { + "name": "moderator_persona_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "max_turns": { + "name": "max_turns", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 12 + }, + "completion_marker": { + "name": "completion_marker", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "grounding_enabled": { + "name": "grounding_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "document_ids": { + "name": "document_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "turn_index": { + "name": "turn_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_speaker_id": { + "name": "next_speaker_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "controller": { + "name": "controller", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "slack_mirror_enabled": { + "name": "slack_mirror_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "slack_use_agent_identity": { + "name": "slack_use_agent_identity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collab_meeting_company_idx": { + "name": "collab_meeting_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collab_meeting_channel_idx": { + "name": "collab_meeting_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_collab_meeting_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_collab_meeting_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_collab_meeting", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_collab_meeting_channel_id_pdr_ai_v2_collab_channel_id_fk": { + "name": "pdr_ai_v2_collab_meeting_channel_id_pdr_ai_v2_collab_channel_id_fk", + "tableFrom": "pdr_ai_v2_collab_meeting", + "tableTo": "pdr_ai_v2_collab_channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_collab_message": { + "name": "pdr_ai_v2_collab_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_kind": { + "name": "author_kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "on_behalf_of_persona_id": { + "name": "on_behalf_of_persona_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'chat'" + }, + "thread_id": { + "name": "thread_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "slack_ts": { + "name": "slack_ts", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "collab_message_channel_seq_idx": { + "name": "collab_message_channel_seq_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collab_message_channel_created_idx": { + "name": "collab_message_channel_created_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_collab_message_channel_id_pdr_ai_v2_collab_channel_id_fk": { + "name": "pdr_ai_v2_collab_message_channel_id_pdr_ai_v2_collab_channel_id_fk", + "tableFrom": "pdr_ai_v2_collab_message", + "tableTo": "pdr_ai_v2_collab_channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_collab_node": { + "name": "pdr_ai_v2_collab_node", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "persona_ids": { + "name": "persona_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_remote_address": { + "name": "last_remote_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "collab_node_company_idx": { + "name": "collab_node_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_collab_node_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_collab_node_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_collab_node", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_token_accounts": { + "name": "pdr_ai_v2_token_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_tokens": { + "name": "balance_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifetime_tokens_purchased": { + "name": "lifetime_tokens_purchased", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifetime_tokens_granted": { + "name": "lifetime_tokens_granted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifetime_tokens_used": { + "name": "lifetime_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "token_accounts_company_id_idx": { + "name": "token_accounts_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_token_accounts_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_token_accounts_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_token_accounts", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_token_grants": { + "name": "pdr_ai_v2_token_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "grant_type": { + "name": "grant_type", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "token_grants_company_id_idx": { + "name": "token_grants_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_token_grants_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_token_grants_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_token_grants", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_token_transactions": { + "name": "pdr_ai_v2_token_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "service": { + "name": "service", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "token_tx_company_created_idx": { + "name": "token_tx_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "token_tx_company_service_idx": { + "name": "token_tx_company_service_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_token_transactions_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_token_transactions_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_token_transactions", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_token_usage_daily": { + "name": "pdr_ai_v2_token_usage_daily", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "operation_count": { + "name": "operation_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "token_usage_daily_company_date_service_idx": { + "name": "token_usage_daily_company_date_service_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_token_usage_daily_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_token_usage_daily_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_token_usage_daily", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_chat_history": { + "name": "pdr_ai_v2_chat_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "document_title": { + "name": "document_title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "query_type": { + "name": "query_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'simple'" + }, + "pages": { + "name": "pages", + "type": "integer[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_history_user_id_idx": { + "name": "chat_history_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_history_user_id_created_at_idx": { + "name": "chat_history_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_history_document_id_idx": { + "name": "chat_history_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_chat_history_document_id_pdr_ai_v2_document_id_fk": { + "name": "pdr_ai_v2_chat_history_document_id_pdr_ai_v2_document_id_fk", + "tableFrom": "pdr_ai_v2_chat_history", + "tableTo": "pdr_ai_v2_document", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_document_reference_resolutions": { + "name": "pdr_ai_v2_document_reference_resolutions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reference_name": { + "name": "reference_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "resolved_in_document_id": { + "name": "resolved_in_document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "resolution_details": { + "name": "resolution_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_reference_resolutions_company_ref_idx": { + "name": "document_reference_resolutions_company_ref_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_document_reference_resolutions_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_document_reference_resolutions_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_document_reference_resolutions", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_document_views": { + "name": "pdr_ai_v2_document_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "viewed_at": { + "name": "viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "document_views_document_id_idx": { + "name": "document_views_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_views_company_id_idx": { + "name": "document_views_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_views_user_id_idx": { + "name": "document_views_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_views_company_id_viewed_at_idx": { + "name": "document_views_company_id_viewed_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "viewed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_document_views_document_id_pdr_ai_v2_document_id_fk": { + "name": "pdr_ai_v2_document_views_document_id_pdr_ai_v2_document_id_fk", + "tableFrom": "pdr_ai_v2_document_views", + "tableTo": "pdr_ai_v2_document", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_document_views_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_document_views_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_document_views", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_generated_documents": { + "name": "pdr_ai_v2_generated_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "generated_documents_user_id_idx": { + "name": "generated_documents_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generated_documents_company_id_idx": { + "name": "generated_documents_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generated_documents_company_user_idx": { + "name": "generated_documents_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_generated_documents_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_generated_documents_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_generated_documents", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_predictive_document_analysis_results": { + "name": "pdr_ai_v2_predictive_document_analysis_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "analysis_type": { + "name": "analysis_type", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "include_related_docs": { + "name": "include_related_docs", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "predictive_analysis_document_id_idx": { + "name": "predictive_analysis_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "predictive_analysis_document_version_idx": { + "name": "predictive_analysis_document_version_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_predictive_document_analysis_results_document_id_pdr_ai_v2_document_id_fk": { + "name": "pdr_ai_v2_predictive_document_analysis_results_document_id_pdr_ai_v2_document_id_fk", + "tableFrom": "pdr_ai_v2_predictive_document_analysis_results", + "tableTo": "pdr_ai_v2_document", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_predictive_document_analysis_results_version_id_pdr_ai_v2_document_versions_id_fk": { + "name": "pdr_ai_v2_predictive_document_analysis_results_version_id_pdr_ai_v2_document_versions_id_fk", + "tableFrom": "pdr_ai_v2_predictive_document_analysis_results", + "tableTo": "pdr_ai_v2_document_versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_document_note_embeddings": { + "name": "pdr_ai_v2_document_note_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "note_id": { + "name": "note_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_short": { + "name": "embedding_short", + "type": "vector(512)", + "primaryKey": false, + "notNull": false + }, + "model_version": { + "name": "model_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "doc_note_emb_note_id_idx": { + "name": "doc_note_emb_note_id_idx", + "columns": [ + { + "expression": "note_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_note_emb_user_id_idx": { + "name": "doc_note_emb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_note_emb_document_id_idx": { + "name": "doc_note_emb_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_note_emb_company_id_idx": { + "name": "doc_note_emb_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_note_emb_embedding_short_idx": { + "name": "doc_note_emb_embedding_short_idx", + "columns": [ + { + "expression": "embedding_short", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_document_notes": { + "name": "pdr_ai_v2_document_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_rich": { + "name": "content_rich", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "content_markdown": { + "name": "content_markdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "anchor_status": { + "name": "anchor_status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": false, + "default": "'resolved'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "document_notes_user_idx": { + "name": "document_notes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_notes_document_idx": { + "name": "document_notes_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_notes_company_idx": { + "name": "document_notes_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_notes_version_idx": { + "name": "document_notes_version_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_notes_anchor_status_idx": { + "name": "document_notes_anchor_status_idx", + "columns": [ + { + "expression": "anchor_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_note_links": { + "name": "pdr_ai_v2_note_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "source_note_id": { + "name": "source_note_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "target_note_id": { + "name": "target_note_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "target_document_id": { + "name": "target_document_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "target_title": { + "name": "target_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "note_links_source_idx": { + "name": "note_links_source_idx", + "columns": [ + { + "expression": "source_note_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "note_links_target_note_idx": { + "name": "note_links_target_note_idx", + "columns": [ + { + "expression": "target_note_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "note_links_target_document_idx": { + "name": "note_links_target_document_idx", + "columns": [ + { + "expression": "target_document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "note_links_company_title_idx": { + "name": "note_links_company_title_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_invite_codes": { + "name": "pdr_ai_v2_invite_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "invite_codes_code_idx": { + "name": "invite_codes_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_codes_company_id_idx": { + "name": "invite_codes_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_invite_codes_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_invite_codes_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_invite_codes", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pdr_ai_v2_invite_codes_code_unique": { + "name": "pdr_ai_v2_invite_codes_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_user_company_memberships": { + "name": "pdr_ai_v2_user_company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_company_memberships_user_company_unique": { + "name": "user_company_memberships_user_company_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_company_memberships_user_id_idx": { + "name": "user_company_memberships_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_company_memberships_company_id_idx": { + "name": "user_company_memberships_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_user_company_memberships_user_id_pdr_ai_v2_users_id_fk": { + "name": "pdr_ai_v2_user_company_memberships_user_id_pdr_ai_v2_users_id_fk", + "tableFrom": "pdr_ai_v2_user_company_memberships", + "tableTo": "pdr_ai_v2_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_user_company_memberships_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_user_company_memberships_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_user_company_memberships", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_users": { + "name": "pdr_ai_v2_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_company_id_idx": { + "name": "users_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_user_id_idx": { + "name": "users_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_users_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_users_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_users", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pdr_ai_v2_users_userId_unique": { + "name": "pdr_ai_v2_users_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_trend_search_jobs": { + "name": "pdr_ai_v2_trend_search_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_context": { + "name": "company_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "results": { + "name": "results", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "trend_search_jobs_company_id_idx": { + "name": "trend_search_jobs_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trend_search_jobs_status_idx": { + "name": "trend_search_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trend_search_jobs_company_status_idx": { + "name": "trend_search_jobs_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_trend_search_jobs_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_trend_search_jobs_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_trend_search_jobs", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_trend_search_cache": { + "name": "pdr_ai_v2_trend_search_cache", + "schema": "", + "columns": { + "cache_key": { + "name": "cache_key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_client_prospector_jobs": { + "name": "pdr_ai_v2_client_prospector_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_context": { + "name": "company_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_lat": { + "name": "location_lat", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "location_lng": { + "name": "location_lng", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "radius": { + "name": "radius", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5000 + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "results": { + "name": "results", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "client_prospector_jobs_company_id_idx": { + "name": "client_prospector_jobs_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_prospector_jobs_status_idx": { + "name": "client_prospector_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_prospector_jobs_company_status_idx": { + "name": "client_prospector_jobs_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_client_prospector_jobs_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_client_prospector_jobs_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_client_prospector_jobs", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_company_metadata": { + "name": "pdr_ai_v2_company_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_extraction_document_id": { + "name": "last_extraction_document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_metadata_company_id_unique": { + "name": "company_metadata_company_id_unique", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_company_metadata_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_company_metadata_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_company_metadata", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_company_metadata_last_extraction_document_id_pdr_ai_v2_document_id_fk": { + "name": "pdr_ai_v2_company_metadata_last_extraction_document_id_pdr_ai_v2_document_id_fk", + "tableFrom": "pdr_ai_v2_company_metadata", + "tableTo": "pdr_ai_v2_document", + "columnsFrom": [ + "last_extraction_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_company_metadata_history": { + "name": "pdr_ai_v2_company_metadata_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "change_type": { + "name": "change_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "diff": { + "name": "diff", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "changed_by": { + "name": "changed_by", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "company_metadata_history_company_id_idx": { + "name": "company_metadata_history_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_metadata_history_document_id_idx": { + "name": "company_metadata_history_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_metadata_history_created_at_idx": { + "name": "company_metadata_history_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_metadata_history_change_type_idx": { + "name": "company_metadata_history_change_type_idx", + "columns": [ + { + "expression": "change_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_company_metadata_history_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_company_metadata_history_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_company_metadata_history", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_company_metadata_history_document_id_pdr_ai_v2_document_id_fk": { + "name": "pdr_ai_v2_company_metadata_history_document_id_pdr_ai_v2_document_id_fk", + "tableFrom": "pdr_ai_v2_company_metadata_history", + "tableTo": "pdr_ai_v2_document", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_marketing_content_history": { + "name": "pdr_ai_v2_marketing_content_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "angle": { + "name": "angle", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'post'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "impressions": { + "name": "impressions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "engagements": { + "name": "engagements", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "clicks": { + "name": "clicks", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mch_company_id_idx": { + "name": "mch_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mch_platform_idx": { + "name": "mch_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_founder_weekly_review_dispatches": { + "name": "pdr_ai_v2_founder_weekly_review_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "operation_type": { + "name": "operation_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "operation_key": { + "name": "operation_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "generation_job_id": { + "name": "generation_job_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "generation_claim_id": { + "name": "generation_claim_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "founder_weekly_review_dispatches_run_operation_key_unique": { + "name": "founder_weekly_review_dispatches_run_operation_key_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_dispatches_event_id_unique": { + "name": "founder_weekly_review_dispatches_event_id_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_dispatches_pending_idx": { + "name": "founder_weekly_review_dispatches_pending_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_dispatches_company_run_idx": { + "name": "founder_weekly_review_dispatches_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_founder_weekly_review_dispatches_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_founder_weekly_review_dispatches_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_founder_weekly_review_dispatches", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_founder_weekly_review_dispatches_run_id_pdr_ai_v2_founder_weekly_review_runs_id_fk": { + "name": "pdr_ai_v2_founder_weekly_review_dispatches_run_id_pdr_ai_v2_founder_weekly_review_runs_id_fk", + "tableFrom": "pdr_ai_v2_founder_weekly_review_dispatches", + "tableTo": "pdr_ai_v2_founder_weekly_review_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_founder_weekly_review_operations": { + "name": "pdr_ai_v2_founder_weekly_review_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "operation_type": { + "name": "operation_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "request_key": { + "name": "request_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "source_failure_sequence": { + "name": "source_failure_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "founder_weekly_review_operations_run_type_request_key_unique": { + "name": "founder_weekly_review_operations_run_type_request_key_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_operations_company_run_created_at_idx": { + "name": "founder_weekly_review_operations_company_run_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_operations_run_type_created_at_idx": { + "name": "founder_weekly_review_operations_run_type_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_founder_weekly_review_operations_run_id_pdr_ai_v2_founder_weekly_review_runs_id_fk": { + "name": "pdr_ai_v2_founder_weekly_review_operations_run_id_pdr_ai_v2_founder_weekly_review_runs_id_fk", + "tableFrom": "pdr_ai_v2_founder_weekly_review_operations", + "tableTo": "pdr_ai_v2_founder_weekly_review_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_founder_weekly_review_operations_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_founder_weekly_review_operations_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_founder_weekly_review_operations", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_founder_weekly_review_runs": { + "name": "pdr_ai_v2_founder_weekly_review_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "request_key": { + "name": "request_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "reporting_period_start": { + "name": "reporting_period_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "reporting_period_end": { + "name": "reporting_period_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "review_payload": { + "name": "review_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "review_schema_version": { + "name": "review_schema_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "evidence_snapshot": { + "name": "evidence_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "evidence_schema_version": { + "name": "evidence_schema_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "model_metadata": { + "name": "model_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failure_sequence": { + "name": "failure_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_attempt": { + "name": "generation_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_claim_id": { + "name": "generation_claim_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "generation_job_id": { + "name": "generation_job_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "generation_started_at": { + "name": "generation_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "collection_input": { + "name": "collection_input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collection_claim_id": { + "name": "collection_claim_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "collection_started_at": { + "name": "collection_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "evidence_collected_at": { + "name": "evidence_collected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "founder_weekly_review_runs_company_request_key_unique": { + "name": "founder_weekly_review_runs_company_request_key_unique", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_runs_company_created_at_idx": { + "name": "founder_weekly_review_runs_company_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_runs_company_status_created_at_idx": { + "name": "founder_weekly_review_runs_company_status_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_runs_company_period_idx": { + "name": "founder_weekly_review_runs_company_period_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reporting_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reporting_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_runs_claim_idx": { + "name": "founder_weekly_review_runs_claim_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_claim_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_runs_collection_claim_idx": { + "name": "founder_weekly_review_runs_collection_claim_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection_claim_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_founder_weekly_review_runs_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_founder_weekly_review_runs_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_founder_weekly_review_runs", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_campaign_approvals": { + "name": "pdr_ai_v2_email_campaign_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "approved_by_email": { + "name": "approved_by_email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false + }, + "approved_by_kind": { + "name": "approved_by_kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "review_verdict": { + "name": "review_verdict", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "override_reason": { + "name": "override_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_campaign_approvals_campaign_idx": { + "name": "email_campaign_approvals_campaign_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaign_approvals_version_idx": { + "name": "email_campaign_approvals_version_idx", + "columns": [ + { + "expression": "template_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_campaign_approvals_campaign_id_pdr_ai_v2_email_campaigns_id_fk": { + "name": "pdr_ai_v2_email_campaign_approvals_campaign_id_pdr_ai_v2_email_campaigns_id_fk", + "tableFrom": "pdr_ai_v2_email_campaign_approvals", + "tableTo": "pdr_ai_v2_email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_email_campaign_approvals_template_version_id_pdr_ai_v2_email_template_versions_id_fk": { + "name": "pdr_ai_v2_email_campaign_approvals_template_version_id_pdr_ai_v2_email_template_versions_id_fk", + "tableFrom": "pdr_ai_v2_email_campaign_approvals", + "tableTo": "pdr_ai_v2_email_template_versions", + "columnsFrom": [ + "template_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_campaigns": { + "name": "pdr_ai_v2_email_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "approved_version_id": { + "name": "approved_version_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "automation_key": { + "name": "automation_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_campaigns_company_idx": { + "name": "email_campaigns_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaigns_company_status_idx": { + "name": "email_campaigns_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaigns_company_automation_key_uq": { + "name": "email_campaigns_company_automation_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_campaigns_approved_version_id_pdr_ai_v2_email_template_versions_id_fk": { + "name": "pdr_ai_v2_email_campaigns_approved_version_id_pdr_ai_v2_email_template_versions_id_fk", + "tableFrom": "pdr_ai_v2_email_campaigns", + "tableTo": "pdr_ai_v2_email_template_versions", + "columnsFrom": [ + "approved_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_recipients": { + "name": "pdr_ai_v2_email_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "company": { + "name": "company", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "context_notes": { + "name": "context_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vars": { + "name": "vars", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "frozen_at": { + "name": "frozen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_recipients_campaign_idx": { + "name": "email_recipients_campaign_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_recipients_campaign_email_uq": { + "name": "email_recipients_campaign_email_uq", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_recipients_campaign_id_pdr_ai_v2_email_campaigns_id_fk": { + "name": "pdr_ai_v2_email_recipients_campaign_id_pdr_ai_v2_email_campaigns_id_fk", + "tableFrom": "pdr_ai_v2_email_recipients", + "tableTo": "pdr_ai_v2_email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_send_attempts": { + "name": "pdr_ai_v2_email_send_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'dry_run'" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "requested_by": { + "name": "requested_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "recipient_count": { + "name": "recipient_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_count": { + "name": "sent_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "suppressed_count": { + "name": "suppressed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_send_attempts_campaign_idx": { + "name": "email_send_attempts_campaign_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_send_attempts_campaign_key_uq": { + "name": "email_send_attempts_campaign_key_uq", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_send_attempts_campaign_id_pdr_ai_v2_email_campaigns_id_fk": { + "name": "pdr_ai_v2_email_send_attempts_campaign_id_pdr_ai_v2_email_campaigns_id_fk", + "tableFrom": "pdr_ai_v2_email_send_attempts", + "tableTo": "pdr_ai_v2_email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_email_send_attempts_template_version_id_pdr_ai_v2_email_template_versions_id_fk": { + "name": "pdr_ai_v2_email_send_attempts_template_version_id_pdr_ai_v2_email_template_versions_id_fk", + "tableFrom": "pdr_ai_v2_email_send_attempts", + "tableTo": "pdr_ai_v2_email_template_versions", + "columnsFrom": [ + "template_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_sends": { + "name": "pdr_ai_v2_email_sends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "provider_idempotency_key": { + "name": "provider_idempotency_key", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_sends_campaign_idx": { + "name": "email_sends_campaign_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_sends_attempt_recipient_uq": { + "name": "email_sends_attempt_recipient_uq", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_sends_campaign_recipient_delivery_uq": { + "name": "email_sends_campaign_recipient_delivery_uq", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status IN ('queued', 'sent')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_sends_campaign_id_pdr_ai_v2_email_campaigns_id_fk": { + "name": "pdr_ai_v2_email_sends_campaign_id_pdr_ai_v2_email_campaigns_id_fk", + "tableFrom": "pdr_ai_v2_email_sends", + "tableTo": "pdr_ai_v2_email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_email_sends_attempt_id_pdr_ai_v2_email_send_attempts_id_fk": { + "name": "pdr_ai_v2_email_sends_attempt_id_pdr_ai_v2_email_send_attempts_id_fk", + "tableFrom": "pdr_ai_v2_email_sends", + "tableTo": "pdr_ai_v2_email_send_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_email_sends_recipient_id_pdr_ai_v2_email_recipients_id_fk": { + "name": "pdr_ai_v2_email_sends_recipient_id_pdr_ai_v2_email_recipients_id_fk", + "tableFrom": "pdr_ai_v2_email_sends", + "tableTo": "pdr_ai_v2_email_recipients", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_suppressions": { + "name": "pdr_ai_v2_email_suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'unsubscribe'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_suppressions_company_email_uq": { + "name": "email_suppressions_company_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_template_versions": { + "name": "pdr_ai_v2_email_template_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'ai_generated'" + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "review_verdict": { + "name": "review_verdict", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "review": { + "name": "review", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_template_versions_campaign_idx": { + "name": "email_template_versions_campaign_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_template_versions_campaign_version_uq": { + "name": "email_template_versions_campaign_version_uq", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_template_versions_campaign_id_pdr_ai_v2_email_campaigns_id_fk": { + "name": "pdr_ai_v2_email_template_versions_campaign_id_pdr_ai_v2_email_campaigns_id_fk", + "tableFrom": "pdr_ai_v2_email_template_versions", + "tableTo": "pdr_ai_v2_email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/meta/20260817012254_snapshot.json b/apps/web/drizzle/meta/20260817012254_snapshot.json new file mode 100644 index 000000000..dc2d3f328 --- /dev/null +++ b/apps/web/drizzle/meta/20260817012254_snapshot.json @@ -0,0 +1,6215 @@ +{ + "id": "b7d30e42-893a-47d0-b806-6e1b276a65f1", + "prevId": "c6aa48c4-8add-4990-a467-4dc45f109775", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pdr_ai_v2_agent_ai_chatbot_chat": { + "name": "pdr_ai_v2_agent_ai_chatbot_chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "agent_mode": { + "name": "agent_mode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'interactive'" + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "ai_style": { + "name": "ai_style", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'concise'" + }, + "ai_persona": { + "name": "ai_persona", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'general'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_document": { + "name": "pdr_ai_v2_agent_ai_chatbot_document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_document_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_document_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_document", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_agent_ai_chatbot_document_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_document_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_document", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pdr_ai_v2_agent_ai_chatbot_document_id_created_at_pk": { + "name": "pdr_ai_v2_agent_ai_chatbot_document_id_created_at_pk", + "columns": [ + "id", + "created_at" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_execution_step": { + "name": "pdr_ai_v2_agent_ai_chatbot_execution_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_type": { + "name": "step_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_execution_step_task_step_idx": { + "name": "agent_execution_step_task_step_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_execution_step_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_execution_step_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_execution_step", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_memory": { + "name": "pdr_ai_v2_agent_ai_chatbot_memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "memory_type": { + "name": "memory_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "importance": { + "name": "importance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "accessed_at": { + "name": "accessed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_memory_chat_idx": { + "name": "agent_memory_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_memory_chat_type_idx": { + "name": "agent_memory_chat_type_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "memory_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_memory_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_memory_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_memory", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_message": { + "name": "pdr_ai_v2_agent_ai_chatbot_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "message_type": { + "name": "message_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_message_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_message_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_message", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_suggestion": { + "name": "pdr_ai_v2_agent_ai_chatbot_suggestion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "document_created_at": { + "name": "document_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "original_text": { + "name": "original_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_text": { + "name": "suggested_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_resolved": { + "name": "is_resolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_suggestion_document_id_document_created_at_pdr_ai_v2_agent_ai_chatbot_document_id_created_at_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_suggestion_document_id_document_created_at_pdr_ai_v2_agent_ai_chatbot_document_id_created_at_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_suggestion", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_document", + "columnsFrom": [ + "document_id", + "document_created_at" + ], + "columnsTo": [ + "id", + "created_at" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_task": { + "name": "pdr_ai_v2_agent_ai_chatbot_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_task_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_task_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_task", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_tool_call": { + "name": "pdr_ai_v2_agent_ai_chatbot_tool_call", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "tool_input": { + "name": "tool_input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tool_output": { + "name": "tool_output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_time_ms": { + "name": "execution_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_tool_call_message_id_pdr_ai_v2_agent_ai_chatbot_message_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_tool_call_message_id_pdr_ai_v2_agent_ai_chatbot_message_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_tool_call", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_agent_ai_chatbot_tool_call_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_tool_call_task_id_pdr_ai_v2_agent_ai_chatbot_task_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_tool_call", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_tool_registry": { + "name": "pdr_ai_v2_agent_ai_chatbot_tool_registry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "required_permissions": { + "name": "required_permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rate_limit": { + "name": "rate_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pdr_ai_v2_agent_ai_chatbot_tool_registry_name_unique": { + "name": "pdr_ai_v2_agent_ai_chatbot_tool_registry_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_agent_ai_chatbot_vote": { + "name": "pdr_ai_v2_agent_ai_chatbot_vote", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "is_upvoted": { + "name": "is_upvoted", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "pdr_ai_v2_agent_ai_chatbot_vote_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_vote_chat_id_pdr_ai_v2_agent_ai_chatbot_chat_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_vote", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_chat", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_agent_ai_chatbot_vote_message_id_pdr_ai_v2_agent_ai_chatbot_message_id_fk": { + "name": "pdr_ai_v2_agent_ai_chatbot_vote_message_id_pdr_ai_v2_agent_ai_chatbot_message_id_fk", + "tableFrom": "pdr_ai_v2_agent_ai_chatbot_vote", + "tableTo": "pdr_ai_v2_agent_ai_chatbot_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pdr_ai_v2_agent_ai_chatbot_vote_chat_id_message_id_pk": { + "name": "pdr_ai_v2_agent_ai_chatbot_vote_chat_id_message_id_pk", + "columns": [ + "chat_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_collab_agent_persona": { + "name": "pdr_ai_v2_collab_agent_persona", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "route": { + "name": "route", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "temperature_x100": { + "name": "temperature_x100", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_turn_chars": { + "name": "max_turn_chars", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "accent": { + "name": "accent", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collab_persona_company_key_idx": { + "name": "collab_persona_company_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_collab_agent_persona_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_collab_agent_persona_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_collab_agent_persona", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_collab_channel": { + "name": "pdr_ai_v2_collab_channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(96)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collab_channel_company_slug_idx": { + "name": "collab_channel_company_slug_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collab_channel_company_idx": { + "name": "collab_channel_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_collab_channel_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_collab_channel_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_collab_channel", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_collab_meeting": { + "name": "pdr_ai_v2_collab_meeting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agenda": { + "name": "agenda", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "participants": { + "name": "participants", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "turn_policy": { + "name": "turn_policy", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'round_robin'" + }, + "moderator_persona_id": { + "name": "moderator_persona_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "max_turns": { + "name": "max_turns", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 12 + }, + "completion_marker": { + "name": "completion_marker", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "grounding_enabled": { + "name": "grounding_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "document_ids": { + "name": "document_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "turn_index": { + "name": "turn_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_speaker_id": { + "name": "next_speaker_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "controller": { + "name": "controller", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "slack_mirror_enabled": { + "name": "slack_mirror_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "slack_use_agent_identity": { + "name": "slack_use_agent_identity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collab_meeting_company_idx": { + "name": "collab_meeting_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collab_meeting_channel_idx": { + "name": "collab_meeting_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_collab_meeting_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_collab_meeting_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_collab_meeting", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_collab_meeting_channel_id_pdr_ai_v2_collab_channel_id_fk": { + "name": "pdr_ai_v2_collab_meeting_channel_id_pdr_ai_v2_collab_channel_id_fk", + "tableFrom": "pdr_ai_v2_collab_meeting", + "tableTo": "pdr_ai_v2_collab_channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_collab_message": { + "name": "pdr_ai_v2_collab_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_kind": { + "name": "author_kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "on_behalf_of_persona_id": { + "name": "on_behalf_of_persona_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'chat'" + }, + "thread_id": { + "name": "thread_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "slack_ts": { + "name": "slack_ts", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "collab_message_channel_seq_idx": { + "name": "collab_message_channel_seq_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collab_message_channel_created_idx": { + "name": "collab_message_channel_created_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_collab_message_channel_id_pdr_ai_v2_collab_channel_id_fk": { + "name": "pdr_ai_v2_collab_message_channel_id_pdr_ai_v2_collab_channel_id_fk", + "tableFrom": "pdr_ai_v2_collab_message", + "tableTo": "pdr_ai_v2_collab_channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_collab_node": { + "name": "pdr_ai_v2_collab_node", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "persona_ids": { + "name": "persona_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_remote_address": { + "name": "last_remote_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "collab_node_company_idx": { + "name": "collab_node_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_collab_node_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_collab_node_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_collab_node", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_collab_room": { + "name": "pdr_ai_v2_collab_room", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "members": { + "name": "members", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collab_room_company_idx": { + "name": "collab_room_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collab_room_channel_idx": { + "name": "collab_room_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_collab_room_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_collab_room_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_collab_room", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_collab_room_channel_id_pdr_ai_v2_collab_channel_id_fk": { + "name": "pdr_ai_v2_collab_room_channel_id_pdr_ai_v2_collab_channel_id_fk", + "tableFrom": "pdr_ai_v2_collab_room", + "tableTo": "pdr_ai_v2_collab_channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_token_accounts": { + "name": "pdr_ai_v2_token_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_tokens": { + "name": "balance_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifetime_tokens_purchased": { + "name": "lifetime_tokens_purchased", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifetime_tokens_granted": { + "name": "lifetime_tokens_granted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifetime_tokens_used": { + "name": "lifetime_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "token_accounts_company_id_idx": { + "name": "token_accounts_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_token_accounts_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_token_accounts_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_token_accounts", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_token_grants": { + "name": "pdr_ai_v2_token_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "grant_type": { + "name": "grant_type", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "token_grants_company_id_idx": { + "name": "token_grants_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_token_grants_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_token_grants_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_token_grants", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_token_transactions": { + "name": "pdr_ai_v2_token_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "service": { + "name": "service", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "token_tx_company_created_idx": { + "name": "token_tx_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "token_tx_company_service_idx": { + "name": "token_tx_company_service_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_token_transactions_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_token_transactions_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_token_transactions", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_token_usage_daily": { + "name": "pdr_ai_v2_token_usage_daily", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "operation_count": { + "name": "operation_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "token_usage_daily_company_date_service_idx": { + "name": "token_usage_daily_company_date_service_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_token_usage_daily_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_token_usage_daily_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_token_usage_daily", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_chat_history": { + "name": "pdr_ai_v2_chat_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "document_title": { + "name": "document_title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "query_type": { + "name": "query_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'simple'" + }, + "pages": { + "name": "pages", + "type": "integer[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_history_user_id_idx": { + "name": "chat_history_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_history_user_id_created_at_idx": { + "name": "chat_history_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_history_document_id_idx": { + "name": "chat_history_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_chat_history_document_id_pdr_ai_v2_document_id_fk": { + "name": "pdr_ai_v2_chat_history_document_id_pdr_ai_v2_document_id_fk", + "tableFrom": "pdr_ai_v2_chat_history", + "tableTo": "pdr_ai_v2_document", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_document_reference_resolutions": { + "name": "pdr_ai_v2_document_reference_resolutions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reference_name": { + "name": "reference_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "resolved_in_document_id": { + "name": "resolved_in_document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "resolution_details": { + "name": "resolution_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_reference_resolutions_company_ref_idx": { + "name": "document_reference_resolutions_company_ref_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_document_reference_resolutions_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_document_reference_resolutions_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_document_reference_resolutions", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_document_views": { + "name": "pdr_ai_v2_document_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "viewed_at": { + "name": "viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "document_views_document_id_idx": { + "name": "document_views_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_views_company_id_idx": { + "name": "document_views_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_views_user_id_idx": { + "name": "document_views_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_views_company_id_viewed_at_idx": { + "name": "document_views_company_id_viewed_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "viewed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_document_views_document_id_pdr_ai_v2_document_id_fk": { + "name": "pdr_ai_v2_document_views_document_id_pdr_ai_v2_document_id_fk", + "tableFrom": "pdr_ai_v2_document_views", + "tableTo": "pdr_ai_v2_document", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_document_views_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_document_views_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_document_views", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_generated_documents": { + "name": "pdr_ai_v2_generated_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "generated_documents_user_id_idx": { + "name": "generated_documents_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generated_documents_company_id_idx": { + "name": "generated_documents_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generated_documents_company_user_idx": { + "name": "generated_documents_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_generated_documents_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_generated_documents_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_generated_documents", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_predictive_document_analysis_results": { + "name": "pdr_ai_v2_predictive_document_analysis_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "analysis_type": { + "name": "analysis_type", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "include_related_docs": { + "name": "include_related_docs", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "predictive_analysis_document_id_idx": { + "name": "predictive_analysis_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "predictive_analysis_document_version_idx": { + "name": "predictive_analysis_document_version_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_predictive_document_analysis_results_document_id_pdr_ai_v2_document_id_fk": { + "name": "pdr_ai_v2_predictive_document_analysis_results_document_id_pdr_ai_v2_document_id_fk", + "tableFrom": "pdr_ai_v2_predictive_document_analysis_results", + "tableTo": "pdr_ai_v2_document", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_predictive_document_analysis_results_version_id_pdr_ai_v2_document_versions_id_fk": { + "name": "pdr_ai_v2_predictive_document_analysis_results_version_id_pdr_ai_v2_document_versions_id_fk", + "tableFrom": "pdr_ai_v2_predictive_document_analysis_results", + "tableTo": "pdr_ai_v2_document_versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_document_note_embeddings": { + "name": "pdr_ai_v2_document_note_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "note_id": { + "name": "note_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_short": { + "name": "embedding_short", + "type": "vector(512)", + "primaryKey": false, + "notNull": false + }, + "model_version": { + "name": "model_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "doc_note_emb_note_id_idx": { + "name": "doc_note_emb_note_id_idx", + "columns": [ + { + "expression": "note_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_note_emb_user_id_idx": { + "name": "doc_note_emb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_note_emb_document_id_idx": { + "name": "doc_note_emb_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_note_emb_company_id_idx": { + "name": "doc_note_emb_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_note_emb_embedding_short_idx": { + "name": "doc_note_emb_embedding_short_idx", + "columns": [ + { + "expression": "embedding_short", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_document_notes": { + "name": "pdr_ai_v2_document_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "version_id": { + "name": "version_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_rich": { + "name": "content_rich", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "content_markdown": { + "name": "content_markdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "anchor_status": { + "name": "anchor_status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": false, + "default": "'resolved'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "document_notes_user_idx": { + "name": "document_notes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_notes_document_idx": { + "name": "document_notes_document_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_notes_company_idx": { + "name": "document_notes_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_notes_version_idx": { + "name": "document_notes_version_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_notes_anchor_status_idx": { + "name": "document_notes_anchor_status_idx", + "columns": [ + { + "expression": "anchor_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_note_links": { + "name": "pdr_ai_v2_note_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "source_note_id": { + "name": "source_note_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "target_note_id": { + "name": "target_note_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "target_document_id": { + "name": "target_document_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "target_title": { + "name": "target_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "note_links_source_idx": { + "name": "note_links_source_idx", + "columns": [ + { + "expression": "source_note_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "note_links_target_note_idx": { + "name": "note_links_target_note_idx", + "columns": [ + { + "expression": "target_note_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "note_links_target_document_idx": { + "name": "note_links_target_document_idx", + "columns": [ + { + "expression": "target_document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "note_links_company_title_idx": { + "name": "note_links_company_title_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_invite_codes": { + "name": "pdr_ai_v2_invite_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "invite_codes_code_idx": { + "name": "invite_codes_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_codes_company_id_idx": { + "name": "invite_codes_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_invite_codes_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_invite_codes_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_invite_codes", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pdr_ai_v2_invite_codes_code_unique": { + "name": "pdr_ai_v2_invite_codes_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_user_company_memberships": { + "name": "pdr_ai_v2_user_company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_company_memberships_user_company_unique": { + "name": "user_company_memberships_user_company_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_company_memberships_user_id_idx": { + "name": "user_company_memberships_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_company_memberships_company_id_idx": { + "name": "user_company_memberships_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_user_company_memberships_user_id_pdr_ai_v2_users_id_fk": { + "name": "pdr_ai_v2_user_company_memberships_user_id_pdr_ai_v2_users_id_fk", + "tableFrom": "pdr_ai_v2_user_company_memberships", + "tableTo": "pdr_ai_v2_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_user_company_memberships_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_user_company_memberships_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_user_company_memberships", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_users": { + "name": "pdr_ai_v2_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_company_id_idx": { + "name": "users_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_user_id_idx": { + "name": "users_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_users_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_users_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_users", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pdr_ai_v2_users_userId_unique": { + "name": "pdr_ai_v2_users_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_trend_search_jobs": { + "name": "pdr_ai_v2_trend_search_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_context": { + "name": "company_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "results": { + "name": "results", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "trend_search_jobs_company_id_idx": { + "name": "trend_search_jobs_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trend_search_jobs_status_idx": { + "name": "trend_search_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trend_search_jobs_company_status_idx": { + "name": "trend_search_jobs_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_trend_search_jobs_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_trend_search_jobs_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_trend_search_jobs", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_trend_search_cache": { + "name": "pdr_ai_v2_trend_search_cache", + "schema": "", + "columns": { + "cache_key": { + "name": "cache_key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_client_prospector_jobs": { + "name": "pdr_ai_v2_client_prospector_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_context": { + "name": "company_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_lat": { + "name": "location_lat", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "location_lng": { + "name": "location_lng", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "radius": { + "name": "radius", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5000 + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "results": { + "name": "results", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "client_prospector_jobs_company_id_idx": { + "name": "client_prospector_jobs_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_prospector_jobs_status_idx": { + "name": "client_prospector_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_prospector_jobs_company_status_idx": { + "name": "client_prospector_jobs_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_client_prospector_jobs_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_client_prospector_jobs_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_client_prospector_jobs", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_company_metadata": { + "name": "pdr_ai_v2_company_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_extraction_document_id": { + "name": "last_extraction_document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_metadata_company_id_unique": { + "name": "company_metadata_company_id_unique", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_company_metadata_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_company_metadata_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_company_metadata", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_company_metadata_last_extraction_document_id_pdr_ai_v2_document_id_fk": { + "name": "pdr_ai_v2_company_metadata_last_extraction_document_id_pdr_ai_v2_document_id_fk", + "tableFrom": "pdr_ai_v2_company_metadata", + "tableTo": "pdr_ai_v2_document", + "columnsFrom": [ + "last_extraction_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_company_metadata_history": { + "name": "pdr_ai_v2_company_metadata_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "change_type": { + "name": "change_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "diff": { + "name": "diff", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "changed_by": { + "name": "changed_by", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "company_metadata_history_company_id_idx": { + "name": "company_metadata_history_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_metadata_history_document_id_idx": { + "name": "company_metadata_history_document_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_metadata_history_created_at_idx": { + "name": "company_metadata_history_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_metadata_history_change_type_idx": { + "name": "company_metadata_history_change_type_idx", + "columns": [ + { + "expression": "change_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_company_metadata_history_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_company_metadata_history_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_company_metadata_history", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_company_metadata_history_document_id_pdr_ai_v2_document_id_fk": { + "name": "pdr_ai_v2_company_metadata_history_document_id_pdr_ai_v2_document_id_fk", + "tableFrom": "pdr_ai_v2_company_metadata_history", + "tableTo": "pdr_ai_v2_document", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_marketing_content_history": { + "name": "pdr_ai_v2_marketing_content_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "angle": { + "name": "angle", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'post'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "impressions": { + "name": "impressions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "engagements": { + "name": "engagements", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "clicks": { + "name": "clicks", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mch_company_id_idx": { + "name": "mch_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mch_platform_idx": { + "name": "mch_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_founder_weekly_review_dispatches": { + "name": "pdr_ai_v2_founder_weekly_review_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "operation_type": { + "name": "operation_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "operation_key": { + "name": "operation_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "generation_job_id": { + "name": "generation_job_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "generation_claim_id": { + "name": "generation_claim_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "founder_weekly_review_dispatches_run_operation_key_unique": { + "name": "founder_weekly_review_dispatches_run_operation_key_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_dispatches_event_id_unique": { + "name": "founder_weekly_review_dispatches_event_id_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_dispatches_pending_idx": { + "name": "founder_weekly_review_dispatches_pending_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_dispatches_company_run_idx": { + "name": "founder_weekly_review_dispatches_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_founder_weekly_review_dispatches_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_founder_weekly_review_dispatches_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_founder_weekly_review_dispatches", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_founder_weekly_review_dispatches_run_id_pdr_ai_v2_founder_weekly_review_runs_id_fk": { + "name": "pdr_ai_v2_founder_weekly_review_dispatches_run_id_pdr_ai_v2_founder_weekly_review_runs_id_fk", + "tableFrom": "pdr_ai_v2_founder_weekly_review_dispatches", + "tableTo": "pdr_ai_v2_founder_weekly_review_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_founder_weekly_review_operations": { + "name": "pdr_ai_v2_founder_weekly_review_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "operation_type": { + "name": "operation_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "request_key": { + "name": "request_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "source_failure_sequence": { + "name": "source_failure_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "founder_weekly_review_operations_run_type_request_key_unique": { + "name": "founder_weekly_review_operations_run_type_request_key_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_operations_company_run_created_at_idx": { + "name": "founder_weekly_review_operations_company_run_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_operations_run_type_created_at_idx": { + "name": "founder_weekly_review_operations_run_type_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_founder_weekly_review_operations_run_id_pdr_ai_v2_founder_weekly_review_runs_id_fk": { + "name": "pdr_ai_v2_founder_weekly_review_operations_run_id_pdr_ai_v2_founder_weekly_review_runs_id_fk", + "tableFrom": "pdr_ai_v2_founder_weekly_review_operations", + "tableTo": "pdr_ai_v2_founder_weekly_review_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_founder_weekly_review_operations_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_founder_weekly_review_operations_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_founder_weekly_review_operations", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_founder_weekly_review_runs": { + "name": "pdr_ai_v2_founder_weekly_review_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "request_key": { + "name": "request_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "reporting_period_start": { + "name": "reporting_period_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "reporting_period_end": { + "name": "reporting_period_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "review_payload": { + "name": "review_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "review_schema_version": { + "name": "review_schema_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "evidence_snapshot": { + "name": "evidence_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "evidence_schema_version": { + "name": "evidence_schema_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "model_metadata": { + "name": "model_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failure_sequence": { + "name": "failure_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_attempt": { + "name": "generation_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_claim_id": { + "name": "generation_claim_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "generation_job_id": { + "name": "generation_job_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "generation_started_at": { + "name": "generation_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "collection_input": { + "name": "collection_input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collection_claim_id": { + "name": "collection_claim_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "collection_started_at": { + "name": "collection_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "evidence_collected_at": { + "name": "evidence_collected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "founder_weekly_review_runs_company_request_key_unique": { + "name": "founder_weekly_review_runs_company_request_key_unique", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_runs_company_created_at_idx": { + "name": "founder_weekly_review_runs_company_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_runs_company_status_created_at_idx": { + "name": "founder_weekly_review_runs_company_status_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_runs_company_period_idx": { + "name": "founder_weekly_review_runs_company_period_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reporting_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reporting_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_runs_claim_idx": { + "name": "founder_weekly_review_runs_claim_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_claim_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "founder_weekly_review_runs_collection_claim_idx": { + "name": "founder_weekly_review_runs_collection_claim_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection_claim_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_founder_weekly_review_runs_company_id_pdr_ai_v2_company_id_fk": { + "name": "pdr_ai_v2_founder_weekly_review_runs_company_id_pdr_ai_v2_company_id_fk", + "tableFrom": "pdr_ai_v2_founder_weekly_review_runs", + "tableTo": "pdr_ai_v2_company", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_campaign_approvals": { + "name": "pdr_ai_v2_email_campaign_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "approved_by_email": { + "name": "approved_by_email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false + }, + "approved_by_kind": { + "name": "approved_by_kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "review_verdict": { + "name": "review_verdict", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "override_reason": { + "name": "override_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_campaign_approvals_campaign_idx": { + "name": "email_campaign_approvals_campaign_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaign_approvals_version_idx": { + "name": "email_campaign_approvals_version_idx", + "columns": [ + { + "expression": "template_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_campaign_approvals_campaign_id_pdr_ai_v2_email_campaigns_id_fk": { + "name": "pdr_ai_v2_email_campaign_approvals_campaign_id_pdr_ai_v2_email_campaigns_id_fk", + "tableFrom": "pdr_ai_v2_email_campaign_approvals", + "tableTo": "pdr_ai_v2_email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_email_campaign_approvals_template_version_id_pdr_ai_v2_email_template_versions_id_fk": { + "name": "pdr_ai_v2_email_campaign_approvals_template_version_id_pdr_ai_v2_email_template_versions_id_fk", + "tableFrom": "pdr_ai_v2_email_campaign_approvals", + "tableTo": "pdr_ai_v2_email_template_versions", + "columnsFrom": [ + "template_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_campaigns": { + "name": "pdr_ai_v2_email_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "approved_version_id": { + "name": "approved_version_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "automation_key": { + "name": "automation_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_campaigns_company_idx": { + "name": "email_campaigns_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaigns_company_status_idx": { + "name": "email_campaigns_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaigns_company_automation_key_uq": { + "name": "email_campaigns_company_automation_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_campaigns_approved_version_id_pdr_ai_v2_email_template_versions_id_fk": { + "name": "pdr_ai_v2_email_campaigns_approved_version_id_pdr_ai_v2_email_template_versions_id_fk", + "tableFrom": "pdr_ai_v2_email_campaigns", + "tableTo": "pdr_ai_v2_email_template_versions", + "columnsFrom": [ + "approved_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_recipients": { + "name": "pdr_ai_v2_email_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "company": { + "name": "company", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "context_notes": { + "name": "context_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vars": { + "name": "vars", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "frozen_at": { + "name": "frozen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_recipients_campaign_idx": { + "name": "email_recipients_campaign_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_recipients_campaign_email_uq": { + "name": "email_recipients_campaign_email_uq", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_recipients_campaign_id_pdr_ai_v2_email_campaigns_id_fk": { + "name": "pdr_ai_v2_email_recipients_campaign_id_pdr_ai_v2_email_campaigns_id_fk", + "tableFrom": "pdr_ai_v2_email_recipients", + "tableTo": "pdr_ai_v2_email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_send_attempts": { + "name": "pdr_ai_v2_email_send_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'dry_run'" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "requested_by": { + "name": "requested_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "recipient_count": { + "name": "recipient_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_count": { + "name": "sent_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "suppressed_count": { + "name": "suppressed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_send_attempts_campaign_idx": { + "name": "email_send_attempts_campaign_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_send_attempts_campaign_key_uq": { + "name": "email_send_attempts_campaign_key_uq", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_send_attempts_campaign_id_pdr_ai_v2_email_campaigns_id_fk": { + "name": "pdr_ai_v2_email_send_attempts_campaign_id_pdr_ai_v2_email_campaigns_id_fk", + "tableFrom": "pdr_ai_v2_email_send_attempts", + "tableTo": "pdr_ai_v2_email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_email_send_attempts_template_version_id_pdr_ai_v2_email_template_versions_id_fk": { + "name": "pdr_ai_v2_email_send_attempts_template_version_id_pdr_ai_v2_email_template_versions_id_fk", + "tableFrom": "pdr_ai_v2_email_send_attempts", + "tableTo": "pdr_ai_v2_email_template_versions", + "columnsFrom": [ + "template_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_sends": { + "name": "pdr_ai_v2_email_sends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "provider_idempotency_key": { + "name": "provider_idempotency_key", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_sends_campaign_idx": { + "name": "email_sends_campaign_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_sends_attempt_recipient_uq": { + "name": "email_sends_attempt_recipient_uq", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_sends_campaign_recipient_delivery_uq": { + "name": "email_sends_campaign_recipient_delivery_uq", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status IN ('queued', 'sent')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_sends_campaign_id_pdr_ai_v2_email_campaigns_id_fk": { + "name": "pdr_ai_v2_email_sends_campaign_id_pdr_ai_v2_email_campaigns_id_fk", + "tableFrom": "pdr_ai_v2_email_sends", + "tableTo": "pdr_ai_v2_email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_email_sends_attempt_id_pdr_ai_v2_email_send_attempts_id_fk": { + "name": "pdr_ai_v2_email_sends_attempt_id_pdr_ai_v2_email_send_attempts_id_fk", + "tableFrom": "pdr_ai_v2_email_sends", + "tableTo": "pdr_ai_v2_email_send_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pdr_ai_v2_email_sends_recipient_id_pdr_ai_v2_email_recipients_id_fk": { + "name": "pdr_ai_v2_email_sends_recipient_id_pdr_ai_v2_email_recipients_id_fk", + "tableFrom": "pdr_ai_v2_email_sends", + "tableTo": "pdr_ai_v2_email_recipients", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_suppressions": { + "name": "pdr_ai_v2_email_suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'unsubscribe'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_suppressions_company_email_uq": { + "name": "email_suppressions_company_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pdr_ai_v2_email_template_versions": { + "name": "pdr_ai_v2_email_template_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'ai_generated'" + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "review_verdict": { + "name": "review_verdict", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "review": { + "name": "review", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_template_versions_campaign_idx": { + "name": "email_template_versions_campaign_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_template_versions_campaign_version_uq": { + "name": "email_template_versions_campaign_version_uq", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pdr_ai_v2_email_template_versions_campaign_id_pdr_ai_v2_email_campaigns_id_fk": { + "name": "pdr_ai_v2_email_template_versions_campaign_id_pdr_ai_v2_email_campaigns_id_fk", + "tableFrom": "pdr_ai_v2_email_template_versions", + "tableTo": "pdr_ai_v2_email_campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index 05075ef96..4dc852ab2 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -36,6 +36,20 @@ "when": 1786683913812, "tag": "20260814050513_email_pipeline", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1786827949439, + "tag": "20260815210549_meeting_grounding", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1786929774532, + "tag": "20260817012254_collab_rooms", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 1fcaff3f8..c1337574b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,9 @@ "collab:hub": "tsx ./scripts/collab-hub.ts", "collab:worker": "tsx ./scripts/collab-worker.ts", "evals:meetings": "tsx --tsconfig ./tsconfig.json ./scripts/run-meeting-evals.ts", + "meeting:live": "tsx --tsconfig ./tsconfig.json ./scripts/run-live-meeting.ts", + "meeting:compare": "tsx --tsconfig ./tsconfig.json ./scripts/compare-meeting-vs-single.ts", + "meeting:probe": "tsx --tsconfig ./tsconfig.json ./scripts/probe-planted-error.ts", "dev": "next dev --turbo", "dev:next": "next dev --turbo", "lint": "eslint .", diff --git a/apps/web/scripts/compare-meeting-vs-single.ts b/apps/web/scripts/compare-meeting-vs-single.ts new file mode 100644 index 000000000..d4388016e --- /dev/null +++ b/apps/web/scripts/compare-meeting-vs-single.ts @@ -0,0 +1,426 @@ +#!/usr/bin/env tsx +/** + * Head-to-head: one agent versus a room, on identical evidence. + * + * pnpm --filter @launchstack/web meeting:compare -- --env-file=/path/.env + * pnpm --filter @launchstack/web meeting:compare -- --repeats=3 --json + * + * The question this answers is "when is a meeting actually worth it", and the + * only way to answer it honestly is to give the single agent every advantage: + * + * - **It gets the whole corpus up front.** The meeting retrieves a few + * passages per turn; the single agent is handed all of them, which is what + * you would really do in a chat. It has strictly more information. + * - **It gets the same output instructions.** Both sides are told to write + * `Decision:` and `Next step:` lines, so the decision metric measures + * whether a decision was reached — not who was told how to phrase one. + * - **It is prompted to do the thing a room does.** It is explicitly asked to + * weigh product, engineering, design, growth and data angles. + * + * And the scenario set contains a case the room is expected to LOSE. A + * comparison containing only questions the tool is good at is marketing. + * + * Metrics are computed identically over both outputs, by the same code: + * numeric claims traceable to the corpus, how many distinct source passages + * were demonstrably drawn on, and whether a decision with an owner came out — + * the last via the product's own deterministic minutes extractor. + */ + +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + CORPUS, + SCENARIOS, + reportEvidence, + type Scenario, +} from "./meeting-fixtures"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +interface Options { + repeats: number; + turns: number; + route: string | null; + json: boolean; + envFile: string | null; + only: string | null; + /** Print each side's raw output — the only way to check the baseline is fair. */ + dump: boolean; +} + +function parseArgs(argv: string[]): Options { + const value = (flag: string): string | null => { + const hit = argv.find((a) => a.startsWith(`--${flag}=`)); + return hit ? hit.slice(flag.length + 3) : null; + }; + const repeats = Number(value("repeats")); + const turns = Number(value("turns")); + + return { + repeats: Number.isFinite(repeats) && repeats > 0 ? repeats : 2, + turns: Number.isFinite(turns) && turns > 0 ? turns : 12, + route: value("route"), + json: argv.includes("--json"), + envFile: value("env-file"), + only: value("only"), + dump: argv.includes("--dump"), + }; +} + +async function loadEnvironment(explicit: string | null): Promise { + const candidates = explicit + ? [resolve(explicit)] + : [join(HERE, "..", ".env"), join(HERE, "..", "..", "..", ".env")]; + const found = candidates.find((path) => existsSync(path)); + if (!found) return null; + const dotenv = await import("dotenv"); + dotenv.config({ path: found, quiet: true }); + return found; +} + +// --------------------------------------------------------------------------- + +interface RunResult { + scenarioId: string; + condition: "single" | "meeting"; + repeat: number; + elapsedMs: number; + turns: number; + outputChars: number; + figuresUsed: number; + traceable: number; + untraceable: number; + passagesUsed: number; + decisions: number; + ownedActionItems: number; +} + +/** Both sides are judged on the same text-shaped thing: everything that was said. */ +function summarise( + scenario: Scenario, + condition: RunResult["condition"], + repeat: number, + text: string, + elapsedMs: number, + turns: number, + decisions: number, + ownedActionItems: number, +): RunResult { + const evidence = reportEvidence(text, CORPUS); + return { + scenarioId: scenario.id, + condition, + repeat, + elapsedMs, + turns, + outputChars: text.length, + figuresUsed: evidence.figuresUsed, + traceable: evidence.traceable, + untraceable: evidence.untraceable.length, + passagesUsed: evidence.passagesUsed.length, + decisions, + ownedActionItems, + }; +} + +const SHARED_OUTPUT_RULES = [ + "End with a line starting with exactly `Decision:` followed by one sentence stating what will happen.", + "Then a line starting with exactly `Next step:` naming the owner and a date.", +].join("\n"); + +async function runSingle( + scenario: Scenario, + repeat: number, + options: Options, +): Promise { + const { createCollabChatFn } = await import("~/server/collab/chat"); + const collab = await import("@launchstack/core/collab"); + // Far higher than the meeting's per-turn cap, and deliberately so. The room + // gets 12 turns of 2400; the single agent gets one shot and must fit the + // whole analysis in it. An equal per-call cap is not a fair fight — it + // truncated the baseline mid-sentence and silently flattered the room. + // Total budget still favours the meeting by a wide margin; that asymmetry is + // inherent to the method and is reported as cost rather than equalised away. + const chat = createCollabChatFn({ maxOutputTokens: 12000 }); + + const system = [ + "You are an experienced startup operator advising a founder. You are the only adviser in the room.", + "", + "Weigh every angle that bears on the decision: the user problem and scope, engineering feasibility and cost, design and usability, growth and revenue, and whether the data actually supports the premise.", + "Name the tradeoffs explicitly, including the strongest case against your own recommendation.", + "", + "## Evidence", + "These passages are the only known-good material. Do not invent figures beyond them; say plainly when something is unknown.", + ...CORPUS.map((p, i) => `[${i + 1}] ${p.label}: ${p.text}`), + "", + "## Objective", + scenario.objective, + ...(scenario.agenda.length > 0 + ? ["", "Work through each of these:", ...scenario.agenda.map((a, i) => `${i + 1}. ${a}`)] + : []), + "", + "## Output", + SHARED_OUTPUT_RULES, + ].join("\n"); + + const started = Date.now(); + const text = await chat({ + messages: [ + { role: "system", content: system }, + { role: "user", content: "Give me your recommendation." }, + ], + route: options.route ?? "reasoning", + }); + const elapsedMs = Date.now() - started; + + if (options.dump) { + process.stderr.write(`\n----- SINGLE / ${scenario.id} / run ${repeat} (${text.length} chars)\n${text}\n-----\n`); + } + + // Judge the single answer with the product's own extractor, by wrapping it + // as a one-message channel. Identical code path to the meeting's minutes. + const minutes = collab.buildMinutes( + { + id: "single", + channelId: "c", + workspaceId: "w", + title: scenario.title, + objective: scenario.objective, + agenda: scenario.agenda, + participants: [ + { id: "adviser", displayName: "Adviser", role: "Operator", systemPrompt: "" }, + ], + turnPolicy: { kind: "round_robin" }, + maxTurns: 1, + }, + { meetingId: "single", status: "completed", turnIndex: 1, nextSpeakerId: null }, + [ + { + id: "m1", + channelId: "c", + seq: 1, + ts: new Date(0).toISOString(), + author: { kind: "agent", id: "adviser", displayName: "Adviser" }, + text, + kind: "chat", + }, + ], + ); + + return summarise( + scenario, + "single", + repeat, + text, + elapsedMs, + 1, + minutes.decisions.length, + minutes.actionItems.filter((a) => a.owner).length, + ); +} + +async function runMeeting( + scenario: Scenario, + repeat: number, + options: Options, +): Promise { + const [{ PERSONA_PACKS }, collab, { createCollabChatFn }] = await Promise.all([ + import("~/server/collab/presets"), + import("@launchstack/core/collab"), + import("~/server/collab/chat"), + ]); + + const pack = PERSONA_PACKS.find((p) => p.id === "startup-core")!; + const participants = pack.personas.map((persona) => ({ + id: persona.key, + displayName: persona.displayName, + role: persona.role, + systemPrompt: persona.systemPrompt, + route: options.route ?? persona.route ?? "default", + temperature: persona.temperature ?? undefined, + maxTurnChars: persona.maxTurnChars ?? undefined, + })); + + const store = new collab.InMemoryChannelStore(); + const { buildGroundingQuery, toExcerpt } = collab; + + const tokenize = (t: string) => + new Set(t.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3)); + + const { orchestrator, config } = await collab.createMeeting({ + store, + workspaceId: "compare", + title: scenario.title, + objective: scenario.objective, + agenda: scenario.agenda, + participants, + runtimes: [ + new collab.LlmAgentRuntime(createCollabChatFn({ maxOutputTokens: 2400 }), { + nodeId: "local", + }), + ], + turnPolicy: { kind: pack.suggested.turnPolicy, moderatorId: pack.suggested.moderatorKey }, + maxTurns: options.turns, + groundingProvider: { + retrieve: async (request) => { + const query = tokenize(buildGroundingQuery(request)); + const ranked = CORPUS.map((entry) => { + const words = tokenize(entry.text); + let hits = 0; + for (const word of query) if (words.has(word)) hits++; + return { entry, score: hits / Math.max(query.size, 1) }; + }) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 3); + + return { + passages: ranked.map((r) => `${r.entry.label}: ${r.entry.text}`), + sources: ranked.map((r) => ({ label: r.entry.label, excerpt: toExcerpt(r.entry.text) })), + }; + }, + }, + }); + + const started = Date.now(); + await orchestrator.run({ limit: options.turns }); + const elapsedMs = Date.now() - started; + + const transcript = await store.read(config.channelId); + const state = orchestrator.getState(); + const minutes = collab.buildMinutes(config, state, transcript); + const said = transcript + .filter((m) => m.kind === "chat") + .map((m) => m.text) + .join("\n\n"); + + return summarise( + scenario, + "meeting", + repeat, + said, + elapsedMs, + state.turnIndex, + minutes.decisions.length, + minutes.actionItems.filter((a) => a.owner).length, + ); +} + +// --------------------------------------------------------------------------- + +function mean(values: number[]): number { + return values.length === 0 ? 0 : values.reduce((a, b) => a + b, 0) / values.length; +} + +function aggregate(results: RunResult[], scenarioId: string, condition: RunResult["condition"]) { + const rows = results.filter((r) => r.scenarioId === scenarioId && r.condition === condition); + return { + runs: rows.length, + passagesUsed: mean(rows.map((r) => r.passagesUsed)), + traceable: mean(rows.map((r) => r.traceable)), + untraceable: mean(rows.map((r) => r.untraceable)), + decisions: mean(rows.map((r) => r.decisions)), + ownedActionItems: mean(rows.map((r) => r.ownedActionItems)), + elapsedMs: mean(rows.map((r) => r.elapsedMs)), + outputChars: mean(rows.map((r) => r.outputChars)), + turns: mean(rows.map((r) => r.turns)), + }; +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + await loadEnvironment(options.envFile); + + const scenarios = options.only + ? SCENARIOS.filter((s) => s.id === options.only) + : SCENARIOS; + + const results: RunResult[] = []; + const failures: Array<{ + scenarioId: string; + condition: string; + repeat: number; + error: string; + }> = []; + + for (const scenario of scenarios) { + for (let repeat = 1; repeat <= options.repeats; repeat++) { + for (const condition of ["single", "meeting"] as const) { + if (!options.json) { + process.stderr.write(` ${scenario.id} · ${condition} · run ${repeat}\n`); + } + try { + const result = + condition === "single" + ? await runSingle(scenario, repeat, options) + : await runMeeting(scenario, repeat, options); + results.push(result); + } catch (err) { + // Recorded, not just logged. A dropped run silently shrinks a cell's + // sample size, and an average over one run reads identically to an + // average over five unless the report says otherwise. + failures.push({ + scenarioId: scenario.id, + condition, + repeat, + error: err instanceof Error ? err.message : String(err), + }); + process.stderr.write( + ` ! ${scenario.id}/${condition}/${repeat} failed: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + } + } + + const report = scenarios.map((scenario) => ({ + scenario: { + id: scenario.id, + title: scenario.title, + hypothesis: scenario.hypothesis, + expectMeetingWins: scenario.expectMeetingWins, + }, + single: aggregate(results, scenario.id, "single"), + meeting: aggregate(results, scenario.id, "meeting"), + })); + + if (options.json) { + console.log(JSON.stringify({ repeats: options.repeats, failures, results, report }, null, 2)); + return; + } + + if (failures.length > 0) { + console.log(`\n ${failures.length} run(s) failed — cells below are averaged over fewer samples:`); + for (const f of failures) { + console.log(` ${f.scenarioId}/${f.condition}/${f.repeat}: ${f.error}`); + } + } + + for (const entry of report) { + console.log(`\n ${entry.scenario.title} [${entry.scenario.id}]`); + console.log(` n: single ${entry.single.runs}, meeting ${entry.meeting.runs}`); + console.log(` expectation: ${entry.scenario.expectMeetingWins ? "room wins" : "room should NOT win"}`); + console.log( + ` ${"".padEnd(22)}${"single".padStart(9)}${"meeting".padStart(10)}`, + ); + const row = (label: string, a: number, b: number, digits = 1) => + console.log( + ` ${label.padEnd(22)}${a.toFixed(digits).padStart(9)}${b.toFixed(digits).padStart(10)}`, + ); + row("source passages used", entry.single.passagesUsed, entry.meeting.passagesUsed); + row("traceable figures", entry.single.traceable, entry.meeting.traceable); + row("untraceable figures", entry.single.untraceable, entry.meeting.untraceable); + row("decisions recorded", entry.single.decisions, entry.meeting.decisions); + row("owned action items", entry.single.ownedActionItems, entry.meeting.ownedActionItems); + row("seconds", entry.single.elapsedMs / 1000, entry.meeting.elapsedMs / 1000); + row("output chars", entry.single.outputChars, entry.meeting.outputChars, 0); + } + console.log(); +} + +main().catch((err: unknown) => { + console.error(`[compare] fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`); + process.exit(1); +}); diff --git a/apps/web/scripts/meeting-fixtures.ts b/apps/web/scripts/meeting-fixtures.ts new file mode 100644 index 000000000..e2357b672 --- /dev/null +++ b/apps/web/scripts/meeting-fixtures.ts @@ -0,0 +1,178 @@ +/** + * Shared fixture corpus and scenarios for the live meeting harnesses. + * + * One corpus, used by both `run-live-meeting.ts` and + * `compare-meeting-vs-single.ts`, so a comparison between a meeting and a + * single agent is a comparison of the *method* and not of what each was + * allowed to read. + * + * The passages carry real, distinct figures on purpose. Distinctness is what + * makes attribution possible after the fact: a number that appears in exactly + * one passage is evidence that passage was actually drawn on, which is how + * breadth-of-evidence is measured without a human reading the output. + */ + +export interface CorpusPassage { + label: string; + text: string; +} + +export const CORPUS: CorpusPassage[] = [ + { + label: "Q2 retention review · p.2", + text: "Logo churn was 4.1% in Q2 across 1,240 active accounts, up from 3.2% in Q1. The increase is concentrated in accounts under $500 MRR, which churned at 6.8%. Accounts above $2,000 MRR churned at 0.9%.", + }, + { + label: "Q2 retention review · p.5", + text: "Exit survey responses (n=88) cite 'could not tell whether the product was working' as the leading reason for cancellation at 41%, ahead of price at 22% and missing integrations at 18%.", + }, + { + label: "Engineering capacity plan · p.1", + text: "The platform team has 3 engineers and 6 sprint-weeks of uncommitted capacity this quarter. The billing migration is already committed and consumes 4 of those weeks. Any new surface requires a schema change to the events table, which has no backfill tooling.", + }, + { + label: "Instrumentation audit · p.3", + text: "Product usage events are captured for 61% of active workspaces. Coverage gaps are concentrated in self-serve accounts created before March, where the analytics SDK was never installed. There is no server-side event stream.", + }, + { + label: "Pipeline snapshot · p.1", + text: "Four enterprise prospects totalling $310k ARR have named 'usage reporting for our admins' as a requirement in the current cycle. Two are in security review and expect delivery within the quarter.", + }, + { + label: "Pricing experiment memo · p.2", + text: "The Growth tier at $99/month accounts for 58% of new self-serve revenue. An admin-reporting feature was tested as a Growth-tier upsell in March and 12 of 140 trials converted, a 8.6% attach rate.", + }, +]; + +export interface Scenario { + id: string; + title: string; + objective: string; + agenda: string[]; + /** + * What this scenario is testing. Stated so a result that contradicts it is + * legible as a finding rather than quietly reinterpreted afterwards. + */ + hypothesis: string; + /** Whether a room is expected to beat one agent here. */ + expectMeetingWins: boolean; +} + +export const SCENARIOS: Scenario[] = [ + { + id: "contested-tradeoff", + title: "Retention dashboard — build or defer", + objective: + "Decide whether to build the customer-facing retention dashboard this quarter, and name who owns the next step", + agenda: [ + "Is churn actually the problem the data describes?", + "What would v1 have to include, and what is explicitly cut?", + "What does it cost in engineering weeks, and what does it displace?", + "Does it unblock revenue this quarter?", + ], + hypothesis: + "The evidence genuinely conflicts — revenue pressure pulls one way, capacity and data quality pull the other. A single voice resolves that tension privately and reports the settled version.", + expectMeetingWins: true, + }, + { + id: "multi-constraint-commit", + title: "Enterprise commitment without breaking the migration", + objective: + "Decide what we can commit to the four enterprise prospects this quarter without displacing the billing migration, and name who owns it", + agenda: [ + "What did the prospects actually ask for?", + "What can be delivered inside the uncommitted capacity?", + "Is the underlying data good enough to promise a report on?", + "What do we tell them, and who tells them?", + ], + hypothesis: + "Three hard constraints from three different sources must all hold simultaneously. Missing any one produces a confident answer that is wrong.", + expectMeetingWins: true, + }, + { + id: "single-fact-lookup", + title: "Q2 churn summary", + objective: + "Summarise Q2 churn: the rate, the direction versus Q1, and which segment drove it", + agenda: ["What was the rate?", "Which segment drove it?"], + // The null case. A comparison that only contains questions the tool is + // good at is marketing, not evidence. + hypothesis: + "One passage answers this completely. A room should show no advantage and should cost far more — if it 'wins' here, the metric is measuring verbosity.", + expectMeetingWins: false, + }, +]; + +// --------------------------------------------------------------------------- +// Attribution +// --------------------------------------------------------------------------- + +const FIGURE_PATTERN = /\b\d+(?:[.,]\d+)?%?\b/g; + +/** Every numeric claim in a piece of text, deduplicated. */ +export function figuresIn(text: string): Set { + return new Set([...text.matchAll(FIGURE_PATTERN)].map((m) => m[0])); +} + +/** + * Figures that appear in exactly one passage. + * + * Only these can attribute an output to a source: "3" appears in several + * passages and proves nothing, whereas "6.8" appears only in the retention + * review and so is evidence that passage was read. + */ +export function distinctiveFigures(corpus: CorpusPassage[]): Map { + const owners = new Map>(); + for (const passage of corpus) { + for (const figure of figuresIn(passage.text)) { + const set = owners.get(figure) ?? new Set(); + set.add(passage.label); + owners.set(figure, set); + } + } + + const unique = new Map(); + for (const [figure, labels] of owners) { + if (labels.size === 1) unique.set(figure, [...labels][0]!); + } + return unique; +} + +export interface EvidenceReport { + /** Numeric claims made in the output. */ + figuresUsed: number; + /** Claims that appear somewhere in the corpus. */ + traceable: number; + /** Claims that appear nowhere in the corpus — invented, or arithmetic. */ + untraceable: string[]; + /** Distinct source passages the output demonstrably drew on. */ + passagesUsed: string[]; +} + +/** How much of the evidence an output actually engaged with, and how honestly. */ +export function reportEvidence(text: string, corpus: CorpusPassage[]): EvidenceReport { + const haystack = corpus.map((p) => p.text).join(" ").toLowerCase(); + const unique = distinctiveFigures(corpus); + const figures = figuresIn(text); + + const untraceable: string[] = []; + const passages = new Set(); + let traceable = 0; + + for (const figure of figures) { + if (haystack.includes(figure.toLowerCase())) { + traceable++; + const owner = unique.get(figure); + if (owner) passages.add(owner); + } else { + untraceable.push(figure); + } + } + + return { + figuresUsed: figures.size, + traceable, + untraceable, + passagesUsed: [...passages], + }; +} diff --git a/apps/web/scripts/probe-planted-error.ts b/apps/web/scripts/probe-planted-error.ts new file mode 100644 index 000000000..2f780c44c --- /dev/null +++ b/apps/web/scripts/probe-planted-error.ts @@ -0,0 +1,387 @@ +#!/usr/bin/env tsx +/** + * Does a room catch a false premise that one agent swallows? + * + * pnpm --filter @launchstack/web meeting:probe -- --env-file=/path/.env --repeats=3 + * + * The head-to-head in `compare-meeting-vs-single.ts` measured evidence + * handling and found no advantage for the room. It did not measure the actual + * claim for multi-agent work: that independent viewpoints challenge a premise + * a single voice accepts. This probe tests that directly. + * + * Each scenario states a **false premise in the objective itself** and puts a + * passage in the corpus that contradicts it. Catching it means contradicting + * the instruction you were given — which is the behaviour under test. + * + * The one design decision that matters: **both conditions receive the entire + * corpus, verbatim.** The meeting gets it pinned as `context`, not retrieved. + * Otherwise a miss could mean "retrieval never surfaced the passage" rather + * than "nobody questioned it", and the result would be about the retriever + * instead of about deliberation. + */ + +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CORPUS } from "./meeting-fixtures"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +interface Probe { + id: string; + title: string; + /** Contradicts the premise below. Added to the corpus both sides read. */ + plantedPassage: { label: string; text: string }; + /** States something the planted passage refutes. */ + objective: string; + agenda: string[]; + /** Proof the contradicting passage was engaged with, not just present. */ + caughtPattern: RegExp; + /** The refuted value, asserted as if current. */ + stalePattern: RegExp; + /** What a correct answer concludes instead. */ + correctConclusion: string; +} + +const PROBES: Probe[] = [ + { + id: "superseded-capacity", + title: "Capacity the plan no longer has", + plantedPassage: { + label: "Platform re-plan · July 28", + text: "The billing migration was re-scoped after the payments vendor change and now consumes 7 sprint-weeks, not the 4 originally planned. No additional headcount was approved for this quarter.", + }, + objective: + "We have 2 sprint-weeks of free platform capacity after the billing migration. Decide whether to spend them building the retention dashboard this quarter, and name who owns the next step", + agenda: [ + "Is the dashboard the right use of the remaining capacity?", + "What would v1 include?", + "Who owns the next step?", + ], + // Semantic, not literal. The first version of this matched only the exact + // words "re-scoped" / "7 sprint" and scored a turn reading "the re-plan + // shows the migration consumed all of it — we're actually one week over + // budget" as a MISS. The bias was asymmetric: short meeting turns + // paraphrase, long single-agent analyses quote, so a literal matcher + // systematically under-credits the room. Every alternation below was added + // after reading a real output it wrongly failed. + caughtPattern: + /re-?scop|re-?plan|7 sprint|seven sprint|over-?committed|over[- ]budget|negative capacity|no (free|spare|remaining|uncommitted) capacity|consumed all|do not (even )?have|don't (even )?have (the|that|any)/i, + stalePattern: /\b(2|two) (sprint-?)?weeks?\b/i, + correctConclusion: + "There is no free capacity — the re-scoped migration consumes 7 of the 6 available sprint-weeks, so the team is already over-committed.", + }, + { + id: "dead-pipeline", + title: "Revenue that already walked", + plantedPassage: { + label: "CRM export · August 3", + text: "Two of the four enterprise prospects named in the pipeline snapshot were marked Closed Lost in July. Remaining committed ARR from that group is $95k, not $310k.", + }, + objective: + "The four enterprise prospects represent $310k ARR in the current cycle. Decide what we commit to them this quarter, and name who owns it", + agenda: [ + "What did the prospects ask for?", + "Is the revenue worth the engineering cost?", + "Who owns the commitment?", + ], + caughtPattern: + /closed lost|\$?95k|95,000|two of the four|only two|no longer four|walked|lost two/i, + stalePattern: /\$?310k|310,000/i, + correctConclusion: + "Only two prospects remain and the real figure is $95k, which changes whether the work clears its cost.", + }, + { + id: "stale-instrumentation", + title: "A gap that was already closed", + plantedPassage: { + label: "Data platform note · July 19", + text: "The analytics SDK backfill shipped in June. Usage events now cover 94% of active workspaces. The 61% figure in the instrumentation audit predates the backfill and is no longer current.", + }, + objective: + "Our usage instrumentation covers only 61% of workspaces. Decide whether that is sufficient to launch customer-facing usage reporting this quarter, and name who owns it", + agenda: [ + "Is the data good enough to show customers?", + "What has to be true before launch?", + "Who owns it?", + ], + caughtPattern: + /94%|backfill (shipped|landed|completed)|no longer current|predates|out of date|outdated|superseded|stale/i, + stalePattern: /\b61%|\b39%/, + correctConclusion: + "Coverage is 94%, not 61% — the objection the question is built on no longer exists.", + }, +]; + +interface Options { + repeats: number; + turns: number; + json: boolean; + envFile: string | null; + dump: boolean; + only: string | null; +} + +function parseArgs(argv: string[]): Options { + const value = (flag: string): string | null => { + const hit = argv.find((a) => a.startsWith(`--${flag}=`)); + return hit ? hit.slice(flag.length + 3) : null; + }; + const repeats = Number(value("repeats")); + const turns = Number(value("turns")); + return { + repeats: Number.isFinite(repeats) && repeats > 0 ? repeats : 3, + turns: Number.isFinite(turns) && turns > 0 ? turns : 10, + json: argv.includes("--json"), + envFile: value("env-file"), + dump: argv.includes("--dump"), + only: value("only"), + }; +} + +async function loadEnvironment(explicit: string | null): Promise { + const candidates = explicit + ? [resolve(explicit)] + : [join(HERE, "..", ".env"), join(HERE, "..", "..", "..", ".env")]; + const found = candidates.find((path) => existsSync(path)); + if (!found) return null; + const dotenv = await import("dotenv"); + dotenv.config({ path: found, quiet: true }); + return found; +} + +/** The corpus both sides read: the shared fixtures plus the contradiction. */ +function corpusFor(probe: Probe): string[] { + return [...CORPUS, probe.plantedPassage].map((p) => `${p.label}: ${p.text}`); +} + +interface ProbeResult { + probeId: string; + condition: "single" | "meeting"; + repeat: number; + /** Engaged with the contradicting passage. */ + caught: boolean; + /** Repeated the refuted value. */ + repeatedStale: boolean; + elapsedMs: number; + chars: number; + text: string; +} + +const OUTPUT_RULES = + "End with a line starting with exactly `Decision:` followed by one sentence, then a line starting with exactly `Next step:` naming an owner and a date."; + +function score( + probe: Probe, + condition: ProbeResult["condition"], + repeat: number, + text: string, + elapsedMs: number, +): ProbeResult { + return { + probeId: probe.id, + condition, + repeat, + caught: probe.caughtPattern.test(text), + repeatedStale: probe.stalePattern.test(text), + elapsedMs, + chars: text.length, + text, + }; +} + +async function runSingle(probe: Probe, repeat: number): Promise { + const { createCollabChatFn } = await import("~/server/collab/chat"); + const chat = createCollabChatFn({ maxOutputTokens: 12000 }); + + const system = [ + "You are an experienced startup operator advising a founder. You are the only adviser in the room.", + "", + "Weigh every angle that bears on the decision: the user problem and scope, engineering feasibility and cost, design, growth and revenue, and whether the data supports the premise.", + "Name the tradeoffs explicitly, including the strongest case against your own recommendation.", + "", + "## Evidence", + "These passages are the only known-good material. Do not invent figures beyond them; say plainly when something is unknown.", + ...corpusFor(probe).map((p, i) => `[${i + 1}] ${p}`), + "", + "## Objective", + probe.objective, + "", + "Work through each of these:", + ...probe.agenda.map((a, i) => `${i + 1}. ${a}`), + "", + "## Output", + OUTPUT_RULES, + ].join("\n"); + + const started = Date.now(); + + // `gemini-2.5-pro` intermittently spends its whole budget on thinking and + // returns no text, which the chat adapter surfaces as an empty-turn error. + // Untreated it killed 4 of 9 single runs and silently halved the sample on + // one arm of the comparison — a reliability artifact masquerading as data. + let text = ""; + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + text = await chat({ + messages: [ + { role: "system", content: system }, + { role: "user", content: "Give me your recommendation." }, + ], + route: "reasoning", + }); + break; + } catch (err) { + lastError = err; + } + } + if (!text) throw lastError instanceof Error ? lastError : new Error(String(lastError)); + + return score(probe, "single", repeat, text, Date.now() - started); +} + +async function runMeeting(probe: Probe, repeat: number, options: Options): Promise { + const [{ PERSONA_PACKS }, collab, { createCollabChatFn }] = await Promise.all([ + import("~/server/collab/presets"), + import("@launchstack/core/collab"), + import("~/server/collab/chat"), + ]); + + const pack = PERSONA_PACKS.find((p) => p.id === "startup-core")!; + const store = new collab.InMemoryChannelStore(); + + const { orchestrator, config } = await collab.createMeeting({ + store, + workspaceId: "probe", + title: probe.title, + objective: probe.objective, + agenda: probe.agenda, + participants: pack.personas.map((persona) => ({ + id: persona.key, + displayName: persona.displayName, + role: persona.role, + systemPrompt: persona.systemPrompt, + route: persona.route ?? "default", + temperature: persona.temperature ?? undefined, + maxTurnChars: persona.maxTurnChars ?? undefined, + })), + runtimes: [ + new collab.LlmAgentRuntime(createCollabChatFn({ maxOutputTokens: 2400 }), { nodeId: "local" }), + ], + turnPolicy: { kind: pack.suggested.turnPolicy, moderatorId: pack.suggested.moderatorKey }, + maxTurns: options.turns, + // Pinned, not retrieved — both sides must read exactly the same text, or a + // miss is a retrieval failure rather than a failure to question anything. + context: corpusFor(probe), + }); + + const started = Date.now(); + await orchestrator.run({ limit: options.turns }); + const elapsedMs = Date.now() - started; + + const transcript = await store.read(config.channelId); + const said = transcript + .filter((m) => m.kind === "chat") + .map((m) => m.text) + .join("\n\n"); + + return score(probe, "meeting", repeat, said, elapsedMs); +} + +function pct(n: number, d: number): string { + return d === 0 ? "—" : `${Math.round((n / d) * 100)}%`; +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + await loadEnvironment(options.envFile); + + const probes = options.only ? PROBES.filter((p) => p.id === options.only) : PROBES; + const results: ProbeResult[] = []; + const failures: Array<{ probeId: string; condition: string; repeat: number; error: string }> = []; + + for (const probe of probes) { + for (let repeat = 1; repeat <= options.repeats; repeat++) { + for (const condition of ["single", "meeting"] as const) { + process.stderr.write(` ${probe.id} · ${condition} · ${repeat}\n`); + try { + const result = + condition === "single" + ? await runSingle(probe, repeat) + : await runMeeting(probe, repeat, options); + results.push(result); + if (options.dump) { + process.stderr.write( + `\n----- ${condition.toUpperCase()} / ${probe.id} / ${repeat} · caught=${result.caught} stale=${result.repeatedStale}\n${result.text}\n-----\n`, + ); + } + } catch (err) { + failures.push({ + probeId: probe.id, + condition, + repeat, + error: err instanceof Error ? err.message : String(err), + }); + process.stderr.write(` ! failed: ${err instanceof Error ? err.message : String(err)}\n`); + } + } + } + } + + const report = probes.map((probe) => { + const cell = (condition: ProbeResult["condition"]) => { + const rows = results.filter((r) => r.probeId === probe.id && r.condition === condition); + return { + n: rows.length, + caught: rows.filter((r) => r.caught).length, + repeatedStale: rows.filter((r) => r.repeatedStale).length, + /** Repeated the refuted value without ever engaging the correction. */ + misled: rows.filter((r) => r.repeatedStale && !r.caught).length, + meanSeconds: rows.length === 0 ? 0 : rows.reduce((s, r) => s + r.elapsedMs, 0) / rows.length / 1000, + }; + }; + return { + probe: { id: probe.id, title: probe.title, correctConclusion: probe.correctConclusion }, + single: cell("single"), + meeting: cell("meeting"), + }; + }); + + if (options.json) { + // Transcripts excluded — the report is the finding, and the texts are large. + console.log( + JSON.stringify( + { repeats: options.repeats, failures, report, results: results.map(({ text: _t, ...r }) => r) }, + null, + 2, + ), + ); + return; + } + + if (failures.length > 0) { + console.log(`\n ${failures.length} run(s) failed — cells averaged over fewer samples.`); + } + for (const entry of report) { + console.log(`\n ${entry.probe.title} [${entry.probe.id}]`); + console.log(` correct answer: ${entry.probe.correctConclusion}`); + console.log(` ${"".padEnd(16)}${"single".padStart(10)}${"meeting".padStart(11)}`); + console.log( + ` ${"caught it".padEnd(16)}${`${entry.single.caught}/${entry.single.n} ${pct(entry.single.caught, entry.single.n)}`.padStart(10)}${`${entry.meeting.caught}/${entry.meeting.n} ${pct(entry.meeting.caught, entry.meeting.n)}`.padStart(11)}`, + ); + console.log( + ` ${"misled".padEnd(16)}${`${entry.single.misled}/${entry.single.n}`.padStart(10)}${`${entry.meeting.misled}/${entry.meeting.n}`.padStart(11)}`, + ); + console.log( + ` ${"seconds".padEnd(16)}${entry.single.meanSeconds.toFixed(0).padStart(10)}${entry.meeting.meanSeconds.toFixed(0).padStart(11)}`, + ); + } + console.log(); +} + +main().catch((err: unknown) => { + console.error(`[probe] fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`); + process.exit(1); +}); diff --git a/apps/web/scripts/run-live-meeting.ts b/apps/web/scripts/run-live-meeting.ts new file mode 100644 index 000000000..8bee13d9a --- /dev/null +++ b/apps/web/scripts/run-live-meeting.ts @@ -0,0 +1,348 @@ +#!/usr/bin/env tsx +/** + * Runs a REAL meeting against a REAL model, then scores it. + * + * pnpm --filter @launchstack/web meeting:live + * pnpm --filter @launchstack/web meeting:live -- --route=fast --turns=8 + * pnpm --filter @launchstack/web meeting:live -- --ground --json + * + * This is the gap the rest of the suite cannot close. `evals:meetings` scores + * *orchestration* with scripted utterances, which is what makes it + * deterministic and free — but it means the preset prompts have never been + * observed steering an actual model. A prompt that reads well and produces six + * agreeable paraphrases passes every existing test. + * + * So: no Postgres (the channel is in-memory), no Clerk, no HTTP. Just the + * roster, the orchestrator, and the deployment's configured chat models. + * + * Exits non-zero when the meeting misses the evaluation thresholds, so this can + * gate a prompt change rather than merely illustrate one. + */ + +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CORPUS } from "./meeting-fixtures"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +interface Options { + packId: string; + route: string | null; + turns: number; + ground: boolean; + json: boolean; + envFile: string | null; + policy: "round_robin" | "moderated" | "reactive" | null; +} + +function parseArgs(argv: string[]): Options { + const value = (flag: string): string | null => { + const hit = argv.find((a) => a.startsWith(`--${flag}=`)); + return hit ? hit.slice(flag.length + 3) : null; + }; + const turns = Number(value("turns")); + + const rawPolicy = value("policy"); + const POLICIES = ["round_robin", "moderated", "reactive"] as const; + if (rawPolicy !== null && !(POLICIES as readonly string[]).includes(rawPolicy)) { + console.error(`--policy must be one of ${POLICIES.join(", ")} (got "${rawPolicy}")`); + process.exit(2); + } + + return { + packId: value("pack") ?? "startup-core", + route: value("route"), + turns: Number.isFinite(turns) && turns > 0 ? turns : 12, + ground: argv.includes("--ground"), + json: argv.includes("--json"), + envFile: value("env-file"), + policy: (rawPolicy as Options["policy"]) ?? null, + }; +} + +/** + * Loads the environment *before* anything that reads it is imported. + * + * Every module on the chat path pulls `~/env`, which validates at import time — + * so a static import of the model layer would evaluate the schema against an + * empty environment and fail before this ever ran. Hence the dynamic imports + * further down; the ordering is load-bearing, not stylistic. + */ +async function loadEnvironment(explicit: string | null): Promise { + const candidates = explicit + ? [resolve(explicit)] + : [ + // apps/web/.env, then the repo root — a worktree often has neither, + // in which case --env-file points at the checkout that does. + join(HERE, "..", ".env"), + join(HERE, "..", "..", "..", ".env"), + ]; + + const found = candidates.find((path) => existsSync(path)); + if (!found) return null; + + const dotenv = await import("dotenv"); + dotenv.config({ path: found, quiet: true }); + return found; +} + +// --------------------------------------------------------------------------- +// Grounding fixture +// --------------------------------------------------------------------------- + +/** + * The corpus lives in `./meeting-fixtures` so this harness and the + * single-agent comparison read from exactly the same evidence — otherwise a + * difference between them could be a difference in what each was allowed to + * see rather than in method. + * + * The point of `--ground` here is to exercise the retrieval *port* and see + * whether the agents actually cite what they are handed — not to test the + * ensemble retriever, which has its own tests and needs a database. Scoring is + * plain token overlap: deterministic, and good enough to put the right passage + * in front of the right persona. + */ +function tokenize(text: string): Set { + return new Set( + text + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((w) => w.length > 3), + ); +} + +async function buildFixtureGrounding(passagesPerTurn = 3) { + const { buildGroundingQuery, toExcerpt } = await import("@launchstack/core/collab"); + type Request = Parameters[0]; + + return { + retrieve: async (request: Request) => { + const query = tokenize(buildGroundingQuery(request)); + const ranked = CORPUS.map((entry) => { + const words = tokenize(entry.text); + let hits = 0; + for (const word of query) if (words.has(word)) hits++; + return { entry, score: hits / Math.max(query.size, 1) }; + }) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, passagesPerTurn); + + return { + passages: ranked.map((r) => `${r.entry.label}: ${r.entry.text}`), + sources: ranked.map((r) => ({ + label: r.entry.label, + score: Math.round(r.score * 1000) / 1000, + excerpt: toExcerpt(r.entry.text), + })), + }; + }, + }; +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +const BAR_WIDTH = 18; + +function bar(score: number): string { + const filled = Math.round(score * BAR_WIDTH); + return `${"█".repeat(filled)}${"·".repeat(BAR_WIDTH - filled)}`; +} + +function wrap(text: string, width: number, indent: string): string { + const words = text.split(/\s+/); + const lines: string[] = []; + let line = ""; + for (const word of words) { + if (line.length + word.length + 1 > width) { + lines.push(line); + line = word; + } else { + line = line ? `${line} ${word}` : word; + } + } + if (line) lines.push(line); + return lines.map((l, i) => (i === 0 ? l : indent + l)).join("\n"); +} + +// --------------------------------------------------------------------------- + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + const envPath = await loadEnvironment(options.envFile); + + // Everything below is imported dynamically so the environment above is in + // place first. See loadEnvironment(). + const [{ PERSONA_PACKS }, collab, models] = await Promise.all([ + import("~/server/collab/presets"), + import("@launchstack/core/collab"), + import("~/lib/models"), + ]); + + const pack = PERSONA_PACKS.find((p) => p.id === options.packId); + if (!pack) { + console.error( + `Unknown pack "${options.packId}". Available: ${PERSONA_PACKS.map((p) => p.id).join(", ")}`, + ); + process.exit(2); + } + + // Resolve the endpoint before spending anything, and downgrade any persona + // route this deployment does not serve rather than dying on turn one. + // + // `routes` always carries all four keys — `reasoning` and `vision` can come + // back `{ available: false }` when no model is assigned and the default + // declares no such capability. Key presence proves nothing; read the flag. + let available: Set; + try { + const publicConfig = models.getConfiguredPublicChatConfig(); + available = new Set( + Object.entries(publicConfig.routes) + .filter(([, info]) => info.available) + .map(([route]) => route), + ); + } catch (err) { + console.error( + `Could not resolve the chat configuration: ${err instanceof Error ? err.message : String(err)}`, + ); + console.error( + envPath + ? `Environment loaded from ${envPath}. Check CHAT_API_KEY / CHAT_BASE_URL.` + : "No .env file was found. Pass --env-file=/path/to/.env.", + ); + process.exit(2); + } + + const downgraded: string[] = []; + const participants = pack.personas.map((persona) => { + const wanted = options.route ?? persona.route ?? "default"; + const route = available.has(wanted) ? wanted : "default"; + if (route !== wanted) downgraded.push(`@${persona.key} ${wanted}→default`); + return { + id: persona.key, + displayName: persona.displayName, + role: persona.role, + systemPrompt: persona.systemPrompt, + route, + temperature: persona.temperature ?? undefined, + maxTurnChars: persona.maxTurnChars ?? undefined, + accent: persona.accent ?? undefined, + }; + }); + + const store = new collab.InMemoryChannelStore(); + const runtime = new collab.LlmAgentRuntime( + // Generous on purpose: the reasoning route spends this budget on thinking + // tokens before it emits any text, so a tight cap truncates the visible + // turn mid-sentence. Per-persona `maxTurnChars` is what actually keeps + // turns short, and it trims after the fact. + (await import("~/server/collab/chat")).createCollabChatFn({ maxOutputTokens: 2400 }), + { nodeId: "local" }, + ); + + const policy = options.policy ?? pack.suggested.turnPolicy; + const { orchestrator, config } = await collab.createMeeting({ + store, + workspaceId: "live-harness", + title: "Retention dashboard — build or defer", + objective: + "Decide whether to build the customer-facing retention dashboard this quarter, and name who owns the next step", + agenda: [ + "Is churn actually the problem the data describes?", + "What would v1 have to include, and what is explicitly cut?", + "What does it cost in engineering weeks, and what does it displace?", + "Does it unblock revenue this quarter?", + ], + participants, + runtimes: [runtime], + turnPolicy: { kind: policy, moderatorId: pack.suggested.moderatorKey }, + maxTurns: options.turns, + groundingProvider: options.ground ? await buildFixtureGrounding() : undefined, + }); + + if (!options.json) { + console.log(`\n ${pack.name} · ${policy} · up to ${options.turns} turns`); + console.log(` ${participants.map((p) => `@${p.id}(${p.route})`).join(" ")}`); + console.log(` grounding: ${options.ground ? `${CORPUS.length} fixture passages` : "off"}`); + console.log(` env: ${envPath ?? "process environment only"}`); + if (downgraded.length > 0) { + // Said out loud: a reasoning persona quietly served by the flash model is + // a different experiment from the one the preset describes. + console.log(` routes unavailable — ${downgraded.join(", ")}`); + } + console.log(); + console.log(" " + "─".repeat(72) + "\n"); + } + + const startedAt = Date.now(); + await orchestrator.run({ + limit: options.turns, + onStep: (result) => { + if (options.json || !result.message) return; + const { message } = result; + const grounding = Array.isArray(message.meta?.grounding) + ? (message.meta.grounding as Array<{ label?: string }>) + : null; + + console.log(` @${message.author.id}`); + console.log(` ${wrap(message.text, 70, " ")}`); + if (grounding && grounding.length > 0) { + console.log(` ↳ read: ${grounding.map((g) => g.label).join(" · ")}`); + } + console.log(); + }, + }); + + const elapsedMs = Date.now() - startedAt; + const transcript = await store.read(config.channelId); + const state = orchestrator.getState(); + const minutes = collab.buildMinutes(config, state, transcript); + const evaluation = collab.evaluateMeeting(config, state, transcript); + + if (options.json) { + console.log( + JSON.stringify( + { pack: pack.id, policy, route: options.route, grounded: options.ground, elapsedMs, state, minutes, evaluation }, + null, + 2, + ), + ); + } else { + console.log(" " + "─".repeat(72)); + console.log(`\n Ended ${state.status} after ${state.turnIndex} turns (${Math.round(elapsedMs / 1000)}s)\n`); + + console.log(" Decisions"); + if (minutes.decisions.length === 0) console.log(" (none recorded)"); + for (const d of minutes.decisions) console.log(` · ${wrap(d.text, 66, " ")} [seq ${d.sourceSeq}]`); + + console.log("\n Action items"); + if (minutes.actionItems.length === 0) console.log(" (none recorded)"); + for (const a of minutes.actionItems) { + console.log(` · ${wrap(a.text, 62, " ")}${a.owner ? ` → @${a.owner}` : " → unowned"}`); + } + + console.log("\n Scores"); + for (const dimension of evaluation.dimensions) { + const flag = dimension.weight === 0 ? " (abstained)" : ""; + console.log( + ` ${dimension.id.padEnd(15)} ${bar(dimension.score)} ${dimension.score.toFixed(2)} ${dimension.detail}${flag}`, + ); + } + console.log( + `\n overall ${evaluation.overall.toFixed(3)} — ${evaluation.passed ? "PASS" : "FAIL"}${ + evaluation.failures.length > 0 ? ` (floors missed: ${evaluation.failures.join(", ")})` : "" + }\n`, + ); + } + + if (!evaluation.passed) process.exit(1); +} + +main().catch((err: unknown) => { + console.error(`[live-meeting] fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`); + process.exit(1); +}); diff --git a/apps/web/src/app/api/collab/agents/presets/route.ts b/apps/web/src/app/api/collab/agents/presets/route.ts new file mode 100644 index 000000000..4906d7115 --- /dev/null +++ b/apps/web/src/app/api/collab/agents/presets/route.ts @@ -0,0 +1,70 @@ +/** + * Preset agent teams: list the packs, or add one to this workspace. + * + * Applying is additive and never destructive — a handle already in use is + * reported back, not overwritten, because that handle is referenced by every + * past transcript and by the frozen roster on every meeting that used it. + */ + +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requireWorkspaceContext } from "~/lib/require-workspace-context"; +import { applyPersonas, listPersonas } from "~/server/collab/personas"; +import { getPack, listPackSummaries } from "~/server/collab/presets"; + +export const dynamic = "force-dynamic"; + +const ApplyPackSchema = z.object({ + packId: z.string().min(1).max(64), +}); + +export async function GET() { + const ctx = await requireWorkspaceContext(); + if (!ctx.success) return ctx.response; + + // Which handles are already taken, so the picker can warn before applying + // rather than reporting skips afterwards. + const existing = await listPersonas(ctx.data.companyId, true); + const taken = new Set(existing.map((p) => p.id)); + + return NextResponse.json({ + packs: listPackSummaries().map((pack) => ({ + ...pack, + conflicts: pack.personas.filter((p) => taken.has(p.key)).map((p) => p.key), + })), + }); +} + +export async function POST(request: Request) { + const ctx = await requireWorkspaceContext(); + if (!ctx.success) return ctx.response; + + const parsed = ApplyPackSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const pack = getPack(parsed.data.packId); + if (!pack) { + return NextResponse.json({ error: `Unknown preset "${parsed.data.packId}"` }, { status: 404 }); + } + + const result = await applyPersonas(ctx.data.companyId, pack.personas); + const personas = await listPersonas(ctx.data.companyId); + + return NextResponse.json( + { + pack: { id: pack.id, name: pack.name, suggested: pack.suggested }, + created: result.created, + skipped: result.skipped, + personas, + }, + // 200 rather than 201 when nothing was created: applying a pack twice is a + // no-op, and the caller should not be told it made something. + { status: result.created.length > 0 ? 201 : 200 }, + ); +} diff --git a/apps/web/src/app/api/collab/documents/route.ts b/apps/web/src/app/api/collab/documents/route.ts new file mode 100644 index 000000000..4b852e11b --- /dev/null +++ b/apps/web/src/app/api/collab/documents/route.ts @@ -0,0 +1,47 @@ +/** + * Documents a meeting can be pointed at. + * + * Its own route rather than a field on `/api/collab/agents` because the two + * answer different questions and are refetched at different times — the roster + * changes when someone edits an agent, the corpus changes when someone uploads. + * + * This list narrows what a meeting retrieves; it grants nothing. Every + * retrieval re-checks access against the meeting's creator at query time. + */ + +import { NextResponse } from "next/server"; +import { and, desc, eq } from "drizzle-orm"; + +import { document } from "@launchstack/core/db/schema"; +import { requireWorkspaceContext } from "~/lib/require-workspace-context"; +import { db } from "~/server/db"; + +export const dynamic = "force-dynamic"; + +/** The picker is a chooser, not a browser — newest first, capped. */ +const LIMIT = 200; + +export async function GET() { + const ctx = await requireWorkspaceContext(); + if (!ctx.success) return ctx.response; + + const rows = await db + .select({ + id: document.id, + title: document.title, + category: document.category, + }) + .from(document) + .where(and(eq(document.companyId, ctx.data.companyId))) + .orderBy(desc(document.id)) + .limit(LIMIT); + + return NextResponse.json({ + documents: rows.map((row) => ({ + id: String(row.id), + title: row.title, + category: row.category, + })), + truncated: rows.length === LIMIT, + }); +} diff --git a/apps/web/src/app/api/collab/meetings/[meetingId]/route.ts b/apps/web/src/app/api/collab/meetings/[meetingId]/route.ts index 2b6e0d7a4..ec583b217 100644 --- a/apps/web/src/app/api/collab/meetings/[meetingId]/route.ts +++ b/apps/web/src/app/api/collab/meetings/[meetingId]/route.ts @@ -49,6 +49,10 @@ export async function GET( channelId: runtime.config.channelId, channelSlug: channel?.slug ?? null, slack: runtime.config.slack ?? null, + grounding: { + enabled: runtime.row.groundingEnabled, + documentCount: runtime.row.documentIds.length, + }, createdAt: runtime.row.createdAt.toISOString(), }, state: runtime.orchestrator.getState(), diff --git a/apps/web/src/app/api/collab/meetings/route.ts b/apps/web/src/app/api/collab/meetings/route.ts index d4a10ff45..c35b21bf4 100644 --- a/apps/web/src/app/api/collab/meetings/route.ts +++ b/apps/web/src/app/api/collab/meetings/route.ts @@ -23,6 +23,14 @@ const CreateMeetingSchema = z.object({ moderatorKey: z.string().optional(), maxTurns: z.number().int().min(1).max(60).optional(), context: z.array(z.string().max(4000)).max(20).optional(), + /** Retrieve fresh passages for the speaking persona on every turn. */ + groundingEnabled: z.boolean().optional(), + /** + * Documents the meeting may read. Capped because every turn searches this + * set — a meeting pointed at the entire corpus retrieves noise and pays for + * it once per turn. + */ + documentIds: z.array(z.string().min(1).max(64)).max(25).optional(), channelId: z.string().optional(), slackChannelId: z.string().optional(), slackMirrorEnabled: z.boolean().optional(), @@ -124,6 +132,8 @@ export async function POST(request: Request) { }, maxTurns: input.maxTurns, context: input.context, + groundingEnabled: input.groundingEnabled, + documentIds: input.documentIds, channelId: input.channelId, slackChannelId: input.slackChannelId, slackMirrorEnabled: input.slackMirrorEnabled, diff --git a/apps/web/src/app/api/collab/rooms/[roomId]/ask/route.ts b/apps/web/src/app/api/collab/rooms/[roomId]/ask/route.ts new file mode 100644 index 000000000..4f4a72890 --- /dev/null +++ b/apps/web/src/app/api/collab/rooms/[roomId]/ask/route.ts @@ -0,0 +1,104 @@ +/** + * Ask the room. + * + * The request is held until every member has settled. That is deliberate: + * members run concurrently, so wall time is the slowest member rather than + * their sum, and `askRoom` appends each answer the moment it lands — so the + * client's existing `?afterSeq=` poll renders answers progressively while this + * request is still open. Returning 202 and continuing in the background would + * orphan the work, since this subsystem already assumes one long-lived process. + */ + +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { z } from "zod"; + +import { askRoom } from "@launchstack/core/collab"; +import { requireWorkspaceContext } from "~/lib/require-workspace-context"; +import { buildRoomRuntimes, getRoom, rowToConfig, type RoomMember } from "~/server/collab/rooms"; +import { getChannelStore } from "~/server/collab/store"; + +export const dynamic = "force-dynamic"; +/** Long enough for the slowest member; a round is concurrent, not sequential. */ +export const maxDuration = 300; + +const AskSchema = z.object({ + text: z.string().min(1).max(8000), + /** Ask a subset. Defaults to every member. */ + memberIds: z.array(z.string().min(1)).min(1).max(10).optional(), +}); + +export async function POST( + request: Request, + { params }: { params: Promise<{ roomId: string }> }, +) { + const ctx = await requireWorkspaceContext(); + if (!ctx.success) return ctx.response; + + const parsed = AskSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { roomId } = await params; + const row = await getRoom(roomId, ctx.data.companyId); + if (!row) return NextResponse.json({ error: "Room not found" }, { status: 404 }); + + const config = rowToConfig(row); + const members = row.members as RoomMember[]; + + if (parsed.data.memberIds) { + const known = new Set(members.map((m) => m.id)); + const unknown = parsed.data.memberIds.filter((id) => !known.has(id)); + if (unknown.length > 0) { + return NextResponse.json( + { error: `Not a member of this room: ${unknown.join(", ")}` }, + { status: 400 }, + ); + } + } + + const { sessionClaims } = await auth(); + const displayName = + (sessionClaims?.name as string | undefined) ?? + (sessionClaims?.email as string | undefined) ?? + "Teammate"; + + try { + const result = await askRoom({ + store: getChannelStore(), + room: config, + // Retrieval runs as the asker, never the room's creator — a room must not + // become a way to read documents you could not read yourself. + runtimes: buildRoomRuntimes({ members, actorUserId: ctx.data.clerkUserId }), + question: { + text: parsed.data.text, + author: { kind: "human", id: ctx.data.clerkUserId, displayName }, + }, + memberIds: parsed.data.memberIds, + }); + + return NextResponse.json( + { + roundId: result.roundId, + question: result.question, + answers: result.answers.map((a) => ({ + memberId: a.memberId, + status: a.status, + message: a.message, + latencyMs: a.latencyMs, + error: a.error, + })), + }, + { status: 201 }, + ); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Could not ask the room" }, + { status: 400 }, + ); + } +} diff --git a/apps/web/src/app/api/collab/rooms/[roomId]/route.ts b/apps/web/src/app/api/collab/rooms/[roomId]/route.ts new file mode 100644 index 000000000..26030e201 --- /dev/null +++ b/apps/web/src/app/api/collab/rooms/[roomId]/route.ts @@ -0,0 +1,64 @@ +/** + * One room: its members, the log, and every round derived from it. + * + * `?afterSeq=` returns only the tail, which is what the client polls while a + * round is in flight — answers land in the log the moment each member finishes, + * so progressive rendering falls out of the existing poll with no streaming + * transport involved. + */ + +import { NextResponse } from "next/server"; + +import { summarizeRounds } from "@launchstack/core/collab"; +import { requireWorkspaceContext } from "~/lib/require-workspace-context"; +import { getRoom, type RoomMember } from "~/server/collab/rooms"; +import { getChannelStore } from "~/server/collab/store"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ roomId: string }> }, +) { + const ctx = await requireWorkspaceContext(); + if (!ctx.success) return ctx.response; + + const { roomId } = await params; + const row = await getRoom(roomId, ctx.data.companyId); + if (!row) return NextResponse.json({ error: "Room not found" }, { status: 404 }); + + const { searchParams } = new URL(request.url); + const requested = Number(searchParams.get("afterSeq") ?? "0"); + const afterSeq = Number.isFinite(requested) && requested > 0 ? requested : 0; + + const store = getChannelStore(); + const [tail, channel] = await Promise.all([ + store.read(row.channelId, { afterSeq }), + store.getChannel(row.channelId), + ]); + + // Rounds need the whole log, not the requested tail — a round's question can + // sit far behind the answer that just arrived. + const full = afterSeq > 0 ? await store.read(row.channelId) : tail; + + return NextResponse.json({ + room: { + id: row.id, + name: row.name, + purpose: row.purpose, + channelId: row.channelId, + channelSlug: channel?.slug ?? null, + members: (row.members as RoomMember[]).map((m) => ({ + id: m.id, + displayName: m.displayName, + role: m.role, + accent: m.accent ?? null, + documentCount: m.documentIds?.length ?? 0, + })), + createdAt: row.createdAt.toISOString(), + }, + messages: tail, + latestSeq: full.length > 0 ? full[full.length - 1]!.seq : 0, + rounds: summarizeRounds(full), + }); +} diff --git a/apps/web/src/app/api/collab/rooms/route.ts b/apps/web/src/app/api/collab/rooms/route.ts new file mode 100644 index 000000000..bb08349a0 --- /dev/null +++ b/apps/web/src/app/api/collab/rooms/route.ts @@ -0,0 +1,123 @@ +/** + * Rooms collection: what this workspace can ask, and creating a new one. + * + * A room's value comes from its members reading *different* things, so the + * create path takes a document set per member rather than one for the room. + */ + +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requireWorkspaceContext } from "~/lib/require-workspace-context"; +import { ensureStarterPersonas, listPersonas } from "~/server/collab/personas"; +import { createRoomForCompany, listRoomsForCompany, type RoomMember } from "~/server/collab/rooms"; +import { getChannelStore } from "~/server/collab/store"; + +export const dynamic = "force-dynamic"; + +const CreateRoomSchema = z.object({ + name: z.string().min(1).max(200), + purpose: z.string().max(2000).optional(), + members: z + .array( + z.object({ + /** A persona key from the workspace roster. */ + personaKey: z.string().min(1), + /** + * Documents this member answers from. Capped because every round runs + * one retrieval per member over this set. + */ + documentIds: z.array(z.string().min(1).max(64)).max(50), + }), + ) + .min(1) + .max(10), +}); + +export async function GET() { + const ctx = await requireWorkspaceContext(); + if (!ctx.success) return ctx.response; + + const { companyId } = ctx.data; + const [rows, channels] = await Promise.all([ + listRoomsForCompany(companyId), + getChannelStore().listChannels(String(companyId)), + ]); + const channelById = new Map(channels.map((c) => [c.id, c])); + + return NextResponse.json({ + rooms: rows.map((row) => ({ + id: row.id, + name: row.name, + purpose: row.purpose, + channelId: row.channelId, + channelSlug: channelById.get(row.channelId)?.slug ?? null, + members: (row.members as RoomMember[]).map((m) => ({ + id: m.id, + displayName: m.displayName, + role: m.role, + accent: m.accent ?? null, + documentCount: m.documentIds?.length ?? 0, + })), + createdAt: row.createdAt.toISOString(), + })), + }); +} + +export async function POST(request: Request) { + const ctx = await requireWorkspaceContext(); + if (!ctx.success) return ctx.response; + + const parsed = CreateRoomSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + const input = parsed.data; + const { companyId, clerkUserId } = ctx.data; + + const roster = await ensureStarterPersonas(companyId).catch(() => listPersonas(companyId)); + const byKey = new Map(roster.map((p) => [p.id, p])); + + const members: RoomMember[] = []; + for (const entry of input.members) { + const persona = byKey.get(entry.personaKey); + if (!persona) { + return NextResponse.json( + { error: `Unknown member "${entry.personaKey}"` }, + { status: 400 }, + ); + } + if (members.some((m) => m.id === persona.id)) { + return NextResponse.json( + { error: `Member "${persona.id}" is listed twice` }, + { status: 400 }, + ); + } + members.push({ + id: persona.id, + displayName: persona.displayName, + role: persona.role, + systemPrompt: persona.systemPrompt, + route: persona.route, + temperature: persona.temperature, + accent: persona.accent, + documentIds: entry.documentIds, + }); + } + + const row = await createRoomForCompany({ + companyId, + createdByUserId: clerkUserId, + name: input.name, + purpose: input.purpose, + members, + }); + + return NextResponse.json( + { room: { id: row.id, name: row.name, channelId: row.channelId } }, + { status: 201 }, + ); +} diff --git a/apps/web/src/app/employer/documents/_workspace/collab/AgentsPanel.tsx b/apps/web/src/app/employer/documents/_workspace/collab/AgentsPanel.tsx index 961329c69..a3ab56aed 100644 --- a/apps/web/src/app/employer/documents/_workspace/collab/AgentsPanel.tsx +++ b/apps/web/src/app/employer/documents/_workspace/collab/AgentsPanel.tsx @@ -27,6 +27,7 @@ import { } from "../settings/contract"; import { Code, CommandBlock, StatusNote } from "../settings/ui"; import { IconTrash, IconX } from "../icons"; +import { PresetTeams } from "./PresetTeams"; import { useAgents } from "./useMeetings"; import { initialsOf, personaColor, type AgentPersonaRecord, type WorkerNode } from "./types"; @@ -69,6 +70,8 @@ export function AgentsPanel({ onActions }: SettingsSectionProps = {}) { {notice && {notice}} {error && {error}} + +
{renderMentions(message.text)} + ); } +interface GroundingSourceMeta { + label?: unknown; + page?: unknown; + excerpt?: unknown; +} + +/** + * What this turn was allowed to read. + * + * Shown even when retrieval came back empty, because "searched and found + * nothing" and "never searched" mean different things when you are deciding + * whether to trust a figure. + */ +function GroundingCitations({ meta }: { meta?: Record }) { + const raw = meta?.grounding; + if (!Array.isArray(raw)) return null; + + const sources = raw + .map((entry) => entry as GroundingSourceMeta) + .filter((entry) => typeof entry?.label === "string" && entry.label.length > 0); + + if (sources.length === 0) { + return ( +
+ Searched the attached documents — nothing relevant found. +
+ ); + } + + return ( +
+ read + {sources.map((source, index) => ( + + {String(source.label)} + + ))} +
+ ); +} + /** Highlights `@handle` so it reads as an address, not as prose. */ function renderMentions(text: string): React.ReactNode[] { const parts: React.ReactNode[] = []; diff --git a/apps/web/src/app/employer/documents/_workspace/collab/NewMeetingDialog.tsx b/apps/web/src/app/employer/documents/_workspace/collab/NewMeetingDialog.tsx index 9236c4cf0..14672af2f 100644 --- a/apps/web/src/app/employer/documents/_workspace/collab/NewMeetingDialog.tsx +++ b/apps/web/src/app/employer/documents/_workspace/collab/NewMeetingDialog.tsx @@ -12,7 +12,12 @@ import React, { useEffect, useMemo, useState } from "react"; import { IconHash, IconServer, IconSlack, IconX } from "../icons"; import { useAgents } from "./useMeetings"; -import { initialsOf, personaColor, type AgentPersonaRecord } from "./types"; +import { + initialsOf, + personaColor, + type AgentPersonaRecord, + type WorkspaceDocument, +} from "./types"; export interface NewMeetingDialogProps { open: boolean; @@ -49,11 +54,33 @@ export function NewMeetingDialog({ open, onClose, onCreated }: NewMeetingDialogP const [maxTurns, setMaxTurns] = useState(10); const [slackChannelId, setSlackChannelId] = useState(""); const [mirror, setMirror] = useState(false); + const [groundingDocIds, setGroundingDocIds] = useState([]); + const [documents, setDocuments] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const personas = useMemo(() => data?.personas.filter((p) => !p.archived) ?? [], [data]); + // Loaded when the dialog opens rather than on mount: the corpus can be large + // and most sessions never open this dialog. + useEffect(() => { + if (!open) return; + let cancelled = false; + void (async () => { + try { + const response = await fetch("/api/collab/documents"); + const body = (await response.json()) as { documents?: WorkspaceDocument[] }; + if (!cancelled) setDocuments(body.documents ?? []); + } catch { + // A meeting without grounding is still a meeting — leave the picker + // empty rather than blocking the dialog on it. + } + })(); + return () => { + cancelled = true; + }; + }, [open]); + // Pre-select the first three so the picker starts from a working room. useEffect(() => { if (!open || personas.length === 0 || selected.length > 0) return; @@ -87,6 +114,8 @@ export function NewMeetingDialog({ open, onClose, onCreated }: NewMeetingDialogP turnPolicy: policy, moderatorKey: policy === "moderated" ? moderator || undefined : undefined, maxTurns, + groundingEnabled: groundingDocIds.length > 0, + documentIds: groundingDocIds.length > 0 ? groundingDocIds : undefined, slackChannelId: slackChannelId.trim() || undefined, slackMirrorEnabled: mirror && slackChannelId.trim().length > 0, slackUseAgentIdentity: true, @@ -117,6 +146,7 @@ export function NewMeetingDialog({ open, onClose, onCreated }: NewMeetingDialogP setMaxTurns(10); setSlackChannelId(""); setMirror(false); + setGroundingDocIds([]); }; return ( @@ -315,6 +345,25 @@ export function NewMeetingDialog({ open, onClose, onCreated }: NewMeetingDialogP /> + + + setGroundingDocIds((prev) => + prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id], + ) + } + /> + + void; +}) { + if (documents.length === 0) { + return ( +

+ No documents in this workspace yet. +

+ ); + } + + return ( +
+
+ {documents.map((doc) => { + const checked = selected.includes(doc.id); + return ( + + ); + })} +
+ + {selected.length === 0 + ? "Nothing selected — agents will run ungrounded." + : `${selected.length} document${selected.length === 1 ? "" : "s"} · searched once per turn`} + +
+ ); +} + function PersonaToggle({ persona, selected, diff --git a/apps/web/src/app/employer/documents/_workspace/collab/PresetTeams.tsx b/apps/web/src/app/employer/documents/_workspace/collab/PresetTeams.tsx new file mode 100644 index 000000000..f00dfd9c4 --- /dev/null +++ b/apps/web/src/app/employer/documents/_workspace/collab/PresetTeams.tsx @@ -0,0 +1,217 @@ +"use client"; + +/** + * Preset teams — a whole room added in one click. + * + * Deliberately placed above the roster: a workspace that has never run a + * meeting has four generic starter agents and no idea what a good room looks + * like, and "write six system prompts first" is the step where people give up. + * + * Applying is additive. A handle already in use is shown as a conflict *before* + * the click rather than reported as a skip afterwards, because the resolution + * (rename the existing agent, or accept the pack minus that seat) is a decision + * only the user can make. + */ + +import React, { useCallback, useEffect, useState } from "react"; + +import { Badge, Button, Card, Section } from "~/app/employer/_components/primitives"; +import { StatusNote } from "../settings/ui"; +import { initialsOf, type PersonaPack } from "./types"; + +interface PresetTeamsProps { + /** Refreshes the roster after a pack lands. */ + onApplied: () => Promise | void; +} + +export function PresetTeams({ onApplied }: PresetTeamsProps) { + const [packs, setPacks] = useState([]); + const [loading, setLoading] = useState(true); + const [applying, setApplying] = useState(null); + const [notice, setNotice] = useState<{ tone: "ok" | "danger"; text: string } | null>(null); + + const load = useCallback(async () => { + try { + const response = await fetch("/api/collab/agents/presets"); + const body = (await response.json()) as { packs?: PersonaPack[]; error?: string }; + if (!response.ok) throw new Error(body.error ?? "Could not load preset teams"); + setPacks(body.packs ?? []); + } catch (err) { + setNotice({ + tone: "danger", + text: err instanceof Error ? err.message : "Could not load preset teams", + }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const apply = useCallback( + async (pack: PersonaPack) => { + setApplying(pack.id); + setNotice(null); + try { + const response = await fetch("/api/collab/agents/presets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ packId: pack.id }), + }); + const body = (await response.json()) as { + created?: string[]; + skipped?: string[]; + error?: string; + }; + if (!response.ok) throw new Error(body.error ?? "Could not add that team"); + + const created = body.created ?? []; + const skipped = body.skipped ?? []; + setNotice({ + tone: "ok", + text: + created.length === 0 + ? `Every agent in ${pack.name} is already on the roster. Nothing changed.` + : `Added ${created.length} agent${created.length === 1 ? "" : "s"}` + + (skipped.length > 0 + ? `. Kept your existing ${skipped.map((k) => `@${k}`).join(", ")}.` + : "."), + }); + await load(); + await onApplied(); + } catch (err) { + setNotice({ + tone: "danger", + text: err instanceof Error ? err.message : "Could not add that team", + }); + } finally { + setApplying(null); + } + }, + [load, onApplied], + ); + + if (loading && packs.length === 0) return null; + + return ( +
+ {notice && {notice.text}} + +
+ {packs.map((pack) => ( + void apply(pack)} + /> + ))} +
+
+ ); +} + +function PackCard({ + pack, + busy, + onApply, +}: { + pack: PersonaPack; + busy: boolean; + onApply: () => void; +}) { + const allPresent = pack.conflicts.length === pack.personas.length; + + return ( + +
+
+
+
+ {pack.name} + {pack.personas.length} agents + {allPresent && Already added} +
+

{pack.description}

+

+ Best for: {pack.bestFor} +

+
+ + +
+ +
+ {pack.personas.map((persona) => { + const conflict = pack.conflicts.includes(persona.key); + return ( +
+ + {initialsOf(persona.displayName)} + + + {persona.role} + @{persona.key} + +
+ ); + })} +
+ +

+ Suggested: {pack.suggested.turnPolicy.replace(/_/g, " ")} + {pack.suggested.moderatorKey ? ` chaired by @${pack.suggested.moderatorKey}` : ""}, up to{" "} + {pack.suggested.maxTurns} turns. + {pack.conflicts.length > 0 && !allPresent + ? ` Your existing ${pack.conflicts.map((k) => `@${k}`).join(", ")} will be kept as-is.` + : ""} +

+
+
+ ); +} diff --git a/apps/web/src/app/employer/documents/_workspace/collab/types.ts b/apps/web/src/app/employer/documents/_workspace/collab/types.ts index 7b786f023..150630c5b 100644 --- a/apps/web/src/app/employer/documents/_workspace/collab/types.ts +++ b/apps/web/src/app/employer/documents/_workspace/collab/types.ts @@ -98,6 +98,7 @@ export interface MeetingDetail { channelId: string; channelSlug: string | null; slack: { channelId: string; enabled: boolean; useAgentIdentity?: boolean } | null; + grounding: { enabled: boolean; documentCount: number }; createdAt: string; }; state: MeetingState; @@ -130,6 +131,30 @@ export interface AgentsResponse { slack: { canPost: boolean; canReceive: boolean; missing: string[] }; } +export interface PersonaPackMember { + key: string; + displayName: string; + role: string; + accent: string | null; + promptPreview: string; +} + +export interface PersonaPack { + id: string; + name: string; + description: string; + bestFor: string; + suggested: { turnPolicy: string; moderatorKey?: string; maxTurns: number }; + personas: PersonaPackMember[]; + /** Handles this workspace already uses — applying leaves them untouched. */ + conflicts: string[]; +} + +export interface WorkspaceDocument { + id: string; + title: string; +} + export const MEETING_STATUS_META: Record< MeetingStatus, { label: string; tone: "live" | "idle" | "human" | "done" | "bad" } diff --git a/apps/web/src/server/collab/grounding.ts b/apps/web/src/server/collab/grounding.ts new file mode 100644 index 000000000..afae45efe --- /dev/null +++ b/apps/web/src/server/collab/grounding.ts @@ -0,0 +1,118 @@ +/** + * Postgres/RAG implementation of the engine's turn-grounding port. + * + * This is the wiring that was missing: the product already has ensemble + * retrieval over the workspace's documents (`~/lib/tools/rag`), and meeting + * agents were the one place that reasoned about those documents without ever + * reading them. Every turn now retrieves against the speaking persona's role + * and the last thing said, and records what it read on the message it + * produced. + * + * Two properties are deliberate: + * + * - **Per-persona, not per-meeting.** The analyst and the engineer ask the + * corpus different questions. Retrieving once at creation and sharing the + * result gives every agent the union of nobody's actual need. + * - **Never fatal.** A retrieval failure returns no passages. The orchestrator + * treats that as an ungrounded turn, which is worse than a grounded one and + * very much better than a dead meeting. + */ + +import { + buildGroundingQuery, + toExcerpt, + type GroundingSource, + type TurnGrounding, + type TurnGroundingProvider, + type TurnGroundingRequest, +} from "@launchstack/core/collab"; + +import { executeRAGSearch } from "~/lib/tools/rag"; + +export interface MeetingGroundingOptions { + /** Documents the meeting is allowed to read. Empty disables retrieval. */ + documentIds: string[]; + /** + * Whose document access is checked. The meeting's creator — retrieval must + * not become a way to read documents the person who started it cannot. + */ + userId: string; + /** Passages per turn. Small on purpose: see `PASSAGES_PER_TURN`. */ + topK?: number; +} + +/** + * Four passages, not ten. + * + * Every turn already carries the full transcript, and grounding is appended to + * that. A generous top-k makes each turn more expensive at exactly the point + * where cost is already growing with the square of the turn count, and buries + * the relevant passage among near-misses. Four is enough to answer a question + * and few enough that a wrong retrieval is visible in the transcript rather + * than absorbed. + */ +export const PASSAGES_PER_TURN = 4; + +export class RagTurnGroundingProvider implements TurnGroundingProvider { + constructor(private readonly options: MeetingGroundingOptions) {} + + async retrieve(request: TurnGroundingRequest): Promise { + if (this.options.documentIds.length === 0) return { passages: [] }; + + const query = buildGroundingQuery(request); + if (query.trim().length === 0) return { passages: [] }; + + const { results } = await executeRAGSearch( + { + query, + documentIds: this.options.documentIds, + topK: this.options.topK ?? PASSAGES_PER_TURN, + }, + this.options.userId, + ); + + const passages: string[] = []; + const sources: GroundingSource[] = []; + for (const result of results) { + if (!result.content || result.content.trim().length === 0) continue; + const title = result.documentTitle?.trim() ?? ""; + const label = [title || "Workspace document", result.page ? `p.${result.page}` : null] + .filter(Boolean) + .join(" · "); + + passages.push(`${label}: ${result.content.trim()}`); + sources.push({ + label, + documentId: result.documentId || undefined, + page: result.page || undefined, + score: typeof result.relevanceScore === "number" ? result.relevanceScore : undefined, + excerpt: toExcerpt(result.content), + }); + } + + return { passages, sources }; + } +} + +/** + * Builds a provider for a meeting, or null when the meeting is not grounded. + * + * Returning null rather than an empty provider matters downstream: the + * orchestrator records a `grounding` key on a message only when a provider ran, + * so "grounded and found nothing" stays distinguishable from "never asked". + */ +export function buildMeetingGrounding(input: { + groundingEnabled: boolean; + documentIds: string[] | null; + createdByUserId: string | null; +}): RagTurnGroundingProvider | null { + if (!input.groundingEnabled) return null; + if (!input.createdByUserId) return null; + const documentIds = input.documentIds ?? []; + if (documentIds.length === 0) return null; + + return new RagTurnGroundingProvider({ + documentIds, + userId: input.createdByUserId, + }); +} diff --git a/apps/web/src/server/collab/personas.ts b/apps/web/src/server/collab/personas.ts index b8afeef0d..d21dddc6f 100644 --- a/apps/web/src/server/collab/personas.ts +++ b/apps/web/src/server/collab/personas.ts @@ -171,3 +171,65 @@ export async function ensureStarterPersonas(companyId: bigint) { } return listPersonas(companyId); } + +export interface ApplyPersonasResult { + created: string[]; + /** Handles already in use — left exactly as they were. */ + skipped: string[]; +} + +/** + * Splits a candidate roster into what would be created and what already + * exists. Pure, and separate from the writes, because this is the rule worth + * testing: which handles are considered taken. + */ +export function partitionByHandle( + existingKeys: Iterable, + candidates: PersonaInput[], +): { toCreate: PersonaInput[]; skipped: string[] } { + const taken = new Set(existingKeys); + const toCreate: PersonaInput[] = []; + const skipped: string[] = []; + + for (const candidate of candidates) { + if (taken.has(candidate.key)) { + skipped.push(candidate.key); + continue; + } + // Added as we go, so a pack that lists the same handle twice creates it + // once rather than tripping the unique index on the second insert. + taken.add(candidate.key); + toCreate.push(candidate); + } + return { toCreate, skipped }; +} + +/** + * Adds a set of personas to a workspace, additively. + * + * An existing handle is never overwritten. A persona key is referenced by past + * transcripts and by the frozen participant list on every meeting that used + * it, so silently replacing `@eng` because a preset also defines `@eng` would + * rewrite the meaning of history. The caller is told what was skipped and can + * offer to rename. + * + * Archived personas count as taken: their handle is still live in old + * transcripts, and the unique index is on `(company_id, key)` regardless. + */ +export async function applyPersonas( + companyId: bigint, + personas: PersonaInput[], +): Promise { + const existing = await listPersonas(companyId, true); + const { toCreate, skipped } = partitionByHandle( + existing.map((p) => p.id), + personas, + ); + + const created: string[] = []; + for (const persona of toCreate) { + await createPersona(companyId, persona); + created.push(persona.key); + } + return { created, skipped }; +} diff --git a/apps/web/src/server/collab/presets.ts b/apps/web/src/server/collab/presets.ts new file mode 100644 index 000000000..851a8df00 --- /dev/null +++ b/apps/web/src/server/collab/presets.ts @@ -0,0 +1,283 @@ +/** + * Preset agent teams. + * + * A pack is a room that already works: handles that read naturally in a + * transcript, roles that do not overlap, and a suggested turn policy that + * suits how that room actually argues. Applying one is additive and + * idempotent — existing handles are never overwritten, because a persona key + * appears in past transcripts and in frozen meeting rosters. + * + * On the prompts. Four rules were applied to every one of them, each of them + * a lesson from watching multi-agent meetings fail: + * + * 1. **Give the agent a reason to disagree.** A persona told to "be helpful" + * converges on whatever was said last, and a room of six of those produces + * six paraphrases. Each prompt below names what this participant is + * accountable for and what it should refuse to let pass. + * 2. **Force a shape on the output.** "Be concrete" is not actionable; "give + * the estimate in sprints and name the long pole" is. Every prompt says + * what a good turn from this role contains. + * 3. **Make ignorance sayable.** Grounding passages arrive per turn and are + * often thin. Every prompt has an explicit instruction for the case where + * the material does not support an answer, because the alternative is a + * confident number nobody can trace. + * 4. **Say who to hand to.** Meetings stall when nobody addresses anyone. + * Each prompt names the handles this role most often needs. + */ + +import type { PersonaInput } from "./personas"; + +export interface PersonaPack { + id: string; + name: string; + /** One line, shown on the pack card. */ + description: string; + /** Who this room is for — shown under the description. */ + bestFor: string; + personas: PersonaInput[]; + /** Defaults the new-meeting dialog pre-fills when this pack is applied. */ + suggested: { + turnPolicy: "round_robin" | "moderated" | "reactive"; + moderatorKey?: string; + maxTurns: number; + }; +} + +/** + * Shared house style. Appended to every preset prompt rather than repeated in + * each, so a change to how agents speak is one edit and cannot drift between + * roles. + */ +const HOUSE_STYLE = [ + "", + "## House rules", + "- One chat message per turn. No preamble, no sign-off, no restating the question.", + "- Never repeat a point already made. Add, disagree, or hand off.", + "- Disagree explicitly when you disagree. Naming the tradeoff is more useful than finding the middle.", + // Broadcast turns are the failure mode a live run surfaced immediately: a + // message asking five people at once gets one answer and silently drops the + // other four, because the floor can only move to one of them. The room then + // reads as though the specialists were ignored. + "- Hand off to exactly ONE person, in your final sentence, written with a leading @. Never address two people in the same turn — the floor can only go to one, so every extra question is thrown away.", + "- Never write your own handle. You already have the floor.", + // Same reason as the chair's closing line: minutes are extracted by rule, so + // a commitment phrased as "I can look at that" leaves no action item behind. + "- When you commit to work, start the sentence with `I'll own` and give it a day. That is what puts it in the minutes with your name on it.", + "- If the grounding passages do not support a claim, say which fact is missing instead of estimating.", +].join("\n"); + +function prompt(body: string[]): string { + return [...body, HOUSE_STYLE].join("\n"); +} + +const STARTUP_TEAM: PersonaInput[] = [ + { + key: "founder", + displayName: "Alex Rivera", + role: "Founder / CEO", + accent: "oklch(0.55 0.14 250)", + route: "reasoning", + temperature: 0.4, + maxTurnChars: 900, + systemPrompt: prompt([ + "You are the founder and CEO. You chair this meeting and you are accountable for the company still existing in eighteen months.", + "", + "## What you are for", + "Forcing a decision. A meeting that ends in 'let's explore it further' with no owner and no date is a meeting you failed to chair.", + "", + "## How you run a turn", + "- Opening turn: state the decision to be made, the constraint that makes it hard (runway, a deadline, a customer), and what a good outcome looks like. Then hand to the person whose input gates everything else.", + "- Middle turns: cut scope, kill options that are not on the critical path, and ask for the number that would change your mind.", + "- Every few turns, say which agenda item the room is on. Once an item has an answer, say so and move to the next one by name. Spending the whole meeting on item one is how a meeting fails.", + // The minutes extractor is deterministic and matches literal cue + // phrases. Left to itself the model writes "we should build it", which + // is not a decision anything can record — so the chair is told the exact + // opening words that make the decision and its owner extractable. + "- Closing turn: write the outcome as a line starting with exactly `Decision:` followed by one sentence, then a line starting with exactly `Next step:` naming the owner by handle and a date. Then end the meeting.", + "", + "## What you refuse to let pass", + "- A recommendation with no cost attached.", + "- Consensus reached because nobody wanted to argue. If the room agrees too fast, name the strongest case against and make someone answer it.", + "- Work that serves a hypothetical customer. Ask which real one asked for it.", + "", + "You hand to @product for scope, @eng for feasibility, @growth for whether it sells, @data for whether the premise is even true.", + ]), + }, + { + key: "product", + displayName: "Priya Nandakumar", + role: "Product Manager", + accent: "oklch(0.56 0.15 300)", + route: "default", + temperature: 0.5, + maxTurnChars: 900, + systemPrompt: prompt([ + "You are the product manager. You own the problem statement and the scope, and you are accountable for the team building the smallest thing that actually resolves the user's problem.", + "", + "## What you are for", + "Keeping the room honest about which user, with which problem, in which situation. Most bad product meetings are two people solving different problems without noticing.", + "", + "## How you run a turn", + "- Restate the user problem in one sentence, in the user's words, before discussing any solution.", + "- Propose scope as a cut list, not a wish list: what ships in v1, what is explicitly deferred, and what you are deliberately not doing.", + "- Name the success metric and its current baseline. A metric with no baseline is a slogan.", + "- Flag when a proposal solves a problem nobody reported.", + "", + "## What you refuse to let pass", + "- A feature justified by a competitor having it.", + "- Scope that grew during the meeting without anything being removed.", + "- 'We'll figure out the metric later.'", + "", + "You hand to @design for whether the flow works, @eng for what v1 costs, @data for whether the baseline is real.", + ]), + }, + { + key: "eng", + displayName: "Marcus Chen", + role: "Engineering Lead", + accent: "oklch(0.55 0.14 225)", + route: "reasoning", + temperature: 0.35, + maxTurnChars: 1000, + systemPrompt: prompt([ + "You are the engineering lead. You are accountable for the estimate being true and for the system still being maintainable after this ships.", + "", + "## What you are for", + "Pricing the work honestly and naming what breaks. You are the only person in the room who knows what the second-order cost is.", + "", + "## How you run a turn", + "- Give effort in sprints or engineer-weeks, with an explicit confidence: firm, rough, or a spike is needed first.", + "- Name the long pole — the single item that determines the timeline — and say what would shorten it.", + "- Flag anything requiring a data migration, a backfill, a rollback plan, or a breaking API change. These are the items that turn a two-week estimate into a quarter.", + "- Offer a cheaper alternative whenever you reject a proposal on cost. 'Too expensive' with no counter-proposal is not an engineering opinion.", + "", + "## What you refuse to let pass", + "- An estimate given without knowing the acceptance criteria — ask @product for them instead of guessing.", + "- A deadline set by working backwards from a date rather than forwards from the work.", + "- Load-bearing complexity introduced for a case nobody has hit yet.", + "", + "You hand to @product to cut scope, @founder when the honest answer is that it does not fit the timeline, @data when the design depends on a volume or rate nobody has measured.", + ]), + }, + { + key: "design", + displayName: "Ines Okafor", + role: "Product Designer", + accent: "oklch(0.58 0.15 165)", + route: "default", + temperature: 0.6, + maxTurnChars: 800, + systemPrompt: prompt([ + "You are the product designer. You are accountable for the thing being usable by someone who did not attend this meeting.", + "", + "## What you are for", + "Walking the actual flow, step by step, and finding where a real person stalls. Everyone else in the room discusses the feature; you discuss the sequence of screens and decisions a user moves through.", + "", + "## How you run a turn", + "- Describe the flow as numbered steps and point at the step where users will drop.", + "- Name the empty state, the error state, and the loading state. Features are demoed in the happy path and lived in the other three.", + "- Raise accessibility concretely: keyboard path, contrast, screen-reader labelling, target size. Not as a checklist item at the end.", + "- Say when a proposal adds a decision the user should not have to make.", + "", + "## What you refuse to let pass", + "- A setting added because the team could not decide. That is the team's decision being outsourced to the user.", + "- Copy written from the system's perspective rather than the user's.", + "- 'We'll polish it later.' The flow is the product; polish is not the same thing as structure.", + "", + "You hand to @product when the flow reveals the problem was framed wrong, @eng when the good interaction has a real cost.", + ]), + }, + { + key: "growth", + displayName: "Dani Whitfield", + role: "Growth & Business Development", + accent: "oklch(0.6 0.17 50)", + route: "default", + temperature: 0.6, + maxTurnChars: 900, + systemPrompt: prompt([ + "You are growth and business development. You are accountable for this reaching customers and for the deals that depend on it.", + "", + "## What you are for", + "Connecting the build to the pipeline. You are the person who knows which prospects asked for this, what they are paying today, and what the sales motion actually is.", + "", + "## How you run a turn", + "- Attach real demand to a proposal: which accounts asked, what stage they are at, what it unblocks. If nobody asked, say so plainly.", + "- Name the motion — self-serve, sales-led, partner-led — because it decides what has to be built, not just how it is marketed.", + "- Give pricing and packaging implications when they exist, including who is cannibalised.", + "- Flag commitments already made to a customer or partner that the room does not know about.", + "", + "## What you refuse to let pass", + "- A launch with no distribution plan. Shipping is not a channel.", + "- Positioning that requires the customer to already understand the category.", + "- A roadmap promise being made to a prospect in this meeting without @founder agreeing to it.", + "", + "You hand to @product when demand implies different scope, @data for whether the funnel supports the claim, @founder for anything that becomes a commitment.", + ]), + }, + { + key: "data", + displayName: "Tomas Lindqvist", + role: "Data Analyst", + accent: "oklch(0.5 0.13 195)", + route: "reasoning", + temperature: 0.25, + maxTurnChars: 900, + systemPrompt: prompt([ + "You are the data analyst. You are accountable for every number said in this room being either traceable or explicitly labelled as an estimate.", + "", + "## What you are for", + "Checking whether the premise is true. The most valuable turn you take is often the one where you show that the problem the room is solving is not the problem the numbers describe.", + "", + "## How you run a turn", + "- Quote figures with their source and period. A number with no denominator and no date is not evidence.", + "- Separate what is measured from what is inferred, every time. Say 'measured' or 'estimated' out loud.", + "- When asked for a number you do not have, say what would have to be instrumented to get it and how long that takes — do not approximate to be helpful.", + "- Challenge a metric that will move for reasons unrelated to the change being discussed.", + "", + "## What you refuse to let pass", + "- A percentage with no base. '40% improvement' on eleven users is noise.", + "- Survivorship: conclusions from the customers who stayed.", + "- A success metric that cannot be measured until long after the decision to continue must be made.", + "", + "You hand to @product when the data reframes the problem, @growth when the funnel claim does not hold, @founder when the premise itself is wrong.", + ]), + }, +]; + +export const PERSONA_PACKS: PersonaPack[] = [ + { + id: "startup-core", + name: "Tech startup — core team", + description: + "Founder, product, engineering, design, growth and data. The room most product and roadmap decisions actually need.", + bestFor: "Build/no-build calls, roadmap tradeoffs, launch readiness, scope cuts under a deadline.", + personas: STARTUP_TEAM, + // Moderated, chaired by the founder: this room has a natural chair, and the + // failure mode without one is six specialists talking past each other. + suggested: { turnPolicy: "moderated", moderatorKey: "founder", maxTurns: 14 }, + }, +]; + +export function getPack(packId: string): PersonaPack | null { + return PERSONA_PACKS.find((p) => p.id === packId) ?? null; +} + +/** Pack summaries for the picker — the full prompts are not sent to the browser. */ +export function listPackSummaries() { + return PERSONA_PACKS.map((pack) => ({ + id: pack.id, + name: pack.name, + description: pack.description, + bestFor: pack.bestFor, + suggested: pack.suggested, + personas: pack.personas.map((p) => ({ + key: p.key, + displayName: p.displayName, + role: p.role, + accent: p.accent ?? null, + /** Enough to judge the agent without shipping the whole prompt. */ + promptPreview: p.systemPrompt.split("\n")[0] ?? "", + })), + })); +} diff --git a/apps/web/src/server/collab/qa-participant.ts b/apps/web/src/server/collab/qa-participant.ts new file mode 100644 index 000000000..a48fa053d --- /dev/null +++ b/apps/web/src/server/collab/qa-participant.ts @@ -0,0 +1,187 @@ +/** + * Room members backed by the workspace's own documents. + * + * This is the internal half of a room: each member is bound to a *different* + * set of documents, so asking the room one question gets you an answer per + * corpus rather than one answer averaged over all of them. That asymmetry is + * the entire reason a room beats asking a single agent — members who read the + * same material produce the same answer N times. + * + * Built on `executeRAGSearch`, which is already callable and already + * access-checks documents. The larger Q&A route (`/api/agents/documentQ&A/...`) + * has a richer pipeline — web search, attachments, ANN fallbacks — but it is + * ~330 lines inlined in a request handler where every failure path returns a + * `NextResponse`, so it cannot be called from here without a refactor that + * risks the product's main feature. That extraction is worth doing; it is not + * worth doing in the same change as a new subsystem. + */ + +import { + toExcerpt, + type AgentPersona, + type AgentRuntime, + type AgentTurnRequest, + type AgentTurnResult, + type GroundingSource, +} from "@launchstack/core/collab"; + +import { executeRAGSearch } from "~/lib/tools/rag"; +import { createCollabChatFn } from "./chat"; + +/** Which documents a member can see. Empty means the member has no sources. */ +export interface QaMemberBinding { + /** Document ids this member answers from. */ + documentIds: string[]; + /** Passages per question. Small: a member's answer should be checkable. */ + topK?: number; +} + +export const PASSAGES_PER_QUESTION = 5; + +export interface WorkspaceQaRuntimeOptions { + /** + * Returns the binding for a persona, or null when this runtime does not own + * it. Null is what lets a room mix document-backed members with model-only + * ones in a single roster. + */ + binding: (persona: AgentPersona) => QaMemberBinding | null; + /** + * Whose document access is checked. + * + * Deliberately the **asking human**, not the room's creator. Grounding on the + * meeting path uses the creator, which would make a room a laundering device: + * a member asks a question and gets an answer retrieved under someone else's + * grants. The room's document set narrows the corpus; the asker authorizes it. + */ + actorUserId: string; + maxOutputTokens?: number; +} + +export class WorkspaceQaRuntime implements AgentRuntime { + readonly nodeId = "local"; + private readonly chat = createCollabChatFn({ maxOutputTokens: 900 }); + + constructor(private readonly options: WorkspaceQaRuntimeOptions) {} + + serves(persona: AgentPersona): boolean { + // Only personas this runtime has a binding for. Ordering matters at the + // call site: the generic local runtime claims every persona without a + // nodeId, so this must be consulted first or a document-backed member + // silently becomes a model with no sources. + return this.options.binding(persona) !== null; + } + + async takeTurn({ persona, context, transcript }: AgentTurnRequest): Promise { + const binding = this.options.binding(persona); + if (!binding) throw new Error(`No document binding for @${persona.id}`); + + const question = transcript.at(-1)?.text?.trim() ?? context.objective; + if (question.length === 0) { + return { text: "", meta: { adapter: "workspace-qa", declined: true, reason: "no_question" } }; + } + + if (binding.documentIds.length === 0) { + return { + text: "I have no documents assigned, so nothing I can see bears on this.", + meta: { adapter: "workspace-qa", declined: true, reason: "no_documents" }, + }; + } + + const started = Date.now(); + const { results } = await executeRAGSearch( + { + query: question, + documentIds: binding.documentIds, + topK: binding.topK ?? PASSAGES_PER_QUESTION, + }, + this.options.actorUserId, + ); + + // Retrieving nothing is a real answer — "my sources don't cover this" is + // exactly what a room needs to hear from a member, and it is much more + // useful than a fluent guess. + if (results.length === 0) { + return { + text: "Nothing in my sources covers that.", + meta: { + adapter: "workspace-qa", + declined: true, + reason: "no_matches", + documentsSearched: binding.documentIds.length, + latencyMs: Date.now() - started, + }, + }; + } + + const sources: GroundingSource[] = []; + const passages: string[] = []; + for (const result of results) { + const content = result.content?.trim(); + if (!content) continue; + const title = result.documentTitle?.trim() ?? ""; + const label = [title || "Workspace document", result.page ? `p.${result.page}` : null] + .filter(Boolean) + .join(" · "); + passages.push(`${label}: ${content}`); + sources.push({ + label, + documentId: result.documentId || undefined, + page: result.page || undefined, + score: typeof result.relevanceScore === "number" ? result.relevanceScore : undefined, + excerpt: toExcerpt(content), + }); + } + + const text = await this.chat({ + messages: [ + { role: "system", content: buildQaMemberPrompt(persona, context.title, passages) }, + { role: "user", content: question }, + ], + route: persona.route, + temperature: persona.temperature, + }); + + return { + text: text.trim(), + meta: { + adapter: "workspace-qa", + grounding: sources, + documentsSearched: binding.documentIds.length, + passagesUsed: passages.length, + latencyMs: Date.now() - started, + }, + }; + } +} + +/** + * The member's standing instructions. + * + * Written rather than reusing `buildSystemPrompt` because a room member is not + * holding a floor: it answers once, from a corpus the other members cannot see, + * and the single most valuable thing it can say is "my sources don't cover + * this". A prompt that rewards fluency over that turns a room of specialists + * back into a room of generalists. + */ +export function buildQaMemberPrompt( + persona: AgentPersona, + roomName: string, + passages: string[], +): string { + return [ + `You are ${persona.displayName}, the ${persona.role}, answering a question in "${roomName}".`, + "", + persona.systemPrompt.trim(), + "", + "## Your sources", + "These passages are the only material you can see. Other members of this room are answering the same question from different sources.", + ...passages.map((p, i) => `[${i + 1}] ${p}`), + "", + "## How to answer", + "- Answer only from the passages above. Quote the figure or clause you are relying on.", + "- Be short and specific. No preamble, no restating the question.", + "- If the passages only partly cover the question, answer the part they cover and say plainly which part they do not.", + "- If they do not bear on the question at all, say exactly that. Being the member with nothing to add is a useful answer; a plausible guess is not.", + "- Never infer what another member would say. You cannot see their sources.", + ].join("\n"); +} diff --git a/apps/web/src/server/collab/rooms.ts b/apps/web/src/server/collab/rooms.ts new file mode 100644 index 000000000..8655089dd --- /dev/null +++ b/apps/web/src/server/collab/rooms.ts @@ -0,0 +1,142 @@ +/** + * Room loading and the runtime stack a round is answered with. + * + * Stateless on purpose. A meeting keeps a cached live orchestrator because it + * carries a turn cursor between requests; a room carries nothing between + * rounds, so every request builds what it needs and throws it away. That is + * also what makes rooms safe on more than one replica, which meetings are not. + */ + +import { and, asc, desc, eq } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; + +import { + slugify, + type AgentPersona, + type AgentRuntime, + type RoomConfig, +} from "@launchstack/core/collab"; + +import { collabRoom } from "~/server/db/schema"; +import { db } from "~/server/db"; +import { getChannelStore } from "./store"; +import { WorkspaceQaRuntime, type QaMemberBinding } from "./qa-participant"; + +type RoomRow = typeof collabRoom.$inferSelect; + +/** A member as stored: a persona plus the documents it answers from. */ +export interface RoomMember extends AgentPersona { + documentIds: string[]; +} + +export function rowToConfig(row: RoomRow): RoomConfig & { members: RoomMember[] } { + return { + id: row.id, + channelId: row.channelId, + workspaceId: String(row.companyId), + name: row.name, + purpose: row.purpose ?? undefined, + members: row.members as RoomMember[], + }; +} + +/** + * The runtimes a round is answered with. + * + * Exactly one, deliberately. Every room member carries a document binding, so + * the document-backed runtime always serves and a generic-LLM fallback could + * never be reached — including it would only create a silent failure mode where + * a misconfigured member stops citing anything and nothing says why. A member + * with no documents declines explicitly instead, which is the honest answer. + * + * (This is also why room members are not subject to the ordering hazard on the + * meeting path, where `LlmAgentRuntime` claims every persona without a node id + * and must therefore be consulted last.) + */ +export function buildRoomRuntimes(input: { + members: RoomMember[]; + actorUserId: string; +}): AgentRuntime[] { + const bindings = new Map( + input.members.map((m) => [m.id, { documentIds: m.documentIds ?? [] }]), + ); + + return [ + new WorkspaceQaRuntime({ + binding: (persona) => bindings.get(persona.id) ?? null, + actorUserId: input.actorUserId, + }), + ]; +} + +export interface NewRoomInput { + companyId: bigint; + createdByUserId: string; + name: string; + purpose?: string; + members: RoomMember[]; +} + +export async function createRoomForCompany(input: NewRoomInput) { + const store = getChannelStore(); + const channelId = `chan_${randomUUID().replace(/-/g, "")}`; + + await store.createChannel({ + id: channelId, + slug: await uniqueChannelSlug(input.companyId, slugify(input.name)), + name: input.name, + topic: input.purpose, + workspaceId: String(input.companyId), + createdByUserId: input.createdByUserId, + }); + + const [row] = await db + .insert(collabRoom) + .values({ + id: `room_${randomUUID().replace(/-/g, "")}`, + companyId: input.companyId, + channelId, + name: input.name, + purpose: input.purpose, + members: input.members, + createdByUserId: input.createdByUserId, + }) + .returning(); + if (!row) throw new Error("Failed to create room"); + return row; +} + +export async function getRoom(roomId: string, companyId: bigint): Promise { + const [row] = await db + .select() + .from(collabRoom) + .where(and(eq(collabRoom.id, roomId), eq(collabRoom.companyId, companyId))) + .limit(1); + return row ?? null; +} + +export async function listRoomsForCompany(companyId: bigint, limit = 50) { + return db + .select() + .from(collabRoom) + .where(and(eq(collabRoom.companyId, companyId), eq(collabRoom.archived, false))) + .orderBy(desc(collabRoom.createdAt)) + .limit(limit); +} + +async function uniqueChannelSlug(companyId: bigint, base: string): Promise { + const { collabChannel } = await import("~/server/db/schema"); + let candidate = base; + let suffix = 1; + for (;;) { + const [existing] = await db + .select({ id: collabChannel.id }) + .from(collabChannel) + .where(and(eq(collabChannel.companyId, companyId), eq(collabChannel.slug, candidate))) + .orderBy(asc(collabChannel.id)) + .limit(1); + if (!existing) return candidate; + suffix++; + candidate = `${base}-${suffix}`; + } +} diff --git a/apps/web/src/server/collab/runtime.ts b/apps/web/src/server/collab/runtime.ts index 6b4959864..d98e5f537 100644 --- a/apps/web/src/server/collab/runtime.ts +++ b/apps/web/src/server/collab/runtime.ts @@ -34,6 +34,7 @@ import { collabChannel, collabMeeting, collabNode } from "~/server/db/schema"; import { db } from "~/server/db"; import { env } from "~/env"; import { createCollabChatFn } from "./chat"; +import { buildMeetingGrounding } from "./grounding"; import { getChannelStore, type PostgresChannelStore } from "./store"; import { getSlackClient } from "./slack"; @@ -207,6 +208,15 @@ export async function getMeetingRuntime( config, runtimes: buildRuntimes(), initialState: rowToState(row), + groundingProvider: buildMeetingGrounding({ + groundingEnabled: row.groundingEnabled, + documentIds: row.documentIds, + createdByUserId: row.createdByUserId, + }) ?? undefined, + // Retrieval being down degrades the turn; it must not end the meeting. + onGroundingError: (error, personaId) => { + console.error(`[collab:grounding] ${meetingId} @${personaId}: ${error.message}`); + }, }); // Coalescing persist: only the LATEST state matters (each write is the @@ -268,6 +278,10 @@ export interface NewMeetingInput { turnPolicy?: TurnPolicy; maxTurns?: number; context?: string[]; + /** Retrieve fresh passages for the speaking persona on every turn. */ + groundingEnabled?: boolean; + /** Documents the meeting may read. Required for grounding to do anything. */ + documentIds?: string[]; /** Reuse an existing channel instead of opening a new one. */ channelId?: string; slackChannelId?: string; @@ -307,6 +321,8 @@ export async function createMeetingForCompany(input: NewMeetingInput) { moderatorPersonaId: input.turnPolicy?.moderatorId, maxTurns: input.maxTurns ?? 12, context: input.context, + groundingEnabled: input.groundingEnabled ?? false, + documentIds: input.documentIds ?? [], slackChannelId: input.slackChannelId, slackMirrorEnabled: input.slackMirrorEnabled ?? false, slackUseAgentIdentity: input.slackUseAgentIdentity ?? false, diff --git a/apps/web/src/server/db/schema/collab.ts b/apps/web/src/server/db/schema/collab.ts index d167bc4f3..81713e7b2 100644 --- a/apps/web/src/server/db/schema/collab.ts +++ b/apps/web/src/server/db/schema/collab.ts @@ -171,6 +171,18 @@ export const collabMeeting = pgTable( completionMarker: varchar("completion_marker", { length: 64 }), /** Grounding passages pinned to the meeting (retrieved knowledge). */ context: jsonb("context").$type(), + /** + * When true, each turn additionally retrieves passages for the persona + * about to speak. Off by default: retrieval costs a search per turn, + * and a meeting with no documents attached has nothing to retrieve. + */ + groundingEnabled: boolean("grounding_enabled").notNull().default(false), + /** + * Documents this meeting may read. Access is re-checked against the + * creator on every retrieval — this list narrows the corpus, it does + * not grant anything. + */ + documentIds: jsonb("document_ids").$type().notNull().default(sql`'[]'::jsonb`), status: varchar("status", { length: 24, enum: ["scheduled", "running", "paused", "human_control", "completed", "failed"], @@ -198,6 +210,55 @@ export const collabMeeting = pgTable( }) ); +// ============================================================================ +// Rooms +// ============================================================================ + +/** + * A room is a question surface, not a conversation. + * + * Deliberately its own table rather than a flavour of `collab_meeting`. A + * meeting row carries a turn policy, a turn cap, a completion marker, a status + * machine, a turn cursor and a controller — every one of which is meaningless + * for a fan-out. Reusing it would also make rooms load through + * `getMeetingRuntime()` and surface in the meetings list as permanently + * scheduled meetings. + * + * There is no round table: a round is derived from the channel log, where the + * question carries its id and expected roster and each answer carries the round + * id. Same rule the rest of this subsystem follows — the log is the truth. + */ +export const collabRoom = pgTable( + "collab_room", + { + id: varchar("id", { length: 64 }).primaryKey().notNull(), + companyId: bigint("company_id", { mode: "bigint" }) + .notNull() + .references(() => company.id, { onDelete: "cascade" }), + channelId: varchar("channel_id", { length: 64 }) + .notNull() + .references(() => collabChannel.id, { onDelete: "cascade" }), + name: varchar("name", { length: 256 }).notNull(), + purpose: text("purpose"), + /** + * Frozen copy of the members, each with the documents it answers from. + * Frozen for the same reason a meeting's participants are: editing a + * persona must not rewrite what was said in a past round. + */ + members: jsonb("members").notNull(), + archived: boolean("archived").notNull().default(false), + createdByUserId: varchar("created_by_user_id", { length: 256 }), + createdAt: timestamp("created_at", { withTimezone: true }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(() => new Date()), + }, + (table) => ({ + companyIdx: index("collab_room_company_idx").on(table.companyId), + channelIdx: index("collab_room_channel_idx").on(table.channelId), + }) +); + // ============================================================================ // Worker nodes // ============================================================================ @@ -262,7 +323,19 @@ export const collabAgentPersonaRelations = relations(collabAgentPersona, ({ one }), })); +export const collabRoomRelations = relations(collabRoom, ({ one }) => ({ + channel: one(collabChannel, { + fields: [collabRoom.channelId], + references: [collabChannel.id], + }), + company: one(company, { + fields: [collabRoom.companyId], + references: [company.id], + }), +})); + export type CollabChannel = InferSelectModel; +export type CollabRoom = InferSelectModel; export type CollabMessage = InferSelectModel; export type CollabAgentPersona = InferSelectModel; export type CollabMeeting = InferSelectModel; diff --git a/docs/collaboration.md b/docs/collaboration.md index fd94689af..2d4a4d395 100644 --- a/docs/collaboration.md +++ b/docs/collaboration.md @@ -17,26 +17,33 @@ and writers of the same log, with nothing to keep in sync. | Path | What it is | | --- | --- | -| `packages/core/src/collab/` | The engine. No Next, no Clerk, no database, no `process.env`. | -| `packages/core/src/collab/net/` | The signed HTTP protocol, the hub, the worker, the `node:http` adapter. | -| `packages/core/src/collab/slack/` | Slack Web API port, signature verification, the two-way bridge. | -| `packages/core/src/collab/evals.ts` | Deterministic meeting scoring. | -| `packages/core/src/db/schema/collab.ts` | Channels, messages, personas, meetings, nodes. | -| `apps/web/src/server/collab/` | Postgres-backed store, the process-wide hub, the model adapter. | +| `packages/adapters/src/collab/` | The engine. No Next, no Clerk, no database, no `process.env`. | +| `packages/adapters/src/collab/net/` | The signed HTTP protocol, the hub, the worker, the `node:http` adapter. | +| `packages/adapters/src/collab/slack/` | Slack Web API port, signature verification, the two-way bridge. | +| `packages/adapters/src/collab/grounding.ts` | The turn-level retrieval port and its query builder. | +| `packages/adapters/src/collab/evals.ts` | Deterministic meeting scoring. | +| `packages/core/src/collab/` | Re-export facade only (ADR-002). No logic may be added here. | +| `apps/web/src/server/db/schema/collab.ts` | Channels, messages, personas, meetings, nodes. | +| `apps/web/src/server/collab/` | Postgres store, the process-wide hub, the model adapter, presets, retrieval. | | `apps/web/src/app/api/collab/` | Meeting routes, the hub mount, the Slack events receiver. | | `apps/web/src/app/employer/documents/_workspace/collab/` | The Meetings surface and the agent roster. | | `apps/web/scripts/collab-hub.ts` | Standalone hub, for running the meeting plane on its own host. | | `apps/web/scripts/collab-worker.ts` | Agent worker. Run this on any machine that should host agents. | +Import from `@launchstack/core/collab` regardless — the facade is the public +entry point, and `scripts/ci/check-core-facade.mjs` keeps it a pure re-export. + --- ## Running a meeting 1. **Settings → Agents & nodes** defines the roster. A workspace that has never opened it gets four starter agents seeded on first read, so nothing - dead-ends on an empty picker. -2. **Meetings → New** picks the participants, states the objective, and chooses - how the floor moves. + dead-ends on an empty picker. **Preset teams** at the top of that pane add a + whole room in one click — see [Preset teams](#preset-teams). +2. **Meetings → New** picks the participants, states the objective, chooses how + the floor moves, and optionally attaches documents to ground the agents in — + see [Grounding](#grounding). 3. The channel view shows the transcript. `Run` advances a few turns at a time; `Take over` stops the agents and gives you the floor; `End` closes the meeting and produces minutes. @@ -70,6 +77,112 @@ quality shortfall — the eval suite scores it as an outright failure. --- +## Rooms + +A **meeting** is a conversation: one speaker at a time, elected from the +transcript, working toward a close. A **room** is a question: one question, +every member, concurrently, no floor to hold. + +``` +POST /api/collab/rooms/{id}/ask { "text": "What breaks if the token becomes a JWT?" } + + @legal Clause 11.2 caps liability at 12 months of fees. ← reads contracts/ + @ops Nothing in my sources covers that. ← reads runbooks/ + @finance The Growth tier is 58% of self-serve revenue. ← reads finance/ +``` + +Members are bound to **different document sets**, which is the entire point. +Measured across 39 live runs, a room whose members all read the same corpus +performs no better than asking one agent and costs 3–5× the wall-clock — so the +value of a room is exactly the information its members do *not* share. + +| | | +| --- | --- | +| Concurrent | Members run in parallel; wall time is the slowest member, not the sum. | +| Isolated | Each member sees the question only, never the other answers. Otherwise the first to finish anchors the rest and it stops being a fan-out. | +| Independently settled | A member that fails, declines, times out, or has no runtime becomes one message. There is no round-level failure. | +| Derived | A round has no row: the question carries its id and expected roster, each answer carries the round id. `summarizeRounds()` reconstructs it from the log. | + +Retrieval runs as the **asking human**, never the room's creator — otherwise a +room becomes a way to read documents you could not open yourself. The room's +document set narrows the corpus; the asker authorizes it. + +A member that retrieves nothing declines rather than answering, and the model is +never consulted in that case. "My sources don't cover this" is the most useful +thing a specialist can say in a room; a fluent guess is the least. + +### Not in a room + +No turn policy, no moderator, no minutes, no completion marker, no `control` +route. A room has no state machine to drive. External sessions — a Claude Code +or Codex session joining from another machine — are tracked separately; today's +members are all in-process. + +--- + +## Preset teams + +`Settings → Agents & nodes → Preset teams` adds a whole room at once. The pack +that ships is **Tech startup — core team**: `@founder` (chair), `@product`, +`@eng`, `@design`, `@growth`, `@data`. + +Applying is additive and idempotent. A handle already in use is reported as a +conflict *before* the click and left exactly as it is — a persona key appears +in past transcripts and in the frozen participant list of every meeting that +used it, so overwriting one rewrites the meaning of history. + +Packs live in `apps/web/src/server/collab/presets.ts`. The prompts follow four +rules, each of them a lesson from a meeting that went badly: + +1. **Give the agent a reason to disagree.** Every prompt has a *What you refuse + to let pass* section. A persona told to be helpful converges on whatever was + said last, and six of those produce six paraphrases. +2. **Force a shape on the output.** "Be concrete" is not actionable; "give the + estimate in sprints and name the long pole" is. +3. **Make ignorance sayable.** Every prompt says what to do when the material + does not support an answer, because the alternative is a confident number + nobody can trace. +4. **Say who to hand to.** Meetings stall when nobody is addressed. + +`__tests__/api/collab/presets.test.ts` enforces the structural half of that: +prompts carry each section, handles are mention-safe, a pack fits under the +ten-participant cap, and — the one that has already caught a real bug — a +prompt only ever hands off to a handle that exists in the same pack. + +--- + +## Grounding + +A meeting can read the workspace's documents. Attach them in **Meetings → New** +and every turn retrieves passages for the persona *about to speak*, using its +role, the objective, and the last thing said. + +Retrieval is a port, not a field. `TurnGroundingProvider` lives in the engine, +which never learns what a document or an index is; the implementation over the +existing ensemble retriever is `apps/web/src/server/collab/grounding.ts`. + +| | | +| --- | --- | +| Per-persona, not per-meeting | The analyst and the engineer ask the corpus different questions. Retrieving once at creation gives everyone the union of nobody's actual need. | +| Four passages per turn | Each turn already carries the full transcript. A generous top-k inflates the turn at exactly the point where cost already grows with the square of the turn count. | +| Never fatal | A retrieval failure yields no passages and the turn proceeds. A meeting that dies because the index blinked is worse than an ungrounded one. | +| Recorded on the message | What a turn read is stored on the message it produced — label, page, score, and a truncated excerpt. | + +`MeetingConfig.context` still pins passages for the whole meeting; retrieved +ones are appended after it, never in place of it. + +Storing the excerpt is what keeps a grounded meeting auditable *and* +scoreable. A reader can check a figure without the index still being around, +and the `grounding` eval dimension can tell a cited number from an invented +one — before this, a meeting grounded purely by retrieval scored as "no context +supplied, dimension not applicable". + +The transcript shows what each turn read, and distinguishes *searched and found +nothing* from *never searched* — they mean different things when you are +deciding whether to trust a figure. + +--- + ## Slack Set `SLACK_BOT_TOKEN` to mirror turns into a channel, and `SLACK_SIGNING_SECRET` @@ -195,6 +308,43 @@ pnpm --filter @launchstack/web evals:meetings # readable report pnpm --filter @launchstack/web evals:meetings -- --json ``` +### Against a real model + +The suite above is deterministic because its agents are scripted — which means +it cannot tell you whether a *prompt* steers a model well. A preset that reads +beautifully and produces six agreeable paraphrases passes every scripted test. + +```bash +pnpm --filter @launchstack/web meeting:live -- --env-file=/path/to/.env --ground +pnpm --filter @launchstack/web meeting:live -- --route=fast --turns=8 # cheap smoke run +``` + +Runs the preset roster as a real meeting against the deployment's configured +models, prints each turn and what it read, then scores it and exits non-zero on +a threshold miss — so a prompt change can be gated rather than merely admired. +No Postgres, no Clerk, no HTTP: the channel is in-memory and `--ground` uses an +in-process fixture corpus, so the retrieval *port* is exercised without the +retriever needing a database. + +Expect run-to-run variance; two runs of the shipped roster scored 0.93 and 0.87. +Treat a single number as a smoke test and a repeated regression as a finding. + +Three defects were found by the first live run and none of them could have been +found by the scripted suite: + +- **`findMention` took the first mention.** A real turn opens by answering the + last speaker and closes by asking someone else, so the floor kept going back + to whoever had just spoken and the specialists were never reached. Every + scripted line carries exactly one mention, which made first and last + identical. Now the last mention wins. +- **Broadcast turns.** An agent asking five people at once gets one answer and + silently drops four, because the floor can only move to one of them. The + house rules now mandate exactly one handoff, in the final sentence. +- **Minutes were always empty.** Extraction matches literal cue phrases, and + models write "we should build it", which is not a recordable decision. The + chair is now told the exact opening words (`Decision:`, `Next step:`) that + make the outcome and its owner extractable. + Scenarios run as real meetings — same orchestrator, same turn policies, same takeover path — with scripted utterances standing in for model output. That makes the *orchestration* the thing under test and keeps the suite @@ -236,7 +386,9 @@ pnpm exec jest __tests__/collab __tests__/api/collab | `__tests__/collab/hub-network.test.ts` | Hub ↔ worker over real sockets, timeouts, worker restart | | `__tests__/collab/two-machine.test.ts` | Three OS processes, one meeting, over the host's routable address | | `__tests__/collab/slack-bridge.test.ts` | Mirroring, echo loops, duplicate delivery, commands, signatures | +| `__tests__/collab/meeting-grounding.test.ts` | Retrieval per turn, provenance, degradation when the index is down | | `__tests__/collab/meeting-evals.test.ts` | The scoring function and the scenario suite | +| `__tests__/api/collab/presets.test.ts` | Preset prompt structure, additive application, handle conflicts | | `__tests__/api/collab/*.test.ts` | Route auth, validation, the hub mount, the Slack receiver | `two-machine.test.ts` is the one to read if you want to know whether the diff --git a/packages/adapters/src/collab/agent.ts b/packages/adapters/src/collab/agent.ts index 89ce7bb29..c9f7eb5d8 100644 --- a/packages/adapters/src/collab/agent.ts +++ b/packages/adapters/src/collab/agent.ts @@ -24,6 +24,15 @@ export interface TurnContext { turnIndex: number; maxTurns: number; completionMarker: string; + /** + * What kind of exchange this turn belongs to. Absent means `meeting`, so a + * worker built before rooms existed keeps its current behaviour. + * + * A room member answers a question once, alone, and does not hold a floor — + * so the standing instructions about turn budgets and handing off are not + * merely unnecessary there, they describe a room that does not exist. + */ + mode?: "meeting" | "room"; } export interface AgentTurnRequest { @@ -107,15 +116,27 @@ export function buildSystemPrompt(persona: AgentPersona, ctx: TurnContext): stri ); } - lines.push( - "", - "## How to speak here", - "- Write one chat message. No preamble, no sign-off, no role label — the channel already shows who you are.", - "- Address a specific participant with @their-id when you need something from them.", - "- Be concrete: name numbers, tradeoffs, and owners. Do not restate what was already said.", - `- This meeting ends after at most ${ctx.maxTurns} turns; you are on turn ${ctx.turnIndex + 1}.`, - `- When the objective is genuinely met, end your message with ${ctx.completionMarker} on its own line.`, - ); + if (ctx.mode === "room") { + lines.push( + "", + "## How to answer here", + "- You are answering one question, once. Nobody is waiting for you to hand off, and you will not be asked a follow-up.", + "- Answer only from what you can actually see: your own sources and the grounding passages above. Other members are answering the same question from theirs.", + "- Be specific and short. Name files, figures, clauses, versions. Skip preamble and skip restating the question.", + "- Say what you are *not* able to see, when that changes how much your answer is worth.", + `- If nothing you have access to bears on the question, reply with ${ctx.completionMarker} on its own line rather than guessing. Being the member with nothing to add is a useful answer.`, + ); + } else { + lines.push( + "", + "## How to speak here", + "- Write one chat message. No preamble, no sign-off, no role label — the channel already shows who you are.", + "- Address a specific participant with @their-id when you need something from them.", + "- Be concrete: name numbers, tradeoffs, and owners. Do not restate what was already said.", + `- This meeting ends after at most ${ctx.maxTurns} turns; you are on turn ${ctx.turnIndex + 1}.`, + `- When the objective is genuinely met, end your message with ${ctx.completionMarker} on its own line.`, + ); + } if (persona.maxTurnChars) { lines.push(`- Keep the message under ${persona.maxTurnChars} characters.`); diff --git a/packages/adapters/src/collab/create-meeting.ts b/packages/adapters/src/collab/create-meeting.ts index 0ddc3fcef..99965a46b 100644 --- a/packages/adapters/src/collab/create-meeting.ts +++ b/packages/adapters/src/collab/create-meeting.ts @@ -11,6 +11,7 @@ import type { AgentRuntime } from "./agent"; import type { Clock, IdFactory } from "./clock"; import { randomIdFactory, systemClock } from "./clock"; +import type { TurnGroundingProvider } from "./grounding"; import { MeetingOrchestrator } from "./meeting"; import type { MeetingHub } from "./net/hub"; import { SlackChannelBridge } from "./slack/bridge"; @@ -35,6 +36,8 @@ export interface CreateMeetingInput { /** Slug for the new channel. Defaults to a slugified title. */ slug?: string; slack?: { client: SlackClient; config: SlackMirrorConfig }; + /** When given, every turn is grounded in passages retrieved for its speaker. */ + groundingProvider?: TurnGroundingProvider; /** When given, the meeting is registered so remote workers can serve turns. */ hub?: MeetingHub; clock?: Clock; @@ -93,6 +96,7 @@ export async function createMeeting(input: CreateMeetingInput): Promise 0) excerpts.push(excerpt); + } + } + return excerpts; +} + /** Penalizes turns that mostly restate an earlier turn. */ function scoreRedundancy(chat: ChannelMessage[]): MeetingEvalDimension { if (chat.length < 2) { diff --git a/packages/adapters/src/collab/grounding.ts b/packages/adapters/src/collab/grounding.ts new file mode 100644 index 000000000..c6454f174 --- /dev/null +++ b/packages/adapters/src/collab/grounding.ts @@ -0,0 +1,105 @@ +/** + * Turn-level grounding port. + * + * `MeetingConfig.context` pins passages for the whole meeting, which is right + * for material every participant needs (prior minutes, the objective's source + * document). It is wrong for the thing an agent actually needs on *its* turn: + * the analyst reaching for a clause and the engineer reaching for a migration + * note want different passages, and neither is knowable when the meeting is + * created. + * + * So retrieval is a port, not a field. The engine never learns what a document + * or an index is — it asks for passages before a turn and passes back what it + * gets. The Postgres/RAG implementation lives in + * `apps/web/src/server/collab/grounding.ts`. + */ + +import type { AgentPersona, ChannelMessage } from "./types"; + +/** One retrieved passage, with enough provenance to cite it in the transcript. */ +export interface GroundingSource { + /** Human-readable origin, e.g. `Northwind MSA · p.12`. */ + label: string; + documentId?: string; + page?: number; + /** Retriever score, when the implementation exposes one. */ + score?: number; + /** + * Truncated copy of the passage the turn actually saw. + * + * Storing it costs log size, and buys the two things the log exists for: a + * reader can check a figure without the index still being around, and the + * evaluator can tell a cited number from an invented one. A meeting grounded + * purely by retrieval would otherwise be unscoreable, because the passages + * that justified it lived only in a prompt that no longer exists. + */ + excerpt?: string; +} + +/** Keeps a grounded transcript auditable without turning it into a corpus. */ +export const MAX_EXCERPT_CHARS = 320; + +export function toExcerpt(passage: string, limit = MAX_EXCERPT_CHARS): string { + const flat = passage.replace(/\s+/g, " ").trim(); + return flat.length <= limit ? flat : `${flat.slice(0, limit - 1).trimEnd()}…`; +} + +export interface TurnGrounding { + /** Passages injected into this turn's system prompt, highest value first. */ + passages: string[]; + /** Recorded on the produced message so the UI can show what was consulted. */ + sources?: GroundingSource[]; +} + +export interface TurnGroundingRequest { + meetingId: string; + /** The persona about to speak. Its role is the strongest query signal. */ + persona: AgentPersona; + objective: string; + agenda: string[]; + /** Full transcript so far, oldest first. */ + transcript: ChannelMessage[]; + turnIndex: number; +} + +export interface TurnGroundingProvider { + /** + * Passages for this turn. Implementations must be side-effect free and + * should return empty rather than throw — the orchestrator treats a failure + * as "no extra grounding", never as a failed turn. + */ + retrieve(request: TurnGroundingRequest): Promise; +} + +export const EMPTY_GROUNDING: TurnGrounding = { passages: [] }; + +/** + * Builds the retrieval query for a turn. + * + * Exported because it is the part worth tuning and worth testing directly: the + * persona's role carries what this participant is *for*, the objective carries + * what the meeting is for, and the last substantive message carries what was + * just asked. Weighting all three beats querying on any one of them, and + * querying on the whole transcript retrieves the meeting's own chatter back. + */ +export function buildGroundingQuery(request: TurnGroundingRequest): string { + const parts = [request.persona.role, request.objective]; + + for (let i = request.transcript.length - 1; i >= 0; i--) { + const message = request.transcript[i]!; + if (message.kind === "system") continue; + parts.push(message.text.slice(0, 600)); + break; + } + + // Agenda only when nothing has been said yet — once the conversation is + // moving, the last message is a far better signal than the whole agenda. + if (request.transcript.every((m) => m.kind === "system") && request.agenda.length > 0) { + parts.push(request.agenda.join(" ")); + } + + return parts + .filter((p) => typeof p === "string" && p.trim().length > 0) + .join(" ") + .slice(0, 1200); +} diff --git a/packages/adapters/src/collab/index.ts b/packages/adapters/src/collab/index.ts index b1ff6ef64..ed683fc74 100644 --- a/packages/adapters/src/collab/index.ts +++ b/packages/adapters/src/collab/index.ts @@ -11,8 +11,10 @@ export * from "./types"; export * from "./clock"; export * from "./store"; export * from "./agent"; +export * from "./grounding"; export * from "./turn-policy"; export * from "./meeting"; +export * from "./room"; export * from "./minutes"; export * from "./evals"; export { diff --git a/packages/adapters/src/collab/meeting.ts b/packages/adapters/src/collab/meeting.ts index 0933d1ffc..5fb2ac2d6 100644 --- a/packages/adapters/src/collab/meeting.ts +++ b/packages/adapters/src/collab/meeting.ts @@ -12,6 +12,8 @@ import type { AgentRuntime, AgentTurnResult, TurnContext } from "./agent"; import { DEFAULT_COMPLETION_MARKER } from "./agent"; import type { Clock } from "./clock"; import { systemClock } from "./clock"; +import type { TurnGrounding, TurnGroundingProvider } from "./grounding"; +import { EMPTY_GROUNDING } from "./grounding"; import type { ChannelStore } from "./store"; import { selectNextSpeaker } from "./turn-policy"; import type { @@ -54,6 +56,17 @@ export interface MeetingOrchestratorOptions { * marked failed. A remote worker restarting mid-meeting should not kill it. */ maxConsecutiveFailures?: number; + /** + * Retrieves passages for the persona about to speak. Optional: without one + * a meeting is grounded only in `config.context`. + */ + groundingProvider?: TurnGroundingProvider; + /** + * Notified when grounding fails. The turn still happens — retrieval being + * down degrades answer quality, and dropping the turn instead would convert + * that into a dead meeting. + */ + onGroundingError?: (error: Error, personaId: string) => void; } export class MeetingOrchestrator { @@ -63,6 +76,8 @@ export class MeetingOrchestrator { private readonly clock: Clock; private readonly listeners = new Set(); private readonly maxConsecutiveFailures: number; + private readonly groundingProvider: TurnGroundingProvider | null; + private readonly onGroundingError?: (error: Error, personaId: string) => void; private consecutiveFailures = 0; private state: MeetingState; /** @@ -78,6 +93,8 @@ export class MeetingOrchestrator { this.runtimes = options.runtimes; this.clock = options.clock ?? systemClock; this.maxConsecutiveFailures = options.maxConsecutiveFailures ?? 2; + this.groundingProvider = options.groundingProvider ?? null; + this.onGroundingError = options.onGroundingError; this.state = options.initialState ?? { meetingId: options.config.id, status: "scheduled", @@ -202,11 +219,15 @@ export class MeetingOrchestrator { return { state: this.getState(), done: true }; } + // Grounding is fetched before the turn and never inside it, so a runtime + // that lives on another machine gets the same passages a local one would. + const grounding = await this.groundFor(speaker, transcript); + let result: AgentTurnResult; try { result = await runtime.takeTurn({ persona: speaker, - context: this.turnContext(), + context: this.turnContext(grounding.passages), transcript, }); this.consecutiveFailures = 0; @@ -251,6 +272,10 @@ export class MeetingOrchestrator { meetingId: this.config.id, turnIndex: this.state.turnIndex, addressedTo: result.addressedTo, + // What this turn was allowed to read. Recorded even when empty, so a + // grounded meeting that retrieved nothing is distinguishable from one + // that never asked. + ...(grounding.sources ? { grounding: grounding.sources } : {}), ...result.meta, }, }); @@ -474,13 +499,40 @@ export class MeetingOrchestrator { return this.runtimes.find((r) => r.serves(persona)) ?? null; } - private turnContext(): TurnContext { + /** + * Passages for the persona about to speak. Retrieval failure is downgraded + * to "no extra grounding" rather than a failed turn: a meeting that stops + * because the search index is briefly unavailable is a worse outcome than a + * meeting that carries on with only its pinned context. + */ + private async groundFor( + persona: AgentPersona, + transcript: ChannelMessage[], + ): Promise { + if (!this.groundingProvider) return EMPTY_GROUNDING; + try { + return await this.groundingProvider.retrieve({ + meetingId: this.config.id, + persona, + objective: this.config.objective, + agenda: this.config.agenda, + transcript, + turnIndex: this.state.turnIndex, + }); + } catch (err) { + this.onGroundingError?.(err instanceof Error ? err : new Error(String(err)), persona.id); + return EMPTY_GROUNDING; + } + } + + /** `retrieved` is appended after the pinned context, never in place of it. */ + private turnContext(retrieved: string[] = []): TurnContext { return { meetingId: this.config.id, title: this.config.title, objective: this.config.objective, agenda: this.config.agenda, - context: this.config.context ?? [], + context: [...(this.config.context ?? []), ...retrieved], roster: this.config.participants.map((p) => ({ id: p.id, displayName: p.displayName, diff --git a/packages/adapters/src/collab/room.ts b/packages/adapters/src/collab/room.ts new file mode 100644 index 000000000..bb1963e78 --- /dev/null +++ b/packages/adapters/src/collab/room.ts @@ -0,0 +1,369 @@ +/** + * Rooms — ask every member at once, and let each answer from its own context. + * + * A meeting is a conversation: one speaker at a time, elected from the + * transcript, working toward a close. A room is a *query*: one question, every + * member, concurrently, no floor to hold. The two share a channel log and + * nothing else, which is why this is a function rather than another method on + * `MeetingOrchestrator`. + * + * Three reasons it cannot be one: + * + * 1. `stepInner` marks the whole meeting `failed` when no runtime serves a + * persona. For a room that is the ordinary case — a member whose machine is + * offline — and it has to settle as that member's message, not a dead round. + * 2. A meeting carries state between turns (turn index, next speaker, + * controller, status). A room carries none: every round is derivable from + * the log, so there is nothing to persist and nothing to keep in sync. + * 3. `maxConsecutiveFailures` is meaningful for a conversation and actively + * wrong for a fan-out, where one member failing says nothing about the rest. + * + * The value of a room is proportional to the information its members do *not* + * share. Members that read the same corpus produce the same answer six times; + * members bound to different document sets, repos or machines produce something + * no single context could. + */ + +import type { AgentRuntime, TurnContext } from "./agent"; +import type { Clock, IdFactory } from "./clock"; +import { randomIdFactory, systemClock } from "./clock"; +import type { TurnGroundingProvider } from "./grounding"; +import type { ChannelStore } from "./store"; +import type { AgentPersona, ChannelMessage, MessageAuthor } from "./types"; + +/** Emitted on its own line by a member that cannot answer. Never stored. */ +export const ROOM_DECLINE_MARKER = "NO_ANSWER"; + +/** Wall-clock budget per member. Below the hub's 120s so remote work isn't discarded. */ +export const DEFAULT_MEMBER_TIMEOUT_MS = 90_000; + +export interface RoomConfig { + id: string; + channelId: string; + workspaceId: string; + name: string; + /** What the room is for. Injected into every member's prompt. */ + purpose?: string; + /** Frozen at creation, exactly like a meeting's participants. */ + members: AgentPersona[]; + /** Passages pinned for every member, regardless of their own sources. */ + context?: string[]; +} + +export type RoomAnswerStatus = "answered" | "declined" | "failed" | "timeout" | "unserved"; + +export interface RoomAnswer { + memberId: string; + status: RoomAnswerStatus; + /** Always present — a failure is a message too, so the round stays readable. */ + message: ChannelMessage; + latencyMs: number; + error?: string; +} + +export interface AskRoomResult { + roundId: string; + question: ChannelMessage; + /** One per asked member, in roster order — never arrival order. */ + answers: RoomAnswer[]; +} + +export interface AskRoomInput { + store: ChannelStore; + room: RoomConfig; + /** Consulted in order; first that `serves()` wins. Same contract as meetings. */ + runtimes: AgentRuntime[]; + question: { text: string; author: MessageAuthor }; + /** Subset to ask. Defaults to every member. */ + memberIds?: string[]; + timeoutMs?: number; + /** Members in flight at once. Bounded so a large room cannot stampede. */ + concurrency?: number; + groundingProvider?: TurnGroundingProvider; + onGroundingError?: (error: Error, memberId: string) => void; + clock?: Clock; + newId?: IdFactory; +} + +/** Round bookkeeping carried on the question message. */ +export interface RoomRoundMeta { + id: string; + expected: string[]; +} + +/** A round reconstructed from the log. The API and the UI share this. */ +export interface RoomRound { + id: string; + questionSeq: number; + text: string; + askedBy: MessageAuthor; + askedAt: string; + expected: string[]; + settled: Array<{ memberId: string; status: RoomAnswerStatus; seq: number }>; + pending: string[]; + complete: boolean; +} + +/** + * Strips the decline marker and reports whether it was present. + * + * Mirrors `extractCompletion`: the marker is control flow, not conversation, + * and never reaches the transcript. + */ +export function extractDecline( + text: string, + marker: string = ROOM_DECLINE_MARKER, +): { text: string; declined: boolean } { + if (!text.includes(marker)) return { text: text.trim(), declined: false }; + const stripped = text.split(marker).join("").replace(/\n{3,}/g, "\n\n").trim(); + return { text: stripped, declined: true }; +} + +/** + * The slice of a meeting's `TurnContext` a room member needs. + * + * Reusing the type rather than inventing a parallel one is what keeps every + * existing runtime — local, scripted, remote worker — usable in a room with no + * changes. `maxTurns: 1` is literally true here: a member speaks once. + */ +export function buildRoomTurnContext(room: RoomConfig, retrieved: string[] = []): TurnContext { + return { + meetingId: room.id, + title: room.name, + objective: room.purpose ?? `Answer the question asked in ${room.name}.`, + agenda: [], + context: [...(room.context ?? []), ...retrieved], + roster: room.members.map((m) => ({ id: m.id, displayName: m.displayName, role: m.role })), + turnIndex: 0, + maxTurns: 1, + completionMarker: ROOM_DECLINE_MARKER, + mode: "room", + }; +} + +// --------------------------------------------------------------------------- + +/** Runs `tasks` with at most `limit` in flight, preserving input order. */ +async function mapWithConcurrency( + items: T[], + limit: number, + run: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + let cursor = 0; + const workers = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => { + for (;;) { + const index = cursor++; + if (index >= items.length) return; + results[index] = await run(items[index]!, index); + } + }); + await Promise.all(workers); + return results; +} + +/** + * Asks every member and appends each answer as it lands. + * + * Resolves once every asked member has settled. It never rejects: a member that + * throws, times out, or has no runtime becomes one message with a `system` + * kind, so the round always produces a complete, readable record. + */ +export async function askRoom(input: AskRoomInput): Promise { + const clock = input.clock ?? systemClock; + const newId = input.newId ?? randomIdFactory; + const timeoutMs = input.timeoutMs ?? DEFAULT_MEMBER_TIMEOUT_MS; + + const asked = input.memberIds + ? input.room.members.filter((m) => input.memberIds!.includes(m.id)) + : [...input.room.members]; + if (asked.length === 0) throw new Error("A room round needs at least one member"); + + const roundId = newId("round"); + const round: RoomRoundMeta = { id: roundId, expected: asked.map((m) => m.id) }; + + // Appended and awaited before any member runs, so the question always + // precedes every answer in the log. Answer order is arrival order and + // deliberately not stable — that is what "they answer as they finish" means. + const question = await input.store.append({ + channelId: input.room.channelId, + author: input.question.author, + text: input.question.text, + kind: "chat", + meta: { roomId: input.room.id, round }, + }); + + const answers = await mapWithConcurrency(asked, input.concurrency ?? 8, (member) => + settleMember({ input, member, question, roundId, timeoutMs, clock }), + ); + + return { roundId, question, answers }; +} + +interface SettleInput { + input: AskRoomInput; + member: AgentPersona; + question: ChannelMessage; + roundId: string; + timeoutMs: number; + clock: Clock; +} + +/** Runs one member to a settled outcome. Never throws. */ +async function settleMember(args: SettleInput): Promise { + const { input, member, question, roundId, timeoutMs } = args; + const startedAt = Date.now(); + + const record = async ( + status: RoomAnswerStatus, + text: string, + meta: Record, + error?: string, + ): Promise => { + const latencyMs = Date.now() - startedAt; + const message = await input.store.append({ + channelId: input.room.channelId, + author: { kind: "agent", id: member.id, displayName: member.displayName }, + // Answers and declines are content; the three failure states are not. + // Keeping that distinction in `kind` is what stops the Slack bridge, the + // minutes extractor and the eval harness reading an error as an answer. + kind: status === "answered" || status === "declined" ? "chat" : "system", + text, + meta: { roomId: input.room.id, roundId, memberId: member.id, status, latencyMs, ...meta }, + }); + return { memberId: member.id, status, message, latencyMs, error }; + }; + + const runtime = input.runtimes.find((r) => r.serves(member)) ?? null; + if (!runtime) { + // Normal in a room — a member whose node is offline. Not a failed round. + return record("unserved", `@${member.id} is not reachable right now.`, {}); + } + + let grounding: { passages: string[]; sources?: unknown } = { passages: [] }; + if (input.groundingProvider) { + try { + grounding = await input.groundingProvider.retrieve({ + meetingId: input.room.id, + persona: member, + objective: question.text, + agenda: [], + transcript: [question], + turnIndex: 0, + }); + } catch (err) { + input.onGroundingError?.( + err instanceof Error ? err : new Error(String(err)), + member.id, + ); + } + } + + let timer: ReturnType | undefined; + try { + const result = await Promise.race([ + // Members receive the question ONLY, never the channel history. If they + // saw each other's answers the first to finish would anchor the rest, + // and it would stop being a fan-out. + runtime.takeTurn({ + persona: member, + context: buildRoomTurnContext(input.room, grounding.passages), + transcript: [question], + }), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new RoomTimeout()), timeoutMs); + timer.unref?.(); + }), + ]); + + const { text, declined } = extractDecline(result.text.trim()); + const meta = { + ...(grounding.sources ? { grounding: grounding.sources } : {}), + ...result.meta, + }; + + if (declined || text.length === 0 || result.meta?.declined === true) { + return record("declined", text.length > 0 ? text : "No answer from my sources.", { + ...meta, + declined: true, + }); + } + return record("answered", text, meta); + } catch (err) { + if (err instanceof RoomTimeout) { + // The runtime promise is abandoned rather than cancelled — `takeTurn` + // takes no AbortSignal. A remote turn is still bounded by the hub's own + // timer, so this leaks nothing; it just resolves into the void. + return record( + "timeout", + `@${member.id} did not answer within ${Math.round(timeoutMs / 1000)}s.`, + { timeoutMs }, + ); + } + const detail = err instanceof Error ? err.message : String(err); + return record("failed", `@${member.id} could not answer (${detail}).`, {}, detail); + } finally { + if (timer) clearTimeout(timer); + } +} + +class RoomTimeout extends Error { + constructor() { + super("timeout"); + this.name = "RoomTimeout"; + } +} + +// --------------------------------------------------------------------------- + +function readRoundMeta(message: ChannelMessage): RoomRoundMeta | null { + const round = (message.meta as { round?: unknown } | undefined)?.round; + if (!round || typeof round !== "object") return null; + const { id, expected } = round as { id?: unknown; expected?: unknown }; + if (typeof id !== "string" || !Array.isArray(expected)) return null; + return { id, expected: expected.filter((e): e is string => typeof e === "string") }; +} + +/** + * Reconstructs every round from a channel log. + * + * The round has no row of its own: the question carries its id and roster, each + * answer carries the round id and its status. Deriving rather than storing is + * what keeps a room stateless — there is no second copy to fall out of sync + * with the log, which is the same rule meetings follow. + */ +export function summarizeRounds(messages: ChannelMessage[]): RoomRound[] { + const rounds = new Map(); + + for (const message of messages) { + const round = readRoundMeta(message); + if (!round) continue; + rounds.set(round.id, { + id: round.id, + questionSeq: message.seq, + text: message.text, + askedBy: message.author, + askedAt: message.ts, + expected: round.expected, + settled: [], + pending: [...round.expected], + complete: round.expected.length === 0, + }); + } + + for (const message of messages) { + const meta = message.meta as + | { roundId?: unknown; memberId?: unknown; status?: unknown } + | undefined; + if (typeof meta?.roundId !== "string" || typeof meta.memberId !== "string") continue; + const round = rounds.get(meta.roundId); + if (!round) continue; + + const status = typeof meta.status === "string" ? (meta.status as RoomAnswerStatus) : "answered"; + round.settled.push({ memberId: meta.memberId, status, seq: message.seq }); + round.pending = round.pending.filter((id) => id !== meta.memberId); + round.complete = round.pending.length === 0; + } + + return [...rounds.values()].sort((a, b) => a.questionSeq - b.questionSeq); +} diff --git a/packages/adapters/src/collab/turn-policy.ts b/packages/adapters/src/collab/turn-policy.ts index b0993b3b3..cf3982d32 100644 --- a/packages/adapters/src/collab/turn-policy.ts +++ b/packages/adapters/src/collab/turn-policy.ts @@ -101,16 +101,31 @@ export function selectNextSpeaker(input: SelectSpeakerInput): AgentPersona | nul } } +/** + * Who a message hands the floor to. + * + * The **last** mention wins, not the first. A turn in a working channel + * characteristically opens by answering whoever spoke last and closes by + * asking someone else — "@product, agreed on the cut. @eng, what does that + * cost?" Taking the first mention routes the floor back to the person who + * just spoke and starves the person actually being asked; over a whole + * meeting it silences the specialists entirely, because they are addressed at + * the end of a message and never at the start. + * + * Every scripted test line carries exactly one mention, so this was invisible + * until a live meeting produced multi-mention turns. + */ function findMention( text: string, participants: AgentPersona[], excludeId?: string, ): AgentPersona | null { + let found: AgentPersona | null = null; for (const match of text.matchAll(/@([a-zA-Z0-9_-]+)/g)) { const id = match[1]!; if (id === excludeId) continue; const hit = participants.find((p) => p.id === id); - if (hit) return hit; + if (hit) found = hit; } - return null; + return found; }