feat: article history page + provenance section in synthesis detail

- Add ArticleHistoryEntry/ArticleHistoryResponse types
- Add articleHistoryApi client (list + getProvenance endpoints)
- Add ArticleHistory page with status/source_type filters and pagination
- Add collapsible provenance section to SynthesisDetail
- Register /article-history route in App.tsx
- Add viewHistory link in Settings near articleHistoryDays input
- Add all French i18n strings for article history feature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
oabrivard 3 months ago
parent 55fe828e58
commit 6fc6fff1f3

@ -16,6 +16,7 @@ const Settings = lazy(() => import('~/pages/Settings'));
const Sources = lazy(() => import('~/pages/Sources'));
const GenerateSynthesis = lazy(() => import('~/pages/GenerateSynthesis'));
const SynthesisDetail = lazy(() => import('~/pages/SynthesisDetail'));
const ArticleHistory = lazy(() => import('~/pages/ArticleHistory'));
const AdminProviders = lazy(() => import('~/pages/admin/Providers'));
const AdminRateLimits = lazy(() => import('~/pages/admin/RateLimits'));
const AdminUsers = lazy(() => import('~/pages/admin/Users'));
@ -63,6 +64,7 @@ const App: Component = () => {
<Route path="/sources" component={Sources} />
<Route path="/generate" component={GenerateSynthesis} />
<Route path="/synthesis/:id" component={SynthesisDetail} />
<Route path="/article-history" component={ArticleHistory} />
</Route>
{/* Admin routes with admin layout wrapper */}

@ -0,0 +1,17 @@
import { api } from './client';
import type { ArticleHistoryResponse, ArticleHistoryEntry } from '~/types';
export const articleHistoryApi = {
list: (params: { limit?: number; offset?: number; status?: string; source_type?: string } = {}): Promise<ArticleHistoryResponse> => {
const query = new URLSearchParams();
if (params.limit) query.set('limit', String(params.limit));
if (params.offset) query.set('offset', String(params.offset));
if (params.status) query.set('status', params.status);
if (params.source_type) query.set('source_type', params.source_type);
const qs = query.toString();
return api.get<ArticleHistoryResponse>(`/article-history${qs ? '?' + qs : ''}`);
},
getProvenance: (synthesisId: string): Promise<ArticleHistoryEntry[]> =>
api.get<ArticleHistoryEntry[]>(`/syntheses/${synthesisId}/provenance`),
};

@ -331,6 +331,26 @@ const fr = {
'admin.users.makeAdmin': 'Promouvoir admin',
'admin.users.makeUser': 'Retirer admin',
// Article History
'articleHistory.title': 'Historique des articles',
'articleHistory.date': 'Date',
'articleHistory.articleTitle': 'Titre',
'articleHistory.url': 'URL',
'articleHistory.sourceType': 'Source',
'articleHistory.status': 'Statut',
'articleHistory.category': 'Categorie',
'articleHistory.synthesis': 'Synthese',
'articleHistory.empty': 'Aucun historique disponible.',
'articleHistory.filterStatus': 'Filtrer par statut',
'articleHistory.filterSourceType': 'Filtrer par type de source',
'articleHistory.all': 'Tous',
'articleHistory.statusUsed': 'Utilise',
'articleHistory.statusFiltered': 'Filtre',
'articleHistory.viewHistory': 'Historique des articles',
'articleHistory.provenance': 'Provenance',
'articleHistory.provenanceEmpty': 'Aucune donnee de provenance disponible pour cette synthese.',
'articleHistory.provenanceDescription': 'Articles candidats traites lors de la generation de cette synthese.',
// Common
'common.loading': 'Chargement...',
'common.error': 'Une erreur est survenue.',

@ -0,0 +1,294 @@
import {
type Component,
createSignal,
onMount,
Show,
For,
createEffect,
} from 'solid-js';
import { A } from '@solidjs/router';
import { useI18n } from '~/i18n';
import { articleHistoryApi } from '~/api/articleHistory';
import type { ArticleHistoryEntry } from '~/types';
import LoadingSpinner from '~/components/ui/LoadingSpinner';
const PAGE_SIZE = 50;
const STATUS_OPTIONS = [
'used',
'filtered_empty',
'filtered_history',
'filtered_diversity',
'filtered_homepage',
'filtered_duplicate',
'filtered_cross_phase_dedup',
];
const SOURCE_TYPE_OPTIONS = [
'personalized_source',
'web_search',
'overflow',
];
function statusBadgeClass(status: string): string {
if (status === 'used') return 'bg-green-100 text-green-800';
if (status.startsWith('filtered_')) return 'bg-red-100 text-red-800';
return 'bg-gray-100 text-gray-700';
}
function truncateUrl(url: string, maxLen = 50): string {
return url.length > maxLen ? url.slice(0, maxLen) + '...' : url;
}
/**
* Article history page displays a paginated, filterable table of every
* article candidate processed across all synthesis jobs.
*/
const ArticleHistory: Component = () => {
const { t } = useI18n();
const [items, setItems] = createSignal<ArticleHistoryEntry[]>([]);
const [total, setTotal] = createSignal(0);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
const [filterStatus, setFilterStatus] = createSignal('');
const [filterSourceType, setFilterSourceType] = createSignal('');
const [page, setPage] = createSignal(0);
const totalPages = () => Math.max(1, Math.ceil(total() / PAGE_SIZE));
const fetchHistory = async () => {
setLoading(true);
setError(null);
try {
const result = await articleHistoryApi.list({
limit: PAGE_SIZE,
offset: page() * PAGE_SIZE,
status: filterStatus() || undefined,
source_type: filterSourceType() || undefined,
});
setItems(result.items);
setTotal(result.total);
} catch {
setError(t('common.error'));
} finally {
setLoading(false);
}
};
onMount(fetchHistory);
// Re-fetch when filters or page change (but not on first mount — onMount covers that)
createEffect(() => {
// Track all reactive dependencies
filterStatus();
filterSourceType();
page();
// Skip the initial run which is handled by onMount
});
const handleFilterChange = () => {
setPage(0);
fetchHistory();
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
fetchHistory();
};
return (
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Page header */}
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900">
{t('articleHistory.title')}
</h1>
</div>
{/* Filters */}
<div class="mb-4 flex flex-col sm:flex-row gap-3">
<div>
<label for="filter-status" class="block text-xs font-medium text-gray-500 mb-1">
{t('articleHistory.filterStatus')}
</label>
<select
id="filter-status"
class="block pl-3 pr-8 py-2 text-sm border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md border"
value={filterStatus()}
onChange={(e) => {
setFilterStatus(e.currentTarget.value);
handleFilterChange();
}}
>
<option value="">{t('articleHistory.all')}</option>
<For each={STATUS_OPTIONS}>
{(s) => <option value={s}>{s}</option>}
</For>
</select>
</div>
<div>
<label for="filter-source-type" class="block text-xs font-medium text-gray-500 mb-1">
{t('articleHistory.filterSourceType')}
</label>
<select
id="filter-source-type"
class="block pl-3 pr-8 py-2 text-sm border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 rounded-md border"
value={filterSourceType()}
onChange={(e) => {
setFilterSourceType(e.currentTarget.value);
handleFilterChange();
}}
>
<option value="">{t('articleHistory.all')}</option>
<For each={SOURCE_TYPE_OPTIONS}>
{(s) => <option value={s}>{s}</option>}
</For>
</select>
</div>
</div>
{/* Error */}
<Show when={error()}>
<div class="mb-4 p-4 rounded-md bg-red-50 text-red-800 border border-red-200">
{error()}
</div>
</Show>
{/* Loading */}
<Show when={loading()}>
<LoadingSpinner />
</Show>
{/* Table */}
<Show when={!loading()}>
<Show
when={items().length > 0}
fallback={
<div class="text-center py-12 text-gray-500">
{t('articleHistory.empty')}
</div>
}
>
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('articleHistory.date')}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('articleHistory.status')}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('articleHistory.articleTitle')}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('articleHistory.url')}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('articleHistory.sourceType')}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('articleHistory.category')}
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('articleHistory.synthesis')}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<For each={items()}>
{(entry) => (
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 whitespace-nowrap text-xs text-gray-500">
{new Date(entry.created_at).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</td>
<td class="px-3 py-2 whitespace-nowrap">
<span class={`inline-flex px-2 py-0.5 text-xs rounded-full font-medium ${statusBadgeClass(entry.status)}`}>
{entry.status}
</span>
</td>
<td class="px-3 py-2 max-w-xs">
<span class="block truncate text-gray-900" title={entry.title}>
{entry.title || '—'}
</span>
</td>
<td class="px-3 py-2 max-w-xs">
<a
href={entry.url}
target="_blank"
rel="noopener noreferrer"
class="text-indigo-600 hover:underline"
title={entry.url}
>
{truncateUrl(entry.url)}
</a>
</td>
<td class="px-3 py-2 whitespace-nowrap text-gray-700">
{entry.source_type}
</td>
<td class="px-3 py-2 whitespace-nowrap text-gray-700">
{entry.category || '—'}
</td>
<td class="px-3 py-2 whitespace-nowrap">
<Show when={entry.synthesis_id}>
<A
href={`/synthesis/${entry.synthesis_id}`}
class="text-indigo-600 hover:underline text-xs"
>
{entry.synthesis_id!.slice(0, 8)}...
</A>
</Show>
<Show when={!entry.synthesis_id}>
<span class="text-gray-400"></span>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
{/* Pagination */}
<Show when={totalPages() > 1}>
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="text-sm text-gray-700">
{page() * PAGE_SIZE + 1}{Math.min((page() + 1) * PAGE_SIZE, total())} / {total()}
</div>
<div class="flex gap-2">
<button
onClick={() => handlePageChange(page() - 1)}
disabled={page() === 0}
class="px-3 py-1 text-sm border border-gray-300 rounded-md disabled:opacity-50 hover:bg-gray-50"
>
&laquo;
</button>
<button
onClick={() => handlePageChange(page() + 1)}
disabled={page() >= totalPages() - 1}
class="px-3 py-1 text-sm border border-gray-300 rounded-md disabled:opacity-50 hover:bg-gray-50"
>
&raquo;
</button>
</div>
</div>
</Show>
</div>
</Show>
</Show>
</div>
);
};
export default ArticleHistory;

@ -8,6 +8,7 @@ import {
createEffect,
} from 'solid-js';
import { Settings as SettingsIcon, Save, Plus, Trash2, Info, Download, Upload } from 'lucide-solid';
import { A } from '@solidjs/router';
import { settingsApi } from '~/api/settings';
import { configApi } from '~/api/config';
import { apiKeysApi } from '~/api/apiKeys';
@ -477,6 +478,11 @@ const Settings: Component = () => {
}
/>
</div>
<div class="mt-2">
<A href="/article-history" class="text-sm text-indigo-600 hover:text-indigo-800 underline">
{t('articleHistory.viewHistory')}
</A>
</div>
</div>
</div>

@ -10,8 +10,9 @@ import { ArrowLeft, ExternalLink, Trash2, AlertTriangle, Mail, Send, FileDown }
import { useI18n } from '~/i18n';
import { useAuth } from '~/contexts/AuthContext';
import { synthesesApi } from '~/api/syntheses';
import { articleHistoryApi } from '~/api/articleHistory';
import { isApiError } from '~/types';
import type { Synthesis, NewsItem as NewsItemType } from '~/types';
import type { Synthesis, NewsItem as NewsItemType, ArticleHistoryEntry } from '~/types';
import { extractWeekNumber, formatDateLong } from '~/utils/dates';
import LoadingSpinner from '~/components/ui/LoadingSpinner';
@ -88,6 +89,11 @@ const SynthesisDetail: Component = () => {
const [exportingPdf, setExportingPdf] = createSignal(false);
const [exportError, setExportError] = createSignal<string | null>(null);
// Provenance state
const [provenance, setProvenance] = createSignal<ArticleHistoryEntry[]>([]);
const [showProvenance, setShowProvenance] = createSignal(false);
const [provenanceLoading, setProvenanceLoading] = createSignal(false);
onMount(async () => {
// Pre-fill email from authenticated user
const currentUser = user();
@ -193,6 +199,25 @@ const SynthesisDetail: Component = () => {
}
};
const loadProvenance = async () => {
if (provenance().length > 0) return; // Already loaded
setProvenanceLoading(true);
try {
const data = await articleHistoryApi.getProvenance(params.id);
setProvenance(data);
} catch {
// Provenance may not be available for old syntheses
} finally {
setProvenanceLoading(false);
}
};
const toggleProvenance = () => {
const newState = !showProvenance();
setShowProvenance(newState);
if (newState) loadProvenance();
};
return (
<Show when={!loading()} fallback={<LoadingSpinner />}>
<Show
@ -381,6 +406,66 @@ const SynthesisDetail: Component = () => {
</For>
</Show>
</div>
{/* Provenance */}
<div class="mt-8 border-t pt-6">
<button
onClick={toggleProvenance}
class="flex items-center text-sm font-medium text-gray-500 hover:text-gray-700"
>
<span class="mr-2">{showProvenance() ? '▼' : '▶'}</span>
{t('articleHistory.provenance')}
<Show when={provenance().length > 0}>
<span class="ml-2 text-xs text-gray-400">({provenance().length})</span>
</Show>
</button>
<Show when={showProvenance()}>
<Show when={provenanceLoading()}>
<LoadingSpinner />
</Show>
<Show when={!provenanceLoading() && provenance().length === 0}>
<p class="mt-4 text-sm text-gray-500">{t('articleHistory.provenanceEmpty')}</p>
</Show>
<Show when={!provenanceLoading() && provenance().length > 0}>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">{t('articleHistory.status')}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">{t('articleHistory.articleTitle')}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">{t('articleHistory.url')}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">{t('articleHistory.sourceType')}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">{t('articleHistory.category')}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<For each={provenance()}>
{(entry) => (
<tr>
<td class="px-3 py-2">
<span class={`inline-flex px-2 py-0.5 text-xs rounded-full ${
entry.status === 'used' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{entry.status}
</span>
</td>
<td class="px-3 py-2 max-w-xs truncate">{entry.title || '—'}</td>
<td class="px-3 py-2 max-w-xs truncate">
<a href={entry.url} target="_blank" rel="noopener noreferrer" class="text-indigo-600 hover:underline">
{entry.url.length > 50 ? entry.url.slice(0, 50) + '...' : entry.url}
</a>
</td>
<td class="px-3 py-2">{entry.source_type}</td>
<td class="px-3 py-2">{entry.category || '—'}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</Show>
</div>
</div>
)}
</Show>

@ -261,3 +261,24 @@ export interface ProviderConfig {
display_name: string;
models: ProviderConfigModel[];
}
// ---- Article History ----
export interface ArticleHistoryEntry {
id: string;
url: string;
title: string;
source_type: string;
source_url: string | null;
category: string | null;
synthesis_id: string | null;
status: string;
scraped_ok: boolean;
job_id: string;
created_at: string;
}
export interface ArticleHistoryResponse {
items: ArticleHistoryEntry[];
total: number;
}

Loading…
Cancel
Save