Merged in feature/chatbot1 (pull request #223)
Chatbot functionality * baseline working * missing test file * more tests
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
-- Chatbot backend support, milestone M1: bot.sql.
|
||||
-- See plans/chatbot_plan_codex.v10.md §5 (Core SQL Queries) for the
|
||||
-- canonical query shapes. Only M1 queries live here; M4 algorithm
|
||||
-- queries (CompleteBotTurn, FailBotTurn, MarkBotTurnSessionDeleted,
|
||||
-- InsertBotTurnPrompt, GetBotTurn, etc.) are added in M4.
|
||||
--
|
||||
-- Naming conventions:
|
||||
-- - All bot_* tables use snake_case columns. New named params
|
||||
-- (@session_id, @client_id, @created_by, @grace_seconds, @attempt_id,
|
||||
-- @ordinal, @session_ids, @turn_limit) follow snake_case so sqlc
|
||||
-- generates Go field names ClientID, SessionID, CreatedBy, etc.
|
||||
|
||||
-- name: LockBotSessionForOwner :one
|
||||
-- Owner-scoped session lookup with row-level lock. Returns the session
|
||||
-- plus two derived ordinals computed from bot_turns:
|
||||
-- last_seen_ordinal = MAX(ordinal) regardless of status
|
||||
-- last_terminal_ordinal = MAX(ordinal) where status <> 'in_flight'
|
||||
-- The FOR UPDATE OF s clause locks the session row but not the
|
||||
-- bot_turns rows (those are read uncontended by the subselects).
|
||||
-- Must be called inside a transaction. Plan §5.
|
||||
SELECT s.*,
|
||||
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal,
|
||||
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal
|
||||
FROM bot_sessions s
|
||||
WHERE s.id = @session_id
|
||||
AND s.client_id = @client_id
|
||||
AND s.created_by = @created_by
|
||||
AND s.is_deleted = false
|
||||
FOR UPDATE OF s;
|
||||
|
||||
-- name: AbandonExpiredInflightForOwner :exec
|
||||
-- Owner-scoped grace-window abandonment. Mutates bot_turns rows whose
|
||||
-- attempt_started_at is older than NOW() - grace_seconds AND whose owning
|
||||
-- session matches the caller's (client_id, created_by, is_deleted=false).
|
||||
-- The EXISTS predicate is defense in depth — the service layer is
|
||||
-- expected to call LockBotSessionForOwner first. Plan §5.
|
||||
UPDATE bot_turns bt
|
||||
SET status = 'abandoned',
|
||||
error = jsonb_build_object(
|
||||
'code', 'turn_abandoned_grace_expired',
|
||||
'message', 'no terminal status reached within grace window'
|
||||
)
|
||||
WHERE bt.session_id = @session_id
|
||||
AND bt.status = 'in_flight'
|
||||
AND bt.attempt_started_at < NOW() - make_interval(secs => @grace_seconds::int)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM bot_sessions s
|
||||
WHERE s.id = bt.session_id
|
||||
AND s.client_id = @client_id
|
||||
AND s.created_by = @created_by
|
||||
AND s.is_deleted = false
|
||||
);
|
||||
|
||||
-- name: ResetBotTurnForRetry :exec
|
||||
-- Reset a terminal-failure turn back to in_flight for retry. Mints a
|
||||
-- fresh attempt_id, resets attempt_started_at and created_at, and
|
||||
-- nullifies completion/error. Only rows in (errored, abandoned) are
|
||||
-- eligible; in_flight, completed, and session_deleted rows are no-ops.
|
||||
-- Plan §5.
|
||||
UPDATE bot_turns
|
||||
SET status = 'in_flight',
|
||||
attempt_id = @attempt_id,
|
||||
attempt_started_at = NOW(),
|
||||
created_at = NOW(),
|
||||
completion = NULL,
|
||||
error = NULL
|
||||
WHERE session_id = @session_id
|
||||
AND ordinal = @ordinal
|
||||
AND status IN ('errored', 'abandoned');
|
||||
|
||||
-- name: ListLastTurnsForSessions :many
|
||||
-- Session-list preview query. For each session_id in @session_ids,
|
||||
-- return the most recent @turn_limit turns ordered by ordinal DESC.
|
||||
-- The service caller is expected to pass turn_limit = min(request.limit,
|
||||
-- cfg.RecentTurns) per plan §3. The full row column set is returned per
|
||||
-- plan §5: session_id, ordinal, prompt, completion, status, created_at,
|
||||
-- latency_ms, tokens_in, tokens_out, error.
|
||||
SELECT session_id, ordinal, prompt, completion, status, created_at,
|
||||
latency_ms, tokens_in, tokens_out, error
|
||||
FROM (
|
||||
SELECT bt.*,
|
||||
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY ordinal DESC) AS rn
|
||||
FROM bot_turns bt
|
||||
WHERE bt.session_id = ANY(@session_ids::uuid[])
|
||||
) ranked
|
||||
WHERE rn <= @turn_limit::int
|
||||
ORDER BY session_id, ordinal DESC;
|
||||
|
||||
-- ---------- Milestone M2 queries ----------
|
||||
--
|
||||
-- Session/scope CRUD queries used by internal/bot/service_session.go.
|
||||
-- All reads carry the derived last_terminal_ordinal column (plan §4) so
|
||||
-- the API response can populate `lastTurn` without a follow-up query.
|
||||
-- Owner-scoped queries filter on (client_id, created_by, is_deleted=false);
|
||||
-- super-admin reads filter on (client_id, is_deleted=false) only.
|
||||
|
||||
-- name: InsertBotSession :one
|
||||
-- Insert a new owner-scoped session. Title and state default to '' / '{}'
|
||||
-- on the schema; we let those defaults apply rather than passing the
|
||||
-- caller's intent on creation. Plan §6 says title is derived from the
|
||||
-- first turn's prompt (handled by M4's AddTurn path, not here).
|
||||
INSERT INTO bot_sessions (client_id, created_by)
|
||||
VALUES (@client_id, @created_by)
|
||||
RETURNING id, client_id, created_by, created_at, updated_at, title, state, is_deleted;
|
||||
|
||||
-- name: GetBotSessionForOwner :one
|
||||
-- Read-only owner-scoped session read. Mirrors LockBotSessionForOwner
|
||||
-- but without FOR UPDATE so callers that only need to display a session
|
||||
-- do not block writers. The derived ordinal columns match plan §4.
|
||||
SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted,
|
||||
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal,
|
||||
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal
|
||||
FROM bot_sessions s
|
||||
WHERE s.id = @session_id
|
||||
AND s.client_id = @client_id
|
||||
AND s.created_by = @created_by
|
||||
AND s.is_deleted = false;
|
||||
|
||||
-- name: ListBotSessionsForOwner :many
|
||||
-- Owner-scoped list with limit/offset. Backed by
|
||||
-- idx_bot_sessions_client_user_active (plan §4): the WHERE filter and
|
||||
-- the (client_id, created_by, updated_at DESC) ordering match the index
|
||||
-- column order so the planner can satisfy the request without a sort.
|
||||
SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted,
|
||||
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal,
|
||||
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal
|
||||
FROM bot_sessions s
|
||||
WHERE s.client_id = @client_id
|
||||
AND s.created_by = @created_by
|
||||
AND s.is_deleted = false
|
||||
ORDER BY s.updated_at DESC
|
||||
LIMIT @limit_val::int OFFSET @offset_val::int;
|
||||
|
||||
-- name: GetBotSessionForSuperAdmin :one
|
||||
-- Super-admin client-scoped session read. No created_by predicate per
|
||||
-- plan §9. is_deleted=false filter is the M2 default; including
|
||||
-- soft-deleted rows is out of scope for M2.
|
||||
SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted,
|
||||
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal,
|
||||
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal
|
||||
FROM bot_sessions s
|
||||
WHERE s.id = @session_id
|
||||
AND s.client_id = @client_id
|
||||
AND s.is_deleted = false;
|
||||
|
||||
-- name: ListBotSessionsForSuperAdmin :many
|
||||
-- Super-admin client-scoped list. Backed by idx_bot_sessions_client_active.
|
||||
-- Same ordering invariant as ListBotSessionsForOwner.
|
||||
SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted,
|
||||
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal,
|
||||
COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal
|
||||
FROM bot_sessions s
|
||||
WHERE s.client_id = @client_id
|
||||
AND s.is_deleted = false
|
||||
ORDER BY s.updated_at DESC
|
||||
LIMIT @limit_val::int OFFSET @offset_val::int;
|
||||
|
||||
-- name: SoftDeleteBotSessionForSuperAdmin :execrows
|
||||
-- Mark a session as deleted. Returns the affected row count so the
|
||||
-- caller can distinguish 0 (not found / already deleted) from 1 (soft
|
||||
-- delete applied). Filters on (client_id, is_deleted=false) so a second
|
||||
-- delete on an already-deleted row returns 0.
|
||||
UPDATE bot_sessions
|
||||
SET is_deleted = true,
|
||||
updated_at = NOW()
|
||||
WHERE id = @session_id
|
||||
AND client_id = @client_id
|
||||
AND is_deleted = false;
|
||||
|
||||
-- name: MarkInflightTurnsSessionDeletedForSession :exec
|
||||
-- Side-effect of a super-admin DELETE: any in-flight turn on the deleted
|
||||
-- session must transition to status='session_deleted' with an explanatory
|
||||
-- error JSON so the M4 AddTurn tx2 callback finds a terminal row instead
|
||||
-- of a stale in_flight row. The bot_turns_status_fields_consistent CHECK
|
||||
-- requires error IS NOT NULL on session_deleted rows.
|
||||
UPDATE bot_turns
|
||||
SET status = 'session_deleted',
|
||||
error = jsonb_build_object(
|
||||
'code', 'session_deleted_by_admin',
|
||||
'message', 'session deleted while turn in flight'
|
||||
)
|
||||
WHERE session_id = @session_id
|
||||
AND status = 'in_flight';
|
||||
|
||||
-- name: ListBotSessionDocumentsForSession :many
|
||||
-- Returns the document_id set persisted in bot_session_documents for the
|
||||
-- given session, in deterministic order. M2 returns these as-is — no
|
||||
-- folder expansion (that lives in the M3 FastAPI payload assembly).
|
||||
SELECT document_id
|
||||
FROM bot_session_documents
|
||||
WHERE session_id = @session_id
|
||||
ORDER BY document_id;
|
||||
|
||||
-- name: ListBotSessionFoldersForSession :many
|
||||
-- Returns the folder_id set persisted in bot_session_folders for the
|
||||
-- given session, in deterministic order.
|
||||
SELECT folder_id
|
||||
FROM bot_session_folders
|
||||
WHERE session_id = @session_id
|
||||
ORDER BY folder_id;
|
||||
|
||||
-- name: DeleteBotSessionDocumentsForSession :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Wipe the persisted document scope for a session. Used as the first
|
||||
-- half of the replace-set semantics in PatchBotSessionScope. The caller
|
||||
-- must follow with InsertBotSessionDocument inside the same transaction
|
||||
-- so the union write is atomic.
|
||||
DELETE FROM bot_session_documents
|
||||
WHERE session_id = @session_id;
|
||||
|
||||
-- name: DeleteBotSessionFoldersForSession :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Wipe the persisted folder scope for a session. Same role as
|
||||
-- DeleteBotSessionDocumentsForSession.
|
||||
DELETE FROM bot_session_folders
|
||||
WHERE session_id = @session_id;
|
||||
|
||||
-- name: InsertBotSessionDocument :exec
|
||||
-- Add one (session_id, document_id) link. ON CONFLICT DO NOTHING covers
|
||||
-- the rare case where the same id appears twice in the new set; the
|
||||
-- service-layer dedupe runs first so this is defense in depth.
|
||||
INSERT INTO bot_session_documents (session_id, document_id)
|
||||
VALUES (@session_id, @document_id)
|
||||
ON CONFLICT (session_id, document_id) DO NOTHING;
|
||||
|
||||
-- name: InsertBotSessionFolder :exec
|
||||
-- Add one (session_id, folder_id) link.
|
||||
INSERT INTO bot_session_folders (session_id, folder_id)
|
||||
VALUES (@session_id, @folder_id)
|
||||
ON CONFLICT (session_id, folder_id) DO NOTHING;
|
||||
|
||||
-- name: GetDocumentClientIDsForBotScope :many
|
||||
-- Bulk client-id lookup for a candidate document set. Returns one row
|
||||
-- per matching document so the service can detect cross-client ids by
|
||||
-- comparing against the session's client_id and missing rows. Documents
|
||||
-- not found return no row (caller must compare set sizes).
|
||||
SELECT id, clientId AS client_id
|
||||
FROM documents
|
||||
WHERE id = ANY(@document_ids::uuid[]);
|
||||
|
||||
-- name: GetFolderClientIDsForBotScope :many
|
||||
-- Bulk client-id lookup for a candidate folder set. Same shape and
|
||||
-- semantics as GetDocumentClientIDsForBotScope.
|
||||
SELECT id, clientId AS client_id
|
||||
FROM folders
|
||||
WHERE id = ANY(@folder_ids::uuid[]);
|
||||
|
||||
-- name: TouchBotSessionUpdatedAt :exec
|
||||
-- Bumps updated_at on a bot_sessions row so the owner list-by-updated_at
|
||||
-- ordering reflects the most recent activity. Used after PatchScope.
|
||||
UPDATE bot_sessions
|
||||
SET updated_at = NOW()
|
||||
WHERE id = @session_id;
|
||||
|
||||
-- ---------- Milestone M3 queries ----------
|
||||
|
||||
-- name: GetCompletedTurnsForSessionAscending :many
|
||||
-- Returns the most recent N completed turns for a session, ordered by
|
||||
-- ordinal ASCENDING for the FastAPI conversation_history payload (plan §7).
|
||||
-- The CTE first filters to completed turns and DESCENDS by ordinal so the
|
||||
-- LIMIT clause picks the newest N; the outer SELECT then re-orders ASC
|
||||
-- because conversation_history is "two messages per turn, ascending
|
||||
-- ordinal" per plan §7. The service caller passes turn_limit = cfg.RecentTurns.
|
||||
WITH recent AS (
|
||||
SELECT ordinal, prompt, completion, created_at
|
||||
FROM bot_turns
|
||||
WHERE session_id = @session_id
|
||||
AND status = 'completed'
|
||||
ORDER BY ordinal DESC
|
||||
LIMIT @turn_limit::int
|
||||
)
|
||||
SELECT ordinal, prompt, completion, created_at
|
||||
FROM recent
|
||||
ORDER BY ordinal ASC;
|
||||
|
||||
-- ---------- Milestone M4 queries ----------
|
||||
--
|
||||
-- Turn CRUD + AddTurn algorithm queries per plan §6. Each mutating
|
||||
-- query compares-and-sets on attempt_id so a stale tx2 callback after
|
||||
-- abandonment or retry affects zero rows (plan §11 invariant).
|
||||
|
||||
-- name: GetBotTurn :one
|
||||
-- Read one turn by (session_id, ordinal). Returns pgx.ErrNoRows when
|
||||
-- no row exists. The service uses this in tx1 after
|
||||
-- LockBotSessionForOwner so the existing-row classification (plan §6)
|
||||
-- runs against a stable snapshot.
|
||||
SELECT session_id, ordinal, prompt, completion, status, attempt_id,
|
||||
attempt_started_at, created_at, latency_ms, tokens_in,
|
||||
tokens_out, error
|
||||
FROM bot_turns
|
||||
WHERE session_id = @session_id
|
||||
AND ordinal = @ordinal;
|
||||
|
||||
-- name: FindBotTurnByPrompt :one
|
||||
-- Find the lowest-ordinal turn whose prompt matches input. Used by the
|
||||
-- handler to pick a target ordinal under prompt-match semantics: a
|
||||
-- retry of an errored/abandoned/completed/session_deleted turn must
|
||||
-- reuse the existing row's ordinal so the service classifies it via
|
||||
-- the existing-row branch of plan §6. pgx.ErrNoRows when no match.
|
||||
SELECT session_id, ordinal, prompt, completion, status
|
||||
FROM bot_turns
|
||||
WHERE session_id = @session_id
|
||||
AND prompt = @prompt
|
||||
ORDER BY ordinal ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: InsertBotTurnPrompt :one
|
||||
-- Insert a new in_flight turn at the requested ordinal. The partial
|
||||
-- unique index `bot_turns_one_inflight_per_session` rejects insertion
|
||||
-- when another in_flight row exists for the session — the service
|
||||
-- catches the unique-violation pgconn.PgError and maps it to
|
||||
-- ErrPriorTurnInFlight. Returns the inserted row so the caller can
|
||||
-- surface created_at without a follow-up read.
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id,
|
||||
attempt_started_at, created_at)
|
||||
VALUES (@session_id, @ordinal, @prompt, 'in_flight', @attempt_id,
|
||||
NOW(), NOW())
|
||||
RETURNING session_id, ordinal, prompt, completion, status, attempt_id,
|
||||
attempt_started_at, created_at, latency_ms, tokens_in,
|
||||
tokens_out, error;
|
||||
|
||||
-- name: CompleteBotTurn :execrows
|
||||
-- Compare-and-set: flips status from in_flight to completed when
|
||||
-- attempt_id matches. Returns affected row count so a stale tx2
|
||||
-- callback (whose attempt_id no longer matches) reports 0 rows and the
|
||||
-- service returns ErrTurnSuperseded. Plan §6 + plan §11.
|
||||
UPDATE bot_turns
|
||||
SET status = 'completed',
|
||||
completion = @completion,
|
||||
latency_ms = @latency_ms,
|
||||
tokens_in = @tokens_in,
|
||||
tokens_out = @tokens_out
|
||||
WHERE session_id = @session_id
|
||||
AND ordinal = @ordinal
|
||||
AND attempt_id = @attempt_id
|
||||
AND status = 'in_flight';
|
||||
|
||||
-- name: FailBotTurn :execrows
|
||||
-- Compare-and-set: flips status from in_flight to errored when
|
||||
-- attempt_id matches. Error JSON shape is `{"code","message","attempt"}`
|
||||
-- per plan §6.
|
||||
UPDATE bot_turns
|
||||
SET status = 'errored',
|
||||
error = @error
|
||||
WHERE session_id = @session_id
|
||||
AND ordinal = @ordinal
|
||||
AND attempt_id = @attempt_id
|
||||
AND status = 'in_flight';
|
||||
|
||||
-- name: MarkBotTurnSessionDeleted :execrows
|
||||
-- Compare-and-set: flips status from in_flight to session_deleted when
|
||||
-- attempt_id matches. Used by the AddTurn tx2 path when
|
||||
-- LockBotSessionForOwner finds the session disappeared mid-FastAPI-call.
|
||||
-- Plan §6.
|
||||
UPDATE bot_turns
|
||||
SET status = 'session_deleted',
|
||||
error = @error
|
||||
WHERE session_id = @session_id
|
||||
AND ordinal = @ordinal
|
||||
AND attempt_id = @attempt_id
|
||||
AND status = 'in_flight';
|
||||
|
||||
-- name: SetBotSessionTitle :exec
|
||||
-- Sets the session title and bumps updated_at, but ONLY when the
|
||||
-- existing title is empty. Plan §6: title is derived from the first
|
||||
-- turn's prompt on ordinal=1; the empty-title guard prevents a retry
|
||||
-- on ordinal=1 (after an errored/abandoned reset) from clobbering a
|
||||
-- title that was already set on a prior successful attempt.
|
||||
UPDATE bot_sessions
|
||||
SET title = @title,
|
||||
updated_at = NOW()
|
||||
WHERE id = @session_id
|
||||
AND title = '';
|
||||
|
||||
-- name: ListTurnsForSession :many
|
||||
-- Owner-scoped list of every turn for a session, ordered ASC by ordinal.
|
||||
-- The owner check is the responsibility of the caller; the service
|
||||
-- runs GetBotSessionForOwner first so a non-owner caller never reaches
|
||||
-- this query.
|
||||
SELECT session_id, ordinal, prompt, completion, status, attempt_id,
|
||||
attempt_started_at, created_at, latency_ms, tokens_in,
|
||||
tokens_out, error
|
||||
FROM bot_turns
|
||||
WHERE session_id = @session_id
|
||||
ORDER BY ordinal ASC;
|
||||
|
||||
-- name: UpdateBotSessionState :exec
|
||||
-- Persists a session_state object after tx2's compare-and-set. Plan §6
|
||||
-- mandates that null updated_session_state preserves prior; this query
|
||||
-- is only invoked when the service has decided to write a non-null
|
||||
-- (possibly stripped) value. NULL @state explicitly clears state.
|
||||
UPDATE bot_sessions
|
||||
SET state = COALESCE(@state, '{}'::jsonb),
|
||||
updated_at = NOW()
|
||||
WHERE id = @session_id
|
||||
AND client_id = @client_id;
|
||||
Reference in New Issue
Block a user