From b51e48d2ee29a1cb0d5a5f538c7ce8d9f8970447 Mon Sep 17 00:00:00 2001 From: Linjie-top Date: Thu, 12 Mar 2026 16:23:39 +0800 Subject: [PATCH 1/6] fix(paper): improve saved papers table layout --- .../components/research/SavedPapersList.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/web/src/components/research/SavedPapersList.tsx b/web/src/components/research/SavedPapersList.tsx index 6e6ab9fd..a1d81dcf 100644 --- a/web/src/components/research/SavedPapersList.tsx +++ b/web/src/components/research/SavedPapersList.tsx @@ -821,7 +821,7 @@ export default function SavedPapersList() { ) : ( <>
- +
@@ -831,12 +831,12 @@ export default function SavedPapersList() { aria-label="Select all" /> - Title - Source - Saved - Judge - Status - Actions + Title + Source + Saved + Judge + Status + Actions @@ -857,27 +857,29 @@ export default function SavedPapersList() { aria-label={`Select ${paper.title}`} /> - -
{paper.title}
+ +
+ {paper.title} +
{(paper.authors || []).slice(0, 4).join(", ") || "Unknown authors"}
{paper.venue ?
{paper.venue}
: null}
- + {paper.primary_source || paper.source || "unknown"} - +
{formatDate(item.saved_at)}
Published: {formatDate(paper.publication_date || paper.published_at)}
- +
{formatJudge(item.latest_judge?.overall)}
{item.latest_judge?.recommendation ? (
{item.latest_judge.recommendation}
) : null}
- + {status} From 442cbf5940a5b7f34e6e7dcc04b4fe2b62554e31 Mon Sep 17 00:00:00 2001 From: Linjie-top Date: Thu, 12 Mar 2026 17:32:48 +0800 Subject: [PATCH 2/6] fix(paper): unify unsave with feedback API --- .../components/research/SavedPapersList.tsx | 72 +++++++++++++------ 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/web/src/components/research/SavedPapersList.tsx b/web/src/components/research/SavedPapersList.tsx index a1d81dcf..f96d2094 100644 --- a/web/src/components/research/SavedPapersList.tsx +++ b/web/src/components/research/SavedPapersList.tsx @@ -26,6 +26,11 @@ import { import { Input } from "@/components/ui/input" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Textarea } from "@/components/ui/textarea" +import { + currentFeedbackFromRequestAction, + toggleSaveFeedbackAction, + type PaperFeedbackRequestAction, +} from "@/lib/paper-feedback" type SavedPaperSort = "saved_at" | "judge_score" | "published_at" @@ -194,6 +199,7 @@ export default function SavedPapersList() { // Track filter state const [tracks, setTracks] = useState([]) const [selectedTrackId, setSelectedTrackId] = useState(null) + const activeTrackId = selectedTrackId // Fetch tracks on mount useEffect(() => { @@ -279,30 +285,52 @@ export default function SavedPapersList() { const isAllSelected = pagedItems.length > 0 && selectedIds.size === pagedItems.length - const unsavePaper = useCallback(async (paperId: number) => { - setUpdatingAction({ paperId, action: "unsave" }) - setError(null) - try { - const res = await fetch(`/api/papers/${paperId}/save?user_id=default`, { - method: "DELETE", - }) + const unsavePaper = useCallback( + async (paperId: number, externalId: string | null) => { + const requestAction: PaperFeedbackRequestAction = toggleSaveFeedbackAction(true) + setUpdatingAction({ paperId, action: "unsave" }) + setError(null) + try { + const payload = { + user_id: "default", + track_id: activeTrackId, + paper_id: externalId || String(paperId), + action: requestAction, + weight: 0.0, + context_run_id: null, + context_rank: undefined, + metadata: {}, + } - if (!res.ok) { - const errorText = await res.text() - if (errorText.startsWith(" prev.filter((row) => row.paper.id !== paperId)) - } catch (err) { - const detail = err instanceof Error ? err.message : String(err) - setError(detail) - } finally { - setUpdatingAction(null) - } - }, []) + const data = await res.json() + const currentAction = currentFeedbackFromRequestAction(requestAction) + + if (currentAction !== "save") { + setItems((prev) => prev.filter((row) => row.paper.id !== paperId)) + } + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + setError(detail) + } finally { + setUpdatingAction(null) + } + }, + [activeTrackId], + ) const toggleReadStatus = useCallback( async (paperId: number, currentStatus: ReadingStatus) => { @@ -898,7 +926,7 @@ export default function SavedPapersList() { size="sm" variant="ghost" disabled={rowUpdating} - onClick={() => unsavePaper(paper.id)} + onClick={() => unsavePaper(paper.id, paper.external_url || paper.url || null)} > {unsaving ? : "Unsave"} From 05f02b8d801e34a1a5c5bf80b477dcc3accfac2e Mon Sep 17 00:00:00 2001 From: Linjie-top Date: Thu, 12 Mar 2026 18:44:20 +0800 Subject: [PATCH 3/6] fix(paper): scope saved papers per track --- .../infrastructure/stores/research_store.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/paperbot/infrastructure/stores/research_store.py b/src/paperbot/infrastructure/stores/research_store.py index 6343444f..411eee61 100644 --- a/src/paperbot/infrastructure/stores/research_store.py +++ b/src/paperbot/infrastructure/stores/research_store.py @@ -723,17 +723,37 @@ def list_saved_papers( .scalars() .all() ) + + # Track latest effective save/unsave state globally per paper + # and per (paper, track) pair. This ensures that track-scoped + # saved views only include papers whose *current* state for that + # track is "save", rather than any track that ever saved it. latest_save_state: Dict[int, tuple[bool, Optional[datetime]]] = {} + latest_per_track: Dict[tuple[int, int], str] = {} + for row in feedback_rows: pid = int(row.paper_ref_id or 0) if pid <= 0: continue normalized_action = self._normalize_feedback_action(str(row.action or "")) - if normalized_action == "save": - saved_track_membership[pid].add(int(row.track_id or 0)) + + # Per-track state: first row encountered in descending + # timestamp order is the effective state for that + # (paper, track) pair. + tid = int(row.track_id or 0) + key = (pid, tid) + if tid > 0 and key not in latest_per_track: + latest_per_track[key] = normalized_action + + # Global latest save/unsave per paper (track-agnostic) if pid not in latest_save_state: latest_save_state[pid] = (normalized_action == "save", row.ts) + # Build membership mapping from effective per-track states. + for (pid, tid), action in latest_per_track.items(): + if action == "save": + saved_track_membership[pid].add(tid) + for pid, (is_saved, ts) in latest_save_state.items(): if not is_saved: saved_at_by_paper.pop(pid, None) From b95c225a2d0e7b30c08ed52bd2e8bb65dbba5add Mon Sep 17 00:00:00 2001 From: Linjie-top Date: Thu, 12 Mar 2026 20:28:54 +0800 Subject: [PATCH 4/6] chore(logging): surface cleanup and FTS5 failures --- src/paperbot/context_engine/engine.py | 14 ++++++++++---- src/paperbot/infrastructure/stores/memory_store.py | 5 +++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/paperbot/context_engine/engine.py b/src/paperbot/context_engine/engine.py index e784c0c3..27172793 100644 --- a/src/paperbot/context_engine/engine.py +++ b/src/paperbot/context_engine/engine.py @@ -1191,14 +1191,20 @@ async def close(self) -> None: maybe_coro = close_fn() if asyncio.iscoroutine(maybe_coro): await maybe_coro - except Exception: - pass + except Exception as exc: # pragma: no cover - best-effort cleanup + Logger.warning( + f"Failed to close search_service: {exc}", + file=LogFiles.HARVEST, + ) if self.paper_store is not None: close_fn = getattr(self.paper_store, "close", None) if callable(close_fn): try: close_fn() - except Exception: - pass + except Exception as exc: # pragma: no cover - best-effort cleanup + Logger.warning( + f"Failed to close paper_store: {exc}", + file=LogFiles.HARVEST, + ) return None diff --git a/src/paperbot/infrastructure/stores/memory_store.py b/src/paperbot/infrastructure/stores/memory_store.py index 7aea7e0d..796f10b8 100644 --- a/src/paperbot/infrastructure/stores/memory_store.py +++ b/src/paperbot/infrastructure/stores/memory_store.py @@ -453,8 +453,9 @@ def _ensure_fts5(conn) -> None: " END" ) ) - except Exception: - pass # FTS5 not available or already set up — degrade silently + except Exception as exc: + # FTS5 not available or already set up — degrade silently but log for debugging. + logger.warning("Failed to ensure FTS5 tables/triggers: %s", exc) @staticmethod def _ensure_vec_table(conn, dim: int = _EMBEDDING_DIM) -> None: From 6004f565b2c8a543b3a35bc92e4797bebcc35278 Mon Sep 17 00:00:00 2001 From: Linjie-top Date: Thu, 12 Mar 2026 20:42:48 +0800 Subject: [PATCH 5/6] fix(paper): refine saved filters and cleanup code --- .../infrastructure/stores/research_store.py | 13 +++++-------- web/src/components/research/SavedPapersList.tsx | 2 -- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/paperbot/infrastructure/stores/research_store.py b/src/paperbot/infrastructure/stores/research_store.py index 411eee61..f141e417 100644 --- a/src/paperbot/infrastructure/stores/research_store.py +++ b/src/paperbot/infrastructure/stores/research_store.py @@ -729,7 +729,7 @@ def list_saved_papers( # saved views only include papers whose *current* state for that # track is "save", rather than any track that ever saved it. latest_save_state: Dict[int, tuple[bool, Optional[datetime]]] = {} - latest_per_track: Dict[tuple[int, int], str] = {} + seen_per_track: set[tuple[int, int]] = set() for row in feedback_rows: pid = int(row.paper_ref_id or 0) @@ -742,18 +742,15 @@ def list_saved_papers( # (paper, track) pair. tid = int(row.track_id or 0) key = (pid, tid) - if tid > 0 and key not in latest_per_track: - latest_per_track[key] = normalized_action + if tid > 0 and key not in seen_per_track: + seen_per_track.add(key) + if normalized_action == "save": + saved_track_membership[pid].add(tid) # Global latest save/unsave per paper (track-agnostic) if pid not in latest_save_state: latest_save_state[pid] = (normalized_action == "save", row.ts) - # Build membership mapping from effective per-track states. - for (pid, tid), action in latest_per_track.items(): - if action == "save": - saved_track_membership[pid].add(tid) - for pid, (is_saved, ts) in latest_save_state.items(): if not is_saved: saved_at_by_paper.pop(pid, None) diff --git a/web/src/components/research/SavedPapersList.tsx b/web/src/components/research/SavedPapersList.tsx index f96d2094..7362871a 100644 --- a/web/src/components/research/SavedPapersList.tsx +++ b/web/src/components/research/SavedPapersList.tsx @@ -315,8 +315,6 @@ export default function SavedPapersList() { } throw new Error(errorText) } - - const data = await res.json() const currentAction = currentFeedbackFromRequestAction(requestAction) if (currentAction !== "save") { From c4d756663b77eed15bf264cd68434ce451d362d9 Mon Sep 17 00:00:00 2001 From: Linjie-top Date: Thu, 12 Mar 2026 21:32:42 +0800 Subject: [PATCH 6/6] fix(paper): refine paper context cleanup and year filter --- src/paperbot/context_engine/engine.py | 15 ++ .../research/YearMonthRangePicker.tsx | 170 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 web/src/components/research/YearMonthRangePicker.tsx diff --git a/src/paperbot/context_engine/engine.py b/src/paperbot/context_engine/engine.py index 27172793..10478131 100644 --- a/src/paperbot/context_engine/engine.py +++ b/src/paperbot/context_engine/engine.py @@ -1207,4 +1207,19 @@ async def close(self) -> None: f"Failed to close paper_store: {exc}", file=LogFiles.HARVEST, ) + + if ( + self.evidence_retriever is not None + and self.evidence_retriever is not self.paper_store + ): + close_fn = getattr(self.evidence_retriever, "close", None) + if callable(close_fn): + try: + close_fn() + except Exception as exc: # pragma: no cover - best-effort cleanup + Logger.warning( + f"Failed to close evidence_retriever: {exc}", + file=LogFiles.HARVEST, + ) + return None diff --git a/web/src/components/research/YearMonthRangePicker.tsx b/web/src/components/research/YearMonthRangePicker.tsx new file mode 100644 index 00000000..208cc403 --- /dev/null +++ b/web/src/components/research/YearMonthRangePicker.tsx @@ -0,0 +1,170 @@ +"use client" + +import { useMemo } from "react" +import { CalendarRange } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, +} from "@/components/ui/dropdown-menu" + +export type DatePreset = "all" | "last12" | "last3" | "last5" | "custom" + +interface YearRangePickerProps { + preset: DatePreset + onPresetChange: (next: DatePreset) => void + + yearFrom: string + yearTo: string + + onYearFromChange: (value: string) => void + onYearToChange: (value: string) => void + + disabled?: boolean + className?: string +} + +export function YearMonthRangePicker({ + preset, + onPresetChange, + yearFrom, + yearTo, + onYearFromChange, + onYearToChange, + disabled = false, + className, +}: YearRangePickerProps) { + const currentYear = new Date().getFullYear() + + const summaryLabel = useMemo(() => { + if (!yearFrom.trim() && !yearTo.trim()) { + if (preset === "last12") return "Last 12 months" + if (preset === "last3") return "Last 3 years" + if (preset === "last5") return "Last 5 years" + return "All time" + } + const from = yearFrom || "…" + const to = yearTo || "…" + return `${from} → ${to}` + }, [preset, yearFrom, yearTo]) + + const handleYearInputChange = (raw: string, kind: "from" | "to") => { + const trimmed = raw.trim() + if (!trimmed) { + if (kind === "from") onYearFromChange("") + else onYearToChange("") + onPresetChange("custom") + return + } + const n = Number(trimmed) + if (!Number.isInteger(n)) return + if (n < 1900 || n > currentYear) return + if (kind === "from") onYearFromChange(String(n)) + else onYearToChange(String(n)) + onPresetChange("custom") + } + + const applyPreset = (next: DatePreset) => { + const year = new Date().getFullYear() + if (next === "all") { + onYearFromChange("") + onYearToChange("") + } else if (next === "last12") { + onYearFromChange(String(year - 1)) + onYearToChange(String(year)) + } else if (next === "last3") { + onYearFromChange(String(year - 2)) + onYearToChange(String(year)) + } else if (next === "last5") { + onYearFromChange(String(year - 4)) + onYearToChange(String(year)) + } + onPresetChange(next) + } + + return ( + + + + + +
+ Presets +
+ applyPreset("all")} /> + applyPreset("last12")} /> + applyPreset("last3")} /> + applyPreset("last5")} /> +
+
+ +
+ handleYearInputChange(v, "from")} + currentYear={currentYear} + /> + handleYearInputChange(v, "to")} + currentYear={currentYear} + /> +
+
+
+ ) +} + +function YearColumn({ title, year, onYearChange, currentYear }: { title: string; year: string; onYearChange: (v: string) => void; currentYear: number }) { + return ( +
+
{title}
+
+ onYearChange(e.target.value)} + placeholder="YYYY" + aria-label={`${title} year`} + className="h-7 w-[90px] border bg-background px-1 text-xs shadow-none [appearance:textfield] focus-visible:ring-0" + min={1900} + max={currentYear} + /> +
+
+ ) +} + +function PresetChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { + return ( + + ) +}