diff --git a/src/paperbot/context_engine/engine.py b/src/paperbot/context_engine/engine.py index 0f8cb93f..9982dbac 100644 --- a/src/paperbot/context_engine/engine.py +++ b/src/paperbot/context_engine/engine.py @@ -1236,21 +1236,35 @@ 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 - if self.evidence_retriever is not None and self.evidence_retriever is not self.paper_store: + except Exception as exc: # pragma: no cover - best-effort cleanup + Logger.warning( + 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: - pass + 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/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: diff --git a/src/paperbot/infrastructure/stores/research_store.py b/src/paperbot/infrastructure/stores/research_store.py index 6343444f..f141e417 100644 --- a/src/paperbot/infrastructure/stores/research_store.py +++ b/src/paperbot/infrastructure/stores/research_store.py @@ -723,14 +723,31 @@ 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]]] = {} + seen_per_track: set[tuple[int, int]] = set() + 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 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) diff --git a/web/src/components/research/SavedPapersList.tsx b/web/src/components/research/SavedPapersList.tsx index 6e6ab9fd..7362871a 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,50 @@ 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) - } - }, []) + 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) => { @@ -821,7 +847,7 @@ export default function SavedPapersList() { ) : ( <>
- +
@@ -831,12 +857,12 @@ export default function SavedPapersList() { aria-label="Select all" /> - Title - Source - Saved - Judge - Status - Actions + Title + Source + Saved + Judge + Status + Actions @@ -857,27 +883,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} @@ -896,7 +924,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"} 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 ( + + ) +}