Intermediate commit

This commit is contained in:
Justin Visser 2026-02-26 16:09:57 +01:00
parent 0e9e49df16
commit 3d4cfeff1a
65 changed files with 5507 additions and 333 deletions

View file

@ -12,15 +12,15 @@ const props = withDefaults(
const sizeClass = computed(() => {
if (props.size === "sm") {
return "h-5 w-5";
return "h-7 w-7";
}
if (props.size === "lg") {
return "h-8 w-8";
return "h-10 w-10";
}
if (props.size === "xl") {
return "h-12 w-12";
return "h-14 w-14";
}
return "h-6 w-6";
return "h-7 w-7";
});
const logoMaskStyle: Record<string, string> = {

View file

@ -95,6 +95,40 @@ export interface TriggerPublicationLinkRepairResult {
summary: Record<string, unknown>;
}
export interface NearDuplicateClusterMember {
publication_id: number;
title: string;
year: number | null;
citation_count: number;
}
export interface NearDuplicateCluster {
cluster_key: string;
winner_publication_id: number;
member_count: number;
similarity_score: number;
members: NearDuplicateClusterMember[];
}
export interface TriggerPublicationNearDuplicateRepairPayload {
dry_run?: boolean;
similarity_threshold?: number;
min_shared_tokens?: number;
max_year_delta?: number;
max_clusters?: number;
selected_cluster_keys?: string[];
requested_by?: string;
confirmation_text?: string;
}
export interface TriggerPublicationNearDuplicateRepairResult {
job_id: number;
status: string;
scope: Record<string, unknown>;
summary: Record<string, unknown>;
clusters: NearDuplicateCluster[];
}
export async function getAdminDbIntegrityReport(): Promise<AdminDbIntegrityReport> {
const response = await apiRequest<AdminDbIntegrityReport>("/admin/db/integrity", { method: "GET" });
return response.data;
@ -122,6 +156,19 @@ export async function triggerPublicationLinkRepair(
return response.data;
}
export async function triggerPublicationNearDuplicateRepair(
payload: TriggerPublicationNearDuplicateRepairPayload,
): Promise<TriggerPublicationNearDuplicateRepairResult> {
const response = await apiRequest<TriggerPublicationNearDuplicateRepairResult>(
"/admin/db/repairs/publication-near-duplicates",
{
method: "POST",
body: payload,
},
);
return response.data;
}
export async function listAdminPdfQueue(
page = 1,
pageSize = 100,

View file

@ -1,6 +1,13 @@
import { apiRequest } from "@/lib/api/client";
export type PublicationMode = "all" | "unread" | "latest";
export type PublicationSortBy =
| "first_seen"
| "title"
| "year"
| "citations"
| "scholar"
| "pdf_status";
export interface DisplayIdentifier {
kind: string;
@ -54,7 +61,7 @@ export interface PublicationsQuery {
favoriteOnly?: boolean;
scholarProfileId?: number;
search?: string;
sortBy?: string;
sortBy?: PublicationSortBy;
sortDir?: "asc" | "desc";
page?: number;
pageSize?: number;

View file

@ -20,11 +20,14 @@ import {
listAdminPdfQueue,
requeueAdminPdfLookup,
requeueAllAdminPdfLookups,
triggerPublicationNearDuplicateRepair,
triggerPublicationLinkRepair,
type AdminDbIntegrityCheck,
type AdminDbIntegrityReport,
type AdminDbRepairJob,
type AdminPdfQueueItem,
type NearDuplicateCluster,
type TriggerPublicationNearDuplicateRepairResult,
type TriggerPublicationLinkRepairResult,
} from "@/features/admin_dbops";
import {
@ -43,6 +46,7 @@ const SECTION_PDF = "pdf";
const SCOPE_SINGLE_USER = "single_user";
const SCOPE_ALL_USERS = "all_users";
const APPLY_ALL_USERS_CONFIRM_TEXT = "REPAIR ALL USERS";
const APPLY_NEAR_DUPLICATES_CONFIRM_TEXT = "MERGE SELECTED DUPLICATES";
const DROP_PUBLICATIONS_CONFIRM_TEXT = "DROP ALL PUBLICATIONS";
type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS;
@ -82,6 +86,17 @@ const repairRequestedBy = ref("");
const repairDryRun = ref(true);
const repairGcOrphans = ref(false);
const repairConfirmationText = ref("");
const runningNearDuplicateScan = ref(false);
const applyingNearDuplicateRepair = ref(false);
const nearDuplicateRequestedBy = ref("");
const nearDuplicateSimilarityThreshold = ref("0.78");
const nearDuplicateMinSharedTokens = ref("3");
const nearDuplicateMaxYearDelta = ref("1");
const nearDuplicateMaxClusters = ref("25");
const nearDuplicateConfirmationText = ref("");
const nearDuplicateSelectedClusterKeys = ref<Set<string>>(new Set());
const nearDuplicateClusters = ref<NearDuplicateCluster[]>([]);
const lastNearDuplicateResult = ref<TriggerPublicationNearDuplicateRepairResult | null>(null);
const droppingPublications = ref(false);
const dropConfirmationText = ref("");
@ -105,6 +120,7 @@ const activeUser = computed(() => users.value.find((user) => user.id === activeU
const typedConfirmationRequired = computed(
() => repairScopeMode.value === SCOPE_ALL_USERS && !repairDryRun.value,
);
const nearDuplicateApplyEnabled = computed(() => nearDuplicateSelectedClusterKeys.value.size > 0);
const pdfQueuePageSizeValue = computed(() => {
const parsed = Number(pdfQueuePageSize.value);
if (!Number.isFinite(parsed)) {
@ -213,6 +229,64 @@ function validateTypedConfirmation(): string {
return normalized;
}
function parseBoundedNumber(
raw: string,
options: { minimum: number; maximum: number; fallback: number },
): number {
const { minimum, maximum, fallback } = options;
const parsed = Number(raw.trim());
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(minimum, Math.min(maximum, parsed));
}
function nearDuplicatePayloadBase(): {
similarity_threshold: number;
min_shared_tokens: number;
max_year_delta: number;
max_clusters: number;
requested_by: string | undefined;
} {
return {
similarity_threshold: parseBoundedNumber(nearDuplicateSimilarityThreshold.value, {
minimum: 0.5,
maximum: 1.0,
fallback: 0.78,
}),
min_shared_tokens: Math.trunc(
parseBoundedNumber(nearDuplicateMinSharedTokens.value, {
minimum: 1,
maximum: 8,
fallback: 3,
}),
),
max_year_delta: Math.trunc(
parseBoundedNumber(nearDuplicateMaxYearDelta.value, {
minimum: 0,
maximum: 5,
fallback: 1,
}),
),
max_clusters: Math.trunc(
parseBoundedNumber(nearDuplicateMaxClusters.value, {
minimum: 1,
maximum: 200,
fallback: 25,
}),
),
requested_by: nearDuplicateRequestedBy.value.trim() || undefined,
};
}
function selectedNearDuplicateKeys(): string[] {
return [...nearDuplicateSelectedClusterKeys.value].sort((left, right) => left.localeCompare(right));
}
function checkboxEventChecked(event: Event): boolean {
return event.target instanceof HTMLInputElement ? event.target.checked : false;
}
function summaryCount(job: AdminDbRepairJob, key: string): string {
const value = job.summary[key];
return typeof value === "number" ? String(value) : "n/a";
@ -384,6 +458,67 @@ async function onRunRepair(): Promise<void> {
}
}
function onToggleNearDuplicateClusterSelection(clusterKey: string, checked: boolean): void {
const next = new Set(nearDuplicateSelectedClusterKeys.value);
if (checked) {
next.add(clusterKey);
} else {
next.delete(clusterKey);
}
nearDuplicateSelectedClusterKeys.value = next;
}
async function onRunNearDuplicateScan(): Promise<void> {
runningNearDuplicateScan.value = true;
clearAlerts();
try {
const result = await triggerPublicationNearDuplicateRepair({
dry_run: true,
...nearDuplicatePayloadBase(),
});
nearDuplicateClusters.value = result.clusters;
nearDuplicateSelectedClusterKeys.value = new Set();
nearDuplicateConfirmationText.value = "";
lastNearDuplicateResult.value = result;
successMessage.value = `Near-duplicate scan completed (job #${result.job_id}).`;
await refreshRepairJobs();
} catch (error) {
assignError(error, "Unable to scan for near-duplicate publications.");
} finally {
runningNearDuplicateScan.value = false;
}
}
async function onApplyNearDuplicateRepair(): Promise<void> {
applyingNearDuplicateRepair.value = true;
clearAlerts();
try {
const selectedKeys = selectedNearDuplicateKeys();
if (selectedKeys.length === 0) {
throw new Error("Select at least one near-duplicate cluster before applying.");
}
if (nearDuplicateConfirmationText.value.trim() !== APPLY_NEAR_DUPLICATES_CONFIRM_TEXT) {
throw new Error(`Type '${APPLY_NEAR_DUPLICATES_CONFIRM_TEXT}' to confirm merge.`);
}
const result = await triggerPublicationNearDuplicateRepair({
dry_run: false,
selected_cluster_keys: selectedKeys,
confirmation_text: nearDuplicateConfirmationText.value.trim(),
...nearDuplicatePayloadBase(),
});
nearDuplicateClusters.value = result.clusters;
nearDuplicateSelectedClusterKeys.value = new Set();
nearDuplicateConfirmationText.value = "";
lastNearDuplicateResult.value = result;
successMessage.value = `Merged selected near-duplicate clusters (job #${result.job_id}).`;
await refreshRepairJobs();
} catch (error) {
assignError(error, "Unable to apply near-duplicate merge.");
} finally {
applyingNearDuplicateRepair.value = false;
}
}
function onScopeModeChange(): void {
ensureRepairUserSelected();
}
@ -672,6 +807,98 @@ watch(
</div>
</AppCard>
<AppCard class="space-y-3">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Near-Duplicate Publication Repair</h2>
<AppHelpHint text="Run a dry-run scan first, verify candidate clusters, then merge only selected clusters." />
</div>
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onRunNearDuplicateScan">
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Similarity threshold</span>
<AppInput v-model="nearDuplicateSimilarityThreshold" placeholder="0.78" />
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Min shared tokens</span>
<AppInput v-model="nearDuplicateMinSharedTokens" placeholder="3" />
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Max year delta</span>
<AppInput v-model="nearDuplicateMaxYearDelta" placeholder="1" />
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Max preview clusters</span>
<AppInput v-model="nearDuplicateMaxClusters" placeholder="25" />
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
<span>Requested by (optional)</span>
<AppInput v-model="nearDuplicateRequestedBy" placeholder="email/name/ticket id" />
</label>
<div class="md:col-span-2">
<AppButton type="submit" :disabled="runningNearDuplicateScan">
{{ runningNearDuplicateScan ? "Scanning..." : "Scan near-duplicate clusters" }}
</AppButton>
</div>
</form>
<div v-if="nearDuplicateClusters.length > 0" class="space-y-3">
<AppTable label="Near duplicate clusters table">
<thead>
<tr>
<th scope="col">Select</th>
<th scope="col">Cluster</th>
<th scope="col">Winner</th>
<th scope="col">Members</th>
<th scope="col">Similarity</th>
</tr>
</thead>
<tbody>
<tr v-for="cluster in nearDuplicateClusters" :key="cluster.cluster_key">
<td>
<input
:id="`near-dup-${cluster.cluster_key}`"
class="h-4 w-4 rounded border-stroke-default bg-surface-card"
type="checkbox"
:checked="nearDuplicateSelectedClusterKeys.has(cluster.cluster_key)"
@change="onToggleNearDuplicateClusterSelection(cluster.cluster_key, checkboxEventChecked($event))"
/>
</td>
<td class="font-mono text-xs">{{ cluster.cluster_key }}</td>
<td>#{{ cluster.winner_publication_id }}</td>
<td>
<div class="grid gap-1">
<span v-for="member in cluster.members" :key="member.publication_id" class="text-xs text-secondary">
#{{ member.publication_id }} · {{ member.title }}
</span>
</div>
</td>
<td>{{ cluster.similarity_score.toFixed(2) }}</td>
</tr>
</tbody>
</AppTable>
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onApplyNearDuplicateRepair">
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
<span>Type '{{ APPLY_NEAR_DUPLICATES_CONFIRM_TEXT }}' to merge selected clusters</span>
<AppInput v-model="nearDuplicateConfirmationText" :placeholder="APPLY_NEAR_DUPLICATES_CONFIRM_TEXT" />
</label>
<div class="md:col-span-2">
<AppButton type="submit" :disabled="applyingNearDuplicateRepair || !nearDuplicateApplyEnabled">
{{ applyingNearDuplicateRepair ? "Merging..." : "Merge selected clusters" }}
</AppButton>
</div>
</form>
</div>
<div v-if="lastNearDuplicateResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
<div class="mb-2 flex flex-wrap items-center gap-2">
<AppBadge :tone="statusTone(lastNearDuplicateResult.status)">Job #{{ lastNearDuplicateResult.job_id }}</AppBadge>
<span class="text-secondary">Status: {{ lastNearDuplicateResult.status }}</span>
</div>
<pre class="overflow-x-auto text-secondary">{{ JSON.stringify(lastNearDuplicateResult.summary, null, 2) }}</pre>
</div>
</AppCard>
<AppCard class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1">

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
import { ApiRequestError } from "@/lib/api/errors";
@ -26,6 +26,8 @@ const refreshingAfterCompletion = ref(false);
const auth = useAuthStore();
const runStatus = useRunStatusStore();
const userSettings = useUserSettingsStore();
const DASHBOARD_RUN_STATUS_SYNC_INTERVAL_MS = 5000;
let runStatusSyncTimer: ReturnType<typeof setInterval> | null = null;
const isStartBlocked = computed(
() =>
@ -145,13 +147,35 @@ function shouldRefreshAfterRunChange(
if (!nextRun || !previousRun) {
return false;
}
if (nextRun.status === "running") {
return false;
}
if (nextRun.id !== previousRun.id) {
return true;
}
return previousRun.status === "running";
if (nextRun.status === previousRun.status) {
return false;
}
const nextActive = nextRun.status === "running" || nextRun.status === "resolving";
const previousActive = previousRun.status === "running" || previousRun.status === "resolving";
return nextActive || previousActive;
}
function startRunStatusSyncLoop(): void {
if (runStatusSyncTimer !== null) {
return;
}
runStatusSyncTimer = setInterval(() => {
if (runStatus.isRunActive) {
return;
}
void runStatus.syncLatest();
}, DASHBOARD_RUN_STATUS_SYNC_INTERVAL_MS);
}
function stopRunStatusSyncLoop(): void {
if (runStatusSyncTimer === null) {
return;
}
clearInterval(runStatusSyncTimer);
runStatusSyncTimer = null;
}
async function loadSnapshot(): Promise<void> {
@ -228,10 +252,15 @@ async function onCancelRun(): Promise<void> {
}
onMounted(() => {
startRunStatusSyncLoop();
void loadSnapshot();
void runStatus.syncLatest();
});
onUnmounted(() => {
stopRunStatusSyncLoop();
});
watch(
() => runStatus.latestRun,
(nextRun, previousRun) => {
@ -372,7 +401,7 @@ watch(
<AppButton
v-if="auth.isAdmin && runStatus.isLikelyRunning"
variant="danger"
:disabled="!activeRunId || isCancelAnimating.value"
:disabled="!activeRunId || isCancelAnimating"
@click="onCancelRun"
>
<span class="inline-flex items-center gap-2">

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import AppPage from "@/components/layout/AppPage.vue";
@ -33,7 +33,8 @@ type PublicationSortKey =
| "scholar"
| "year"
| "citations"
| "first_seen";
| "first_seen"
| "pdf_status";
type BulkAction =
| "mark_selected_read"
@ -70,6 +71,8 @@ const router = useRouter();
const textCollator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
const runStatus = useRunStatusStore();
const userSettings = useUserSettingsStore();
const PUBLICATIONS_RUN_STATUS_SYNC_INTERVAL_MS = 5000;
let runStatusSyncTimer: ReturnType<typeof setInterval> | null = null;
function normalizeScholarFilterQuery(value: unknown): string {
if (Array.isArray(value)) {
@ -179,6 +182,26 @@ function publicationIdentifierLabel(item: PublicationItem): string | null {
return item.display_identifier?.label ?? null;
}
function startRunStatusSyncLoop(): void {
if (runStatusSyncTimer !== null) {
return;
}
runStatusSyncTimer = setInterval(() => {
if (runStatus.isRunActive) {
return;
}
void runStatus.syncLatest();
}, PUBLICATIONS_RUN_STATUS_SYNC_INTERVAL_MS);
}
function stopRunStatusSyncLoop(): void {
if (runStatusSyncTimer === null) {
return;
}
clearInterval(runStatusSyncTimer);
runStatusSyncTimer = null;
}
const selectedScholarName = computed(() => {
const selectedId = Number(selectedScholarFilter.value);
if (!Number.isInteger(selectedId) || selectedId <= 0) {
@ -203,11 +226,12 @@ const filteredPublications = computed(() => {
const base = listState.value?.publications ?? [];
const merged = [...stream, ...base];
const seenIds = new Set();
const seenKeys = new Set<string>();
const deduped: typeof base = [];
for (const item of merged) {
if (!seenIds.has(item.publication_id)) {
seenIds.add(item.publication_id);
const key = publicationKey(item);
if (!seenKeys.has(key)) {
seenKeys.add(key);
deduped.push(item);
}
}
@ -234,14 +258,41 @@ function publicationSortValue(item: PublicationItem, key: PublicationSortKey): n
if (key === "citations") {
return item.citation_count;
}
if (key === "pdf_status") {
if (item.pdf_url || item.pdf_status === "resolved") {
return 4;
}
if (item.pdf_status === "running") {
return 3;
}
if (item.pdf_status === "queued") {
return 2;
}
if (item.pdf_status === "failed") {
return 0;
}
return 1;
}
const timestamp = Date.parse(item.first_seen_at);
return Number.isNaN(timestamp) ? 0 : timestamp;
}
const sortedPublications = computed(() => {
// Server already returns data in the correct sort order.
// We just pass through the filtered/merged list without client-side re-sorting.
return filteredPublications.value;
const direction = sortDirection.value === "asc" ? 1 : -1;
return [...filteredPublications.value].sort((left, right) => {
const leftValue = publicationSortValue(left, sortKey.value);
const rightValue = publicationSortValue(right, sortKey.value);
let comparison = 0;
if (typeof leftValue === "string" && typeof rightValue === "string") {
comparison = textCollator.compare(leftValue, rightValue);
} else {
comparison = Number(leftValue) - Number(rightValue);
}
if (comparison !== 0) {
return comparison * direction;
}
return right.publication_id - left.publication_id;
});
});
const visibleUnreadKeys = computed(() => {
@ -271,6 +322,7 @@ const totalPages = computed(() => {
});
const selectedCount = computed(() => selectedPublicationKeys.value.size);
const totalCount = computed(() => listState.value?.total_count ?? 0);
const visibleCount = computed(() => sortedPublications.value.length);
const visibleUnreadCount = computed(() => visibleUnreadKeys.value.size);
const visibleFavoriteCount = computed(
@ -432,7 +484,7 @@ async function toggleSort(nextKey: PublicationSortKey): Promise<void> {
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
} else {
sortKey.value = nextKey;
sortDirection.value = nextKey === "first_seen" ? "desc" : "asc";
sortDirection.value = nextKey === "first_seen" || nextKey === "pdf_status" ? "desc" : "asc";
}
currentPage.value = 1;
publicationSnapshot.value = null;
@ -800,9 +852,32 @@ watch(searchQuery, () => {
onMounted(() => {
syncFiltersFromRoute();
startRunStatusSyncLoop();
void Promise.all([loadScholarFilters(), loadPublications(), runStatus.syncLatest()]);
});
onUnmounted(() => {
stopRunStatusSyncLoop();
});
watch(
() => runStatus.latestRun,
async (nextRun, previousRun) => {
const nextRunId = nextRun && (nextRun.status === "running" || nextRun.status === "resolving")
? nextRun.id
: null;
const previousRunId = previousRun && (previousRun.status === "running" || previousRun.status === "resolving")
? previousRun.id
: null;
if (nextRunId === null || nextRunId === previousRunId) {
return;
}
publicationSnapshot.value = null;
currentPage.value = 1;
await loadPublications();
},
);
watch(
() => [route.query.scholar, route.query.favorite, route.query.page],
async () => {
@ -1008,7 +1083,11 @@ watch(
Scholar <span aria-hidden="true" class="sort-marker">{{ sortMarker('scholar') }}</span>
</button>
</th>
<th scope="col" class="w-[8.5rem] whitespace-nowrap text-left font-semibold text-ink-primary">PDF</th>
<th scope="col" class="w-[8.5rem] whitespace-nowrap">
<button type="button" class="table-sort" @click="toggleSort('pdf_status')">
PDF <span aria-hidden="true" class="sort-marker">{{ sortMarker('pdf_status') }}</span>
</button>
</th>
<th scope="col" class="w-16 whitespace-nowrap">
<button type="button" class="table-sort" @click="toggleSort('year')">
Year <span aria-hidden="true" class="sort-marker">{{ sortMarker('year') }}</span>
@ -1135,7 +1214,7 @@ watch(
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-2 text-xs text-secondary">
<span>
visible {{ visibleCount }} · unread {{ visibleUnreadCount }} · favorites {{ visibleFavoriteCount }}
total {{ totalCount }} · visible {{ visibleCount }} · unread {{ visibleUnreadCount }} · favorites {{ visibleFavoriteCount }}
· selected {{ selectedCount }}
</span>
<div class="flex items-center gap-2">

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import AppPage from "@/components/layout/AppPage.vue";
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
@ -34,6 +34,7 @@ import {
} from "@/features/scholars";
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
import { ApiRequestError } from "@/lib/api/errors";
import { useRunStatusStore } from "@/stores/run_status";
const loading = ref(true);
const saving = ref(false);
@ -66,6 +67,10 @@ const successMessage = ref<string | null>(null);
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i;
const nameSearchWip = true;
const SCHOLARS_LIVE_SYNC_INTERVAL_MS = 4000;
let scholarsLiveSyncTimer: ReturnType<typeof setInterval> | null = null;
const runStatus = useRunStatusStore();
const trackedScholarIds = computed(() => new Set(scholars.value.map((item) => item.scholar_id)));
const activeScholarSettings = computed(
@ -260,14 +265,54 @@ function syncImageDrafts(): void {
imageUrlDraftByScholarId.value = next;
}
function applyScholarList(nextScholars: ScholarProfile[]): void {
scholars.value = nextScholars;
syncImageDrafts();
}
function upsertScholar(profile: ScholarProfile): void {
const existingIndex = scholars.value.findIndex((item) => item.id === profile.id);
if (existingIndex < 0) {
applyScholarList([profile, ...scholars.value]);
return;
}
const next = [...scholars.value];
next[existingIndex] = profile;
applyScholarList(next);
}
function stopScholarLiveSync(): void {
if (scholarsLiveSyncTimer === null) {
return;
}
clearInterval(scholarsLiveSyncTimer);
scholarsLiveSyncTimer = null;
}
function startScholarLiveSync(): void {
if (scholarsLiveSyncTimer !== null) {
return;
}
scholarsLiveSyncTimer = setInterval(() => {
void refreshScholarsSilently();
}, SCHOLARS_LIVE_SYNC_INTERVAL_MS);
}
async function refreshScholarsSilently(): Promise<void> {
try {
applyScholarList(await listScholars());
} catch (_error) {
// Keep existing list when transient refresh fails.
}
}
async function loadScholars(): Promise<void> {
loading.value = true;
try {
scholars.value = await listScholars();
syncImageDrafts();
applyScholarList(await listScholars());
} catch (error) {
scholars.value = [];
applyScholarList([]);
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
@ -360,7 +405,11 @@ async function onAddScholars(): Promise<void> {
}
const settled = await Promise.allSettled(
scholarIds.map((scholarId) => createScholar({ scholar_id: scholarId })),
scholarIds.map(async (scholarId) => {
const created = await createScholar({ scholar_id: scholarId });
upsertScholar(created);
return created;
}),
);
const failures: string[] = [];
@ -399,7 +448,7 @@ async function onAddScholars(): Promise<void> {
errorRequestId.value = requestIdFromFailures;
}
await loadScholars();
await refreshScholarsSilently();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
@ -453,12 +502,12 @@ async function onAddCandidate(candidate: ScholarSearchCandidate): Promise<void>
successMessage.value = null;
try {
await createScholar({
const created = await createScholar({
scholar_id: candidate.scholar_id,
profile_image_url: candidate.profile_image_url ?? undefined,
});
upsertScholar(created);
successMessage.value = `${candidate.display_name} added.`;
await loadScholars();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
@ -608,6 +657,22 @@ async function onResetImage(profile: ScholarProfile): Promise<void> {
onMounted(() => {
void loadScholars();
});
onUnmounted(() => {
stopScholarLiveSync();
});
watch(
() => runStatus.isLikelyRunning,
(isRunning) => {
if (isRunning) {
startScholarLiveSync();
return;
}
stopScholarLiveSync();
},
{ immediate: true },
);
</script>
<template>

View file

@ -49,6 +49,35 @@ function buildRunsPayload(runs: ReturnType<typeof buildRun>[]) {
};
}
class FakeEventSource {
static instances: FakeEventSource[] = [];
public readonly url: string;
public closed = false;
private listeners = new Map<string, Array<(event: { data: string }) => void>>();
constructor(url: string) {
this.url = url;
FakeEventSource.instances.push(this);
}
addEventListener(eventType: string, callback: (event: { data: string }) => void): void {
const existing = this.listeners.get(eventType) ?? [];
this.listeners.set(eventType, [...existing, callback]);
}
emit(eventType: string, payload: unknown): void {
const callbacks = this.listeners.get(eventType) ?? [];
for (const callback of callbacks) {
callback({ data: JSON.stringify(payload) });
}
}
close(): void {
this.closed = true;
}
}
describe("run status store", () => {
const mockedListRuns = vi.mocked(listRuns);
const mockedTriggerManualRun = vi.mocked(triggerManualRun);
@ -309,4 +338,42 @@ describe("run status store", () => {
expect(store.latestRun?.new_publication_count).toBe(5);
});
it("applies identifier_updated SSE events to live publications", () => {
const previousEventSource = (globalThis as any).EventSource;
FakeEventSource.instances = [];
(globalThis as any).EventSource = FakeEventSource as any;
try {
const store = useRunStatusStore();
store.setLatestRun(buildRun({ id: 314, status: "running", end_dt: null }));
const stream = FakeEventSource.instances[0];
expect(stream).toBeDefined();
stream.emit("publication_discovered", {
publication_id: 22,
scholar_profile_id: 7,
scholar_label: "Ada Lovelace",
title: "Optimization Notes",
pub_url: null,
first_seen_at: "2026-02-26T10:00:00Z",
});
expect(store.livePublications).toHaveLength(1);
expect(store.livePublications[0].display_identifier).toBeNull();
stream.emit("identifier_updated", {
publication_id: 22,
display_identifier: {
kind: "doi",
value: "10.1000/xyz",
label: "DOI: 10.1000/xyz",
url: "https://doi.org/10.1000/xyz",
confidence_score: 0.95,
},
});
expect(store.livePublications[0].display_identifier?.kind).toBe("doi");
expect(store.livePublications[0].display_identifier?.value).toBe("10.1000/xyz");
} finally {
(globalThis as any).EventSource = previousEventSource;
}
});
});

View file

@ -40,6 +40,8 @@ let eventSource: EventSource | null = null;
let activeStreamRunId: number | null = null;
const ACTIVE_STATUSES = new Set(["running", "resolving"]);
type StreamDisplayIdentifier = PublicationItem["display_identifier"];
function parseRunId(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
@ -86,6 +88,46 @@ function parsePublicationCount(value: unknown, fallback: number): number {
return fallback;
}
function parseDisplayIdentifier(value: unknown): StreamDisplayIdentifier {
if (!value || typeof value !== "object") {
return null;
}
const payload = value as Record<string, unknown>;
if (typeof payload.kind !== "string" || typeof payload.value !== "string" || typeof payload.label !== "string") {
return null;
}
if (typeof payload.confidence_score !== "number" || !Number.isFinite(payload.confidence_score)) {
return null;
}
const url = typeof payload.url === "string" ? payload.url : null;
return {
kind: payload.kind,
value: payload.value,
label: payload.label,
url,
confidence_score: payload.confidence_score,
};
}
function withUpdatedDisplayIdentifier(
items: PublicationItem[],
update: {
publicationId: number;
displayIdentifier: StreamDisplayIdentifier;
},
): PublicationItem[] {
const { publicationId, displayIdentifier } = update;
let changed = false;
const next = items.map((item) => {
if (item.publication_id !== publicationId) {
return item;
}
changed = true;
return { ...item, display_identifier: displayIdentifier };
});
return changed ? next : items;
}
function reconcileRunCounters(previous: RunListItem | null, next: RunListItem | null): RunListItem | null {
if (previous === null || next === null) {
return next;
@ -200,7 +242,7 @@ export const useRunStatusStore = defineStore("runStatus", {
this.updateEventSource();
},
updateEventSource(): void {
const targetRunId = this.latestRun?.status === "running" ? this.latestRun.id : null;
const targetRunId = isActiveStatus(this.latestRun?.status) ? this.latestRun?.id ?? null : null;
if (activeStreamRunId === targetRunId) {
return;
}
@ -251,6 +293,25 @@ export const useRunStatusStore = defineStore("runStatus", {
console.error("Failed to parse SSE event", err);
}
});
eventSource.addEventListener("identifier_updated", (e) => {
try {
const data = JSON.parse(e.data);
const publicationId = parseRunId(data?.publication_id);
const displayIdentifier = parseDisplayIdentifier(data?.display_identifier);
if (publicationId === null || displayIdentifier === null) {
return;
}
this.livePublications = withUpdatedDisplayIdentifier(
this.livePublications,
{
publicationId,
displayIdentifier,
},
);
} catch (err) {
console.error("Failed to parse SSE event", err);
}
});
eventSource.onerror = () => {
// Reconnecting is handled automatically by EventSource,
// but if it's permanently closed, we could do something here.
@ -429,6 +490,7 @@ export const useRunStatusStore = defineStore("runStatus", {
this.lastErrorRequestId = null;
this.lastSyncAt = null;
this.safetyState = createDefaultSafetyState();
this.livePublications = [];
},
},
});