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
This commit is contained in:
parent
303ffacfa5
commit
301dd4e20a
15 changed files with 429 additions and 68 deletions
|
|
@ -395,6 +395,16 @@ class SourceInsightResponse(BaseModel):
|
||||||
updated: str
|
updated: str
|
||||||
|
|
||||||
|
|
||||||
|
class InsightCreationResponse(BaseModel):
|
||||||
|
"""Response for async insight creation."""
|
||||||
|
|
||||||
|
status: Literal["pending"] = "pending"
|
||||||
|
message: str = "Insight generation started"
|
||||||
|
source_id: str
|
||||||
|
transformation_id: str
|
||||||
|
command_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SaveAsNoteRequest(BaseModel):
|
class SaveAsNoteRequest(BaseModel):
|
||||||
notebook_id: Optional[str] = Field(None, description="Notebook ID to add note to")
|
notebook_id: Optional[str] = Field(None, description="Notebook ID to add note to")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,13 @@ from fastapi import (
|
||||||
)
|
)
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse, Response
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from surreal_commands import execute_command_sync
|
from surreal_commands import execute_command_sync, submit_command
|
||||||
|
|
||||||
from api.command_service import CommandService
|
from api.command_service import CommandService
|
||||||
from api.models import (
|
from api.models import (
|
||||||
AssetModel,
|
AssetModel,
|
||||||
CreateSourceInsightRequest,
|
CreateSourceInsightRequest,
|
||||||
|
InsightCreationResponse,
|
||||||
SourceCreate,
|
SourceCreate,
|
||||||
SourceInsightResponse,
|
SourceInsightResponse,
|
||||||
SourceListResponse,
|
SourceListResponse,
|
||||||
|
|
@ -963,44 +964,56 @@ async def get_source_insights(source_id: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sources/{source_id}/insights", response_model=SourceInsightResponse)
|
@router.post(
|
||||||
|
"/sources/{source_id}/insights",
|
||||||
|
response_model=InsightCreationResponse,
|
||||||
|
status_code=202,
|
||||||
|
)
|
||||||
async def create_source_insight(source_id: str, request: CreateSourceInsightRequest):
|
async def create_source_insight(source_id: str, request: CreateSourceInsightRequest):
|
||||||
"""Create a new insight for a source by running a transformation."""
|
"""
|
||||||
|
Start insight generation for a source by running a transformation.
|
||||||
|
|
||||||
|
This endpoint returns immediately with a 202 Accepted status.
|
||||||
|
The transformation runs asynchronously in the background via the job queue.
|
||||||
|
Poll GET /sources/{source_id}/insights to see when the insight is ready.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get source
|
# Validate source exists
|
||||||
source = await Source.get(source_id)
|
source = await Source.get(source_id)
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(status_code=404, detail="Source not found")
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
|
|
||||||
# Get transformation
|
# Validate transformation exists
|
||||||
transformation = await Transformation.get(request.transformation_id)
|
transformation = await Transformation.get(request.transformation_id)
|
||||||
if not transformation:
|
if not transformation:
|
||||||
raise HTTPException(status_code=404, detail="Transformation not found")
|
raise HTTPException(status_code=404, detail="Transformation not found")
|
||||||
|
|
||||||
# Run transformation graph
|
# Submit transformation as background job (fire-and-forget)
|
||||||
from open_notebook.graphs.transformation import graph as transform_graph
|
command_id = submit_command(
|
||||||
|
"open_notebook",
|
||||||
await transform_graph.ainvoke(
|
"run_transformation",
|
||||||
input=dict(source=source, transformation=transformation) # type: ignore[arg-type]
|
{
|
||||||
|
"source_id": source_id,
|
||||||
|
"transformation_id": request.transformation_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Submitted run_transformation command {command_id} for source {source_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the newly created insight (last one)
|
# Return immediately with command_id for status tracking
|
||||||
insights = await source.get_insights()
|
return InsightCreationResponse(
|
||||||
if insights:
|
status="pending",
|
||||||
newest = insights[-1]
|
message="Insight generation started",
|
||||||
return SourceInsightResponse(
|
source_id=source_id,
|
||||||
id=newest.id or "",
|
transformation_id=request.transformation_id,
|
||||||
source_id=source_id,
|
command_id=str(command_id),
|
||||||
insight_type=newest.insight_type,
|
)
|
||||||
content=newest.content,
|
|
||||||
created=str(newest.created),
|
|
||||||
updated=str(newest.updated),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to create insight")
|
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating insight for source {source_id}: {str(e)}")
|
logger.error(f"Error starting insight generation for source {source_id}: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error creating insight: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error starting insight generation: {str(e)}"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@
|
||||||
- **`embed_note_command`**: Embeds a single note using unified embedding pipeline with content-type aware processing. Uses MARKDOWN content type detection. Retry: 5 attempts, exponential jitter 1-60s.
|
- **`embed_note_command`**: Embeds a single note using unified embedding pipeline with content-type aware processing. Uses MARKDOWN content type detection. Retry: 5 attempts, exponential jitter 1-60s.
|
||||||
- **`embed_insight_command`**: Embeds a single source insight. Uses MARKDOWN content type. Retry: 5 attempts, exponential jitter 1-60s.
|
- **`embed_insight_command`**: Embeds a single source insight. Uses MARKDOWN content type. Retry: 5 attempts, exponential jitter 1-60s.
|
||||||
- **`embed_source_command`**: Embeds a source by chunking full_text with content-type aware splitters (HTML, Markdown, plain), then batch embedding all chunks. Uses single Esperanto API call. Retry: 5 attempts, exponential jitter 1-60s.
|
- **`embed_source_command`**: Embeds a source by chunking full_text with content-type aware splitters (HTML, Markdown, plain), then batch embedding all chunks. Uses single Esperanto API call. Retry: 5 attempts, exponential jitter 1-60s.
|
||||||
|
- **`create_insight_command`**: Creates a source insight with automatic retry on transaction conflicts. Creates the DB record, then submits `embed_insight` command (fire-and-forget). Retry: 5 attempts, exponential jitter 1-60s. Used by `Source.add_insight()`.
|
||||||
- **`rebuild_embeddings_command`**: Submits individual embed_* commands for all sources/notes/insights. Returns immediately; actual embedding happens async. No retry (coordinator only).
|
- **`rebuild_embeddings_command`**: Submits individual embed_* commands for all sources/notes/insights. Returns immediately; actual embedding happens async. No retry (coordinator only).
|
||||||
|
|
||||||
### Other Commands
|
### Other Commands
|
||||||
|
|
||||||
- **`process_source_command`**: Ingests content through `source_graph`, creates embeddings (optional), and generates insights. Retries on transaction conflicts (exp. jitter, max 5×).
|
- **`process_source_command`**: Ingests content through `source_graph`, creates embeddings (optional), and generates insights. Retries on transaction conflicts (exp. jitter, max 15×, 1-120s).
|
||||||
|
- **`run_transformation_command`**: Runs a transformation on an existing source to generate an insight. Executes the transformation graph (LLM call) then creates insight via `create_insight_command`. Used by `POST /sources/{id}/insights` API endpoint. Retry: 5 attempts, exponential jitter 1-60s.
|
||||||
- **`generate_podcast_command`**: Creates podcasts via `podcast-creator` library using stored episode/speaker profiles.
|
- **`generate_podcast_command`**: Creates podcasts via `podcast-creator` library using stored episode/speaker profiles.
|
||||||
- **`process_text_command`** (example): Test fixture for text operations (uppercase, lowercase, reverse, word_count).
|
- **`process_text_command`** (example): Test fixture for text operations (uppercase, lowercase, reverse, word_count).
|
||||||
- **`analyze_data_command`** (example): Test fixture for numeric aggregations.
|
- **`analyze_data_command`** (example): Test fixture for numeric aggregations.
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,23 @@ class RebuildEmbeddingsOutput(CommandOutput):
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class EmbedNoteInput(CommandInput):
|
||||||
"""Input for embedding a single note."""
|
"""Input for embedding a single note."""
|
||||||
|
|
||||||
|
|
@ -412,6 +429,115 @@ async def embed_source_command(input_data: EmbedSourceInput) -> EmbedSourceOutpu
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
async def collect_items_for_rebuild(
|
||||||
mode: str,
|
mode: str,
|
||||||
include_sources: bool,
|
include_sources: bool,
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ from open_notebook.domain.transformation import Transformation
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from open_notebook.graphs.source import source_graph
|
from open_notebook.graphs.source import source_graph
|
||||||
|
from open_notebook.graphs.transformation import graph as transform_graph
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.error(f"Failed to import source_graph: {e}")
|
logger.error(f"Failed to import graphs: {e}")
|
||||||
raise ValueError("source_graph not available")
|
raise ValueError("graphs not available")
|
||||||
|
|
||||||
|
|
||||||
def full_model_dump(model):
|
def full_model_dump(model):
|
||||||
|
|
@ -151,3 +152,121 @@ async def process_source_command(
|
||||||
processing_time=processing_time,
|
processing_time=processing_time,
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RUN TRANSFORMATION COMMAND
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class RunTransformationInput(CommandInput):
|
||||||
|
"""Input for running a transformation on an existing source."""
|
||||||
|
|
||||||
|
source_id: str
|
||||||
|
transformation_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class RunTransformationOutput(CommandOutput):
|
||||||
|
"""Output from transformation command."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
source_id: str
|
||||||
|
transformation_id: str
|
||||||
|
processing_time: float
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@command(
|
||||||
|
"run_transformation",
|
||||||
|
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 run_transformation_command(
|
||||||
|
input_data: RunTransformationInput,
|
||||||
|
) -> RunTransformationOutput:
|
||||||
|
"""
|
||||||
|
Run a transformation on an existing source to generate an insight.
|
||||||
|
|
||||||
|
This command runs the transformation graph which:
|
||||||
|
1. Loads the source and transformation
|
||||||
|
2. Calls the LLM to generate insight content
|
||||||
|
3. Creates the insight via create_insight command (fire-and-forget)
|
||||||
|
|
||||||
|
Use this command for UI-triggered insight generation to avoid blocking
|
||||||
|
the HTTP request while the LLM processes.
|
||||||
|
|
||||||
|
Retry Strategy:
|
||||||
|
- Retries up to 5 times for transient failures
|
||||||
|
- Uses exponential-jitter backoff (1-60s)
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"Running transformation {input_data.transformation_id} "
|
||||||
|
f"on source {input_data.source_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load source
|
||||||
|
source = await Source.get(input_data.source_id)
|
||||||
|
if not source:
|
||||||
|
raise ValueError(f"Source '{input_data.source_id}' not found")
|
||||||
|
|
||||||
|
# Load transformation
|
||||||
|
transformation = await Transformation.get(input_data.transformation_id)
|
||||||
|
if not transformation:
|
||||||
|
raise ValueError(
|
||||||
|
f"Transformation '{input_data.transformation_id}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run transformation graph (includes LLM call + insight creation)
|
||||||
|
await transform_graph.ainvoke(
|
||||||
|
input=dict(source=source, transformation=transformation)
|
||||||
|
)
|
||||||
|
|
||||||
|
processing_time = time.time() - start_time
|
||||||
|
logger.info(
|
||||||
|
f"Successfully ran transformation {input_data.transformation_id} "
|
||||||
|
f"on source {input_data.source_id} in {processing_time:.2f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
return RunTransformationOutput(
|
||||||
|
success=True,
|
||||||
|
source_id=input_data.source_id,
|
||||||
|
transformation_id=input_data.transformation_id,
|
||||||
|
processing_time=processing_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
except RuntimeError:
|
||||||
|
logger.debug(
|
||||||
|
f"Transaction conflict running transformation - will retry"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except (ConnectionError, TimeoutError) as e:
|
||||||
|
logger.debug(
|
||||||
|
f"Network/timeout error running transformation "
|
||||||
|
f"({type(e).__name__}: {e}) - will retry"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
processing_time = time.time() - start_time
|
||||||
|
logger.error(
|
||||||
|
f"Failed to run transformation {input_data.transformation_id} "
|
||||||
|
f"on source {input_data.source_id}: {e}"
|
||||||
|
)
|
||||||
|
logger.exception(e)
|
||||||
|
|
||||||
|
return RunTransformationOutput(
|
||||||
|
success=False,
|
||||||
|
source_id=input_data.source_id,
|
||||||
|
transformation_id=input_data.transformation_id,
|
||||||
|
processing_time=processing_time,
|
||||||
|
error_message=str(e),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -58,16 +58,21 @@ export default function NotebookPage() {
|
||||||
notes: {}
|
notes: {}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize default selections when sources/notes load
|
// Initialize and update selections when sources load or change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sources && sources.length > 0) {
|
if (sources && sources.length > 0) {
|
||||||
setContextSelections(prev => {
|
setContextSelections(prev => {
|
||||||
const newSourceSelections = { ...prev.sources }
|
const newSourceSelections = { ...prev.sources }
|
||||||
sources.forEach(source => {
|
sources.forEach(source => {
|
||||||
// Only set default if not already set
|
const currentMode = newSourceSelections[source.id]
|
||||||
if (!(source.id in newSourceSelections)) {
|
const hasInsights = source.insights_count > 0
|
||||||
// Default to 'insights' if has insights, otherwise 'full'
|
|
||||||
newSourceSelections[source.id] = source.insights_count > 0 ? 'insights' : 'full'
|
if (currentMode === undefined) {
|
||||||
|
// Initial setup - default based on insights availability
|
||||||
|
newSourceSelections[source.id] = hasInsights ? 'insights' : 'full'
|
||||||
|
} else if (currentMode === 'full' && hasInsights) {
|
||||||
|
// Source gained insights while in 'full' mode - auto-switch to 'insights'
|
||||||
|
newSourceSelections[source.id] = 'insights'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return { ...prev, sources: newSourceSelections }
|
return { ...prev, sources: newSourceSelections }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { isAxiosError } from 'axios'
|
import { isAxiosError } from 'axios'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
@ -81,6 +82,7 @@ export function SourceDetailContent({
|
||||||
onClose
|
onClose
|
||||||
}: SourceDetailContentProps) {
|
}: SourceDetailContentProps) {
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [source, setSource] = useState<SourceDetailResponse | null>(null)
|
const [source, setSource] = useState<SourceDetailResponse | null>(null)
|
||||||
const [insights, setInsights] = useState<SourceInsightResponse[]>([])
|
const [insights, setInsights] = useState<SourceInsightResponse[]>([])
|
||||||
const [transformations, setTransformations] = useState<Transformation[]>([])
|
const [transformations, setTransformations] = useState<Transformation[]>([])
|
||||||
|
|
@ -154,12 +156,36 @@ export function SourceDetailContent({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setCreatingInsight(true)
|
setCreatingInsight(true)
|
||||||
await insightsApi.create(sourceId, {
|
const response = await insightsApi.create(sourceId, {
|
||||||
transformation_id: selectedTransformation
|
transformation_id: selectedTransformation
|
||||||
})
|
})
|
||||||
toast.success(t.common.success)
|
// Show toast for async operation
|
||||||
await fetchInsights()
|
toast.success(t.sources.insightGenerationStarted)
|
||||||
setSelectedTransformation('')
|
setSelectedTransformation('')
|
||||||
|
|
||||||
|
// Poll for command completion if we have a command_id
|
||||||
|
if (response.command_id) {
|
||||||
|
// Poll in background (don't block UI)
|
||||||
|
insightsApi.waitForCommand(response.command_id, {
|
||||||
|
maxAttempts: 120, // Up to 4 minutes (120 * 2s)
|
||||||
|
intervalMs: 2000
|
||||||
|
}).then(success => {
|
||||||
|
if (success) {
|
||||||
|
void fetchInsights()
|
||||||
|
// Invalidate sources queries so notebook page refreshes with updated insights_count
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['sources'] })
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Error waiting for insight command:', err)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Fallback: refresh after delay if no command_id
|
||||||
|
setTimeout(() => {
|
||||||
|
void fetchInsights()
|
||||||
|
// Also invalidate sources queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['sources'] })
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create insight:', err)
|
console.error('Failed to create insight:', err)
|
||||||
toast.error(t.common.error)
|
toast.error(t.common.error)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,21 @@ export interface CreateSourceInsightRequest {
|
||||||
transformation_id: string
|
transformation_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InsightCreationResponse {
|
||||||
|
status: 'pending'
|
||||||
|
message: string
|
||||||
|
source_id: string
|
||||||
|
transformation_id: string
|
||||||
|
command_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandJobStatusResponse {
|
||||||
|
job_id: string
|
||||||
|
status: string
|
||||||
|
result?: Record<string, unknown>
|
||||||
|
error_message?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const insightsApi = {
|
export const insightsApi = {
|
||||||
listForSource: async (sourceId: string) => {
|
listForSource: async (sourceId: string) => {
|
||||||
const response = await apiClient.get<SourceInsightResponse[]>(`/sources/${sourceId}/insights`)
|
const response = await apiClient.get<SourceInsightResponse[]>(`/sources/${sourceId}/insights`)
|
||||||
|
|
@ -25,7 +40,7 @@ export const insightsApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (sourceId: string, data: CreateSourceInsightRequest) => {
|
create: async (sourceId: string, data: CreateSourceInsightRequest) => {
|
||||||
const response = await apiClient.post<SourceInsightResponse>(
|
const response = await apiClient.post<InsightCreationResponse>(
|
||||||
`/sources/${sourceId}/insights`,
|
`/sources/${sourceId}/insights`,
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
|
|
@ -34,5 +49,46 @@ export const insightsApi = {
|
||||||
|
|
||||||
delete: async (insightId: string) => {
|
delete: async (insightId: string) => {
|
||||||
await apiClient.delete(`/insights/${insightId}`)
|
await apiClient.delete(`/insights/${insightId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getCommandStatus: async (commandId: string) => {
|
||||||
|
const response = await apiClient.get<CommandJobStatusResponse>(
|
||||||
|
`/commands/jobs/${commandId}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll command status until completed or failed.
|
||||||
|
* Returns true if completed successfully, false if failed.
|
||||||
|
*/
|
||||||
|
waitForCommand: async (
|
||||||
|
commandId: string,
|
||||||
|
options?: { maxAttempts?: number; intervalMs?: number }
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const maxAttempts = options?.maxAttempts ?? 60 // Default 60 attempts
|
||||||
|
const intervalMs = options?.intervalMs ?? 2000 // Default 2 seconds
|
||||||
|
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
try {
|
||||||
|
const status = await insightsApi.getCommandStatus(commandId)
|
||||||
|
if (status.status === 'completed') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (status.status === 'failed' || status.status === 'canceled') {
|
||||||
|
console.error('Command failed:', status.error_message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Still running, wait and retry
|
||||||
|
await new Promise(resolve => setTimeout(resolve, intervalMs))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking command status:', error)
|
||||||
|
// Continue polling on error
|
||||||
|
await new Promise(resolve => setTimeout(resolve, intervalMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Timeout
|
||||||
|
console.warn('Command polling timed out')
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -357,6 +357,7 @@ export const enUS = {
|
||||||
viewInsight: "View Insight",
|
viewInsight: "View Insight",
|
||||||
deleteInsight: "Delete Insight",
|
deleteInsight: "Delete Insight",
|
||||||
deleteInsightConfirm: "Are you sure you want to delete this insight? This action cannot be undone.",
|
deleteInsightConfirm: "Are you sure you want to delete this insight? This action cannot be undone.",
|
||||||
|
insightGenerationStarted: "Insight generation started. It will appear shortly.",
|
||||||
deleteNoteConfirm: 'Are you sure you want to delete this note? This action cannot be undone.',
|
deleteNoteConfirm: 'Are you sure you want to delete this note? This action cannot be undone.',
|
||||||
editNote: 'Edit note',
|
editNote: 'Edit note',
|
||||||
createNote: 'Create note',
|
createNote: 'Create note',
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,7 @@ export const jaJP = {
|
||||||
viewInsight: "インサイトを表示",
|
viewInsight: "インサイトを表示",
|
||||||
deleteInsight: "インサイトを削除",
|
deleteInsight: "インサイトを削除",
|
||||||
deleteInsightConfirm: "このインサイトを削除しますか?この操作は元に戻せません。",
|
deleteInsightConfirm: "このインサイトを削除しますか?この操作は元に戻せません。",
|
||||||
|
insightGenerationStarted: "インサイトの生成が開始されました。まもなく表示されます。",
|
||||||
deleteNoteConfirm: 'このノートを削除しますか?この操作は元に戻せません。',
|
deleteNoteConfirm: 'このノートを削除しますか?この操作は元に戻せません。',
|
||||||
editNote: 'ノートを編集',
|
editNote: 'ノートを編集',
|
||||||
createNote: 'ノートを作成',
|
createNote: 'ノートを作成',
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,7 @@ export const ptBR = {
|
||||||
viewInsight: "Ver Insight",
|
viewInsight: "Ver Insight",
|
||||||
deleteInsight: "Excluir Insight",
|
deleteInsight: "Excluir Insight",
|
||||||
deleteInsightConfirm: "Tem certeza que deseja excluir este insight? Esta ação não pode ser desfeita.",
|
deleteInsightConfirm: "Tem certeza que deseja excluir este insight? Esta ação não pode ser desfeita.",
|
||||||
|
insightGenerationStarted: "Geração de insight iniciada. Aparecerá em breve.",
|
||||||
deleteNoteConfirm: "Tem certeza que deseja excluir esta nota? Esta ação não pode ser desfeita.",
|
deleteNoteConfirm: "Tem certeza que deseja excluir esta nota? Esta ação não pode ser desfeita.",
|
||||||
editNote: "Editar nota",
|
editNote: "Editar nota",
|
||||||
createNote: "Criar nota",
|
createNote: "Criar nota",
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,7 @@ export const zhCN = {
|
||||||
noNotebooksAvailable: "暂无可用笔记本",
|
noNotebooksAvailable: "暂无可用笔记本",
|
||||||
deleteInsight: "删除见解",
|
deleteInsight: "删除见解",
|
||||||
deleteInsightConfirm: "确定要删除此见解吗?此操作无法撤销。",
|
deleteInsightConfirm: "确定要删除此见解吗?此操作无法撤销。",
|
||||||
|
insightGenerationStarted: "见解生成已开始,稍后将显示。",
|
||||||
notEmbeddedAlert: "内容未嵌入向量",
|
notEmbeddedAlert: "内容未嵌入向量",
|
||||||
notEmbeddedDesc: "此内容尚未为了向量搜索进行嵌入。嵌入可以启用高级搜索功能并更好地发现内容。",
|
notEmbeddedDesc: "此内容尚未为了向量搜索进行嵌入。嵌入可以启用高级搜索功能并更好地发现内容。",
|
||||||
openOnYoutube: "在 YouTube 上打开",
|
openOnYoutube: "在 YouTube 上打开",
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,7 @@ export const zhTW = {
|
||||||
noNotebooksAvailable: "暫無可用筆記本",
|
noNotebooksAvailable: "暫無可用筆記本",
|
||||||
deleteInsight: "刪除見解",
|
deleteInsight: "刪除見解",
|
||||||
deleteInsightConfirm: "確定要刪除此見解嗎?此操作無法撤銷。",
|
deleteInsightConfirm: "確定要刪除此見解嗎?此操作無法撤銷。",
|
||||||
|
insightGenerationStarted: "見解生成已開始,稍後將顯示。",
|
||||||
notEmbeddedAlert: "內容未嵌入向量",
|
notEmbeddedAlert: "內容未嵌入向量",
|
||||||
notEmbeddedDesc: "此內容尚未為了向量搜尋進行嵌入。嵌入可以啟用進階搜尋功能並更好地發現內容。",
|
notEmbeddedDesc: "此內容尚未為了向量搜尋進行嵌入。嵌入可以啟用進階搜尋功能並更好地發現內容。",
|
||||||
openOnYoutube: "在 YouTube 上開啟",
|
openOnYoutube: "在 YouTube 上開啟",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ Two base classes support different persistence patterns: **ObjectModel** (mutabl
|
||||||
- `vectorize()`: Submit async embedding job (returns command_id, fire-and-forget)
|
- `vectorize()`: Submit async embedding job (returns command_id, fire-and-forget)
|
||||||
- `get_status()`, `get_processing_progress()`: Track job via surreal_commands
|
- `get_status()`, `get_processing_progress()`: Track job via surreal_commands
|
||||||
- `get_context()`: Returns summary for LLM context
|
- `get_context()`: Returns summary for LLM context
|
||||||
- `add_insight()`: Generate and store insights with embeddings
|
- `add_insight()`: Submit async insight creation via `create_insight_command` (fire-and-forget, returns command_id)
|
||||||
|
|
||||||
- **Note**: Standalone or linked notes
|
- **Note**: Standalone or linked notes
|
||||||
- `save()`: Submits `embed_note` command after save (fire-and-forget)
|
- `save()`: Submits `embed_note` command after save (fire-and-forget)
|
||||||
|
|
@ -80,7 +80,7 @@ Two base classes support different persistence patterns: **ObjectModel** (mutabl
|
||||||
- **Auto-embedding behavior**:
|
- **Auto-embedding behavior**:
|
||||||
- `Note.save()` → auto-submits `embed_note` command
|
- `Note.save()` → auto-submits `embed_note` command
|
||||||
- `Source.save()` → does NOT auto-submit (must call `vectorize()` explicitly)
|
- `Source.save()` → does NOT auto-submit (must call `vectorize()` explicitly)
|
||||||
- `Source.add_insight()` → auto-submits `embed_insight` command
|
- `Source.add_insight()` → submits `create_insight_command` which handles DB insert + `embed_insight` command (all fire-and-forget)
|
||||||
- **Relationship strings**: Must match SurrealDB schema (reference, artifact, refers_to)
|
- **Relationship strings**: Must match SurrealDB schema (reference, artifact, refers_to)
|
||||||
|
|
||||||
## How to Add New Model
|
## How to Add New Model
|
||||||
|
|
|
||||||
|
|
@ -454,53 +454,52 @@ class Source(ObjectModel):
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
raise DatabaseOperationError(e)
|
raise DatabaseOperationError(e)
|
||||||
|
|
||||||
async def add_insight(self, insight_type: str, content: str) -> Any:
|
async def add_insight(self, insight_type: str, content: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Add an insight to this source.
|
Submit insight creation as an async command (fire-and-forget).
|
||||||
|
|
||||||
Creates the insight record without embedding, then submits an async
|
Submits a create_insight command that handles database operations with
|
||||||
embed_insight command to generate the embedding in the background.
|
automatic retry logic for transaction conflicts. The command also submits
|
||||||
|
an embed_insight command for async embedding.
|
||||||
|
|
||||||
|
This method returns immediately after submitting the command - it does NOT
|
||||||
|
wait for the insight to be created. Use this for batch operations where
|
||||||
|
throughput is more important than immediate confirmation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
insight_type: Type/category of the insight
|
insight_type: Type/category of the insight
|
||||||
content: The insight content text
|
content: The insight content text
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The created insight record(s)
|
command_id for optional tracking, or None if submission failed
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidInputError: If insight_type or content is empty
|
||||||
"""
|
"""
|
||||||
if not insight_type or not content:
|
if not insight_type or not content:
|
||||||
raise InvalidInputError("Insight type and content must be provided")
|
raise InvalidInputError("Insight type and content must be provided")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create insight WITHOUT embedding (fire-and-forget embedding via command)
|
# Submit create_insight command (fire-and-forget)
|
||||||
result = await repo_query(
|
# Command handles retries internally for transaction conflicts
|
||||||
"""
|
command_id = submit_command(
|
||||||
CREATE source_insight CONTENT {
|
"open_notebook",
|
||||||
"source": $source_id,
|
"create_insight",
|
||||||
"insight_type": $insight_type,
|
|
||||||
"content": $content,
|
|
||||||
};""",
|
|
||||||
{
|
{
|
||||||
"source_id": ensure_record_id(self.id),
|
"source_id": str(self.id),
|
||||||
"insight_type": insight_type,
|
"insight_type": insight_type,
|
||||||
"content": content,
|
"content": content,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Submitted create_insight command {command_id} for source {self.id} "
|
||||||
|
f"(type={insight_type})"
|
||||||
|
)
|
||||||
|
return str(command_id)
|
||||||
|
|
||||||
# Submit embedding command (fire-and-forget)
|
|
||||||
if result and len(result) > 0:
|
|
||||||
insight_id = str(result[0].get("id", ""))
|
|
||||||
if insight_id:
|
|
||||||
submit_command(
|
|
||||||
"open_notebook",
|
|
||||||
"embed_insight",
|
|
||||||
{"insight_id": insight_id},
|
|
||||||
)
|
|
||||||
logger.debug(f"Submitted embed_insight command for {insight_id}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding insight to source {self.id}: {str(e)}")
|
logger.error(f"Error submitting create_insight for source {self.id}: {e}")
|
||||||
raise
|
return None
|
||||||
|
|
||||||
def _prepare_save_data(self) -> dict:
|
def _prepare_save_data(self) -> dict:
|
||||||
"""Override to ensure command field is always RecordID format for database"""
|
"""Override to ensure command field is always RecordID format for database"""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue