Skip to main content

mnem_ingest/
types.rs

1//! Shared data types used throughout the ingest pipeline.
2//!
3//! Kept in a single file for B5a; if the surface grows past ~150 lines in
4//! later sub-waves we will split (`section.rs`, `chunk.rs`, `config.rs`).
5
6use std::ops::Range;
7
8use mnem_core::id::Cid;
9use mnem_ner_providers::NerConfig;
10use serde::{Deserialize, Serialize};
11
12/// A hierarchical text region extracted from a source.
13///
14/// Produced by parsers in [`crate::md`] / [`crate::text`] and consumed by
15/// chunkers in [`mod@crate::chunk`]. The `byte_range` always refers to offsets
16/// in the *original* source input (not the post-parse normalized text), so
17/// downstream stages can slice back into the raw document for diffing or
18/// provenance tracking.
19///
20/// Heading depth uses `CommonMark`'s 1-indexed convention (`# H1 → 1`). A
21/// depth of `0` indicates "no heading" (e.g. top-of-file prose before any
22/// heading, or the synthetic root produced by [`crate::text::parse_text`]).
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Section {
25    /// Heading text, without the leading `#` markers and trimmed.
26    pub heading: Option<String>,
27    /// Heading depth (1–6 for actual headings, 0 for headless prose).
28    pub depth: u8,
29    /// Body text contained under this heading (code blocks are kept intact).
30    pub text: String,
31    /// Byte range in the original source input.
32    pub byte_range: Range<usize>,
33}
34
35/// A single chunk emitted by a [`crate::chunk::ChunkerKind`].
36///
37/// `section_path` records the hierarchy of headings that enclose this
38/// chunk, from the root of the document down. It is used by downstream
39/// stages for breadcrumb display and for attaching graph edges back to
40/// the enclosing `Doc` node.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Chunk {
43    /// Heading hierarchy from outermost to innermost.
44    pub section_path: Vec<String>,
45    /// Chunk body text.
46    pub text: String,
47    /// Whitespace-split token count (deterministic estimate).
48    pub tokens_estimate: u32,
49}
50
51/// Programming language for [`SourceKind::Code`].
52///
53/// New languages can be added without breaking the public API because
54/// `source_kind_for_path` falls back to [`SourceKind::Text`] for any
55/// extension not listed here.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum CodeLanguage {
59    /// Rust source (`.rs`).
60    Rust,
61    /// Python source (`.py`, `.pyi`).
62    Python,
63    /// JavaScript source (`.js`, `.mjs`, `.cjs`).
64    JavaScript,
65    /// TypeScript source (`.ts`, `.tsx`, `.mts`, `.cts`).
66    TypeScript,
67    /// Go source (`.go`).
68    Go,
69    /// Java source (`.java`).
70    Java,
71    /// C source (`.c`, `.h`).
72    C,
73    /// C++ source (`.cpp`, `.cc`, `.cxx`, `.hpp`, `.hxx`).
74    Cpp,
75    /// Ruby source (`.rb`, `.gemspec`, `.rake`, `.erb`).
76    Ruby,
77    /// C# source (`.cs`, `.csx`).
78    CSharp,
79}
80
81impl CodeLanguage {
82    /// Map a lowercase file extension to a language variant, or `None` if
83    /// the extension is not a recognised code file.
84    #[must_use]
85    pub fn from_extension(ext: &str) -> Option<Self> {
86        match ext {
87            "rs" => Some(Self::Rust),
88            "py" | "pyi" => Some(Self::Python),
89            "js" | "mjs" | "cjs" => Some(Self::JavaScript),
90            "ts" | "tsx" | "mts" | "cts" => Some(Self::TypeScript),
91            "go" => Some(Self::Go),
92            "java" => Some(Self::Java),
93            "c" | "h" => Some(Self::C),
94            "cpp" | "cc" | "cxx" | "c++" | "hpp" | "hxx" => Some(Self::Cpp),
95            "rb" | "gemspec" | "rake" | "erb" => Some(Self::Ruby),
96            "cs" | "csx" => Some(Self::CSharp),
97            _ => None,
98        }
99    }
100
101    /// Short lowercase name used in `mnem:source_kind` props and diagnostics.
102    #[must_use]
103    pub const fn as_str(self) -> &'static str {
104        match self {
105            Self::Rust => "rust",
106            Self::Python => "python",
107            Self::JavaScript => "javascript",
108            Self::TypeScript => "typescript",
109            Self::Go => "go",
110            Self::Java => "java",
111            Self::C => "c",
112            Self::Cpp => "cpp",
113            Self::Ruby => "ruby",
114            Self::CSharp => "csharp",
115        }
116    }
117}
118
119/// The kind of source being ingested.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "lowercase")]
122pub enum SourceKind {
123    /// `CommonMark` + GFM (tables, fenced code).
124    Markdown,
125    /// UTF-8 plain text, no structure inferred.
126    Text,
127    /// PDF (text-layer extraction).
128    Pdf,
129    /// Chat transcript (JSON/JSONL).
130    Conversation,
131    /// Source code file parsed with tree-sitter.
132    Code(CodeLanguage),
133}
134
135/// Which chunker strategy to use, and its parameters.
136///
137/// Re-exported from [`mod@crate::chunk`] for convenience.
138pub type ChunkerKind = crate::chunk::ChunkerKind;
139
140/// Configuration for an ingest run.
141///
142/// `ntype` is the `Node::ntype` string applied to the root document node
143/// once Phase-B5c wires commit. Typical values: `"Doc"`, `"Note"`,
144/// `"Transcript"`.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct IngestConfig {
147    /// Which chunker to use.
148    pub chunker: ChunkerKind,
149    /// `Node::ntype` of the root Doc node.
150    pub ntype: String,
151    /// Target maximum tokens per chunk (advisory; used by recursive chunker).
152    pub max_tokens: u32,
153    /// Overlap tokens between adjacent chunks (recursive chunker only).
154    pub overlap: u32,
155    /// NER provider selection. Defaults to [`NerConfig::Rule`] (the
156    /// capitalized-phrase heuristic). Set to [`NerConfig::None`] to
157    /// suppress all entity extraction.
158    #[serde(default)]
159    pub ner: NerConfig,
160}
161
162impl Default for IngestConfig {
163    fn default() -> Self {
164        Self {
165            chunker: ChunkerKind::Paragraph,
166            ntype: "Doc".into(),
167            max_tokens: 512,
168            overlap: 32,
169            ner: NerConfig::default(),
170        }
171    }
172}
173
174/// Outcome of a completed ingest run.
175///
176/// Phase-B5c wires the real pipeline: `commit_cid` is `Some(_)` whenever
177/// the caller committed the transaction after [`crate::Ingester::ingest`]
178/// returned; `None` when they ran a dry-run (ingest without commit) or
179/// when the underlying backend reports no change. `node_count` counts
180/// every `Node` added (the Doc root, one per chunk, one per unique
181/// entity). `entity_count` and `relation_count` report extraction
182/// output before dedup. `chunk_count` reports the number of chunks
183/// produced by the chunker stage. `edge_count` reports the total number
184/// of edges written by the run (structural `chunk_of`, entity
185/// `chunk_mentions`, and extractor-found relations all included);
186/// `relation_count` is a strict subset covering only the third group.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct IngestResult {
189    /// Commit produced by the run, if any.
190    pub commit_cid: Option<Cid>,
191    /// Number of graph nodes created.
192    pub node_count: u64,
193    /// Number of chunks produced.
194    pub chunk_count: u64,
195    /// Number of entity nodes created (deduplicated across the run).
196    pub entity_count: u64,
197    /// Number of relation edges created (LLM-extracted subject-object
198    /// triples only). Strict subset of [`Self::edge_count`].
199    pub relation_count: u64,
200    /// Total number of edges written: structural (`chunk_of`), entity
201    /// mentions (`chunk_mentions`), and extracted relations combined.
202    /// Single-chunk ingests produce at least one edge (the `chunk_of`
203    /// link between the chunk and its Doc parent) even when the
204    /// extractor finds zero relations.
205    pub edge_count: u64,
206    /// Wall-clock elapsed time in milliseconds.
207    pub elapsed_ms: u64,
208}
209
210/// Recognised conversation-export formats.
211///
212/// Used by [`crate::conversation::parse_conversation`] to route JSON into
213/// the right schema decoder. [`Self::Generic`] is the fallback for
214/// `[{"role", "content", "timestamp"?}]` shaped payloads.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum ConversationFormat {
218    /// `ChatGPT` export (`conversations.json`) with a `mapping` tree of
219    /// message nodes keyed by UUID.
220    ChatGpt,
221    /// Claude export with a flat `{"conversation": [{role, content}]}`
222    /// top-level object.
223    Claude,
224    /// Generic `[{role, content, timestamp?}]` array.
225    Generic,
226}
227
228/// A single turn in a conversation.
229///
230/// `timestamp` is an optional Unix epoch in seconds - some exports
231/// (Claude, generic) omit it and we preserve that absence rather than
232/// fabricating zeroes.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct Message {
235    /// Speaker role, e.g. `"user"`, `"assistant"`, `"system"`, `"tool"`.
236    pub role: String,
237    /// Turn text content. Multi-part `ChatGPT` messages are concatenated
238    /// with `"\n\n"` separators by the parser.
239    pub content: String,
240    /// Unix epoch seconds, if the source provided one.
241    pub timestamp: Option<u64>,
242}
243
244/// Configuration for the entity + relation extractor.
245///
246/// Entity extraction is handled entirely by the NER provider wired via
247/// [`IngestConfig::ner`]. The provider may return any label strings it
248/// chooses; there is no fixed vocabulary.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct ExtractorConfig {
251    /// Call the NER provider for named-entity extraction. All labels
252    /// returned by the provider pass through unconditionally.
253    #[serde(default = "default_true")]
254    pub extract_ner: bool,
255    /// Maximum number of whitespace-separated tokens between two entity
256    /// spans that may still be linked by a proximity relation.
257    pub relation_window_tokens: usize,
258}
259
260fn default_true() -> bool {
261    true
262}
263
264impl Default for ExtractorConfig {
265    fn default() -> Self {
266        Self {
267            extract_ner: true,
268            relation_window_tokens: 6,
269        }
270    }
271}
272
273/// Advisory inputs for [`crate::chunk::auto_chunker`].
274///
275/// Defaults match the production heuristics documented on each
276/// [`crate::SourceKind`] → [`ChunkerKind`] mapping. Callers only need to
277/// override when they want tighter or looser chunking than the out-of-
278/// the-box behaviour.
279#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
280pub struct ChunkerAuto {
281    /// Override `max_tokens` for recursive chunking. `None` picks the
282    /// per-source-kind default.
283    pub max_tokens: Option<u32>,
284    /// Override `overlap` for recursive chunking. `None` picks the
285    /// per-source-kind default.
286    pub overlap: Option<u32>,
287    /// Override the session-chunker boundary for conversations. `None`
288    /// picks the default of 10 messages per chunk.
289    pub max_messages: Option<usize>,
290}