1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
use crate::domain::anchor::Anchor;
use crate::domain::citation::Citation;
use crate::domain::knowledge_gap::KnowledgeGap;
use crate::domain::paper::{Paper, PaperStatus, Rating, ReadingStatus};
use crate::domain::research_report::ResearchReport;
use crate::domain::research_state::ResearchState;
use crate::domain::research_topic::ResearchTopic;
use crate::error::Result;
pub trait IndexStore: Send + Sync {
// Papers
fn insert_paper(&self, paper: &Paper) -> Result<()>;
fn get_paper(&self, id: &str) -> Result<Option<Paper>>;
/// Look up a paper by its DOI (exact match). Used by the import pipeline
/// to skip records that are already in the library.
fn find_paper_by_doi(&self, doi: &str) -> Result<Option<Paper>>;
/// Look up a paper by its OpenAlex work id (`W…`). Used by the reference
/// graph to dedupe hydrated referenced papers on re-runs.
fn find_paper_by_openalex_id(&self, openalex_id: &str) -> Result<Option<Paper>>;
/// Look up a paper by its stored source PDF path. Second identity key:
/// the same file re-ingested must not become a second row.
fn find_paper_by_pdf_path(&self, path: &str) -> Result<Option<Paper>>;
/// Look up a paper by title, compared in normalized form
/// ([`crate::domain::paper::normalize_title`]). Last identity key, for
/// papers carrying neither a DOI nor a pdf_path. `title` must already be
/// normalized.
fn find_paper_by_title(&self, title: &str) -> Result<Option<Paper>>;
/// Store (or replace) the extracted full body text of a paper. Kept out of
/// the `Paper` domain type so MCP/tool responses never carry megabytes of
/// body text.
fn set_paper_body(&self, paper_id: &str, body: &str) -> Result<()>;
/// Fetch the stored body text of a paper, if any.
fn get_paper_body(&self, paper_id: &str) -> Result<Option<String>>;
/// Record where the paper's PDF lives on disk (a locally ingested file or
/// a downloaded arXiv PDF), so `reingest` can re-extract without
/// redownloading.
fn set_paper_pdf_path(&self, paper_id: &str, path: &str) -> Result<()>;
/// Store the search-only keywords for a paper (LLM- or agent-generated).
/// Overwrites any previous value; the FTS update trigger reindexes the row.
fn set_paper_keywords(&self, id: &str, keywords: &str) -> Result<()>;
/// Papers that have no keywords yet — the enrichment work queue.
fn papers_missing_keywords(&self, limit: usize) -> Result<Vec<Paper>>;
/// Same, scoped to one topic. The `keywords = ''` filter lives in SQL so a
/// window full of already-enriched papers cannot hide the ones that need work.
fn papers_missing_keywords_by_topic(&self, topic_id: &str, limit: usize) -> Result<Vec<Paper>>;
/// Papers ordered oldest-update first — the re-enrichment queue, so repeated
/// `--force` runs advance through the library instead of repeating its head.
fn papers_stalest(&self, limit: usize) -> Result<Vec<Paper>>;
/// Same, scoped to one topic.
fn papers_by_topic_stalest(&self, topic_id: &str, limit: usize) -> Result<Vec<Paper>>;
fn update_paper_status(&self, id: &str, status: PaperStatus) -> Result<()>;
fn update_reading_status(&self, id: &str, status: ReadingStatus) -> Result<()>;
fn update_rating(&self, id: &str, rating: Rating) -> Result<()>;
fn clear_rating(&self, id: &str) -> Result<()>;
fn search_papers(&self, query: &str, limit: usize) -> Result<Vec<Paper>>;
fn list_papers(&self, limit: Option<usize>) -> Result<Vec<Paper>>;
/// Papers explicitly linked to `topic_id` via `topic_papers`, most relevant
/// first. Used so analysis/report builders scope LLM context to the
/// requested topic rather than arbitrary recent papers.
fn list_papers_by_topic(&self, topic_id: &str, limit: Option<usize>) -> Result<Vec<Paper>>;
// Topics
fn insert_topic(&self, topic: &ResearchTopic) -> Result<()>;
fn get_topic(&self, id: &str) -> Result<Option<ResearchTopic>>;
fn list_topics(&self) -> Result<Vec<ResearchTopic>>;
fn link_paper_to_topic(&self, paper_id: &str, topic_id: &str, relevance: f32) -> Result<()>;
// Knowledge gaps
fn insert_gap(&self, gap: &KnowledgeGap) -> Result<()>;
fn list_gaps(&self, topic_id: Option<&str>) -> Result<Vec<KnowledgeGap>>;
// Research state
fn get_research_state(&self, topic_id: &str) -> Result<Option<ResearchState>>;
fn update_research_state(&self, state: &ResearchState) -> Result<()>;
// Reports
fn insert_report(&self, report: &ResearchReport) -> Result<()>;
fn list_reports(&self, limit: Option<usize>) -> Result<Vec<ResearchReport>>;
// Citations
/// Insert citation edges, skipping pairs already stored. Returns how many
/// were newly inserted.
fn insert_citations(&self, citations: &[Citation]) -> Result<usize>;
/// Reference edges originating from `paper_id`, insertion order.
fn citations_for_paper(&self, paper_id: &str) -> Result<Vec<Citation>>;
/// Citation edges pointing at `paper_id` (works citing it), insertion
/// order.
fn citations_citing_paper(&self, paper_id: &str) -> Result<Vec<Citation>>;
/// Set the `context` label on existing citation edges. Pairs with no
/// stored edge are ignored. Returns how many rows changed.
fn set_citation_contexts(&self, citations: &[Citation]) -> Result<usize>;
/// Body-text matches for `query`, each carrying the matching snippet and
/// where in the document it sits. Papers whose body is not stored (no PDF
/// ingest) cannot match. `paper_id` scopes the search to one paper; without
/// it the whole library is searched.
fn search_body_evidence(
&self,
query: &str,
paper_id: Option<&str>,
limit: usize,
) -> Result<Vec<BodyEvidence>>;
// Index management
fn rebuild_index(&self) -> Result<()>;
fn init_schema(&self) -> Result<()>;
}
/// One body-text match: which paper, the surrounding text, and where it sits.
#[derive(Debug, Clone, serde::Serialize)]
pub struct BodyEvidence {
pub paper_id: String,
pub title: String,
/// Matching text with surrounding context; match terms are bracketed.
pub snippet: String,
pub anchor: Anchor,
}