diff --git a/CLAUDE.md b/CLAUDE.md index c43d135..3def3e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,7 @@ cd frontend && npx tsc --noEmit - `GET /api/v1/admin/users` — user list - `PUT /api/v1/admin/users/:id/role` — role management -## Database (16 migrations) +## Database (17 migrations) Tables: `users`, `sessions`, `magic_link_tokens`, `user_settings`, `sources`, `syntheses`, `admin_providers`, `admin_rate_limits`, `user_api_keys`, `audit_log` ## Environment Variables diff --git a/backend/migrations/20260324000017_create_llm_call_log.sql b/backend/migrations/20260324000017_create_llm_call_log.sql new file mode 100644 index 0000000..0645d18 --- /dev/null +++ b/backend/migrations/20260324000017_create_llm_call_log.sql @@ -0,0 +1,14 @@ +CREATE TABLE llm_call_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + job_id UUID NOT NULL, + call_type TEXT NOT NULL, + model TEXT NOT NULL, + system_prompt TEXT NOT NULL DEFAULT '', + user_prompt TEXT NOT NULL DEFAULT '', + response_body TEXT NOT NULL DEFAULT '', + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_llm_call_log_job_id ON llm_call_log(job_id); +CREATE INDEX idx_llm_call_log_user_id ON llm_call_log(user_id, created_at); diff --git a/backend/src/db/llm_call_log.rs b/backend/src/db/llm_call_log.rs new file mode 100644 index 0000000..849baee --- /dev/null +++ b/backend/src/db/llm_call_log.rs @@ -0,0 +1,97 @@ +//! LLM call logging: tracks every LLM interaction during synthesis generation. + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::errors::AppError; + +/// Row returned from llm_call_log queries. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct LlmCallLogRow { + pub id: Uuid, + pub call_type: String, + pub model: String, + pub system_prompt: String, + pub user_prompt: String, + pub response_body: String, + pub duration_ms: i32, + pub created_at: DateTime, +} + +/// Insert a single LLM call log entry. +pub async fn insert( + pool: &PgPool, + user_id: Uuid, + job_id: Uuid, + call_type: &str, + model: &str, + system_prompt: &str, + user_prompt: &str, + response_body: &str, + duration_ms: i32, +) -> Result<(), AppError> { + sqlx::query( + r#" + INSERT INTO llm_call_log (user_id, job_id, call_type, model, system_prompt, user_prompt, response_body, duration_ms) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, + ) + .bind(user_id) + .bind(job_id) + .bind(call_type) + .bind(model) + .bind(system_prompt) + .bind(user_prompt) + .bind(response_body) + .bind(duration_ms) + .execute(pool) + .await?; + Ok(()) +} + +/// List all LLM call log entries for a generation job. +pub async fn list_by_job_id( + pool: &PgPool, + user_id: Uuid, + job_id: Uuid, +) -> Result, AppError> { + let rows = sqlx::query_as::<_, LlmCallLogRow>( + r#" + SELECT id, call_type, model, system_prompt, user_prompt, response_body, duration_ms, created_at + FROM llm_call_log + WHERE user_id = $1 AND job_id = $2 + ORDER BY created_at ASC + "#, + ) + .bind(user_id) + .bind(job_id) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Truncate old LLM call log entries: replace prompt/response with first 500 chars. +pub async fn truncate_old( + pool: &PgPool, + user_id: Uuid, + days: i32, +) -> Result { + let result = sqlx::query( + r#" + UPDATE llm_call_log + SET system_prompt = LEFT(system_prompt, 500) || E'\n[truncated]', + user_prompt = LEFT(user_prompt, 500) || E'\n[truncated]', + response_body = LEFT(response_body, 500) || E'\n[truncated]' + WHERE user_id = $1 + AND created_at < now() - make_interval(days => $2) + AND LENGTH(system_prompt) > 510 + "#, + ) + .bind(user_id) + .bind(days) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index d704916..ff008f8 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -1,5 +1,6 @@ pub mod article_history; pub mod api_keys; +pub mod llm_call_log; pub mod audit; pub mod magic_links; pub mod providers;