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}