From cdf9764b25c09fc8171f79db56e6922daf6ffa0f Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Tue, 5 May 2026 18:22:20 -0400 Subject: [PATCH 01/24] added resume feature --- .../synth-scholar/ReviewActionsDropdown.tsx | 109 ++++++++++++++++++ src/app/user/synth-scholar/page.tsx | 109 +++++++++++++++--- src/hooks/useSynthScholar.ts | 7 +- 3 files changed, 209 insertions(+), 16 deletions(-) create mode 100644 src/app/components/synth-scholar/ReviewActionsDropdown.tsx diff --git a/src/app/components/synth-scholar/ReviewActionsDropdown.tsx b/src/app/components/synth-scholar/ReviewActionsDropdown.tsx new file mode 100644 index 0000000..bb7e9e2 --- /dev/null +++ b/src/app/components/synth-scholar/ReviewActionsDropdown.tsx @@ -0,0 +1,109 @@ +"use client"; + +/** + * ReviewActionsDropdown — split-button retry control for failed/cancelled reviews. + * + * Mirrors aep-knowledge-synthesis/ui/src/pages/PRISMAReview.tsx (~lines 1836–1898), + * adapted to brainkb-ui's bkb-* design tokens and the local Icon component. + * + * The primary button performs the most useful default action: + * - "Resume from step N" when a checkpoint exists (last_completed_step > 0) + * - "Retry" otherwise + * + * The dropdown caret reveals all four options: + * 1. Resume from step N (resume: true) — only shown when step > 0 + * 2. Full restart (resume: false) + * 3. Retry (use cache) (enable_cache: true) + * 4. Retry (fresh search) (enable_cache: false) + */ + +import React from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/src/app/components/ui/dropdown-menu"; +import { Icon } from "@/src/app/components/design-system"; + +type RetryBody = { enable_cache?: boolean; resume?: boolean }; + +type Props = { + /** Step the pipeline last checkpointed at; 0 / undefined → no checkpoint. */ + lastCompletedStep?: number; + /** True while a retry mutation is in flight (disables the buttons). */ + isPending: boolean; + /** Caller wires this to `useRetryReview().mutate` for a specific reviewId. */ + onRetry: (body?: RetryBody) => void; +}; + +export function ReviewActionsDropdown({ + lastCompletedStep, + isPending, + onRetry, +}: Props) { + const step = lastCompletedStep ?? 0; + const canResume = step > 0; + + // Default click on the primary button = the most useful action: resume if + // we have a checkpoint, otherwise plain retry. + const primaryAction = () => { + onRetry(canResume ? { resume: true } : undefined); + }; + + return ( +
+ {/* Primary button — left half of the split control */} + + + {/* Caret — right half opens the menu */} + + + + + + {canResume && ( + onRetry({ resume: true })}> + + Resume from step {step} + + )} + onRetry({ resume: false })}> + + Full restart + + onRetry({ enable_cache: true })}> + Retry (use cache) + + onRetry({ enable_cache: false })}> + Retry (fresh search) + + + +
+ ); +} diff --git a/src/app/user/synth-scholar/page.tsx b/src/app/user/synth-scholar/page.tsx index 38ff778..dab16dd 100644 --- a/src/app/user/synth-scholar/page.tsx +++ b/src/app/user/synth-scholar/page.tsx @@ -45,6 +45,7 @@ import type { RunReviewRequest, } from "@/src/types/synthScholar"; import { PlanConfirmDialog } from "@/src/app/components/synth-scholar/PlanConfirmDialog"; +import { ReviewActionsDropdown } from "@/src/app/components/synth-scholar/ReviewActionsDropdown"; // OpenRouter model catalogue. Slugs use Anthropic's API-ID format (hyphens, not // dots) — `anthropic/claude-opus-4.7` is NOT a valid OpenRouter slug; OpenRouter @@ -1403,6 +1404,16 @@ function ReviewDetail({ reviewId }: { reviewId: string }) { } }, [detail.data?.status, pendingPlan]); + // 1-second tick used by the progress card to render "last update Ns ago". + // Without it the seconds-since-last-event display would only refresh when + // a new event arrives — defeating the whole point of a stuck-detector. + const [nowTick, setNowTick] = React.useState(() => Date.now()); + React.useEffect(() => { + if (!isLive) return; + const id = setInterval(() => setNowTick(Date.now()), 1000); + return () => clearInterval(id); + }, [isLive]); + if (!detail.data && detail.isLoading) { return (
@@ -1452,13 +1463,13 @@ function ReviewDetail({ reviewId }: { reviewId: string }) { )} {(r.status === "failed" || r.status === "cancelled") && ( - + + retry.mutate({ reviewId, body }) + } + /> )} {r.status === "completed" && ( <> @@ -1618,24 +1629,92 @@ function ReviewDetail({ reviewId }: { reviewId: string }) { the detail endpoint doesn't echo them in its response body. */} {(isLive || progress.events.length > 0) && (() => { const latest = [...progress.events].reverse().find((e) => e.kind && e.kind !== "log") ?? progress.events[progress.events.length - 1]; + // Overall pipeline progress — 18 top-level stages in PRISMA pipeline, + // populated as stage_index / stage_total on every classified SSE event. + const overallPct = (latest?.stage_index != null && latest?.stage_total) + ? Math.min(100, Math.round((latest.stage_index / latest.stage_total) * 100)) + : null; + // Within-stage progress (e.g. "47 of 100 articles charted"). + const stageHasItems = latest?.stage_done != null && latest?.stage_total != null && latest.stage_total > 0; + const stagePct = stageHasItems + ? Math.min(100, Math.round((latest.stage_done! / latest.stage_total!) * 100)) + : null; + // Stuck detector: live runs that haven't emitted an event for >60s + // are likely either deep in a long stage or genuinely stalled. Show + // a "last update Ns ago" hint so the user can tell the difference. + const sinceLast = (isLive && progress.lastEventAt) + ? Math.round((nowTick - progress.lastEventAt) / 1000) + : null; + const stale = sinceLast != null && sinceLast > 60; return (
-
-
-

Progress

+
+
+

+ Progress {overallPct != null && ( + + {overallPct}% + + )} +

{latest?.stage ? latest.stage : "Awaiting first event"} - {latest?.stage_total != null && ( + {latest?.stage_index != null && latest?.stage_total != null && ( - · step {latest.stage_done ?? 0} of {latest.stage_total} + · stage {latest.stage_index} of {latest.stage_total} + + )} + {stagePct != null && ( + + · {latest!.stage_done}/{latest!.stage_total} ({stagePct}%) )}
- - {progress.step} events - +
+ + {progress.events.length} events · step {progress.step} + + {sinceLast != null && ( + + {stale ? "⚠ " : ""}last update {sinceLast}s ago + {stale && " (long stage or stalled)"} + + )} +
+ {/* Top-level pipeline progress bar */} + {overallPct != null && ( +
+
+
+ )}
(null); const queryClient = useQueryClient(); const shouldReconnectRef = useRef(false); const isDoneRef = useRef(false); @@ -228,6 +232,7 @@ export function useProgressStream( setEvents((prev) => [...prev, event]); setLatestMessage(event.message); setStep((prev) => Math.max(prev, event.step)); + setLastEventAt(Date.now()); if (event.event_type === "plan_review" && event.plan && onPlanReviewRef.current) { onPlanReviewRef.current(event.plan as ReviewPlan, event); } @@ -293,7 +298,7 @@ export function useProgressStream( } }, [isStreaming, isDone, reviewId, start]); - return { events, isStreaming, latestMessage, step, isDone }; + return { events, isStreaming, latestMessage, step, isDone, lastEventAt }; } export function usePlanResponse() { From 5f51ad382397833a539a9cef285b7aa88dac8baf Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Tue, 5 May 2026 18:44:32 -0400 Subject: [PATCH 02/24] resume feature from experiment ui added --- .../components/synth-scholar/InfoPopover.tsx | 117 +++++ .../components/synth-scholar/fieldGuides.tsx | 418 ++++++++++++++++++ src/app/components/ui/popover.tsx | 50 +++ src/app/user/synth-scholar/page.tsx | 250 ++++++++--- 4 files changed, 776 insertions(+), 59 deletions(-) create mode 100644 src/app/components/synth-scholar/InfoPopover.tsx create mode 100644 src/app/components/synth-scholar/fieldGuides.tsx create mode 100644 src/app/components/ui/popover.tsx diff --git a/src/app/components/synth-scholar/InfoPopover.tsx b/src/app/components/synth-scholar/InfoPopover.tsx new file mode 100644 index 0000000..fa18fb3 --- /dev/null +++ b/src/app/components/synth-scholar/InfoPopover.tsx @@ -0,0 +1,117 @@ +"use client"; + +/** + * InfoPopover — small "(i)" affordance that reveals a short description + * (and an optional reference link) for a form field. + * + * Usage: + * + * + * + * Click-triggered (not hover) so it works on touch devices and lets users + * copy-paste the description / click the link without it disappearing. + */ + +import React from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/src/app/components/ui/popover"; +import { Icon } from "@/src/app/components/design-system"; + +export type InfoLink = { href: string; label?: string }; + +type Props = { + title?: string; + description: React.ReactNode; + link?: InfoLink; + /** Visually hide the icon and use the wrapped child as the trigger. */ + asChild?: boolean; + children?: React.ReactNode; +}; + +export function InfoPopover({ title, description, link, asChild, children }: Props) { + const trigger = asChild ? ( + children + ) : ( + + ); + + return ( + + {trigger} + + {title && ( +
+ {title} +
+ )} +
{description}
+ {link && ( + + {link.label ?? "Learn more"} ↗ + + )} +
+
+ ); +} diff --git a/src/app/components/synth-scholar/fieldGuides.tsx b/src/app/components/synth-scholar/fieldGuides.tsx new file mode 100644 index 0000000..595841b --- /dev/null +++ b/src/app/components/synth-scholar/fieldGuides.tsx @@ -0,0 +1,418 @@ +"use client"; + +/** + * fieldGuides — descriptions + reference links for SynthScholar form fields. + * + * Each entry is rendered inside an InfoPopover on the create-review form. + * Centralizing them here keeps the JSX of the form readable and lets us + * iterate on copy / links without hunting through hundreds of lines of UI. + */ + +import React from "react"; +import type { InfoLink } from "./InfoPopover"; + +export type FieldGuide = { + title: string; + description: React.ReactNode; + link?: InfoLink; +}; + +const PICO_LINK: InfoLink = { + href: "https://www.cochranelibrary.com/about-pico", + label: "PICO at Cochrane Library", +}; + +const PRISMA_LINK: InfoLink = { + href: "https://www.prisma-statement.org/prisma-2020", + label: "PRISMA 2020 statement", +}; + +export const FIELD_GUIDES: Record = { + // ── Protocol — basics ───────────────────────────────────────────────── + title: { + title: "Title", + description: ( + <> + A concise, descriptive name for your review. Aim for the population + + intervention + outcome in one phrase, e.g. "GLP-1 agonists for + type-2 diabetes: efficacy and safety". + + ), + }, + objective: { + title: "Objective / research question", + description: ( + <> + The single research question your review answers. Often phrased as + "What is the effect of I compared to C on O + in P?". Defaults to the title if blank. + + ), + }, + + // ── PICO ─────────────────────────────────────────────────────────────── + population: { + title: "P — Population", + description: ( + <> + Who is being studied: condition, demographic, sample group. Be + specific about disease severity, age range, setting (hospital, + community), and any inclusion-defining traits. + + ), + link: PICO_LINK, + }, + intervention: { + title: "I — Intervention", + description: ( + <> + The treatment, exposure, diagnostic test, or risk factor under study. + Specify dose / frequency / duration if relevant. + + ), + link: PICO_LINK, + }, + comparison: { + title: "C — Comparison", + description: ( + <> + The control group or alternative the intervention is measured against — + placebo, standard of care, no treatment, or a different active + intervention. + + ), + link: PICO_LINK, + }, + outcome: { + title: "O — Outcome", + description: ( + <> + The clinical endpoints you'll measure: mortality, symptom score, + quality of life, biomarker change, adverse events, etc. Distinguish + primary vs. secondary outcomes. + + ), + link: PICO_LINK, + }, + + inclusion: { + title: "Inclusion criteria", + description: ( + <> + Eligibility rules a study must meet to be included. Common axes: study + design (RCT, cohort, case-control), language, publication date range, + peer-review status. One rule per line is best. + + ), + link: { + href: "https://training.cochrane.org/handbook/current/chapter-03", + label: "Cochrane Handbook ch. 3 — Defining the criteria", + }, + }, + exclusion: { + title: "Exclusion criteria", + description: ( + <> + Reasons to drop a study: editorials, conference abstracts, animal-only + studies, retracted papers, n < some threshold, etc. Should be + non-overlapping with inclusion criteria. + + ), + }, + + // ── Search strategy ─────────────────────────────────────────────────── + databases: { + title: "Databases", + description: ( + <> + Which bibliographic sources the search agent will query. PubMed and + bioRxiv/medRxiv cover most biomedical literature; OpenAlex and CORE + broaden coverage to grey literature and OA preprints. + + ), + }, + date_range: { + title: "Publication date range", + description: ( + <> + Optional YYYY-MM-DD bounds restricting which articles are searched. + Leave blank for no date filter. Useful when the field's terminology + or guidelines have shifted recently. + + ), + }, + hops: { + title: "Citation hops", + description: ( + <> + How many citation-chain traversals to perform after the initial + search. 0 = no traversal, search-results only. + Higher values surface seminal works cited by your seed set but + increase runtime and cost. + + ), + }, + + // ── Registration & disclosures ──────────────────────────────────────── + registration: { + title: "Registration number", + description: ( + <> + PROSPERO, OSF, or other registry ID for this protocol. Required by + many journals for systematic reviews. + + ), + link: { + href: "https://www.crd.york.ac.uk/PROSPERO/", + label: "PROSPERO registry", + }, + }, + protocol_url: { + title: "Protocol URL", + description: ( + <> + Public link to your pre-registered protocol document. Helps reviewers + check that what you ran matches what you planned. + + ), + }, + funding: { + title: "Funding sources", + description: ( + <> + Grants and institutional support that paid for this review. Required + for transparent reporting per PRISMA 2020 item 26. + + ), + link: PRISMA_LINK, + }, + competing_interests: { + title: "Competing interests", + description: ( + <> + Financial / non-financial conflicts of interest of the review team. + PRISMA 2020 item 26 — required for publication in most journals. + + ), + link: PRISMA_LINK, + }, + + // ── Risk-of-bias & charting ─────────────────────────────────────────── + rob_tool: { + title: "Risk-of-bias tool", + description: ( + <> + The framework the agent will use to score bias for each included + study. Pick the one that matches your study designs: +
    +
  • RoB 2 — randomised trials
  • +
  • ROBINS-I — non-randomised intervention studies
  • +
  • QUADAS-2 — diagnostic accuracy
  • +
  • Newcastle-Ottawa — observational
  • +
  • SYRCLE — animal studies
  • +
+ + ), + link: { + href: "https://methods.cochrane.org/risk-bias-tools", + label: "Cochrane RoB tools overview", + }, + }, + charting_questions: { + title: "Charting questions", + description: ( + <> + Custom data-extraction prompts the LLM will answer for every included + article. Use the question text verbatim as the field key in the + output. Leave empty to use built-in PRISMA-style charting fields. + + ), + }, + appraisal_domains: { + title: "Appraisal domains", + description: ( + <> + Quality / methodological domains the LLM scores per study (max 4). + Examples: study design, sample size adequacy, + outcome measurement, statistical analysis. + + ), + }, + + // ── Per-group analysis ──────────────────────────────────────────────── + grouping_dimension: { + title: "Grouping dimension", + description: ( + <> + Optional attribute the synthesis will stratify by — e.g.{" "} + disorder_cohort, age_group,{" "} + severity. The LLM partitions studies on this attribute + and produces a per-group narrative + Q&A. + + ), + }, + default_group_questions: { + title: "Default per-group questions", + description: ( + <> + Questions answered separately within each group (max 10, one per + line). Use these for cross-cutting questions you want answered for + every cohort: prevalence, response rate, comparative effects, etc. + + ), + }, + + // ── Run configuration ───────────────────────────────────────────────── + mode: { + title: "Single vs. compare mode", + description: ( + <> + Single — one model produces the review.{" "} + Compare — 2–5 models run in parallel and a consensus + model merges them. Compare mode costs more but surfaces disagreements + between models that the consensus call resolves. + + ), + }, + model: { + title: "Model", + description: ( + <> + The LLM that drives every agent in the pipeline (planner, screener, + extractor, appraiser, synthesizer). OpenRouter slug format — + provider/model-name. Larger models reason better but + cost more per article processed. + + ), + link: { + href: "https://openrouter.ai/models", + label: "OpenRouter model catalogue", + }, + }, + consensus_model: { + title: "Consensus model", + description: ( + <> + In compare mode, the model that synthesizes the per-model outputs into + a single review. Pick a strong reasoning model — it sees N reviews + and must reconcile contradictions. + + ), + }, + max_results: { + title: "Max results per query", + description: ( + <> + How many search hits to retain per database query. Higher values + improve recall but slow down screening and cost more LLM tokens. + Typical range: 50–300. + + ), + }, + related_depth: { + title: "Related-articles depth", + description: ( + <> + How many "related work" expansion rounds. 0 = + search-only. 1 = pull related articles for each seed. + 2+ = recursive related-of-related. Increases recall + of seminal and adjacent papers. + + ), + }, + biorxiv_days: { + title: "bioRxiv lookback days", + description: ( + <> + How far back to scan bioRxiv / medRxiv preprints. The bioRxiv API is + time-sliced rather than keyword-indexed, so this is a coverage + window, not a publication-date filter. Typical: 365–730 days. + + ), + }, + max_articles: { + title: "Max articles", + description: ( + <> + Hard cap on total articles processed after deduplication. The + re-ranker keeps the top N by relevance. Useful for quick test runs + or budget control. Leave blank for no cap. + + ), + }, + concurrency: { + title: "Concurrency", + description: ( + <> + How many articles the per-article agent stages process in parallel. + Higher values finish faster but bump up against provider rate limits. + 5–10 is a good default; 20+ only + with a paid OpenRouter tier. + + ), + }, + max_plan_iterations: { + title: "Max plan iterations", + description: ( + <> + How many times the search-strategy agent will regenerate the search + plan if you reject it. Only meaningful with{" "} + "Pause for me to review" enabled. + + ), + }, + synthesis_style: { + title: "Synthesis style", + description: ( + <> + Output format for the narrative synthesis section.{" "} + Paragraph = traditional prose,{" "} + Q&A = research-question-driven,{" "} + Bullet list = scannable findings,{" "} + Table = structured comparison rows. + + ), + link: PRISMA_LINK, + }, + data_items: { + title: "Data-extraction items", + description: ( + <> + Custom data fields to extract from every included article (one per + line). Examples: sample size, follow-up duration, + primary outcome value. Leave blank for the built-in + PRISMA-style item set. + + ), + }, + enable_cache: { + title: "Enable cache", + description: ( + <> + Reuse cached articles + LLM responses from prior runs of similar + protocols (when Postgres caching is configured). Faster + cheaper + re-runs; turn off for fresh, audit-grade runs. + + ), + }, + extract_data: { + title: "Extract data", + description: ( + <> + Run the per-article data-charting and critical-appraisal stages. + Required for tables, narrative rows, and per-group Q&A. Disable for + protocol-only runs or quick screening triage. + + ), + }, + pause_for_review: { + title: "Pause for me to review the search strategy", + description: ( + <> + When enabled, the pipeline pauses after the planner produces the + search query so you can approve, revise, or reject it. Recommended + for high-stakes reviews. Maps to auto_confirm = false. + + ), + }, +}; diff --git a/src/app/components/ui/popover.tsx b/src/app/components/ui/popover.tsx new file mode 100644 index 0000000..242795a --- /dev/null +++ b/src/app/components/ui/popover.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/src/app/components/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function PopoverContent({ + className, + align = "start", + sideOffset = 6, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/src/app/user/synth-scholar/page.tsx b/src/app/user/synth-scholar/page.tsx index dab16dd..27c753f 100644 --- a/src/app/user/synth-scholar/page.tsx +++ b/src/app/user/synth-scholar/page.tsx @@ -46,6 +46,8 @@ import type { } from "@/src/types/synthScholar"; import { PlanConfirmDialog } from "@/src/app/components/synth-scholar/PlanConfirmDialog"; import { ReviewActionsDropdown } from "@/src/app/components/synth-scholar/ReviewActionsDropdown"; +import { InfoPopover } from "@/src/app/components/synth-scholar/InfoPopover"; +import { FIELD_GUIDES } from "@/src/app/components/synth-scholar/fieldGuides"; // OpenRouter model catalogue. Slugs use Anthropic's API-ID format (hyphens, not // dots) — `anthropic/claude-opus-4.7` is NOT a valid OpenRouter slug; OpenRouter @@ -368,10 +370,23 @@ function _splitLines(text: string): string[] { .filter(Boolean); } -function StartReviewForm({ onCreated }: { onCreated: (id: string) => void }) { +function StartReviewForm({ + onCreated, + fullPage = false, +}: { + onCreated: (id: string) => void; + /** Render inline as a card (side-panel use) vs. expanded layout for the + ?mode=new full-page experience. fullPage opens all sections by default + and drops the inner card wrapper since the page already supplies one. */ + fullPage?: boolean; +}) { const [form, setForm] = React.useState(INITIAL_FORM); const [keyStatus, setKeyStatus] = React.useState<{ source: "personal" | "shared" | "none"; checked: boolean }>({ source: "none", checked: false }); - const [openSections, setOpenSections] = React.useState({ protocol: true, search: false, metadata: false, rob: false, group: false, run: false }); + const [openSections, setOpenSections] = React.useState( + fullPage + ? { protocol: true, search: true, metadata: true, rob: true, group: true, run: true } + : { protocol: true, search: false, metadata: false, rob: false, group: false, run: false }, + ); const [submitError, setSubmitError] = React.useState(null); const create = useCreateReview(); const createCompare = useCreateCompareReview(); @@ -500,15 +515,26 @@ function StartReviewForm({ onCreated }: { onCreated: (id: string) => void }) { const isSubmitting = create.isPending || createCompare.isPending; return ( -
-
-

- Start a new review -

-
- Configure protocol, search strategy, and run options. Sections are collapsible. + + {!fullPage && ( +
+

+ Start a new review +

+
+ Configure protocol, search strategy, and run options. Sections are collapsible. +
-
+ )} {/* Load-example shortcut. Pre-fills the form with one of the seeded protocols so the user can hit Start right away or tweak one field @@ -587,7 +613,7 @@ function StartReviewForm({ onCreated }: { onCreated: (id: string) => void }) { {/* ── Protocol — basics ─────────────────────────── */}
toggleSection("protocol")}> - + void }) { placeholder="e.g. Efficacy of CRISPR therapies in monogenic disorders" /> - + void }) { />
- + update("pico_population", e.target.value)} /> - + update("pico_intervention", e.target.value)} /> - + update("pico_comparison", e.target.value)} /> - + update("pico_outcome", e.target.value)} />
- +