Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions src/paperbot/context_engine/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions src/paperbot/infrastructure/stores/memory_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 19 additions & 2 deletions src/paperbot/infrastructure/stores/research_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
98 changes: 63 additions & 35 deletions web/src/components/research/SavedPapersList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -194,6 +199,7 @@ export default function SavedPapersList() {
// Track filter state
const [tracks, setTracks] = useState<Track[]>([])
const [selectedTrackId, setSelectedTrackId] = useState<number | null>(null)
const activeTrackId = selectedTrackId

// Fetch tracks on mount
useEffect(() => {
Expand Down Expand Up @@ -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("<!DOCTYPE") || errorText.startsWith("<html")) {
throw new Error(`Server error: ${res.status} ${res.statusText}`)
const res = await fetch(`/api/research/papers/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})

if (!res.ok) {
const errorText = await res.text()
if (errorText.startsWith("<!DOCTYPE") || errorText.startsWith("<html")) {
throw new Error(`Server error: ${res.status} ${res.statusText}`)
}
throw new Error(errorText)
}
throw new Error(errorText)
}
const currentAction = currentFeedbackFromRequestAction(requestAction)

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)
}
}, [])
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) => {
Expand Down Expand Up @@ -821,7 +847,7 @@ export default function SavedPapersList() {
) : (
<>
<div className="rounded-md border">
<Table>
<Table className="table-fixed w-full">
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
Expand All @@ -831,12 +857,12 @@ export default function SavedPapersList() {
aria-label="Select all"
/>
</TableHead>
<TableHead>Title</TableHead>
<TableHead>Source</TableHead>
<TableHead>Saved</TableHead>
<TableHead>Judge</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead className="w-[40%]">Title</TableHead>
<TableHead className="w-[14%]">Source</TableHead>
<TableHead className="w-[16%] text-center">Saved</TableHead>
<TableHead className="w-[10%] text-center">Judge</TableHead>
<TableHead className="w-[10%] text-center">Status</TableHead>
<TableHead className="w-[10%] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
Expand All @@ -857,27 +883,29 @@ export default function SavedPapersList() {
aria-label={`Select ${paper.title}`}
/>
</TableCell>
<TableCell className="max-w-[480px]">
<div className="font-medium">{paper.title}</div>
<TableCell className="max-w-[480px] align-top">
<div className="truncate text-sm font-medium" title={paper.title}>
{paper.title}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{(paper.authors || []).slice(0, 4).join(", ") || "Unknown authors"}
</div>
{paper.venue ? <div className="mt-1 text-xs text-muted-foreground">{paper.venue}</div> : null}
</TableCell>
<TableCell>
<TableCell className="whitespace-nowrap">
<Badge variant="outline">{paper.primary_source || paper.source || "unknown"}</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<TableCell className="text-center text-xs text-muted-foreground">
<div>{formatDate(item.saved_at)}</div>
<div>Published: {formatDate(paper.publication_date || paper.published_at)}</div>
</TableCell>
<TableCell>
<TableCell className="text-center">
<div className="text-sm">{formatJudge(item.latest_judge?.overall)}</div>
{item.latest_judge?.recommendation ? (
<div className="text-xs text-muted-foreground">{item.latest_judge.recommendation}</div>
) : null}
</TableCell>
<TableCell>
<TableCell className="text-center">
<Badge>{status}</Badge>
</TableCell>
<TableCell className="space-x-2 text-right">
Expand All @@ -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 ? <Loader2 className="h-3 w-3 animate-spin" /> : "Unsave"}
</Button>
Expand Down
Loading
Loading