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
- `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`
## 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 response_body: String,
pub duration_ms: i32,
pub article_url: Option<String>,
pub created_at: DateTime<Utc>,
}
@ -32,11 +33,12 @@ pub async fn insert(
user_prompt: &str,
response_body: &str,
duration_ms: i32,
article_url: Option<&str>,
) -> 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)
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, $9)
"#,
)
.bind(user_id)
@ -47,6 +49,7 @@ pub async fn insert(
.bind(user_prompt)
.bind(response_body)
.bind(duration_ms)
.bind(article_url)
.execute(pool)
.await?;
Ok(())
@ -60,7 +63,7 @@ pub async fn list_by_job_id(
) -> Result<Vec<LlmCallLogRow>, AppError> {
let rows = sqlx::query_as::<_, LlmCallLogRow>(
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
WHERE user_id = $1 AND job_id = $2
ORDER BY created_at ASC

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

@ -485,7 +485,7 @@ async fn run_generation_inner(
// Log the LLM call
if let Ok(ref resp) = result {
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)
@ -569,7 +569,7 @@ async fn run_generation_inner(
let llm_start = std::time::Instant::now();
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;
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);
let parsed = parse_llm_output(&raw_results, &user_categories)?;
@ -759,11 +759,13 @@ async fn log_llm_call(
user_prompt: &str,
response: &serde_json::Value,
duration_ms: u64,
article_url: Option<&str>,
) {
let response_str = serde_json::to_string_pretty(response).unwrap_or_default();
db::llm_call_log::insert(
pool, user_id, job_id, call_type, model,
system_prompt, user_prompt, &response_str, duration_ms as i32,
article_url,
)
.await
.ok(); // Don't fail synthesis if logging fails

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

@ -7,6 +7,7 @@ import LoadingSpinner from '~/components/ui/LoadingSpinner';
const CALL_TYPE_BADGE: Record<string, string> = {
search: 'bg-blue-100 text-blue-800',
classify_summarize: 'bg-purple-100 text-purple-800',
classification_phase1: 'bg-purple-100 text-purple-800',
classification_phase2: 'bg-purple-100 text-purple-800',
rewrite: 'bg-green-100 text-green-800',
@ -94,6 +95,20 @@ const LlmLogs: Component = () => {
</span>
</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">
<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">

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

Loading…
Cancel
Save