Skip to main content

lunaris_extract/
lib.rs

1//! lunaris-extract — Phase 3 entity + relation + fact extractor (default-OFF
2//! per blueprint §5.2).
3//!
4//! Extraction is REMOTE-ONLY since the llama.cpp-only cutover (2026-07,
5//! Phase C). Two feature-gated backends behind one dyn-compatible
6//! [`Extractor`] trait:
7//!
8//! - `OllamaExtractor` (`feature = "ollama"`) — POSTs `/api/chat` with the
9//!   GBNF grammar translated to a JSON schema in the `format` field.
10//! - `CloudApiExtractor` (`feature = "cloud-api"`) — provider-mux Anthropic /
11//!   OpenAI / Gemini / MiniMax / OpenAI-compatible URL selectable via
12//!   `LUNARIS_EXTRACT_PROVIDER` env (D-01). Per
13//!   D-21: single retry on transient errors then emit a sentinel entity that
14//!   the [`validator::validate`] pass routes to
15//!   `NeedsReviewReason::TransientAfterRetry`.
16//!
17//! ## Default-OFF contract (blueprint §5.2 + D-11)
18//!
19//! The umbrella `Lunaris` handle keeps the extractor under a
20//! `GraphPipelineHandle` toggle (Plan 03-03). With the toggle OFF the extractor
21//! is dead code: no HTTP client constructed, no extra threads spawned.
22//! Zero-config `open()` resolves [`NoopExtractor`] (degraded mode) — a real
23//! backend requires `LUNARIS_EXTRACT_PROVIDER` (+ provider envs) or an
24//! explicit `with_extractor` call.
25//!
26//! ## EntityId derivation (D-06)
27//!
28//! [`EntityId`] is the deterministic 16-byte truncation of
29//! `blake3(canonical_name_normalized || "::" || entity_type)`. Stable across
30//! re-ingest, across chunks within an Episode, across Episodes. No second-pass
31//! dedupe round trip. See [`types::EntityId::from_name_and_type`].
32//!
33//! ## Validator (D-08)
34//!
35//! [`validator::validate`] walks a [`RawExtractionBatch`] and routes invalid
36//! items to [`validator::ValidatedExtraction::needs_review`] with one of four
37//! structured reasons:
38//!
39//! 1. [`NeedsReviewReason::InvalidBitemporal`] — `valid_from >= valid_to`
40//! 2. [`NeedsReviewReason::StructuralContradiction`] — same `(subject_id,
41//!    predicate)` with overlapping `[valid_from, valid_to]` and conflicting
42//!    `object_id`s within the SAME Episode
43//! 3. [`NeedsReviewReason::GbnfFailure`] — parsed item violates the grammar
44//!    schema (empty name / out-of-range confidence / etc.)
45//! 4. [`NeedsReviewReason::TransientAfterRetry`] — cloud-api retry exhaust
46//!    (D-21 sentinel detection)
47//!
48//! Cross-Episode contradictions (e.g., two Episodes claim different birth years
49//! for Alice) are deferred to the Phase 4 Verifier worker per D-09 — out of
50//! scope here.
51//!
52//! ## Module layout
53//!
54//! - [`types`] — DTOs ([`EntityId`], [`Entity`], [`Relation`], [`Fact`],
55//!   [`ChunkInput`], [`RawExtraction`], [`RawExtractionBatch`],
56//!   [`ExtractionBatch`])
57//! - [`noop`] — [`NoopExtractor`] (always-empty extraction; default when
58//!   graph pipeline is OFF or no provider is configured)
59//! - `ollama` (gated `ollama`) — `OllamaExtractor`
60//! - `cloud_api` (gated `cloud-api`) — `CloudApiExtractor`
61//! - [`validator`] — [`validator::validate`] + `NeedsReviewReason`
62
63#![deny(rust_2018_idioms, unreachable_pub)]
64#![forbid(unsafe_code)]
65
66use async_trait::async_trait;
67use lunaris_core::LunarisError;
68use ulid::Ulid;
69
70// Content-addressed extraction cache decorator — wraps any Extractor so a
71// given (prompt-template, model-namespace, chunk) triple hits the LLM at
72// most once. Always built (std + blake3 + serde only).
73pub mod cached;
74#[cfg(feature = "cloud-api")]
75pub mod cloud_api;
76// RFC 0007 §3 — FallbackExtractor<P, F> static-dispatch combinator with
77// per-instance CircuitBreaker. Always built; the breaker primitive lives
78// in lunaris-core::circuit_breaker.
79pub mod fallback;
80// Phase 11 — backend-agnostic adapter over `lunaris_llm::LlmBackend`.
81pub mod llm_extractor;
82pub mod noop;
83#[cfg(feature = "ollama")]
84pub mod ollama;
85pub mod types;
86pub mod validator;
87
88pub use cached::{CacheStats, CachedExtractor};
89#[cfg(feature = "cloud-api")]
90pub use cloud_api::{CloudApiExtractor, CloudApiExtractorOpts, CloudProvider};
91pub use llm_extractor::{LlmExtractor, LlmExtractorOpts};
92pub use noop::NoopExtractor;
93#[cfg(feature = "ollama")]
94pub use ollama::{OllamaExtractor, OllamaExtractorOpts};
95pub use types::{
96    ChunkInput, Entity, EntityId, ExtractionBatch, Fact, RawExtraction, RawExtractionBatch,
97    Relation,
98};
99pub use validator::{
100    NeedsReviewItem, NeedsReviewReason, ValidatedExtraction, cap_future_valid_from, into_batch,
101    validate,
102};
103
104/// Object-safe async extractor.
105///
106/// Since the llama.cpp-only cutover the shipped backends are
107/// `OllamaExtractor` (HTTP) and `CloudApiExtractor` (Anthropic / OpenAI /
108/// Gemini / MiniMax / OpenAI-compatible URL). All implementations
109/// MUST honour the per-batch timeout (D-02) by falling back to per-chunk
110/// extraction on timeout, and MUST emit either valid extractions or a sentinel
111/// recognized by [`validator::validate`] — never silently drop chunks.
112///
113/// `Arc<dyn Extractor>` is constructible (proven by the compile-time
114/// `extractor_is_dyn_compat` test), so the umbrella `Lunaris::with_extractor`
115/// builder accepts any backend without compile-time monomorphization.
116#[async_trait]
117pub trait Extractor: Send + Sync + 'static {
118    /// Extract entities + relations + facts from a batch of chunk inputs.
119    ///
120    /// Returns a [`RawExtractionBatch`] — call [`validate`] downstream to flag
121    /// NeedsReview cases (per EXTRACT-05). The Plan 03-03 ingest fan-out
122    /// converts the validated batch into `WriteOp::GraphNode` /
123    /// `WriteOp::GraphEdge` ops at the `StoragePort::atomic_write` boundary.
124    async fn extract(
125        &self,
126        episode_id: Ulid,
127        chunks: &[ChunkInput],
128    ) -> Result<RawExtractionBatch, LunarisError>;
129
130    /// Returns `true` when this extractor produces real extractions; `false`
131    /// for [`NoopExtractor`] so callers (Plan 03-03 ingest fan-out) can skip
132    /// the GraphNode / GraphEdge `WriteOp`s when `applies() == false`.
133    fn applies(&self) -> bool {
134        true
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::sync::Arc;
142
143    /// Compile-time proof the trait is dyn-compatible (object-safe). If a
144    /// future addition (generic method, `Self: Sized` bound) breaks this, the
145    /// `Arc<dyn Extractor>` form on the umbrella handle stops compiling.
146    #[test]
147    fn extractor_is_dyn_compat() {
148        fn _check<T: Extractor + ?Sized>() {}
149        _check::<dyn Extractor>();
150        let _: Arc<dyn Extractor> = Arc::new(NoopExtractor);
151    }
152}