open-notebook/commands/embedding_commands.py
Luis Novo 301dd4e20a
fix: async insight creation to prevent transaction conflicts (#512)
Migrate insight creation to the command system with automatic retry logic
to prevent SurrealDB transaction conflicts during batch imports.

Changes:
- Add create_insight_command with retry logic for transaction conflicts
- Add run_transformation_command for async transformation execution
- Make Source.add_insight() fire-and-forget (returns command_id)
- Update POST /sources/{id}/insights to return 202 Accepted immediately
- Frontend polls command status until complete, then refreshes
- Auto-update notebook page icon when source gains insights
- Add i18n keys for insight generation feedback

Related to #489
2026-01-31 15:51:27 -03:00

773 lines
26 KiB
Python

import time
from typing import Dict, List, Literal, Optional
from loguru import logger
from pydantic import BaseModel
from surreal_commands import CommandInput, CommandOutput, command, submit_command
from open_notebook.ai.models import model_manager
from open_notebook.database.repository import ensure_record_id, repo_insert, repo_query
from open_notebook.domain.notebook import Note, Source, SourceInsight
from open_notebook.utils.chunking import ContentType, chunk_text, detect_content_type
from open_notebook.utils.embedding import generate_embedding, generate_embeddings
def full_model_dump(model):
if isinstance(model, BaseModel):
return model.model_dump()
elif isinstance(model, dict):
return {k: full_model_dump(v) for k, v in model.items()}
elif isinstance(model, list):
return [full_model_dump(item) for item in model]
else:
return model
class RebuildEmbeddingsInput(CommandInput):
mode: Literal["existing", "all"]
include_sources: bool = True
include_notes: bool = True
include_insights: bool = True
class RebuildEmbeddingsOutput(CommandOutput):
success: bool
total_items: int
jobs_submitted: int # Count of embedding commands submitted
failed_submissions: int # Count of items that failed to submit
sources_submitted: int = 0
notes_submitted: int = 0
insights_submitted: int = 0
processing_time: float
error_message: Optional[str] = None
# =============================================================================
# NEW EMBEDDING COMMANDS (Phase 3)
# =============================================================================
class CreateInsightInput(CommandInput):
"""Input for creating a source insight with automatic retry on conflicts."""
source_id: str
insight_type: str
content: str
class CreateInsightOutput(CommandOutput):
"""Output from insight creation command."""
success: bool
insight_id: Optional[str] = None
processing_time: float
error_message: Optional[str] = None
class EmbedNoteInput(CommandInput):
"""Input for embedding a single note."""
note_id: str
class EmbedNoteOutput(CommandOutput):
"""Output from note embedding command."""
success: bool
note_id: str
processing_time: float
error_message: Optional[str] = None
class EmbedInsightInput(CommandInput):
"""Input for embedding a single source insight."""
insight_id: str
class EmbedInsightOutput(CommandOutput):
"""Output from insight embedding command."""
success: bool
insight_id: str
processing_time: float
error_message: Optional[str] = None
class EmbedSourceInput(CommandInput):
"""Input for embedding a source (creates multiple chunk embeddings)."""
source_id: str
class EmbedSourceOutput(CommandOutput):
"""Output from source embedding command."""
success: bool
source_id: str
chunks_created: int
processing_time: float
error_message: Optional[str] = None
@command(
"embed_note",
app="open_notebook",
retry={
"max_attempts": 5,
"wait_strategy": "exponential_jitter",
"wait_min": 1,
"wait_max": 60,
"retry_on": [RuntimeError, ConnectionError, TimeoutError],
"retry_log_level": "debug",
},
)
async def embed_note_command(input_data: EmbedNoteInput) -> EmbedNoteOutput:
"""
Generate and store embedding for a single note.
Uses the unified embedding pipeline with automatic chunking and mean pooling
for notes that exceed the chunk size limit.
Flow:
1. Load Note by ID
2. Generate embedding via generate_embedding() (auto-chunks + mean pools if needed)
3. UPSERT note embedding in database
Retry Strategy:
- Retries up to 5 times for transient failures (RuntimeError, ConnectionError, TimeoutError)
- Uses exponential-jitter backoff (1-60s)
- Does NOT retry permanent failures (ValueError, authentication errors)
"""
start_time = time.time()
try:
logger.info(f"Starting embedding for note: {input_data.note_id}")
# 1. Load note
note = await Note.get(input_data.note_id)
if not note:
raise ValueError(f"Note '{input_data.note_id}' not found")
if not note.content or not note.content.strip():
raise ValueError(f"Note '{input_data.note_id}' has no content to embed")
# 2. Generate embedding (auto-chunks + mean pools if needed)
# Notes are typically markdown content
embedding = await generate_embedding(
note.content, content_type=ContentType.MARKDOWN
)
# 3. UPSERT embedding into note record
await repo_query(
"UPDATE $note_id SET embedding = $embedding",
{
"note_id": ensure_record_id(input_data.note_id),
"embedding": embedding,
},
)
processing_time = time.time() - start_time
logger.info(
f"Successfully embedded note {input_data.note_id} in {processing_time:.2f}s"
)
return EmbedNoteOutput(
success=True,
note_id=input_data.note_id,
processing_time=processing_time,
)
except RuntimeError:
logger.debug(
f"Transaction conflict for note {input_data.note_id} - will be retried"
)
raise
except (ConnectionError, TimeoutError) as e:
logger.debug(
f"Network/timeout error for note {input_data.note_id} ({type(e).__name__}: {e}) - will be retried"
)
raise
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"Failed to embed note {input_data.note_id}: {e}")
logger.exception(e)
return EmbedNoteOutput(
success=False,
note_id=input_data.note_id,
processing_time=processing_time,
error_message=str(e),
)
@command(
"embed_insight",
app="open_notebook",
retry={
"max_attempts": 5,
"wait_strategy": "exponential_jitter",
"wait_min": 1,
"wait_max": 60,
"retry_on": [RuntimeError, ConnectionError, TimeoutError],
"retry_log_level": "debug",
},
)
async def embed_insight_command(input_data: EmbedInsightInput) -> EmbedInsightOutput:
"""
Generate and store embedding for a single source insight.
Uses the unified embedding pipeline with automatic chunking and mean pooling
for insights that exceed the chunk size limit.
Flow:
1. Load SourceInsight by ID
2. Generate embedding via generate_embedding() (auto-chunks + mean pools if needed)
3. UPSERT insight embedding in database
Retry Strategy:
- Retries up to 5 times for transient failures (RuntimeError, ConnectionError, TimeoutError)
- Uses exponential-jitter backoff (1-60s)
- Does NOT retry permanent failures (ValueError, authentication errors)
"""
start_time = time.time()
try:
logger.info(f"Starting embedding for insight: {input_data.insight_id}")
# 1. Load insight
insight = await SourceInsight.get(input_data.insight_id)
if not insight:
raise ValueError(f"Insight '{input_data.insight_id}' not found")
if not insight.content or not insight.content.strip():
raise ValueError(
f"Insight '{input_data.insight_id}' has no content to embed"
)
# 2. Generate embedding (auto-chunks + mean pools if needed)
# Insights are typically markdown content (generated by LLM)
embedding = await generate_embedding(
insight.content, content_type=ContentType.MARKDOWN
)
# 3. UPSERT embedding into insight record
await repo_query(
"UPDATE $insight_id SET embedding = $embedding",
{
"insight_id": ensure_record_id(input_data.insight_id),
"embedding": embedding,
},
)
processing_time = time.time() - start_time
logger.info(
f"Successfully embedded insight {input_data.insight_id} in {processing_time:.2f}s"
)
return EmbedInsightOutput(
success=True,
insight_id=input_data.insight_id,
processing_time=processing_time,
)
except RuntimeError:
logger.debug(
f"Transaction conflict for insight {input_data.insight_id} - will be retried"
)
raise
except (ConnectionError, TimeoutError) as e:
logger.debug(
f"Network/timeout error for insight {input_data.insight_id} ({type(e).__name__}: {e}) - will be retried"
)
raise
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"Failed to embed insight {input_data.insight_id}: {e}")
logger.exception(e)
return EmbedInsightOutput(
success=False,
insight_id=input_data.insight_id,
processing_time=processing_time,
error_message=str(e),
)
@command(
"embed_source",
app="open_notebook",
retry={
"max_attempts": 5,
"wait_strategy": "exponential_jitter",
"wait_min": 1,
"wait_max": 60,
"retry_on": [RuntimeError, ConnectionError, TimeoutError],
"retry_log_level": "debug",
},
)
async def embed_source_command(input_data: EmbedSourceInput) -> EmbedSourceOutput:
"""
Generate and store embeddings for a source document.
Creates multiple chunk embeddings stored in the source_embedding table.
Uses content-type aware chunking based on file extension or content heuristics.
Flow:
1. Load Source by ID
2. DELETE existing source_embedding records for this source
3. Detect content type from file path or content
4. Chunk text using appropriate splitter
5. Generate embeddings for all chunks in a single API call
6. Bulk INSERT source_embedding records
Retry Strategy:
- Retries up to 5 times for transient failures (RuntimeError, ConnectionError, TimeoutError)
- Uses exponential-jitter backoff (1-60s)
- Does NOT retry permanent failures (ValueError, authentication errors)
"""
start_time = time.time()
try:
logger.info(f"Starting embedding for source: {input_data.source_id}")
# 1. Load source
source = await Source.get(input_data.source_id)
if not source:
raise ValueError(f"Source '{input_data.source_id}' not found")
if not source.full_text or not source.full_text.strip():
raise ValueError(f"Source '{input_data.source_id}' has no text to embed")
# 2. DELETE existing embeddings (idempotency)
logger.debug(f"Deleting existing embeddings for source {input_data.source_id}")
await repo_query(
"DELETE source_embedding WHERE source = $source_id",
{"source_id": ensure_record_id(input_data.source_id)},
)
# 3. Detect content type from file path if available
file_path = source.asset.file_path if source.asset else None
content_type = detect_content_type(source.full_text, file_path)
logger.debug(f"Detected content type: {content_type.value}")
# 4. Chunk text using appropriate splitter
chunks = chunk_text(source.full_text, content_type=content_type)
total_chunks = len(chunks)
# Log chunk statistics for debugging
chunk_sizes = [len(c) for c in chunks]
logger.info(
f"Created {total_chunks} chunks for source {input_data.source_id} "
f"(sizes: min={min(chunk_sizes) if chunk_sizes else 0}, "
f"max={max(chunk_sizes) if chunk_sizes else 0}, "
f"avg={sum(chunk_sizes)//len(chunk_sizes) if chunk_sizes else 0} chars)"
)
if total_chunks == 0:
raise ValueError("No chunks created after splitting text")
# 5. Generate embeddings for all chunks in single API call
logger.debug(f"Generating embeddings for {total_chunks} chunks")
embeddings = await generate_embeddings(chunks)
# Verify we got embeddings for all chunks
if len(embeddings) != len(chunks):
raise ValueError(
f"Embedding count mismatch: got {len(embeddings)} embeddings "
f"for {len(chunks)} chunks"
)
# 6. Bulk INSERT source_embedding records
records = [
{
"source": ensure_record_id(input_data.source_id),
"order": idx,
"content": chunk,
"embedding": embedding,
}
for idx, (chunk, embedding) in enumerate(zip(chunks, embeddings))
]
logger.debug(f"Inserting {len(records)} source_embedding records")
await repo_insert("source_embedding", records)
processing_time = time.time() - start_time
logger.info(
f"Successfully embedded source {input_data.source_id}: "
f"{total_chunks} chunks in {processing_time:.2f}s"
)
return EmbedSourceOutput(
success=True,
source_id=input_data.source_id,
chunks_created=total_chunks,
processing_time=processing_time,
)
except RuntimeError:
logger.debug(
f"Transaction conflict for source {input_data.source_id} - will be retried"
)
raise
except (ConnectionError, TimeoutError) as e:
logger.debug(
f"Network/timeout error for source {input_data.source_id} ({type(e).__name__}: {e}) - will be retried"
)
raise
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"Failed to embed source {input_data.source_id}: {e}")
logger.exception(e)
return EmbedSourceOutput(
success=False,
source_id=input_data.source_id,
chunks_created=0,
processing_time=processing_time,
error_message=str(e),
)
@command(
"create_insight",
app="open_notebook",
retry={
"max_attempts": 5,
"wait_strategy": "exponential_jitter",
"wait_min": 1,
"wait_max": 60,
"retry_on": [RuntimeError, ConnectionError, TimeoutError],
"retry_log_level": "debug",
},
)
async def create_insight_command(
input_data: CreateInsightInput,
) -> CreateInsightOutput:
"""
Create a source insight with automatic retry on transaction conflicts.
This command wraps the CREATE source_insight operation with retry logic
to handle SurrealDB transaction conflicts that occur during batch imports
when multiple parallel transformations try to create insights concurrently.
Flow:
1. CREATE source_insight record in database
2. Submit embed_insight command (fire-and-forget) for async embedding
3. Return the insight_id
Retry Strategy:
- Retries up to 5 times for transient failures (RuntimeError, ConnectionError, TimeoutError)
- Uses exponential-jitter backoff (1-60s)
- Does NOT retry permanent failures (ValueError, authentication errors)
"""
start_time = time.time()
try:
logger.info(
f"Creating insight for source {input_data.source_id}: "
f"type={input_data.insight_type}"
)
# 1. Create insight record in database
result = await repo_query(
"""
CREATE source_insight CONTENT {
"source": $source_id,
"insight_type": $insight_type,
"content": $content
};
""",
{
"source_id": ensure_record_id(input_data.source_id),
"insight_type": input_data.insight_type,
"content": input_data.content,
},
)
if not result or len(result) == 0:
raise ValueError("Failed to create insight - no result returned")
insight_id = str(result[0].get("id", ""))
if not insight_id:
raise ValueError("Failed to create insight - no ID in result")
# 2. Submit embedding command (fire-and-forget)
submit_command(
"open_notebook",
"embed_insight",
{"insight_id": insight_id},
)
logger.debug(f"Submitted embed_insight command for {insight_id}")
processing_time = time.time() - start_time
logger.info(
f"Successfully created insight {insight_id} for source "
f"{input_data.source_id} in {processing_time:.2f}s"
)
return CreateInsightOutput(
success=True,
insight_id=insight_id,
processing_time=processing_time,
)
except RuntimeError:
logger.debug(
f"Transaction conflict creating insight for source "
f"{input_data.source_id} - will retry"
)
raise
except (ConnectionError, TimeoutError) as e:
logger.debug(
f"Network/timeout error creating insight for source "
f"{input_data.source_id} ({type(e).__name__}: {e}) - will retry"
)
raise
except Exception as e:
processing_time = time.time() - start_time
logger.error(
f"Failed to create insight for source {input_data.source_id}: {e}"
)
logger.exception(e)
return CreateInsightOutput(
success=False,
processing_time=processing_time,
error_message=str(e),
)
async def collect_items_for_rebuild(
mode: str,
include_sources: bool,
include_notes: bool,
include_insights: bool,
) -> Dict[str, List[str]]:
"""
Collect items to rebuild based on mode and include flags.
Returns:
Dict with keys: 'sources', 'notes', 'insights' containing lists of item IDs
"""
items: Dict[str, List[str]] = {"sources": [], "notes": [], "insights": []}
if include_sources:
if mode == "existing":
# Query sources with embeddings (via source_embedding table)
result = await repo_query(
"""
RETURN array::distinct(
SELECT VALUE source.id
FROM source_embedding
WHERE embedding != none AND array::len(embedding) > 0
)
"""
)
# RETURN returns the array directly as the result (not nested)
if result:
items["sources"] = [str(item) for item in result]
else:
items["sources"] = []
else: # mode == "all"
# Query all sources with content
result = await repo_query("SELECT id FROM source WHERE full_text != none")
items["sources"] = [str(item["id"]) for item in result] if result else []
logger.info(f"Collected {len(items['sources'])} sources for rebuild")
if include_notes:
if mode == "existing":
# Query notes with embeddings
result = await repo_query(
"SELECT id FROM note WHERE embedding != none AND array::len(embedding) > 0"
)
else: # mode == "all"
# Query all notes (with content)
result = await repo_query("SELECT id FROM note WHERE content != none")
items["notes"] = [str(item["id"]) for item in result] if result else []
logger.info(f"Collected {len(items['notes'])} notes for rebuild")
if include_insights:
if mode == "existing":
# Query insights with embeddings
result = await repo_query(
"SELECT id FROM source_insight WHERE embedding != none AND array::len(embedding) > 0"
)
else: # mode == "all"
# Query all insights
result = await repo_query("SELECT id FROM source_insight")
items["insights"] = [str(item["id"]) for item in result] if result else []
logger.info(f"Collected {len(items['insights'])} insights for rebuild")
return items
@command("rebuild_embeddings", app="open_notebook", retry=None)
async def rebuild_embeddings_command(
input_data: RebuildEmbeddingsInput,
) -> RebuildEmbeddingsOutput:
"""
Rebuild embeddings for sources, notes, and/or insights.
This command submits individual embedding jobs for each item:
- embed_source for sources
- embed_note for notes
- embed_insight for insights
The command returns after submitting all jobs. Actual embedding
happens asynchronously via the individual commands (which have
their own retry strategies).
Retry Strategy:
- Retries disabled (retry=None) for this coordinator command
- Individual embed_* commands handle their own retries
"""
start_time = time.time()
try:
logger.info("=" * 60)
logger.info(f"Starting embedding rebuild with mode={input_data.mode}")
logger.info(
f"Include: sources={input_data.include_sources}, notes={input_data.include_notes}, insights={input_data.include_insights}"
)
logger.info("=" * 60)
# Check embedding model availability (fail fast)
EMBEDDING_MODEL = await model_manager.get_embedding_model()
if not EMBEDDING_MODEL:
raise ValueError(
"No embedding model configured. Please configure one in the Models section."
)
logger.info(f"Embedding model configured: {EMBEDDING_MODEL}")
# Collect items to process (returns IDs only)
items = await collect_items_for_rebuild(
input_data.mode,
input_data.include_sources,
input_data.include_notes,
input_data.include_insights,
)
total_items = (
len(items["sources"]) + len(items["notes"]) + len(items["insights"])
)
logger.info(f"Total items to rebuild: {total_items}")
if total_items == 0:
logger.warning("No items found to rebuild")
return RebuildEmbeddingsOutput(
success=True,
total_items=0,
jobs_submitted=0,
failed_submissions=0,
processing_time=time.time() - start_time,
)
# Initialize counters
sources_submitted = 0
notes_submitted = 0
insights_submitted = 0
failed_submissions = 0
# Submit embed_source commands for sources
logger.info(f"\nSubmitting {len(items['sources'])} source embedding jobs...")
for idx, source_id in enumerate(items["sources"], 1):
try:
submit_command(
"open_notebook",
"embed_source",
{"source_id": source_id},
)
sources_submitted += 1
if idx % 50 == 0 or idx == len(items["sources"]):
logger.info(
f" Progress: {idx}/{len(items['sources'])} source jobs submitted"
)
except Exception as e:
logger.error(f"Failed to submit embed_source for {source_id}: {e}")
failed_submissions += 1
# Submit embed_note commands for notes
logger.info(f"\nSubmitting {len(items['notes'])} note embedding jobs...")
for idx, note_id in enumerate(items["notes"], 1):
try:
submit_command(
"open_notebook",
"embed_note",
{"note_id": note_id},
)
notes_submitted += 1
if idx % 50 == 0 or idx == len(items["notes"]):
logger.info(
f" Progress: {idx}/{len(items['notes'])} note jobs submitted"
)
except Exception as e:
logger.error(f"Failed to submit embed_note for {note_id}: {e}")
failed_submissions += 1
# Submit embed_insight commands for insights
logger.info(f"\nSubmitting {len(items['insights'])} insight embedding jobs...")
for idx, insight_id in enumerate(items["insights"], 1):
try:
submit_command(
"open_notebook",
"embed_insight",
{"insight_id": insight_id},
)
insights_submitted += 1
if idx % 50 == 0 or idx == len(items["insights"]):
logger.info(
f" Progress: {idx}/{len(items['insights'])} insight jobs submitted"
)
except Exception as e:
logger.error(f"Failed to submit embed_insight for {insight_id}: {e}")
failed_submissions += 1
processing_time = time.time() - start_time
jobs_submitted = sources_submitted + notes_submitted + insights_submitted
logger.info("=" * 60)
logger.info("REBUILD JOBS SUBMITTED")
logger.info(f" Total jobs submitted: {jobs_submitted}/{total_items}")
logger.info(f" Sources: {sources_submitted}")
logger.info(f" Notes: {notes_submitted}")
logger.info(f" Insights: {insights_submitted}")
logger.info(f" Failed submissions: {failed_submissions}")
logger.info(f" Submission time: {processing_time:.2f}s")
logger.info(" Note: Actual embedding happens asynchronously")
logger.info("=" * 60)
return RebuildEmbeddingsOutput(
success=True,
total_items=total_items,
jobs_submitted=jobs_submitted,
failed_submissions=failed_submissions,
sources_submitted=sources_submitted,
notes_submitted=notes_submitted,
insights_submitted=insights_submitted,
processing_time=processing_time,
)
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"Rebuild embeddings failed: {e}")
logger.exception(e)
return RebuildEmbeddingsOutput(
success=False,
total_items=0,
jobs_submitted=0,
failed_submissions=0,
processing_time=processing_time,
error_message=str(e),
)