Merge pull request #590 from lfnovo/feat/error-clarity-handling
feat: improve error clarity for LLM provider failures
This commit is contained in:
commit
7070568941
23 changed files with 489 additions and 188 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.7.2] - 2026-02-16
|
||||
|
||||
### Added
|
||||
- Error classification utility that maps LLM provider errors to user-friendly messages (#506)
|
||||
- Global exception handlers in FastAPI for all custom exception types with proper HTTP status codes
|
||||
- `getApiErrorMessage()` frontend helper that falls back to backend messages when no i18n mapping exists
|
||||
|
||||
### Fixed
|
||||
- LLM errors (invalid API key, wrong model, rate limits) now show descriptive messages instead of "An unexpected error occurred"
|
||||
- SSE streaming error events in source chat and ask hooks were swallowed by inner JSON parse catch blocks
|
||||
- Transformation execution errors were caught and re-wrapped as generic 500s instead of using proper status codes
|
||||
|
||||
### Changed
|
||||
- `ValueError` replaced with `ConfigurationError` in model provisioning for proper error classification
|
||||
- `ConfigurationError` added to command retry `stop_on` lists to avoid retrying permanent config failures
|
||||
|
||||
## [1.7.1] - 2026-02-14
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -218,4 +218,4 @@ See dedicated CLAUDE.md files for detailed guidance:
|
|||
|
||||
---
|
||||
|
||||
**Last Updated**: February 2026 | **Project Version**: 1.7.1
|
||||
**Last Updated**: February 2026 | **Project Version**: 1.7.2
|
||||
|
|
|
|||
92
api/main.py
92
api/main.py
|
|
@ -12,6 +12,16 @@ from loguru import logger
|
|||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from api.auth import PasswordAuthMiddleware
|
||||
from open_notebook.exceptions import (
|
||||
AuthenticationError,
|
||||
ConfigurationError,
|
||||
ExternalServiceError,
|
||||
InvalidInputError,
|
||||
NetworkError,
|
||||
NotFoundError,
|
||||
OpenNotebookError,
|
||||
RateLimitError,
|
||||
)
|
||||
from api.routers import (
|
||||
auth,
|
||||
chat,
|
||||
|
|
@ -154,6 +164,88 @@ async def custom_http_exception_handler(request: Request, exc: StarletteHTTPExce
|
|||
)
|
||||
|
||||
|
||||
def _cors_headers(request: Request) -> dict[str, str]:
|
||||
origin = request.headers.get("origin", "*")
|
||||
return {
|
||||
"Access-Control-Allow-Origin": origin,
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Access-Control-Allow-Methods": "*",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
}
|
||||
|
||||
|
||||
@app.exception_handler(NotFoundError)
|
||||
async def not_found_error_handler(request: Request, exc: NotFoundError):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": str(exc)},
|
||||
headers=_cors_headers(request),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(InvalidInputError)
|
||||
async def invalid_input_error_handler(request: Request, exc: InvalidInputError):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": str(exc)},
|
||||
headers=_cors_headers(request),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(AuthenticationError)
|
||||
async def authentication_error_handler(request: Request, exc: AuthenticationError):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": str(exc)},
|
||||
headers=_cors_headers(request),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RateLimitError)
|
||||
async def rate_limit_error_handler(request: Request, exc: RateLimitError):
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": str(exc)},
|
||||
headers=_cors_headers(request),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(ConfigurationError)
|
||||
async def configuration_error_handler(request: Request, exc: ConfigurationError):
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": str(exc)},
|
||||
headers=_cors_headers(request),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(NetworkError)
|
||||
async def network_error_handler(request: Request, exc: NetworkError):
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"detail": str(exc)},
|
||||
headers=_cors_headers(request),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(ExternalServiceError)
|
||||
async def external_service_error_handler(request: Request, exc: ExternalServiceError):
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"detail": str(exc)},
|
||||
headers=_cors_headers(request),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(OpenNotebookError)
|
||||
async def open_notebook_error_handler(request: Request, exc: OpenNotebookError):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": str(exc)},
|
||||
headers=_cors_headers(request),
|
||||
)
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api", tags=["auth"])
|
||||
app.include_router(config.router, prefix="/api", tags=["config"])
|
||||
|
|
|
|||
|
|
@ -102,8 +102,11 @@ async def stream_ask_response(
|
|||
yield f"data: {json.dumps(completion_data)}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
from open_notebook.utils.error_classifier import classify_error
|
||||
|
||||
_, user_message = classify_error(e)
|
||||
logger.error(f"Error in ask streaming: {str(e)}")
|
||||
error_data = {"type": "error", "message": str(e)}
|
||||
error_data = {"type": "error", "message": user_message}
|
||||
yield f"data: {json.dumps(error_data)}\n\n"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -470,8 +470,11 @@ async def stream_source_chat_response(
|
|||
yield f"data: {json.dumps(completion_event)}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
from open_notebook.utils.error_classifier import classify_error
|
||||
|
||||
_, user_message = classify_error(e)
|
||||
logger.error(f"Error in source chat streaming: {str(e)}")
|
||||
error_event = {"type": "error", "message": str(e)}
|
||||
error_event = {"type": "error", "message": user_message}
|
||||
yield f"data: {json.dumps(error_event)}\n\n"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from api.models import (
|
|||
)
|
||||
from open_notebook.ai.models import Model
|
||||
from open_notebook.domain.transformation import DefaultPrompts, Transformation
|
||||
from open_notebook.exceptions import InvalidInputError
|
||||
from open_notebook.exceptions import InvalidInputError, OpenNotebookError
|
||||
from open_notebook.graphs.transformation import graph as transformation_graph
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -109,6 +109,8 @@ async def execute_transformation(execute_request: TransformationExecuteRequest):
|
|||
|
||||
except HTTPException:
|
||||
raise
|
||||
except OpenNotebookError:
|
||||
raise # Let global exception handlers return proper status codes
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing transformation: {str(e)}")
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from surreal_commands import CommandInput, CommandOutput, command, submit_comman
|
|||
|
||||
from open_notebook.ai.models import model_manager
|
||||
from open_notebook.database.repository import ensure_record_id, repo_insert, repo_query
|
||||
from open_notebook.exceptions import ConfigurationError
|
||||
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
|
||||
|
|
@ -125,7 +126,7 @@ class EmbedSourceOutput(CommandOutput):
|
|||
"wait_strategy": "exponential_jitter",
|
||||
"wait_min": 1,
|
||||
"wait_max": 60,
|
||||
"stop_on": [ValueError], # Don't retry validation errors
|
||||
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
|
||||
"retry_log_level": "debug",
|
||||
},
|
||||
)
|
||||
|
|
@ -217,7 +218,7 @@ async def embed_note_command(input_data: EmbedNoteInput) -> EmbedNoteOutput:
|
|||
"wait_strategy": "exponential_jitter",
|
||||
"wait_min": 1,
|
||||
"wait_max": 60,
|
||||
"stop_on": [ValueError], # Don't retry validation errors
|
||||
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
|
||||
"retry_log_level": "debug",
|
||||
},
|
||||
)
|
||||
|
|
@ -311,7 +312,7 @@ async def embed_insight_command(input_data: EmbedInsightInput) -> EmbedInsightOu
|
|||
"wait_strategy": "exponential_jitter",
|
||||
"wait_min": 1,
|
||||
"wait_max": 60,
|
||||
"stop_on": [ValueError], # Don't retry validation errors
|
||||
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
|
||||
"retry_log_level": "debug",
|
||||
},
|
||||
)
|
||||
|
|
@ -447,7 +448,7 @@ async def embed_source_command(input_data: EmbedSourceInput) -> EmbedSourceOutpu
|
|||
"wait_strategy": "exponential_jitter",
|
||||
"wait_min": 1,
|
||||
"wait_max": 60,
|
||||
"stop_on": [ValueError], # Don't retry validation errors
|
||||
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
|
||||
"retry_log_level": "debug",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from surreal_commands import CommandInput, CommandOutput, command
|
|||
from open_notebook.database.repository import ensure_record_id
|
||||
from open_notebook.domain.notebook import Source
|
||||
from open_notebook.domain.transformation import Transformation
|
||||
from open_notebook.exceptions import ConfigurationError
|
||||
|
||||
try:
|
||||
from open_notebook.graphs.source import source_graph
|
||||
|
|
@ -53,7 +54,7 @@ class SourceProcessingOutput(CommandOutput):
|
|||
"wait_strategy": "exponential_jitter",
|
||||
"wait_min": 1,
|
||||
"wait_max": 120, # Allow queue to drain
|
||||
"stop_on": [ValueError], # Don't retry validation errors
|
||||
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
|
||||
"retry_log_level": "debug", # Avoid log noise during transaction conflicts
|
||||
},
|
||||
)
|
||||
|
|
@ -184,7 +185,7 @@ class RunTransformationOutput(CommandOutput):
|
|||
"wait_strategy": "exponential_jitter",
|
||||
"wait_min": 1,
|
||||
"wait_max": 60,
|
||||
"stop_on": [ValueError], # Don't retry validation errors
|
||||
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
|
||||
"retry_log_level": "debug",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
import { getApiErrorKey } from '@/lib/utils/error-handler'
|
||||
import { getApiErrorMessage } from '@/lib/utils/error-handler'
|
||||
import { searchApi } from '@/lib/api/search'
|
||||
import { AskStreamEvent } from '@/lib/types/search'
|
||||
|
||||
|
|
@ -122,8 +122,12 @@ export function useAsk() {
|
|||
throw new Error(data.message || 'Stream error occurred')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE data:', e, 'Line:', line)
|
||||
// Don't throw - continue processing other lines
|
||||
if (e instanceof SyntaxError) {
|
||||
console.error('Error parsing SSE data:', e, 'Line:', line)
|
||||
// Don't throw - continue processing other lines
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -144,7 +148,7 @@ export function useAsk() {
|
|||
}))
|
||||
|
||||
toast.error(t('apiErrors.askFailed'), {
|
||||
description: t(getApiErrorKey(errorMessage))
|
||||
description: getApiErrorMessage(errorMessage, (key) => t(key))
|
||||
})
|
||||
}
|
||||
}, [t])
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { sourcesApi } from '@/lib/api/sources'
|
|||
import { QUERY_KEYS } from '@/lib/api/query-client'
|
||||
import { useToast } from '@/lib/hooks/use-toast'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
import { getApiErrorKey } from '@/lib/utils/error-handler'
|
||||
import { getApiErrorMessage } from '@/lib/utils/error-handler'
|
||||
import {
|
||||
CreateSourceRequest,
|
||||
UpdateSourceRequest,
|
||||
|
|
@ -131,7 +131,7 @@ export function useCreateSource() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.sources.failedToAddSource)),
|
||||
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSource),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
@ -158,7 +158,7 @@ export function useUpdateSource() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.sources.failedToUpdateSource)),
|
||||
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUpdateSource),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
@ -185,7 +185,7 @@ export function useDeleteSource() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.sources.failedToDeleteSource)),
|
||||
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToDeleteSource),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
@ -212,7 +212,7 @@ export function useFileUpload() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.sources.failedToUploadFile)),
|
||||
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUploadFile),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
@ -270,7 +270,7 @@ export function useRetrySource() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.sources.failedToRetry)),
|
||||
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRetry),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
@ -332,7 +332,7 @@ export function useAddSourcesToNotebook() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.sources.failedToAddSourcesToNotebook)),
|
||||
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSourcesToNotebook),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
@ -366,7 +366,7 @@ export function useRemoveSourceFromNotebook() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.sources.failedToRemoveSourceFromNotebook)),
|
||||
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRemoveSourceFromNotebook),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||
import { transformationsApi } from '@/lib/api/transformations'
|
||||
import { useToast } from '@/lib/hooks/use-toast'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
import { getApiErrorKey } from '@/lib/utils/error-handler'
|
||||
import { getApiErrorMessage } from '@/lib/utils/error-handler'
|
||||
import {
|
||||
CreateTransformationRequest,
|
||||
UpdateTransformationRequest,
|
||||
|
|
@ -49,7 +49,7 @@ export function useCreateTransformation() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.common.error)),
|
||||
description: getApiErrorMessage(error, (key) => t(key)),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
@ -75,7 +75,7 @@ export function useUpdateTransformation() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.common.error)),
|
||||
description: getApiErrorMessage(error, (key) => t(key)),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
@ -99,7 +99,7 @@ export function useDeleteTransformation() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.common.error)),
|
||||
description: getApiErrorMessage(error, (key) => t(key)),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
@ -115,7 +115,7 @@ export function useExecuteTransformation() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.common.error)),
|
||||
description: getApiErrorMessage(error, (key) => t(key)),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
@ -146,7 +146,7 @@ export function useUpdateDefaultPrompt() {
|
|||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t(getApiErrorKey(error, t.common.error)),
|
||||
description: getApiErrorMessage(error, (key) => t(key)),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { getApiErrorKey } from '@/lib/utils/error-handler'
|
||||
import { getApiErrorMessage } from '@/lib/utils/error-handler'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
import { chatApi } from '@/lib/api/chat'
|
||||
import { QUERY_KEYS } from '@/lib/api/query-client'
|
||||
|
|
@ -84,7 +84,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
|
|||
},
|
||||
onError: (err: unknown) => {
|
||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession')))
|
||||
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
|
|||
},
|
||||
onError: (err: unknown) => {
|
||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToUpdateSession')))
|
||||
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToUpdateSession'))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
|
|||
},
|
||||
onError: (err: unknown) => {
|
||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToDeleteSession')))
|
||||
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToDeleteSession'))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
|
|||
})
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession')))
|
||||
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -230,7 +230,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
|
|||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||
console.error('Error sending message:', error)
|
||||
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToSendMessage')))
|
||||
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToSendMessage'))
|
||||
// Remove optimistic message on error
|
||||
setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { getApiErrorKey } from '@/lib/utils/error-handler'
|
||||
import { getApiErrorMessage } from '@/lib/utils/error-handler'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
import { sourceChatApi } from '@/lib/api/source-chat'
|
||||
import {
|
||||
|
|
@ -64,7 +64,7 @@ export function useSourceChat(sourceId: string) {
|
|||
},
|
||||
onError: (err: unknown) => {
|
||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession')))
|
||||
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ export function useSourceChat(sourceId: string) {
|
|||
},
|
||||
onError: (err: unknown) => {
|
||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToUpdateSession')))
|
||||
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToUpdateSession'))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ export function useSourceChat(sourceId: string) {
|
|||
},
|
||||
onError: (err: unknown) => {
|
||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToDeleteSession')))
|
||||
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToDeleteSession'))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ export function useSourceChat(sourceId: string) {
|
|||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||
console.error('Failed to create chat session:', error)
|
||||
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession')))
|
||||
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -182,7 +182,11 @@ export function useSourceChat(sourceId: string) {
|
|||
throw new Error(data.message || 'Stream error')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE data:', e)
|
||||
if (e instanceof SyntaxError) {
|
||||
console.error('Error parsing SSE data:', e)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -190,7 +194,7 @@ export function useSourceChat(sourceId: string) {
|
|||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||
console.error('Error sending message:', error)
|
||||
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToSendMessage')))
|
||||
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToSendMessage'))
|
||||
// Remove optimistic messages on error
|
||||
setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,31 @@ export function getApiErrorKey(errorOrMessage: unknown, fallbackKey?: string): s
|
|||
return fallbackKey || "apiErrors.genericError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the error message, looks up i18n mapping, and falls back to the
|
||||
* backend-provided message when no mapping exists. This ensures user-friendly
|
||||
* error messages from the backend are displayed directly in the UI.
|
||||
*/
|
||||
export function getApiErrorMessage(
|
||||
errorOrMessage: unknown,
|
||||
t: (key: string) => string,
|
||||
fallbackKey?: string
|
||||
): string {
|
||||
const message = formatApiError(errorOrMessage);
|
||||
if (!message) return fallbackKey ? t(fallbackKey) : t("apiErrors.genericError");
|
||||
|
||||
// Try exact match
|
||||
if (ERROR_MAP[message]) return t(ERROR_MAP[message]);
|
||||
|
||||
// Try partial match for dynamic messages (e.g., "Strategy model ...")
|
||||
for (const [key, value] of Object.entries(ERROR_MAP)) {
|
||||
if (message.startsWith(key)) return t(value);
|
||||
}
|
||||
|
||||
// No mapping: return backend message directly (backend is responsible for making it user-friendly)
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a raw error from the API into a user-friendly (potentially translated) string.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from loguru import logger
|
|||
|
||||
from open_notebook.database.repository import ensure_record_id, repo_query
|
||||
from open_notebook.domain.base import ObjectModel, RecordModel
|
||||
from open_notebook.exceptions import ConfigurationError
|
||||
|
||||
ModelType = Union[LanguageModel, EmbeddingModel, SpeechToTextModel, TextToSpeechModel]
|
||||
|
||||
|
|
@ -106,7 +107,7 @@ class ModelManager:
|
|||
try:
|
||||
model: Model = await Model.get(model_id)
|
||||
except Exception:
|
||||
raise ValueError(f"Model with ID {model_id} not found")
|
||||
raise ConfigurationError(f"Model with ID {model_id} not found")
|
||||
|
||||
if not model.type or model.type not in [
|
||||
"language",
|
||||
|
|
@ -114,7 +115,7 @@ class ModelManager:
|
|||
"speech_to_text",
|
||||
"text_to_speech",
|
||||
]:
|
||||
raise ValueError(f"Invalid model type: {model.type}")
|
||||
raise ConfigurationError(f"Invalid model type: {model.type}")
|
||||
|
||||
# Build config from credential if linked, otherwise fall back to env vars
|
||||
config: dict = {}
|
||||
|
|
@ -172,7 +173,7 @@ class ModelManager:
|
|||
config=config,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid model type: {model.type}")
|
||||
raise ConfigurationError(f"Invalid model type: {model.type}")
|
||||
|
||||
async def get_defaults(self) -> DefaultModels:
|
||||
"""Get the default models configuration from database"""
|
||||
|
|
@ -254,7 +255,7 @@ class ModelManager:
|
|||
|
||||
try:
|
||||
return await self.get_model(model_id, **kwargs)
|
||||
except ValueError as e:
|
||||
except (ValueError, ConfigurationError) as e:
|
||||
logger.error(
|
||||
f"Failed to load default model for type '{model_type}': {e}. "
|
||||
f"The configured model_id '{model_id}' may have been deleted or misconfigured. "
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from langchain_core.language_models.chat_models import BaseChatModel
|
|||
from loguru import logger
|
||||
|
||||
from open_notebook.ai.models import model_manager
|
||||
from open_notebook.exceptions import ConfigurationError
|
||||
from open_notebook.utils import token_count
|
||||
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ async def provision_langchain_model(
|
|||
f"model_id={model_id}, default_type={default_type}. "
|
||||
f"Please check Settings → Models and ensure a default model is configured for '{default_type}'."
|
||||
)
|
||||
raise ValueError(
|
||||
raise ConfigurationError(
|
||||
f"No model configured for {selection_reason}. "
|
||||
f"Please go to Settings → Models and configure a default model for '{default_type}'."
|
||||
)
|
||||
|
|
@ -52,7 +53,7 @@ async def provision_langchain_model(
|
|||
f"Selection reason: {selection_reason}. "
|
||||
f"model_id={model_id}, default_type={default_type}."
|
||||
)
|
||||
raise ValueError(
|
||||
raise ConfigurationError(
|
||||
f"Model is not a LanguageModel: {model}. "
|
||||
f"Please check that the model configured for '{default_type}' is a language model, not an embedding or speech model."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ from typing_extensions import TypedDict
|
|||
|
||||
from open_notebook.ai.provision import provision_langchain_model
|
||||
from open_notebook.domain.notebook import vector_search
|
||||
from open_notebook.exceptions import OpenNotebookError
|
||||
from open_notebook.utils import clean_thinking_content
|
||||
from open_notebook.utils.error_classifier import classify_error
|
||||
|
||||
|
||||
class SubGraphState(TypedDict):
|
||||
|
|
@ -46,33 +48,39 @@ class ThreadState(TypedDict):
|
|||
|
||||
|
||||
async def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict:
|
||||
parser = PydanticOutputParser(pydantic_object=Strategy)
|
||||
system_prompt = Prompter(prompt_template="ask/entry", parser=parser).render( # type: ignore[arg-type]
|
||||
data=state # type: ignore[arg-type]
|
||||
)
|
||||
model = await provision_langchain_model(
|
||||
system_prompt,
|
||||
config.get("configurable", {}).get("strategy_model"),
|
||||
"tools",
|
||||
max_tokens=2000,
|
||||
structured=dict(type="json"),
|
||||
)
|
||||
# model = model.bind_tools(tools)
|
||||
# First get the raw response from the model
|
||||
ai_message = await model.ainvoke(system_prompt)
|
||||
try:
|
||||
parser = PydanticOutputParser(pydantic_object=Strategy)
|
||||
system_prompt = Prompter(prompt_template="ask/entry", parser=parser).render( # type: ignore[arg-type]
|
||||
data=state # type: ignore[arg-type]
|
||||
)
|
||||
model = await provision_langchain_model(
|
||||
system_prompt,
|
||||
config.get("configurable", {}).get("strategy_model"),
|
||||
"tools",
|
||||
max_tokens=2000,
|
||||
structured=dict(type="json"),
|
||||
)
|
||||
# model = model.bind_tools(tools)
|
||||
# First get the raw response from the model
|
||||
ai_message = await model.ainvoke(system_prompt)
|
||||
|
||||
# Clean the thinking content from the response
|
||||
message_content = (
|
||||
ai_message.content
|
||||
if isinstance(ai_message.content, str)
|
||||
else str(ai_message.content)
|
||||
)
|
||||
cleaned_content = clean_thinking_content(message_content)
|
||||
# Clean the thinking content from the response
|
||||
message_content = (
|
||||
ai_message.content
|
||||
if isinstance(ai_message.content, str)
|
||||
else str(ai_message.content)
|
||||
)
|
||||
cleaned_content = clean_thinking_content(message_content)
|
||||
|
||||
# Parse the cleaned JSON content
|
||||
strategy = parser.parse(cleaned_content)
|
||||
# Parse the cleaned JSON content
|
||||
strategy = parser.parse(cleaned_content)
|
||||
|
||||
return {"strategy": strategy}
|
||||
return {"strategy": strategy}
|
||||
except OpenNotebookError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_class, user_message = classify_error(e)
|
||||
raise error_class(user_message) from e
|
||||
|
||||
|
||||
async def trigger_queries(state: ThreadState, config: RunnableConfig):
|
||||
|
|
@ -91,47 +99,59 @@ async def trigger_queries(state: ThreadState, config: RunnableConfig):
|
|||
|
||||
|
||||
async def provide_answer(state: SubGraphState, config: RunnableConfig) -> dict:
|
||||
payload = state
|
||||
# if state["type"] == "text":
|
||||
# results = text_search(state["term"], 10, True, True)
|
||||
# else:
|
||||
results = await vector_search(state["term"], 10, True, True)
|
||||
if len(results) == 0:
|
||||
return {"answers": []}
|
||||
payload["results"] = results
|
||||
ids = [r["id"] for r in results]
|
||||
payload["ids"] = ids
|
||||
system_prompt = Prompter(prompt_template="ask/query_process").render(data=payload) # type: ignore[arg-type]
|
||||
model = await provision_langchain_model(
|
||||
system_prompt,
|
||||
config.get("configurable", {}).get("answer_model"),
|
||||
"tools",
|
||||
max_tokens=2000,
|
||||
)
|
||||
ai_message = await model.ainvoke(system_prompt)
|
||||
ai_content = (
|
||||
ai_message.content
|
||||
if isinstance(ai_message.content, str)
|
||||
else str(ai_message.content)
|
||||
)
|
||||
return {"answers": [clean_thinking_content(ai_content)]}
|
||||
try:
|
||||
payload = state
|
||||
# if state["type"] == "text":
|
||||
# results = text_search(state["term"], 10, True, True)
|
||||
# else:
|
||||
results = await vector_search(state["term"], 10, True, True)
|
||||
if len(results) == 0:
|
||||
return {"answers": []}
|
||||
payload["results"] = results
|
||||
ids = [r["id"] for r in results]
|
||||
payload["ids"] = ids
|
||||
system_prompt = Prompter(prompt_template="ask/query_process").render(data=payload) # type: ignore[arg-type]
|
||||
model = await provision_langchain_model(
|
||||
system_prompt,
|
||||
config.get("configurable", {}).get("answer_model"),
|
||||
"tools",
|
||||
max_tokens=2000,
|
||||
)
|
||||
ai_message = await model.ainvoke(system_prompt)
|
||||
ai_content = (
|
||||
ai_message.content
|
||||
if isinstance(ai_message.content, str)
|
||||
else str(ai_message.content)
|
||||
)
|
||||
return {"answers": [clean_thinking_content(ai_content)]}
|
||||
except OpenNotebookError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_class, user_message = classify_error(e)
|
||||
raise error_class(user_message) from e
|
||||
|
||||
|
||||
async def write_final_answer(state: ThreadState, config: RunnableConfig) -> dict:
|
||||
system_prompt = Prompter(prompt_template="ask/final_answer").render(data=state) # type: ignore[arg-type]
|
||||
model = await provision_langchain_model(
|
||||
system_prompt,
|
||||
config.get("configurable", {}).get("final_answer_model"),
|
||||
"tools",
|
||||
max_tokens=2000,
|
||||
)
|
||||
ai_message = await model.ainvoke(system_prompt)
|
||||
final_content = (
|
||||
ai_message.content
|
||||
if isinstance(ai_message.content, str)
|
||||
else str(ai_message.content)
|
||||
)
|
||||
return {"final_answer": clean_thinking_content(final_content)}
|
||||
try:
|
||||
system_prompt = Prompter(prompt_template="ask/final_answer").render(data=state) # type: ignore[arg-type]
|
||||
model = await provision_langchain_model(
|
||||
system_prompt,
|
||||
config.get("configurable", {}).get("final_answer_model"),
|
||||
"tools",
|
||||
max_tokens=2000,
|
||||
)
|
||||
ai_message = await model.ainvoke(system_prompt)
|
||||
final_content = (
|
||||
ai_message.content
|
||||
if isinstance(ai_message.content, str)
|
||||
else str(ai_message.content)
|
||||
)
|
||||
return {"final_answer": clean_thinking_content(final_content)}
|
||||
except OpenNotebookError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_class, user_message = classify_error(e)
|
||||
raise error_class(user_message) from e
|
||||
|
||||
|
||||
agent_state = StateGraph(ThreadState)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ from typing_extensions import TypedDict
|
|||
from open_notebook.ai.provision import provision_langchain_model
|
||||
from open_notebook.config import LANGGRAPH_CHECKPOINT_FILE
|
||||
from open_notebook.domain.notebook import Notebook
|
||||
from open_notebook.exceptions import OpenNotebookError
|
||||
from open_notebook.utils import clean_thinking_content
|
||||
from open_notebook.utils.error_classifier import classify_error
|
||||
|
||||
|
||||
class ThreadState(TypedDict):
|
||||
|
|
@ -25,59 +27,65 @@ class ThreadState(TypedDict):
|
|||
|
||||
|
||||
def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict:
|
||||
system_prompt = Prompter(prompt_template="chat/system").render(data=state) # type: ignore[arg-type]
|
||||
payload = [SystemMessage(content=system_prompt)] + state.get("messages", [])
|
||||
model_id = config.get("configurable", {}).get("model_id") or state.get(
|
||||
"model_override"
|
||||
)
|
||||
|
||||
# Handle async model provisioning from sync context
|
||||
def run_in_new_loop():
|
||||
"""Run the async function in a new event loop"""
|
||||
new_loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(new_loop)
|
||||
return new_loop.run_until_complete(
|
||||
provision_langchain_model(
|
||||
str(payload), model_id, "chat", max_tokens=8192
|
||||
)
|
||||
)
|
||||
finally:
|
||||
new_loop.close()
|
||||
asyncio.set_event_loop(None)
|
||||
|
||||
try:
|
||||
# Try to get the current event loop
|
||||
asyncio.get_running_loop()
|
||||
# If we're in an event loop, run in a thread with a new loop
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_new_loop)
|
||||
model = future.result()
|
||||
except RuntimeError:
|
||||
# No event loop running, safe to use asyncio.run()
|
||||
model = asyncio.run(
|
||||
provision_langchain_model(
|
||||
str(payload),
|
||||
model_id,
|
||||
"chat",
|
||||
max_tokens=8192,
|
||||
)
|
||||
system_prompt = Prompter(prompt_template="chat/system").render(data=state) # type: ignore[arg-type]
|
||||
payload = [SystemMessage(content=system_prompt)] + state.get("messages", [])
|
||||
model_id = config.get("configurable", {}).get("model_id") or state.get(
|
||||
"model_override"
|
||||
)
|
||||
|
||||
ai_message = model.invoke(payload)
|
||||
# Handle async model provisioning from sync context
|
||||
def run_in_new_loop():
|
||||
"""Run the async function in a new event loop"""
|
||||
new_loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(new_loop)
|
||||
return new_loop.run_until_complete(
|
||||
provision_langchain_model(
|
||||
str(payload), model_id, "chat", max_tokens=8192
|
||||
)
|
||||
)
|
||||
finally:
|
||||
new_loop.close()
|
||||
asyncio.set_event_loop(None)
|
||||
|
||||
# Clean thinking content from AI response (e.g., <think>...</think> tags)
|
||||
content = (
|
||||
ai_message.content
|
||||
if isinstance(ai_message.content, str)
|
||||
else str(ai_message.content)
|
||||
)
|
||||
cleaned_content = clean_thinking_content(content)
|
||||
cleaned_message = ai_message.model_copy(update={"content": cleaned_content})
|
||||
try:
|
||||
# Try to get the current event loop
|
||||
asyncio.get_running_loop()
|
||||
# If we're in an event loop, run in a thread with a new loop
|
||||
import concurrent.futures
|
||||
|
||||
return {"messages": cleaned_message}
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_new_loop)
|
||||
model = future.result()
|
||||
except RuntimeError:
|
||||
# No event loop running, safe to use asyncio.run()
|
||||
model = asyncio.run(
|
||||
provision_langchain_model(
|
||||
str(payload),
|
||||
model_id,
|
||||
"chat",
|
||||
max_tokens=8192,
|
||||
)
|
||||
)
|
||||
|
||||
ai_message = model.invoke(payload)
|
||||
|
||||
# Clean thinking content from AI response (e.g., <think>...</think> tags)
|
||||
content = (
|
||||
ai_message.content
|
||||
if isinstance(ai_message.content, str)
|
||||
else str(ai_message.content)
|
||||
)
|
||||
cleaned_content = clean_thinking_content(content)
|
||||
cleaned_message = ai_message.model_copy(update={"content": cleaned_content})
|
||||
|
||||
return {"messages": cleaned_message}
|
||||
except OpenNotebookError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_class, user_message = classify_error(e)
|
||||
raise error_class(user_message) from e
|
||||
|
||||
|
||||
conn = sqlite3.connect(
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ from typing_extensions import TypedDict
|
|||
from open_notebook.ai.provision import provision_langchain_model
|
||||
from open_notebook.config import LANGGRAPH_CHECKPOINT_FILE
|
||||
from open_notebook.domain.notebook import Source, SourceInsight
|
||||
from open_notebook.exceptions import OpenNotebookError
|
||||
from open_notebook.utils import clean_thinking_content
|
||||
from open_notebook.utils.context_builder import ContextBuilder
|
||||
from open_notebook.utils.error_classifier import classify_error
|
||||
|
||||
|
||||
class SourceChatState(TypedDict):
|
||||
|
|
@ -39,6 +41,18 @@ def call_model_with_source_context(
|
|||
3. Handles model provisioning with override support
|
||||
4. Tracks context indicators for referenced insights/content
|
||||
"""
|
||||
try:
|
||||
return _call_model_with_source_context_inner(state, config)
|
||||
except OpenNotebookError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_class, user_message = classify_error(e)
|
||||
raise error_class(user_message) from e
|
||||
|
||||
|
||||
def _call_model_with_source_context_inner(
|
||||
state: SourceChatState, config: RunnableConfig
|
||||
) -> dict:
|
||||
source_id = state.get("source_id")
|
||||
if not source_id:
|
||||
raise ValueError("source_id is required in state")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ from typing_extensions import TypedDict
|
|||
from open_notebook.ai.provision import provision_langchain_model
|
||||
from open_notebook.domain.notebook import Source
|
||||
from open_notebook.domain.transformation import DefaultPrompts, Transformation
|
||||
from open_notebook.exceptions import OpenNotebookError
|
||||
from open_notebook.utils import clean_thinking_content
|
||||
from open_notebook.utils.error_classifier import classify_error
|
||||
|
||||
|
||||
class TransformationState(TypedDict):
|
||||
|
|
@ -23,41 +25,48 @@ async def run_transformation(state: dict, config: RunnableConfig) -> dict:
|
|||
content = state.get("input_text")
|
||||
assert source or content, "No content to transform"
|
||||
transformation: Transformation = state["transformation"]
|
||||
if not content:
|
||||
content = source.full_text
|
||||
transformation_template_text = transformation.prompt
|
||||
default_prompts: DefaultPrompts = DefaultPrompts(transformation_instructions=None)
|
||||
if default_prompts.transformation_instructions:
|
||||
transformation_template_text = f"{default_prompts.transformation_instructions}\n\n{transformation_template_text}"
|
||||
|
||||
transformation_template_text = f"{transformation_template_text}\n\n# INPUT"
|
||||
try:
|
||||
if not content:
|
||||
content = source.full_text
|
||||
transformation_template_text = transformation.prompt
|
||||
default_prompts: DefaultPrompts = DefaultPrompts(transformation_instructions=None)
|
||||
if default_prompts.transformation_instructions:
|
||||
transformation_template_text = f"{default_prompts.transformation_instructions}\n\n{transformation_template_text}"
|
||||
|
||||
system_prompt = Prompter(template_text=transformation_template_text).render(
|
||||
data=state
|
||||
)
|
||||
content_str = str(content) if content else ""
|
||||
payload = [SystemMessage(content=system_prompt), HumanMessage(content=content_str)]
|
||||
chain = await provision_langchain_model(
|
||||
str(payload),
|
||||
config.get("configurable", {}).get("model_id"),
|
||||
"transformation",
|
||||
max_tokens=8192,
|
||||
)
|
||||
transformation_template_text = f"{transformation_template_text}\n\n# INPUT"
|
||||
|
||||
response = await chain.ainvoke(payload)
|
||||
system_prompt = Prompter(template_text=transformation_template_text).render(
|
||||
data=state
|
||||
)
|
||||
content_str = str(content) if content else ""
|
||||
payload = [SystemMessage(content=system_prompt), HumanMessage(content=content_str)]
|
||||
chain = await provision_langchain_model(
|
||||
str(payload),
|
||||
config.get("configurable", {}).get("model_id"),
|
||||
"transformation",
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
# Clean thinking content from the response
|
||||
response_content = (
|
||||
response.content if isinstance(response.content, str) else str(response.content)
|
||||
)
|
||||
cleaned_content = clean_thinking_content(response_content)
|
||||
response = await chain.ainvoke(payload)
|
||||
|
||||
if source:
|
||||
await source.add_insight(transformation.title, cleaned_content)
|
||||
# Clean thinking content from the response
|
||||
response_content = (
|
||||
response.content if isinstance(response.content, str) else str(response.content)
|
||||
)
|
||||
cleaned_content = clean_thinking_content(response_content)
|
||||
|
||||
return {
|
||||
"output": cleaned_content,
|
||||
}
|
||||
if source:
|
||||
await source.add_insight(transformation.title, cleaned_content)
|
||||
|
||||
return {
|
||||
"output": cleaned_content,
|
||||
}
|
||||
except OpenNotebookError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_class, user_message = classify_error(e)
|
||||
raise error_class(user_message) from e
|
||||
|
||||
|
||||
agent_state = StateGraph(TransformationState)
|
||||
|
|
|
|||
97
open_notebook/utils/error_classifier.py
Normal file
97
open_notebook/utils/error_classifier.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""
|
||||
Error classification utility for LLM provider errors.
|
||||
|
||||
Maps raw exceptions from AI providers/Esperanto/LangChain to user-friendly
|
||||
error messages and appropriate exception types.
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from open_notebook.exceptions import (
|
||||
AuthenticationError,
|
||||
ConfigurationError,
|
||||
ExternalServiceError,
|
||||
NetworkError,
|
||||
OpenNotebookError,
|
||||
RateLimitError,
|
||||
)
|
||||
|
||||
# Classification rules: (keywords, exception_class, user_message or None to pass through)
|
||||
_CLASSIFICATION_RULES: list[tuple[list[str], type[OpenNotebookError], str | None]] = [
|
||||
# Authentication errors
|
||||
(
|
||||
["authentication", "unauthorized", "invalid api key", "invalid_api_key", "401"],
|
||||
AuthenticationError,
|
||||
"Authentication failed. Please check your API key in Settings -> Credentials.",
|
||||
),
|
||||
# Rate limit errors
|
||||
(
|
||||
["rate limit", "rate_limit", "429", "too many requests", "quota exceeded"],
|
||||
RateLimitError,
|
||||
"Rate limit exceeded. Please wait a moment and try again.",
|
||||
),
|
||||
# Model not found (pass through original message)
|
||||
(
|
||||
["model not found", "does not exist", "model_not_found"],
|
||||
ConfigurationError,
|
||||
None,
|
||||
),
|
||||
# Configuration errors from provision.py (pass through)
|
||||
(
|
||||
["no model configured", "please go to settings"],
|
||||
ConfigurationError,
|
||||
None,
|
||||
),
|
||||
# Network errors
|
||||
(
|
||||
["connecterror", "timeoutexception", "connection refused", "connection error", "timed out", "timeout"],
|
||||
NetworkError,
|
||||
"Could not connect to the AI provider. Please check your network connection and provider URL.",
|
||||
),
|
||||
# Context length errors
|
||||
(
|
||||
["context length", "token limit", "maximum context", "context_length_exceeded", "max_tokens"],
|
||||
ExternalServiceError,
|
||||
"Content too large for the selected model. Try using a smaller selection or a model with a larger context window.",
|
||||
),
|
||||
# Provider availability errors
|
||||
(
|
||||
["500", "502", "503", "service unavailable", "overloaded", "internal server error"],
|
||||
ExternalServiceError,
|
||||
"The AI provider is temporarily unavailable. Please try again in a few minutes.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def classify_error(exception: BaseException) -> tuple[type[OpenNotebookError], str]:
|
||||
"""
|
||||
Classify a raw exception into a user-friendly error type and message.
|
||||
|
||||
Args:
|
||||
exception: Any exception from LLM providers/Esperanto/LangChain
|
||||
|
||||
Returns:
|
||||
Tuple of (exception_class, user_friendly_message)
|
||||
"""
|
||||
error_str = str(exception).lower()
|
||||
error_type_name = type(exception).__name__.lower()
|
||||
combined = f"{error_type_name}: {error_str}"
|
||||
|
||||
for keywords, exc_class, message in _CLASSIFICATION_RULES:
|
||||
for keyword in keywords:
|
||||
if keyword in combined:
|
||||
user_message = message if message is not None else _truncate(str(exception))
|
||||
return exc_class, user_message
|
||||
|
||||
# Unclassified error - log for future improvement
|
||||
logger.warning(
|
||||
f"Unclassified LLM error ({type(exception).__name__}): {exception}"
|
||||
)
|
||||
return ExternalServiceError, f"AI service error: {_truncate(str(exception))}"
|
||||
|
||||
|
||||
def _truncate(text: str, max_length: int = 200) -> str:
|
||||
"""Truncate text to max_length to avoid leaking verbose internal details."""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length] + "..."
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "open-notebook"
|
||||
version = "1.7.1"
|
||||
version = "1.7.2"
|
||||
description = "An open source implementation of a research assistant, inspired by Google Notebook LM"
|
||||
authors = [
|
||||
{name = "Luis Novo", email = "lfnovo@gmail.com"}
|
||||
|
|
|
|||
2
uv.lock
2
uv.lock
|
|
@ -2095,7 +2095,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "open-notebook"
|
||||
version = "1.7.1"
|
||||
version = "1.7.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "ai-prompter" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue