use std::sync::Arc;
use lunaris_core::{
Chunk, Embedder, Episode, HlcClock, Lsn, LunarisError, StorageError, StoragePort, WriteOp,
keyspace::fact_key as scoped_fact_key, sanitize_graph_ident,
};
use lunaris_extract::{ChunkInput, NeedsReviewItem, ValidatedExtraction, validate};
use lunaris_ingest::{
BakoffConfig, ChunkDraft, HeadingRecord, TokenCounter, chunk_key, chunk_markdown_with_counter,
chunk_markdown_with_headings_with_counter, episode_key, ingest_episode_with_bakeoff,
run_bakeoff,
};
use serde_json::json;
use tracing::Instrument;
use ulid::Ulid;
use crate::graph_pipeline::GraphPipelineHandle;
use crate::handle::Lunaris;
const ENTITIES_INDEX: &str = "entities";
const FACTS_INDEX: &str = "facts";
const GRAPH_NAME: &str = "lunaris_graph";
const VERIFY_QUEUE_TOPIC: &str = "__lunaris_verify__";
const CONSOLIDATE_QUEUE_TOPIC: &str = "__lunaris_consolidate__";
const CHUNK_VECTOR_INDEX: &str = "chunks";
const DEFAULT_TARGET_TOKENS: usize = 500;
const DEFAULT_OVERLAP_TOKENS: usize = 100;
const EMBED_BATCH_SIZE: usize = 32;
impl Lunaris {
pub async fn ingest(&self, episode: Episode) -> Result<Lsn, LunarisError> {
let episode_id = episode.id;
let episode_source = episode.source.clone();
let episode_scope = episode.scope.clone();
let span = tracing::info_span!(
"lunaris.ingest",
correlation_id = tracing::field::Empty,
episode_id = %episode_id,
graph_enabled = self.graph_pipeline.is_enabled(),
);
let token_counter = self.token_counter.clone();
async move {
let bakeoff_config = self.bakeoff_config.clone();
let lsn = if !self.graph_pipeline.is_enabled() {
let (target_tokens, overlap_tokens) = bakeoff_config
.as_deref()
.map(|c| (c.target_tokens, c.overlap_tokens))
.unwrap_or((DEFAULT_TARGET_TOKENS, DEFAULT_OVERLAP_TOKENS));
ingest_episode_with_bakeoff(
self.storage.as_ref(),
self.embedder.as_ref(),
&self.clock,
episode,
token_counter.clone(),
bakeoff_config,
target_tokens,
overlap_tokens,
)
.await?
} else {
ingest_episode_graph_on(
self.storage.as_ref(),
self.embedder.as_ref(),
&self.graph_pipeline,
&self.clock,
episode,
token_counter,
bakeoff_config,
graph_extract_per_session(),
)
.await?
};
publish_consolidate_event(
self.storage.as_ref(),
episode_id,
lsn,
&episode_source,
&episode_scope,
)
.await;
Ok(lsn)
}
.instrument(span)
.await
}
pub async fn ingest_structured(
&self,
payload: crate::structured_ingest::StructuredIngest,
scope: lunaris_core::Scope,
) -> Result<Lsn, LunarisError> {
crate::structured_ingest::ingest_structured_inner(
self.storage.as_ref(),
self.embedder.as_ref(),
&self.clock,
payload,
scope,
)
.await
}
}
async fn publish_consolidate_event(
storage: &dyn StoragePort,
episode_id: Ulid,
lsn: Lsn,
source: &str,
scope: &lunaris_core::Scope,
) {
if !storage.capabilities().queue_native {
tracing::debug!("consolidate queue unavailable; skipping consolidate-queue publish");
return;
}
let envelope = json!({
"kind": "ingest_committed",
"episode_id": episode_id.to_string(),
"lsn_wall_ms": lsn.wall_ms,
"lsn_counter": lsn.counter,
"source": source,
});
let payload = match serde_json::to_vec(&envelope) {
Ok(b) => b,
Err(e) => {
tracing::warn!(
err = %e,
"consolidate serialize failed; skipping consolidate-queue publish"
);
return;
}
};
if let Err(e) = storage.publish(scope, CONSOLIDATE_QUEUE_TOPIC, 0, payload.into()).await {
tracing::warn!(
err = %e,
"consolidate-queue publish failed; ingest still succeeded"
);
}
}
fn graph_extract_per_session() -> bool {
parse_graph_extract_granularity(
std::env::var("LUNARIS_GRAPH_EXTRACT_GRANULARITY").ok().as_deref(),
)
}
fn parse_graph_extract_granularity(raw: Option<&str>) -> bool {
match raw {
None => false,
Some(raw) => match raw.trim().to_ascii_lowercase().as_str() {
"session" | "episode" | "doc" => true,
"chunk" | "chunks" | "" => false,
other => {
tracing::warn!(
value = %other,
"ignoring invalid LUNARIS_GRAPH_EXTRACT_GRANULARITY (want session|chunk); \
defaulting to chunk (per-chunk extraction)"
);
false
}
},
}
}
fn build_extract_inputs(
episode_id: Ulid,
episode_content: &str,
chunks: &[Chunk],
per_session: bool,
) -> Vec<ChunkInput> {
if per_session {
if episode_content.is_empty() {
return Vec::new();
}
vec![ChunkInput {
chunk_id: episode_id,
text: episode_content.to_string(),
heading_path: Vec::new(),
}]
} else {
chunks
.iter()
.map(|c| ChunkInput {
chunk_id: c.id,
text: c.text.clone(),
heading_path: c.heading_path.clone(),
})
.collect()
}
}
#[allow(clippy::too_many_arguments)]
async fn ingest_episode_graph_on(
storage: &dyn StoragePort,
embedder: &dyn Embedder,
graph_pipeline: &Arc<GraphPipelineHandle>,
clock: &HlcClock,
episode: Episode,
counter: std::sync::Arc<dyn TokenCounter + Send + Sync>,
bakeoff_config: Option<Arc<BakoffConfig>>,
per_session_extract: bool,
) -> Result<Lsn, LunarisError> {
let chunks: Vec<Chunk> = if let Some(ref cfg) = bakeoff_config {
let target_tokens = cfg.target_tokens;
let overlap_tokens = cfg.overlap_tokens;
let (_structural_drafts, heading_records): (Vec<ChunkDraft>, Vec<HeadingRecord>) =
chunk_markdown_with_headings_with_counter(
&episode.content,
target_tokens,
overlap_tokens,
counter.as_ref(),
);
let winner = run_bakeoff(
&episode.content,
heading_records,
cfg,
embedder,
counter.as_ref(),
target_tokens,
overlap_tokens,
)
.await?;
let mut out: Vec<Chunk> = Vec::with_capacity(winner.drafts.len());
for (draft, embedding) in winner.drafts.into_iter().zip(winner.embeddings.into_iter()) {
let mut c = draft.into_chunk(episode.scope.clone(), episode.id, clock);
c.embedding = Some(embedding);
out.push(c);
}
out
} else {
let drafts = chunk_markdown_with_counter(
&episode.content,
DEFAULT_TARGET_TOKENS,
DEFAULT_OVERLAP_TOKENS,
counter.as_ref(),
);
let embeddings = embed_with_fallback(embedder, &drafts).await?;
debug_assert_eq!(embeddings.len(), drafts.len());
let mut out: Vec<Chunk> = Vec::with_capacity(drafts.len());
for (draft, embedding) in drafts.into_iter().zip(embeddings.into_iter()) {
let mut c = draft.into_chunk(episode.scope.clone(), episode.id, clock);
c.embedding = Some(embedding);
out.push(c);
}
out
};
let extractor = graph_pipeline.snapshot_extractor().ok_or_else(|| {
LunarisError::Storage(StorageError::Backend(
"graph_pipeline enabled but no extractor installed".into(),
))
})?;
let validated: ValidatedExtraction = if extractor.applies() {
let chunk_inputs: Vec<ChunkInput> =
build_extract_inputs(episode.id, &episode.content, &chunks, per_session_extract);
let extract_span = tracing::info_span!(
"lunaris.extract",
correlation_id = tracing::field::Empty,
episode_id = %episode.id,
chunk_count = chunk_inputs.len(),
);
let raw = extractor.extract(episode.id, &chunk_inputs).instrument(extract_span).await?;
validate(raw)
} else {
ValidatedExtraction::default()
};
let cap = 1
+ 2 * chunks.len()
+ 2 * validated.entities.len()
+ validated.relations.len()
+ 2 * validated.facts.len();
let mut ops: Vec<WriteOp> = Vec::with_capacity(cap);
let episode_value = serde_json::to_vec(&episode).map_err(|e| {
LunarisError::Storage(StorageError::Backend(format!("episode serialize: {e}")))
})?;
ops.push(WriteOp::KvPut { key: episode_key(&episode.scope, episode.id), value: episode_value });
for chunk in &chunks {
let chunk_value = serde_json::to_vec(chunk).map_err(|e| {
LunarisError::Storage(StorageError::Backend(format!("chunk serialize: {e}")))
})?;
ops.push(WriteOp::KvPut { key: chunk_key(&episode.scope, chunk.id), value: chunk_value });
let embedding = chunk.embedding.as_ref().expect("embedding assigned in step 2").clone();
ops.push(WriteOp::VectorUpsert {
index: CHUNK_VECTOR_INDEX.into(),
id: chunk.id.to_bytes().to_vec(),
embedding,
metadata: json!({
"episode_id": chunk.episode_id.to_string(),
"heading_path": chunk.heading_path,
"offset": chunk.offset,
"text": chunk.text,
"valid_time_ms": chunk.bt.valid.0.wall_ms,
"source": &episode.source,
}),
});
}
let embedder_dim = embedder.dim(); for e in &validated.entities {
let id_bytes = e.id.0.to_vec();
ops.push(WriteOp::GraphNode {
graph: GRAPH_NAME.into(),
id: id_bytes.clone(),
label: sanitize_graph_ident(&e.entity_type, "Entity"),
props: json!({
"id_hex": format!("{}", e.id),
"name": e.name,
"type": e.entity_type,
"aliases": e.aliases,
"confidence": e.confidence,
"valid_from_iso": e.valid_from_iso,
"valid_to_iso": e.valid_to_iso,
}),
});
let stub_embedding = det_vec(&e.name, embedder_dim);
ops.push(WriteOp::VectorUpsert {
index: ENTITIES_INDEX.into(),
id: id_bytes,
embedding: stub_embedding,
metadata: json!({"entity_type": e.entity_type, "name": e.name}),
});
}
for r in &validated.relations {
ops.push(WriteOp::GraphEdge {
graph: GRAPH_NAME.into(),
src: r.subject_id.0.to_vec(),
dst: r.object_id.0.to_vec(),
rel: sanitize_graph_ident(&r.predicate, "RELATED_TO"),
props: json!({
"confidence": r.confidence,
"valid_from_iso": r.valid_from_iso,
"valid_to_iso": r.valid_to_iso,
}),
});
}
for f in &validated.facts {
let fact_value = serde_json::to_vec(f).map_err(|err| {
LunarisError::Storage(StorageError::Backend(format!("fact serialize: {err}")))
})?;
ops.push(WriteOp::KvPut { key: scoped_fact_key(&episode.scope, f.id), value: fact_value });
let stub_embedding = det_vec(&f.fact_text, embedder_dim);
ops.push(WriteOp::VectorUpsert {
index: FACTS_INDEX.into(),
id: f.id.to_bytes().to_vec(),
embedding: stub_embedding,
metadata: json!({"predicate": f.predicate, "fact_text": f.fact_text}),
});
}
let lsn = storage.atomic_write(&episode.scope, &ops).await?;
publish_needs_review(storage, &episode.scope, &validated.needs_review).await;
Ok(lsn)
}
async fn embed_with_fallback(
embedder: &dyn Embedder,
drafts: &[ChunkDraft],
) -> Result<Vec<Vec<f32>>, LunarisError> {
let mut out: Vec<Vec<f32>> = Vec::with_capacity(drafts.len());
for batch in drafts.chunks(EMBED_BATCH_SIZE) {
let texts: Vec<&str> = batch.iter().map(|d| d.text.as_str()).collect();
match embedder.embed_batch(&texts).await {
Ok(rows) if rows.len() == texts.len() => out.extend(rows),
Ok(rows) => {
tracing::warn!(
expected = texts.len(),
got = rows.len(),
"embed_batch returned wrong row count; falling back to per-chunk"
);
for text in &texts {
let single = embedder.embed_batch(&[text]).await?;
out.push(single.into_iter().next().ok_or_else(|| {
LunarisError::Storage(StorageError::Backend(
"embed_batch returned 0 rows for single input".into(),
))
})?);
}
}
Err(batch_err) => {
tracing::warn!(
err = %batch_err,
batch_size = texts.len(),
"embed_batch failed; falling back to per-chunk"
);
for text in &texts {
let single = embedder.embed_batch(&[text]).await?;
out.push(single.into_iter().next().ok_or_else(|| {
LunarisError::Storage(StorageError::Backend(
"embed_batch returned 0 rows for single input".into(),
))
})?);
}
}
}
}
Ok(out)
}
pub(crate) async fn publish_needs_review(
storage: &dyn StoragePort,
scope: &lunaris_core::Scope,
items: &[NeedsReviewItem],
) {
if !storage.capabilities().queue_native {
tracing::debug!("verify queue unavailable; skipping verify-queue publish");
return;
}
for item in items {
let envelope = needs_review_envelope(item);
let payload = match serde_json::to_vec(&envelope) {
Ok(b) => b,
Err(e) => {
tracing::warn!(err = %e, "needs_review serialize failed; skipping verify-queue publish");
continue;
}
};
if let Err(e) = storage.publish(scope, VERIFY_QUEUE_TOPIC, 0, payload.into()).await {
tracing::warn!(err = %e, "verify-queue publish failed; ingest still succeeded");
}
}
}
fn needs_review_envelope(item: &NeedsReviewItem) -> serde_json::Value {
match item {
NeedsReviewItem::Entity { reason, raw } => json!({
"kind": "entity",
"item": { "reason": reason, "raw": raw },
}),
NeedsReviewItem::Relation { reason, raw } => json!({
"kind": "relation",
"item": { "reason": reason, "raw": raw },
}),
NeedsReviewItem::Fact { reason, raw } => json!({
"kind": "fact",
"item": { "reason": reason, "raw": raw },
}),
}
}
fn det_vec(text: &str, dim: usize) -> Vec<f32> {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut h = DefaultHasher::new();
text.hash(&mut h);
let mut state = h.finish().max(1);
let mut v = Vec::with_capacity(dim);
for _ in 0..dim {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let bits = (state >> 33) as u32;
v.push(((bits as f32) / (u32::MAX as f32)) - 0.5);
}
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-12);
for x in &mut v {
*x /= norm;
}
v
}
#[cfg(test)]
mod graph_granularity_tests {
use super::{build_extract_inputs, parse_graph_extract_granularity};
use lunaris_core::{Chunk, HlcClock, Scope};
use ulid::Ulid;
fn chunk(text: &str) -> Chunk {
let clock = HlcClock::new(1);
Chunk::new(Scope::dev(), Ulid::new(), text, 10, 0, vec!["h".into()], &clock)
}
#[test]
fn parse_granularity_maps_session_aliases_to_true_and_defaults_to_chunk() {
assert!(parse_graph_extract_granularity(Some("session")));
assert!(parse_graph_extract_granularity(Some(" Episode ")));
assert!(parse_graph_extract_granularity(Some("DOC")));
assert!(!parse_graph_extract_granularity(Some("chunk")));
assert!(!parse_graph_extract_granularity(Some("chunks")));
assert!(!parse_graph_extract_granularity(Some("")));
assert!(!parse_graph_extract_granularity(None));
assert!(!parse_graph_extract_granularity(Some("nonsense")));
}
#[test]
fn per_session_yields_one_whole_session_input_not_per_chunk() {
let eid = Ulid::new();
let content = "user: hi\n\nassistant: hello\n\nuser: bye";
let chunks = vec![chunk("user: hi"), chunk("assistant: hello"), chunk("user: bye")];
let inputs = build_extract_inputs(eid, content, &chunks, true);
assert_eq!(inputs.len(), 1, "per-session must emit exactly one extractor input");
assert_eq!(inputs[0].chunk_id, eid, "the single input is keyed by episode id");
assert_eq!(inputs[0].text, content, "the single input carries the whole session text");
assert!(inputs[0].heading_path.is_empty());
}
#[test]
fn per_session_empty_content_skips_extraction() {
let inputs = build_extract_inputs(Ulid::new(), "", &[], true);
assert!(inputs.is_empty(), "empty episode content must skip the extractor");
}
#[test]
fn per_chunk_yields_one_input_per_chunk_preserving_ids_and_text() {
let chunks = vec![chunk("alpha"), chunk("beta"), chunk("gamma")];
let inputs = build_extract_inputs(Ulid::new(), "alpha beta gamma", &chunks, false);
assert_eq!(inputs.len(), 3, "per-chunk must emit one input per chunk");
for (inp, ch) in inputs.iter().zip(chunks.iter()) {
assert_eq!(inp.chunk_id, ch.id);
assert_eq!(inp.text, ch.text);
assert_eq!(inp.heading_path, ch.heading_path);
}
}
}