2026-08-24 01:37:16 +00:00
|
|
|
use anyhow::Result;
|
2026-09-08 23:16:31 +00:00
|
|
|
use mem_store::{MemoryL1, VectorStore, ChunkL0, EntityRepoOps, EdgeRepoOps};
|
2026-08-24 01:37:16 +00:00
|
|
|
use mem_llm::EmbeddingsClient;
|
2026-09-08 23:16:31 +00:00
|
|
|
use mem_ingest::ingest_pipeline::{IngestPipeline, Episode};
|
2026-09-11 01:11:15 +00:00
|
|
|
use mem_ingest::entity_extractor::{WikiLinkFallbackExtractor, LlmEntityExtractor};
|
|
|
|
|
use mem_ingest::fact_extractor::{SimpleFactExtractor, LlmFactExtractor};
|
2026-09-08 23:16:31 +00:00
|
|
|
use mem_ingest::contradiction_detector::ContradictionHandler;
|
2026-08-24 01:37:16 +00:00
|
|
|
use sqlx::PgPool;
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use pgvector::Vector;
|
|
|
|
|
|
2026-09-15 13:27:47 +09:00
|
|
|
/// Job status enumeration — type-safe alternative to magic strings
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum JobStatus {
|
|
|
|
|
Processing,
|
|
|
|
|
Done,
|
|
|
|
|
DoneWithErrors,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl JobStatus {
|
|
|
|
|
pub fn as_str(&self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
JobStatus::Processing => "processing",
|
|
|
|
|
JobStatus::Done => "done",
|
|
|
|
|
JobStatus::DoneWithErrors => "done_with_errors",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Display for JobStatus {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
write!(f, "{}", self.as_str())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 13:30:19 +09:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
/// Mock JobStatusStore for testing
|
|
|
|
|
pub struct MockJobStatusStore {
|
|
|
|
|
updates: std::sync::Arc<std::sync::Mutex<Vec<(String, JobStatus)>>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MockJobStatusStore {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
updates: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn updates(&self) -> Vec<(String, JobStatus)> {
|
|
|
|
|
self.updates.lock().unwrap().clone()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[async_trait::async_trait]
|
|
|
|
|
impl JobStatusStore for MockJobStatusStore {
|
|
|
|
|
async fn update_status(&self, ingest_id: &str, status: JobStatus) -> Result<()> {
|
|
|
|
|
self.updates.lock().unwrap().push((ingest_id.to_string(), status));
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 13:27:47 +09:00
|
|
|
/// Structured logging context for ingest operations — ensures consistent field names across all logs
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct IngestLogContext {
|
|
|
|
|
pub ingest_id: String,
|
|
|
|
|
pub project: String,
|
|
|
|
|
pub record_id: String,
|
|
|
|
|
pub source: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl IngestLogContext {
|
|
|
|
|
fn new(ingest_id: &str, project: &str, record_id: &str, source: &str) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
ingest_id: ingest_id.to_string(),
|
|
|
|
|
project: project.to_string(),
|
|
|
|
|
record_id: record_id.to_string(),
|
|
|
|
|
source: source.to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 13:30:19 +09:00
|
|
|
/// Job status store trait — abstracts database persistence of job status (enables mocking)
|
|
|
|
|
#[async_trait::async_trait]
|
|
|
|
|
pub trait JobStatusStore: Send + Sync {
|
|
|
|
|
/// Update job status in storage
|
|
|
|
|
async fn update_status(&self, ingest_id: &str, status: JobStatus) -> Result<()>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// PostgreSQL implementation of JobStatusStore
|
|
|
|
|
pub struct PgJobStatusStore {
|
|
|
|
|
pool: PgPool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PgJobStatusStore {
|
|
|
|
|
pub fn new(pool: PgPool) -> Self {
|
|
|
|
|
Self { pool }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[async_trait::async_trait]
|
|
|
|
|
impl JobStatusStore for PgJobStatusStore {
|
|
|
|
|
async fn update_status(&self, ingest_id: &str, status: JobStatus) -> Result<()> {
|
|
|
|
|
sqlx::query("UPDATE ingest_jobs SET status=$1, started_at=NOW() WHERE ingest_id=$2")
|
|
|
|
|
.bind(status.as_str())
|
|
|
|
|
.bind(ingest_id)
|
|
|
|
|
.execute(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 01:11:15 +00:00
|
|
|
|
2026-09-08 23:16:31 +00:00
|
|
|
/// Ingest worker — processes queued records through entity/fact extraction pipeline
|
2026-08-24 01:37:16 +00:00
|
|
|
pub struct IngestWorker {
|
|
|
|
|
pool: PgPool,
|
|
|
|
|
vector_store: Arc<VectorStore>,
|
|
|
|
|
embeddings: Arc<EmbeddingsClient>,
|
2026-09-08 23:16:31 +00:00
|
|
|
pipeline: Arc<IngestPipeline>,
|
2026-09-15 13:30:19 +09:00
|
|
|
job_status_store: Arc<dyn JobStatusStore>,
|
2026-08-24 01:37:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl IngestWorker {
|
2026-09-08 23:16:31 +00:00
|
|
|
/// Create worker with full ingest pipeline
|
2026-08-24 01:37:16 +00:00
|
|
|
pub fn new(
|
|
|
|
|
pool: PgPool,
|
|
|
|
|
embeddings: EmbeddingsClient,
|
2026-09-15 13:30:19 +09:00
|
|
|
) -> Self {
|
|
|
|
|
let job_status_store = Arc::new(PgJobStatusStore::new(pool.clone()));
|
|
|
|
|
Self::with_job_store(pool, embeddings, job_status_store)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create worker with custom job status store (for testing)
|
|
|
|
|
pub fn with_job_store(
|
|
|
|
|
pool: PgPool,
|
|
|
|
|
embeddings: EmbeddingsClient,
|
|
|
|
|
job_status_store: Arc<dyn JobStatusStore>,
|
2026-08-24 01:37:16 +00:00
|
|
|
) -> Self {
|
|
|
|
|
let vector_store = Arc::new(VectorStore::new(pool.clone()));
|
2026-09-08 23:16:31 +00:00
|
|
|
|
2026-09-11 01:11:15 +00:00
|
|
|
// Initialize extraction pipeline — use LLM if LLM_ENDPOINT is set, else fallback to wiki links
|
2026-09-08 23:16:31 +00:00
|
|
|
let entity_extractor: Arc<dyn mem_ingest::entity_extractor::EntityExtractor> =
|
2026-09-11 01:11:15 +00:00
|
|
|
if std::env::var("LLM_ENDPOINT").is_ok() {
|
|
|
|
|
let model = std::env::var("LLM_MODEL").unwrap_or_else(|_| "qwen2.5:3b-instruct".to_string());
|
|
|
|
|
tracing::info!("Using LLM entity extractor: model={}", model);
|
|
|
|
|
Arc::new(LlmEntityExtractor::new(&model))
|
|
|
|
|
} else {
|
|
|
|
|
tracing::info!("LLM_ENDPOINT not set, using WikiLink fallback extractor");
|
|
|
|
|
Arc::new(WikiLinkFallbackExtractor)
|
|
|
|
|
};
|
2026-09-08 23:16:31 +00:00
|
|
|
let fact_extractor: Arc<dyn mem_ingest::fact_extractor::FactExtractor> =
|
2026-09-11 01:11:15 +00:00
|
|
|
if std::env::var("LLM_ENDPOINT").is_ok() {
|
|
|
|
|
let model = std::env::var("LLM_MODEL").unwrap_or_else(|_| "qwen2.5:3b-instruct".to_string());
|
|
|
|
|
tracing::info!("Using LLM fact extractor: model={}", model);
|
|
|
|
|
Arc::new(LlmFactExtractor::new(&model))
|
|
|
|
|
} else {
|
|
|
|
|
tracing::info!("LLM_ENDPOINT not set, using simple pattern fact extractor");
|
|
|
|
|
Arc::new(SimpleFactExtractor)
|
|
|
|
|
};
|
2026-09-08 23:16:31 +00:00
|
|
|
let contradiction_detector = Arc::new(ContradictionHandler::default());
|
|
|
|
|
let pipeline = Arc::new(IngestPipeline::new(
|
|
|
|
|
entity_extractor,
|
|
|
|
|
fact_extractor,
|
|
|
|
|
contradiction_detector,
|
|
|
|
|
));
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
Self {
|
|
|
|
|
pool,
|
|
|
|
|
vector_store,
|
|
|
|
|
embeddings: Arc::new(embeddings),
|
2026-09-08 23:16:31 +00:00
|
|
|
pipeline,
|
2026-09-15 13:30:19 +09:00
|
|
|
job_status_store,
|
2026-08-24 01:37:16 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 13:27:47 +09:00
|
|
|
/// Process ingest job with optional X-Forward-User auth header (API Gateway pattern)
|
|
|
|
|
///
|
|
|
|
|
/// # Arguments
|
|
|
|
|
/// * `project` - Project ID for namespacing
|
|
|
|
|
/// * `ingest_id` - Unique ingest job ID
|
|
|
|
|
/// * `records` - Vec of (content, source) tuples
|
|
|
|
|
/// * `x_forward_user` - Optional X-Forward-User header from API Gateway (None for backward compat)
|
2026-09-14 23:49:27 +09:00
|
|
|
pub async fn process_ingest_with_auth(
|
|
|
|
|
&self,
|
|
|
|
|
project: &str,
|
|
|
|
|
ingest_id: &str,
|
|
|
|
|
records: Vec<(String, String)>, // (content, source)
|
|
|
|
|
x_forward_user: Option<String>,
|
2026-08-24 01:37:16 +00:00
|
|
|
) -> Result<()> {
|
2026-09-14 22:33:16 +09:00
|
|
|
tracing::info!(
|
|
|
|
|
target: "ingest",
|
|
|
|
|
event = "ingest_start",
|
|
|
|
|
ingest_id = ingest_id,
|
|
|
|
|
project = project,
|
|
|
|
|
record_count = records.len(),
|
|
|
|
|
"Starting ingest job"
|
|
|
|
|
);
|
2026-08-24 01:37:16 +00:00
|
|
|
|
2026-09-15 13:30:19 +09:00
|
|
|
// Update job status to processing (via trait, testable)
|
|
|
|
|
if let Err(e) = self.job_status_store.update_status(ingest_id, JobStatus::Processing).await {
|
2026-09-14 22:33:16 +09:00
|
|
|
tracing::error!(
|
|
|
|
|
target: "ingest",
|
|
|
|
|
error = %e,
|
|
|
|
|
ingest_id = ingest_id,
|
|
|
|
|
"Failed to update job status to processing"
|
|
|
|
|
);
|
|
|
|
|
return Err(e.into());
|
|
|
|
|
}
|
2026-08-24 01:37:16 +00:00
|
|
|
|
2026-09-08 23:16:31 +00:00
|
|
|
let mut total_entities = 0;
|
|
|
|
|
let mut total_edges = 0;
|
|
|
|
|
let mut total_reviews = 0;
|
2026-08-24 01:37:16 +00:00
|
|
|
|
2026-09-08 23:16:31 +00:00
|
|
|
// Process each record through the ingest pipeline
|
|
|
|
|
for (idx, (content, source)) in records.iter().enumerate() {
|
2026-09-14 22:33:16 +09:00
|
|
|
let record_id = format!("{}-{}", ingest_id, idx);
|
2026-09-15 13:27:47 +09:00
|
|
|
let log_ctx = IngestLogContext::new(ingest_id, project, &record_id, source);
|
|
|
|
|
|
2026-09-14 22:33:16 +09:00
|
|
|
tracing::debug!(
|
|
|
|
|
target: "ingest",
|
2026-09-15 13:27:47 +09:00
|
|
|
record_id = %log_ctx.record_id,
|
|
|
|
|
source = %log_ctx.source,
|
2026-09-14 22:33:16 +09:00
|
|
|
content_len = content.len(),
|
|
|
|
|
"Processing record"
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-08 23:16:31 +00:00
|
|
|
// Create episode from record
|
|
|
|
|
let episode = Episode {
|
2026-09-14 22:33:16 +09:00
|
|
|
id: record_id.clone(),
|
2026-09-08 23:16:31 +00:00
|
|
|
project_id: project.to_string(),
|
|
|
|
|
text: content.clone(),
|
|
|
|
|
wiki_links: extract_wiki_links(content),
|
2026-08-24 01:37:16 +00:00
|
|
|
};
|
|
|
|
|
|
2026-09-08 23:16:31 +00:00
|
|
|
// Run extraction pipeline (entity + fact extraction + contradiction detection)
|
2026-09-14 23:49:27 +09:00
|
|
|
let x_forward_user_ref = x_forward_user.as_deref();
|
|
|
|
|
match self.pipeline.ingest_with_auth(&episode, x_forward_user_ref).await {
|
2026-09-08 23:16:31 +00:00
|
|
|
Ok(result) => {
|
|
|
|
|
tracing::debug!(
|
2026-09-14 22:33:16 +09:00
|
|
|
target: "ingest",
|
2026-09-15 13:27:47 +09:00
|
|
|
record_id = %log_ctx.record_id,
|
2026-09-14 22:33:16 +09:00
|
|
|
entity_count = result.entities.len(),
|
|
|
|
|
edge_count = result.edges.len(),
|
|
|
|
|
review_count = result.reviews.len(),
|
|
|
|
|
"Pipeline extraction successful"
|
2026-09-08 23:16:31 +00:00
|
|
|
);
|
2026-08-24 01:37:16 +00:00
|
|
|
|
2026-09-15 13:27:47 +09:00
|
|
|
// Save entities to database via helper fn
|
2026-09-08 23:16:31 +00:00
|
|
|
for entity in &result.entities {
|
2026-09-15 13:27:47 +09:00
|
|
|
match save_entity_with_logging(&self.pool, entity, &log_ctx).await {
|
|
|
|
|
Ok(saved) => if saved { total_entities += 1; }
|
|
|
|
|
Err(_) => { /* error already logged */ }
|
2026-09-08 23:16:31 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 13:27:47 +09:00
|
|
|
// Save edges to database via helper fn
|
2026-09-08 23:16:31 +00:00
|
|
|
for edge in &result.edges {
|
2026-09-15 13:27:47 +09:00
|
|
|
match save_edge_with_logging(&self.pool, edge, &log_ctx).await {
|
|
|
|
|
Ok(saved) => if saved { total_edges += 1; }
|
|
|
|
|
Err(_) => { /* error already logged */ }
|
2026-09-08 23:16:31 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
total_reviews += result.reviews.len();
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
2026-09-14 22:33:16 +09:00
|
|
|
tracing::error!(
|
|
|
|
|
target: "ingest",
|
|
|
|
|
error = %e,
|
2026-09-15 13:27:47 +09:00
|
|
|
record_id = %log_ctx.record_id,
|
|
|
|
|
source = %log_ctx.source,
|
2026-09-14 22:33:16 +09:00
|
|
|
"Pipeline extraction failed"
|
|
|
|
|
);
|
2026-09-15 13:27:47 +09:00
|
|
|
// Continue processing other records (no error accumulation)
|
2026-08-24 01:37:16 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 13:30:19 +09:00
|
|
|
// Mark job complete (via trait, testable)
|
2026-09-15 13:27:47 +09:00
|
|
|
let final_status = JobStatus::Done;
|
2026-09-15 13:30:19 +09:00
|
|
|
if let Err(e) = self.job_status_store.update_status(ingest_id, final_status).await {
|
2026-09-14 22:33:16 +09:00
|
|
|
tracing::error!(
|
|
|
|
|
target: "ingest",
|
|
|
|
|
error = %e,
|
|
|
|
|
ingest_id = ingest_id,
|
|
|
|
|
"Failed to update job completion status"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-24 01:37:16 +00:00
|
|
|
|
2026-09-08 23:16:31 +00:00
|
|
|
tracing::info!(
|
2026-09-14 22:33:16 +09:00
|
|
|
target: "ingest",
|
2026-09-11 01:11:15 +00:00
|
|
|
event = "ingest_complete",
|
|
|
|
|
ingest_id = ingest_id,
|
2026-09-14 22:33:16 +09:00
|
|
|
project = project,
|
2026-09-11 01:11:15 +00:00
|
|
|
entities = total_entities,
|
|
|
|
|
edges = total_edges,
|
|
|
|
|
reviews = total_reviews,
|
2026-09-15 13:27:47 +09:00
|
|
|
status = final_status.as_str(),
|
2026-09-14 22:33:16 +09:00
|
|
|
"Ingest job completed"
|
2026-09-08 23:16:31 +00:00
|
|
|
);
|
2026-09-11 01:11:15 +00:00
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Process a single chunk
|
|
|
|
|
pub async fn process_chunk(&self, project: &str, query_id: &str, content: &str, source: &str) -> Result<()> {
|
2026-08-28 07:47:26 -07:00
|
|
|
let embedding = self.embeddings.embed_one(content).await?;
|
2026-08-24 01:37:16 +00:00
|
|
|
let chunk = ChunkL0 {
|
|
|
|
|
id: Uuid::new_v4(),
|
|
|
|
|
project: project.to_string(),
|
|
|
|
|
query_id: query_id.to_string(),
|
|
|
|
|
source: source.to_string(),
|
|
|
|
|
content: content.to_string(),
|
|
|
|
|
tokens: (content.len() / 4) as i32,
|
|
|
|
|
};
|
|
|
|
|
self.vector_store.store_chunk_l0(&chunk).await?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-08 23:16:31 +00:00
|
|
|
|
|
|
|
|
/// Extract wiki links from text (e.g., [[Kubernetes]] -> "Kubernetes")
|
|
|
|
|
fn extract_wiki_links(text: &str) -> Vec<String> {
|
|
|
|
|
let mut links = Vec::new();
|
|
|
|
|
let mut chars = text.chars().peekable();
|
|
|
|
|
|
|
|
|
|
while let Some(ch) = chars.next() {
|
|
|
|
|
if ch == '[' && chars.peek() == Some(&'[') {
|
|
|
|
|
chars.next(); // consume second '['
|
|
|
|
|
let mut link = String::new();
|
|
|
|
|
while let Some(c) = chars.next() {
|
|
|
|
|
if c == ']' && chars.peek() == Some(&']') {
|
|
|
|
|
chars.next(); // consume second ']'
|
|
|
|
|
links.push(link);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
link.push(c);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
links
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 13:27:47 +09:00
|
|
|
/// Save entity with logging — logs at debug level on success, warn on error
|
|
|
|
|
/// Returns Ok(true) if saved, Ok(false) if skipped, Err if fatal error
|
|
|
|
|
async fn save_entity_with_logging(
|
|
|
|
|
pool: &PgPool,
|
|
|
|
|
entity: &mem_core::entity::Entity,
|
|
|
|
|
log_ctx: &IngestLogContext,
|
|
|
|
|
) -> Result<bool> {
|
|
|
|
|
match save_entity_to_db(pool, entity).await {
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
target: "ingest",
|
|
|
|
|
record_id = %log_ctx.record_id,
|
|
|
|
|
entity_name = &entity.name,
|
|
|
|
|
entity_type = entity.entity_type.as_str(),
|
|
|
|
|
"Saved entity"
|
|
|
|
|
);
|
|
|
|
|
Ok(true)
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
target: "ingest",
|
|
|
|
|
error = %e,
|
|
|
|
|
record_id = %log_ctx.record_id,
|
|
|
|
|
entity_name = &entity.name,
|
|
|
|
|
project = %log_ctx.project,
|
|
|
|
|
"Entity save failed"
|
|
|
|
|
);
|
|
|
|
|
// Return Ok(false) to allow processing to continue; don't panic
|
|
|
|
|
Ok(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 13:30:19 +09:00
|
|
|
/// Save entity to database via raw SQL (normally would use EntityRepo trait)
|
|
|
|
|
/// NOTE: async_trait requires manual implementation for non-trait functions
|
2026-09-08 23:16:31 +00:00
|
|
|
async fn save_entity_to_db(pool: &PgPool, entity: &mem_core::entity::Entity) -> Result<()> {
|
|
|
|
|
// Convert OffsetDateTime to PostgreSQL timestamp format
|
|
|
|
|
let t_created_str = entity.t_created.to_string();
|
|
|
|
|
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"INSERT INTO memory_entity (id, project_id, name, entity_type, description, t_created, t_updated, confidence)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6::TIMESTAMPTZ, $7::TIMESTAMPTZ, $8)
|
2026-09-11 01:11:15 +00:00
|
|
|
ON CONFLICT (project_id, name) DO UPDATE SET
|
|
|
|
|
entity_type = EXCLUDED.entity_type,
|
|
|
|
|
description = COALESCE(NULLIF(EXCLUDED.description, ''), memory_entity.description),
|
|
|
|
|
t_updated = NOW(),
|
|
|
|
|
confidence = GREATEST(memory_entity.confidence, EXCLUDED.confidence),
|
|
|
|
|
source_count = memory_entity.source_count + 1"
|
2026-09-08 23:16:31 +00:00
|
|
|
)
|
|
|
|
|
.bind(&entity.id)
|
|
|
|
|
.bind(&entity.project_id)
|
|
|
|
|
.bind(&entity.name)
|
|
|
|
|
.bind(entity.entity_type.as_str())
|
|
|
|
|
.bind(entity.summary.as_deref())
|
|
|
|
|
.bind(&t_created_str)
|
|
|
|
|
.bind(&t_created_str)
|
|
|
|
|
.bind(1.0_f32) // default confidence
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 13:27:47 +09:00
|
|
|
/// Save edge with logging — logs at debug level on success, warn on error
|
|
|
|
|
/// Returns Ok(true) if saved, Ok(false) if skipped, Err if fatal error
|
|
|
|
|
async fn save_edge_with_logging(
|
|
|
|
|
pool: &PgPool,
|
|
|
|
|
edge: &mem_core::edge::Edge,
|
|
|
|
|
log_ctx: &IngestLogContext,
|
|
|
|
|
) -> Result<bool> {
|
|
|
|
|
match save_edge_to_db(pool, edge).await {
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
target: "ingest",
|
|
|
|
|
record_id = %log_ctx.record_id,
|
|
|
|
|
relation_type = &edge.relation_type,
|
|
|
|
|
source_entity = &edge.source_entity_id,
|
|
|
|
|
target_entity = &edge.target_entity_id,
|
|
|
|
|
"Saved edge"
|
|
|
|
|
);
|
|
|
|
|
Ok(true)
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
target: "ingest",
|
|
|
|
|
error = %e,
|
|
|
|
|
record_id = %log_ctx.record_id,
|
|
|
|
|
relation_type = &edge.relation_type,
|
|
|
|
|
project = %log_ctx.project,
|
|
|
|
|
"Edge save failed"
|
|
|
|
|
);
|
|
|
|
|
// Return Ok(false) to allow processing to continue
|
|
|
|
|
Ok(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 23:16:31 +00:00
|
|
|
/// Save edge to database via raw SQL (normally would use EdgeRepo trait)
|
|
|
|
|
/// NOTE: Production DB may have old schema. Gracefully skip if temporal columns missing.
|
|
|
|
|
async fn save_edge_to_db(pool: &PgPool, edge: &mem_core::edge::Edge) -> Result<()> {
|
|
|
|
|
// Try temporal schema first (id, project_id, source_entity_id, etc)
|
|
|
|
|
let result = sqlx::query(
|
2026-09-11 01:11:15 +00:00
|
|
|
"INSERT INTO memory_edge (id, project_id, source_id, target_id, relation_type, fact, t_valid, t_invalid, t_created, confidence)
|
2026-09-08 23:16:31 +00:00
|
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7::TIMESTAMPTZ, $8::TIMESTAMPTZ, $9::TIMESTAMPTZ, $10)
|
|
|
|
|
ON CONFLICT (id) DO NOTHING"
|
|
|
|
|
)
|
|
|
|
|
.bind(&edge.id)
|
|
|
|
|
.bind(&edge.project_id)
|
|
|
|
|
.bind(&edge.source_entity_id)
|
|
|
|
|
.bind(&edge.target_entity_id)
|
|
|
|
|
.bind(&edge.relation_type)
|
|
|
|
|
.bind(&edge.fact)
|
|
|
|
|
.bind(edge.t_valid.map(|t| t.to_string()))
|
|
|
|
|
.bind(edge.t_invalid.map(|t| t.to_string()))
|
|
|
|
|
.bind(edge.t_created.to_string())
|
|
|
|
|
.bind(edge.confidence)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match result {
|
|
|
|
|
Ok(_) => Ok(()),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::debug!("Temporal edge schema not available: {}. Skipping edge save (will be available after schema migration).", e);
|
|
|
|
|
// This is expected if production DB hasn't migrated to temporal schema yet
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|