feat: add article_url to LLM call logs for classify tracing

Adds an optional article_url column to llm_call_log so classify_summarize
entries are traceable back to their source article in the LLM Logs UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
oabrivard 3 months ago
parent b062e81218
commit a5332f0996

@ -117,7 +117,7 @@ cd frontend && npx tsc --noEmit
- `GET /api/v1/admin/users` — user list - `GET /api/v1/admin/users` — user list
- `PUT /api/v1/admin/users/:id/role` — role management - `PUT /api/v1/admin/users/:id/role` — role management
## Database (20 migrations) ## Database (21 migrations)
Tables: `users`, `sessions`, `magic_link_tokens`, `user_settings`, `sources`, `syntheses`, `admin_providers`, `admin_rate_limits`, `user_api_keys`, `audit_log` Tables: `users`, `sessions`, `magic_link_tokens`, `user_settings`, `sources`, `syntheses`, `admin_providers`, `admin_rate_limits`, `user_api_keys`, `audit_log`
## Environment Variables ## Environment Variables

@ -0,0 +1 @@
ALTER TABLE llm_call_log ADD COLUMN article_url TEXT;

@ -17,6 +17,7 @@ pub struct LlmCallLogRow {
pub user_prompt: String, pub user_prompt: String,
pub response_body: String, pub response_body: String,
pub duration_ms: i32, pub duration_ms: i32,
pub article_url: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@ -32,11 +33,12 @@ pub async fn insert(
user_prompt: &str, user_prompt: &str,
response_body: &str, response_body: &str,
duration_ms: i32, duration_ms: i32,
article_url: Option<&str>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO llm_call_log (user_id, job_id, call_type, model, system_prompt, user_prompt, response_body, duration_ms) INSERT INTO llm_call_log (user_id, job_id, call_type, model, system_prompt, user_prompt, response_body, duration_ms, article_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#, "#,
) )
.bind(user_id) .bind(user_id)
@ -47,6 +49,7 @@ pub async fn insert(
.bind(user_prompt) .bind(user_prompt)
.bind(response_body) .bind(response_body)
.bind(duration_ms) .bind(duration_ms)
.bind(article_url)
.execute(pool) .execute(pool)
.await?; .await?;
Ok(()) Ok(())
@ -60,7 +63,7 @@ pub async fn list_by_job_id(
) -> Result<Vec<LlmCallLogRow>, AppError> { ) -> Result<Vec<LlmCallLogRow>, AppError> {
let rows = sqlx::query_as::<_, LlmCallLogRow>( let rows = sqlx::query_as::<_, LlmCallLogRow>(
r#" r#"
SELECT id, call_type, model, system_prompt, user_prompt, response_body, duration_ms, created_at SELECT id, call_type, model, system_prompt, user_prompt, response_body, duration_ms, article_url, created_at
FROM llm_call_log FROM llm_call_log
WHERE user_id = $1 AND job_id = $2 WHERE user_id = $1 AND job_id = $2
ORDER BY created_at ASC ORDER BY created_at ASC

@ -172,6 +172,7 @@ pub async fn extract_article_links_with_llm(
crate::db::llm_call_log::insert( crate::db::llm_call_log::insert(
pool, uid, jid, "link_extraction", model, pool, uid, jid, "link_extraction", model,
&system, &user, &response_str, llm_duration as i32, &system, &user, &response_str, llm_duration as i32,
None,
).await.ok(); ).await.ok();
} }

@ -485,7 +485,7 @@ async fn run_generation_inner(
// Log the LLM call // Log the LLM call
if let Ok(ref resp) = result { if let Ok(ref resp) = result {
let resp_str = serde_json::to_string_pretty(resp).unwrap_or_default(); let resp_str = serde_json::to_string_pretty(resp).unwrap_or_default();
crate::db::llm_call_log::insert(&pool, uid, jid, "classify_summarize", &mdl, &sys, &usr, &resp_str, duration as i32).await.ok(); crate::db::llm_call_log::insert(&pool, uid, jid, "classify_summarize", &mdl, &sys, &usr, &resp_str, duration as i32, Some(&url)).await.ok();
} }
(url, su, title, result) (url, su, title, result)
@ -569,7 +569,7 @@ async fn run_generation_inner(
let llm_start = std::time::Instant::now(); let llm_start = std::time::Instant::now();
let raw_results = provider.call_llm(&model_websearch, &sys_prompt, &usr_prompt, &search_schema).await?; let raw_results = provider.call_llm(&model_websearch, &sys_prompt, &usr_prompt, &search_schema).await?;
let llm_duration = llm_start.elapsed().as_millis() as u64; let llm_duration = llm_start.elapsed().as_millis() as u64;
log_llm_call(&state.pool, user_id, job_id, "search", &model_websearch, &sys_prompt, &usr_prompt, &raw_results, llm_duration).await; log_llm_call(&state.pool, user_id, job_id, "search", &model_websearch, &sys_prompt, &usr_prompt, &raw_results, llm_duration, None).await;
emit_progress(tx, "parsing", "Analyse des resultats...", 75); emit_progress(tx, "parsing", "Analyse des resultats...", 75);
let parsed = parse_llm_output(&raw_results, &user_categories)?; let parsed = parse_llm_output(&raw_results, &user_categories)?;
@ -759,11 +759,13 @@ async fn log_llm_call(
user_prompt: &str, user_prompt: &str,
response: &serde_json::Value, response: &serde_json::Value,
duration_ms: u64, duration_ms: u64,
article_url: Option<&str>,
) { ) {
let response_str = serde_json::to_string_pretty(response).unwrap_or_default(); let response_str = serde_json::to_string_pretty(response).unwrap_or_default();
db::llm_call_log::insert( db::llm_call_log::insert(
pool, user_id, job_id, call_type, model, pool, user_id, job_id, call_type, model,
system_prompt, user_prompt, &response_str, duration_ms as i32, system_prompt, user_prompt, &response_str, duration_ms as i32,
article_url,
) )
.await .await
.ok(); // Don't fail synthesis if logging fails .ok(); // Don't fail synthesis if logging fails

@ -368,6 +368,7 @@ const fr = {
'llmLogs.viewLogs': 'Voir les logs IA', 'llmLogs.viewLogs': 'Voir les logs IA',
'llmLogs.seconds': 's', 'llmLogs.seconds': 's',
'llmLogs.back': 'Retour', 'llmLogs.back': 'Retour',
'llmLogs.articleUrl': 'Article',
// Common // Common
'common.loading': 'Chargement...', 'common.loading': 'Chargement...',

@ -7,6 +7,7 @@ import LoadingSpinner from '~/components/ui/LoadingSpinner';
const CALL_TYPE_BADGE: Record<string, string> = { const CALL_TYPE_BADGE: Record<string, string> = {
search: 'bg-blue-100 text-blue-800', search: 'bg-blue-100 text-blue-800',
classify_summarize: 'bg-purple-100 text-purple-800',
classification_phase1: 'bg-purple-100 text-purple-800', classification_phase1: 'bg-purple-100 text-purple-800',
classification_phase2: 'bg-purple-100 text-purple-800', classification_phase2: 'bg-purple-100 text-purple-800',
rewrite: 'bg-green-100 text-green-800', rewrite: 'bg-green-100 text-green-800',
@ -94,6 +95,20 @@ const LlmLogs: Component = () => {
</span> </span>
</div> </div>
<Show when={entry.article_url}>
<div class="px-5 py-1 text-sm">
<span class="text-gray-500">{t('llmLogs.articleUrl')}: </span>
<a
href={entry.article_url!}
target="_blank"
rel="noopener noreferrer"
class="text-indigo-600 hover:text-indigo-800 underline break-all"
>
{entry.article_url}
</a>
</div>
</Show>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
<details class="group"> <details class="group">
<summary class="cursor-pointer px-5 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 select-none list-none flex items-center gap-2"> <summary class="cursor-pointer px-5 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 select-none list-none flex items-center gap-2">

@ -296,5 +296,6 @@ export interface LlmCallLogEntry {
user_prompt: string; user_prompt: string;
response_body: string; response_body: string;
duration_ms: number; duration_ms: number;
article_url: string | null;
created_at: string; created_at: string;
} }

Loading…
Cancel
Save