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]
|
## [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
|
## [1.7.1] - 2026-02-14
|
||||||
|
|
||||||
### Added
|
### 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 starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
from api.auth import PasswordAuthMiddleware
|
from api.auth import PasswordAuthMiddleware
|
||||||
|
from open_notebook.exceptions import (
|
||||||
|
AuthenticationError,
|
||||||
|
ConfigurationError,
|
||||||
|
ExternalServiceError,
|
||||||
|
InvalidInputError,
|
||||||
|
NetworkError,
|
||||||
|
NotFoundError,
|
||||||
|
OpenNotebookError,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
from api.routers import (
|
from api.routers import (
|
||||||
auth,
|
auth,
|
||||||
chat,
|
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
|
# Include routers
|
||||||
app.include_router(auth.router, prefix="/api", tags=["auth"])
|
app.include_router(auth.router, prefix="/api", tags=["auth"])
|
||||||
app.include_router(config.router, prefix="/api", tags=["config"])
|
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"
|
yield f"data: {json.dumps(completion_data)}\n\n"
|
||||||
|
|
||||||
except Exception as e:
|
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)}")
|
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"
|
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"
|
yield f"data: {json.dumps(completion_event)}\n\n"
|
||||||
|
|
||||||
except Exception as e:
|
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)}")
|
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"
|
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.ai.models import Model
|
||||||
from open_notebook.domain.transformation import DefaultPrompts, Transformation
|
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
|
from open_notebook.graphs.transformation import graph as transformation_graph
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -109,6 +109,8 @@ async def execute_transformation(execute_request: TransformationExecuteRequest):
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
except OpenNotebookError:
|
||||||
|
raise # Let global exception handlers return proper status codes
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error executing transformation: {str(e)}")
|
logger.error(f"Error executing transformation: {str(e)}")
|
||||||
raise HTTPException(
|
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.ai.models import model_manager
|
||||||
from open_notebook.database.repository import ensure_record_id, repo_insert, repo_query
|
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.domain.notebook import Note, Source, SourceInsight
|
||||||
from open_notebook.utils.chunking import ContentType, chunk_text, detect_content_type
|
from open_notebook.utils.chunking import ContentType, chunk_text, detect_content_type
|
||||||
from open_notebook.utils.embedding import generate_embedding, generate_embeddings
|
from open_notebook.utils.embedding import generate_embedding, generate_embeddings
|
||||||
|
|
@ -125,7 +126,7 @@ class EmbedSourceOutput(CommandOutput):
|
||||||
"wait_strategy": "exponential_jitter",
|
"wait_strategy": "exponential_jitter",
|
||||||
"wait_min": 1,
|
"wait_min": 1,
|
||||||
"wait_max": 60,
|
"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",
|
"retry_log_level": "debug",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -217,7 +218,7 @@ async def embed_note_command(input_data: EmbedNoteInput) -> EmbedNoteOutput:
|
||||||
"wait_strategy": "exponential_jitter",
|
"wait_strategy": "exponential_jitter",
|
||||||
"wait_min": 1,
|
"wait_min": 1,
|
||||||
"wait_max": 60,
|
"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",
|
"retry_log_level": "debug",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -311,7 +312,7 @@ async def embed_insight_command(input_data: EmbedInsightInput) -> EmbedInsightOu
|
||||||
"wait_strategy": "exponential_jitter",
|
"wait_strategy": "exponential_jitter",
|
||||||
"wait_min": 1,
|
"wait_min": 1,
|
||||||
"wait_max": 60,
|
"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",
|
"retry_log_level": "debug",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -447,7 +448,7 @@ async def embed_source_command(input_data: EmbedSourceInput) -> EmbedSourceOutpu
|
||||||
"wait_strategy": "exponential_jitter",
|
"wait_strategy": "exponential_jitter",
|
||||||
"wait_min": 1,
|
"wait_min": 1,
|
||||||
"wait_max": 60,
|
"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",
|
"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.database.repository import ensure_record_id
|
||||||
from open_notebook.domain.notebook import Source
|
from open_notebook.domain.notebook import Source
|
||||||
from open_notebook.domain.transformation import Transformation
|
from open_notebook.domain.transformation import Transformation
|
||||||
|
from open_notebook.exceptions import ConfigurationError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from open_notebook.graphs.source import source_graph
|
from open_notebook.graphs.source import source_graph
|
||||||
|
|
@ -53,7 +54,7 @@ class SourceProcessingOutput(CommandOutput):
|
||||||
"wait_strategy": "exponential_jitter",
|
"wait_strategy": "exponential_jitter",
|
||||||
"wait_min": 1,
|
"wait_min": 1,
|
||||||
"wait_max": 120, # Allow queue to drain
|
"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
|
"retry_log_level": "debug", # Avoid log noise during transaction conflicts
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -184,7 +185,7 @@ class RunTransformationOutput(CommandOutput):
|
||||||
"wait_strategy": "exponential_jitter",
|
"wait_strategy": "exponential_jitter",
|
||||||
"wait_min": 1,
|
"wait_min": 1,
|
||||||
"wait_max": 60,
|
"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",
|
"retry_log_level": "debug",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
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 { searchApi } from '@/lib/api/search'
|
||||||
import { AskStreamEvent } from '@/lib/types/search'
|
import { AskStreamEvent } from '@/lib/types/search'
|
||||||
|
|
||||||
|
|
@ -122,8 +122,12 @@ export function useAsk() {
|
||||||
throw new Error(data.message || 'Stream error occurred')
|
throw new Error(data.message || 'Stream error occurred')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing SSE data:', e, 'Line:', line)
|
if (e instanceof SyntaxError) {
|
||||||
// Don't throw - continue processing other lines
|
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'), {
|
toast.error(t('apiErrors.askFailed'), {
|
||||||
description: t(getApiErrorKey(errorMessage))
|
description: getApiErrorMessage(errorMessage, (key) => t(key))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { sourcesApi } from '@/lib/api/sources'
|
||||||
import { QUERY_KEYS } from '@/lib/api/query-client'
|
import { QUERY_KEYS } from '@/lib/api/query-client'
|
||||||
import { useToast } from '@/lib/hooks/use-toast'
|
import { useToast } from '@/lib/hooks/use-toast'
|
||||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import { getApiErrorKey } from '@/lib/utils/error-handler'
|
import { getApiErrorMessage } from '@/lib/utils/error-handler'
|
||||||
import {
|
import {
|
||||||
CreateSourceRequest,
|
CreateSourceRequest,
|
||||||
UpdateSourceRequest,
|
UpdateSourceRequest,
|
||||||
|
|
@ -131,7 +131,7 @@ export function useCreateSource() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.sources.failedToAddSource)),
|
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSource),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -158,7 +158,7 @@ export function useUpdateSource() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.sources.failedToUpdateSource)),
|
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUpdateSource),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -185,7 +185,7 @@ export function useDeleteSource() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.sources.failedToDeleteSource)),
|
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToDeleteSource),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -212,7 +212,7 @@ export function useFileUpload() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.sources.failedToUploadFile)),
|
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUploadFile),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -270,7 +270,7 @@ export function useRetrySource() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.sources.failedToRetry)),
|
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRetry),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -332,7 +332,7 @@ export function useAddSourcesToNotebook() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.sources.failedToAddSourcesToNotebook)),
|
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSourcesToNotebook),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -366,7 +366,7 @@ export function useRemoveSourceFromNotebook() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.sources.failedToRemoveSourceFromNotebook)),
|
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRemoveSourceFromNotebook),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { transformationsApi } from '@/lib/api/transformations'
|
import { transformationsApi } from '@/lib/api/transformations'
|
||||||
import { useToast } from '@/lib/hooks/use-toast'
|
import { useToast } from '@/lib/hooks/use-toast'
|
||||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import { getApiErrorKey } from '@/lib/utils/error-handler'
|
import { getApiErrorMessage } from '@/lib/utils/error-handler'
|
||||||
import {
|
import {
|
||||||
CreateTransformationRequest,
|
CreateTransformationRequest,
|
||||||
UpdateTransformationRequest,
|
UpdateTransformationRequest,
|
||||||
|
|
@ -49,7 +49,7 @@ export function useCreateTransformation() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.common.error)),
|
description: getApiErrorMessage(error, (key) => t(key)),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -75,7 +75,7 @@ export function useUpdateTransformation() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.common.error)),
|
description: getApiErrorMessage(error, (key) => t(key)),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -99,7 +99,7 @@ export function useDeleteTransformation() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.common.error)),
|
description: getApiErrorMessage(error, (key) => t(key)),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -115,7 +115,7 @@ export function useExecuteTransformation() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.common.error)),
|
description: getApiErrorMessage(error, (key) => t(key)),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -146,7 +146,7 @@ export function useUpdateDefaultPrompt() {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: t.common.error,
|
title: t.common.error,
|
||||||
description: t(getApiErrorKey(error, t.common.error)),
|
description: getApiErrorMessage(error, (key) => t(key)),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
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 { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import { chatApi } from '@/lib/api/chat'
|
import { chatApi } from '@/lib/api/chat'
|
||||||
import { QUERY_KEYS } from '@/lib/api/query-client'
|
import { QUERY_KEYS } from '@/lib/api/query-client'
|
||||||
|
|
@ -84,7 +84,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
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) => {
|
onError: (err: unknown) => {
|
||||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
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) => {
|
onError: (err: unknown) => {
|
||||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
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) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -230,7 +230,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||||
console.error('Error sending message:', error)
|
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
|
// Remove optimistic message on error
|
||||||
setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))
|
setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
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 { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import { sourceChatApi } from '@/lib/api/source-chat'
|
import { sourceChatApi } from '@/lib/api/source-chat'
|
||||||
import {
|
import {
|
||||||
|
|
@ -64,7 +64,7 @@ export function useSourceChat(sourceId: string) {
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
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) => {
|
onError: (err: unknown) => {
|
||||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
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) => {
|
onError: (err: unknown) => {
|
||||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
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) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||||
console.error('Failed to create chat session:', error)
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +182,11 @@ export function useSourceChat(sourceId: string) {
|
||||||
throw new Error(data.message || 'Stream error')
|
throw new Error(data.message || 'Stream error')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||||
console.error('Error sending message:', error)
|
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
|
// Remove optimistic messages on error
|
||||||
setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))
|
setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,31 @@ export function getApiErrorKey(errorOrMessage: unknown, fallbackKey?: string): s
|
||||||
return fallbackKey || "apiErrors.genericError";
|
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.
|
* 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.database.repository import ensure_record_id, repo_query
|
||||||
from open_notebook.domain.base import ObjectModel, RecordModel
|
from open_notebook.domain.base import ObjectModel, RecordModel
|
||||||
|
from open_notebook.exceptions import ConfigurationError
|
||||||
|
|
||||||
ModelType = Union[LanguageModel, EmbeddingModel, SpeechToTextModel, TextToSpeechModel]
|
ModelType = Union[LanguageModel, EmbeddingModel, SpeechToTextModel, TextToSpeechModel]
|
||||||
|
|
||||||
|
|
@ -106,7 +107,7 @@ class ModelManager:
|
||||||
try:
|
try:
|
||||||
model: Model = await Model.get(model_id)
|
model: Model = await Model.get(model_id)
|
||||||
except Exception:
|
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 [
|
if not model.type or model.type not in [
|
||||||
"language",
|
"language",
|
||||||
|
|
@ -114,7 +115,7 @@ class ModelManager:
|
||||||
"speech_to_text",
|
"speech_to_text",
|
||||||
"text_to_speech",
|
"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
|
# Build config from credential if linked, otherwise fall back to env vars
|
||||||
config: dict = {}
|
config: dict = {}
|
||||||
|
|
@ -172,7 +173,7 @@ class ModelManager:
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid model type: {model.type}")
|
raise ConfigurationError(f"Invalid model type: {model.type}")
|
||||||
|
|
||||||
async def get_defaults(self) -> DefaultModels:
|
async def get_defaults(self) -> DefaultModels:
|
||||||
"""Get the default models configuration from database"""
|
"""Get the default models configuration from database"""
|
||||||
|
|
@ -254,7 +255,7 @@ class ModelManager:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await self.get_model(model_id, **kwargs)
|
return await self.get_model(model_id, **kwargs)
|
||||||
except ValueError as e:
|
except (ValueError, ConfigurationError) as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to load default model for type '{model_type}': {e}. "
|
f"Failed to load default model for type '{model_type}': {e}. "
|
||||||
f"The configured model_id '{model_id}' may have been deleted or misconfigured. "
|
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 loguru import logger
|
||||||
|
|
||||||
from open_notebook.ai.models import model_manager
|
from open_notebook.ai.models import model_manager
|
||||||
|
from open_notebook.exceptions import ConfigurationError
|
||||||
from open_notebook.utils import token_count
|
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"model_id={model_id}, default_type={default_type}. "
|
||||||
f"Please check Settings → Models and ensure a default model is configured for '{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"No model configured for {selection_reason}. "
|
||||||
f"Please go to Settings → Models and configure a default model for '{default_type}'."
|
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"Selection reason: {selection_reason}. "
|
||||||
f"model_id={model_id}, default_type={default_type}."
|
f"model_id={model_id}, default_type={default_type}."
|
||||||
)
|
)
|
||||||
raise ValueError(
|
raise ConfigurationError(
|
||||||
f"Model is not a LanguageModel: {model}. "
|
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."
|
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.ai.provision import provision_langchain_model
|
||||||
from open_notebook.domain.notebook import vector_search
|
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 import clean_thinking_content
|
||||||
|
from open_notebook.utils.error_classifier import classify_error
|
||||||
|
|
||||||
|
|
||||||
class SubGraphState(TypedDict):
|
class SubGraphState(TypedDict):
|
||||||
|
|
@ -46,33 +48,39 @@ class ThreadState(TypedDict):
|
||||||
|
|
||||||
|
|
||||||
async def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict:
|
async def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict:
|
||||||
parser = PydanticOutputParser(pydantic_object=Strategy)
|
try:
|
||||||
system_prompt = Prompter(prompt_template="ask/entry", parser=parser).render( # type: ignore[arg-type]
|
parser = PydanticOutputParser(pydantic_object=Strategy)
|
||||||
data=state # type: ignore[arg-type]
|
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,
|
model = await provision_langchain_model(
|
||||||
config.get("configurable", {}).get("strategy_model"),
|
system_prompt,
|
||||||
"tools",
|
config.get("configurable", {}).get("strategy_model"),
|
||||||
max_tokens=2000,
|
"tools",
|
||||||
structured=dict(type="json"),
|
max_tokens=2000,
|
||||||
)
|
structured=dict(type="json"),
|
||||||
# model = model.bind_tools(tools)
|
)
|
||||||
# First get the raw response from the model
|
# model = model.bind_tools(tools)
|
||||||
ai_message = await model.ainvoke(system_prompt)
|
# First get the raw response from the model
|
||||||
|
ai_message = await model.ainvoke(system_prompt)
|
||||||
|
|
||||||
# Clean the thinking content from the response
|
# Clean the thinking content from the response
|
||||||
message_content = (
|
message_content = (
|
||||||
ai_message.content
|
ai_message.content
|
||||||
if isinstance(ai_message.content, str)
|
if isinstance(ai_message.content, str)
|
||||||
else str(ai_message.content)
|
else str(ai_message.content)
|
||||||
)
|
)
|
||||||
cleaned_content = clean_thinking_content(message_content)
|
cleaned_content = clean_thinking_content(message_content)
|
||||||
|
|
||||||
# Parse the cleaned JSON content
|
# Parse the cleaned JSON content
|
||||||
strategy = parser.parse(cleaned_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):
|
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:
|
async def provide_answer(state: SubGraphState, config: RunnableConfig) -> dict:
|
||||||
payload = state
|
try:
|
||||||
# if state["type"] == "text":
|
payload = state
|
||||||
# results = text_search(state["term"], 10, True, True)
|
# if state["type"] == "text":
|
||||||
# else:
|
# results = text_search(state["term"], 10, True, True)
|
||||||
results = await vector_search(state["term"], 10, True, True)
|
# else:
|
||||||
if len(results) == 0:
|
results = await vector_search(state["term"], 10, True, True)
|
||||||
return {"answers": []}
|
if len(results) == 0:
|
||||||
payload["results"] = results
|
return {"answers": []}
|
||||||
ids = [r["id"] for r in results]
|
payload["results"] = results
|
||||||
payload["ids"] = ids
|
ids = [r["id"] for r in results]
|
||||||
system_prompt = Prompter(prompt_template="ask/query_process").render(data=payload) # type: ignore[arg-type]
|
payload["ids"] = ids
|
||||||
model = await provision_langchain_model(
|
system_prompt = Prompter(prompt_template="ask/query_process").render(data=payload) # type: ignore[arg-type]
|
||||||
system_prompt,
|
model = await provision_langchain_model(
|
||||||
config.get("configurable", {}).get("answer_model"),
|
system_prompt,
|
||||||
"tools",
|
config.get("configurable", {}).get("answer_model"),
|
||||||
max_tokens=2000,
|
"tools",
|
||||||
)
|
max_tokens=2000,
|
||||||
ai_message = await model.ainvoke(system_prompt)
|
)
|
||||||
ai_content = (
|
ai_message = await model.ainvoke(system_prompt)
|
||||||
ai_message.content
|
ai_content = (
|
||||||
if isinstance(ai_message.content, str)
|
ai_message.content
|
||||||
else str(ai_message.content)
|
if isinstance(ai_message.content, str)
|
||||||
)
|
else str(ai_message.content)
|
||||||
return {"answers": [clean_thinking_content(ai_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:
|
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]
|
try:
|
||||||
model = await provision_langchain_model(
|
system_prompt = Prompter(prompt_template="ask/final_answer").render(data=state) # type: ignore[arg-type]
|
||||||
system_prompt,
|
model = await provision_langchain_model(
|
||||||
config.get("configurable", {}).get("final_answer_model"),
|
system_prompt,
|
||||||
"tools",
|
config.get("configurable", {}).get("final_answer_model"),
|
||||||
max_tokens=2000,
|
"tools",
|
||||||
)
|
max_tokens=2000,
|
||||||
ai_message = await model.ainvoke(system_prompt)
|
)
|
||||||
final_content = (
|
ai_message = await model.ainvoke(system_prompt)
|
||||||
ai_message.content
|
final_content = (
|
||||||
if isinstance(ai_message.content, str)
|
ai_message.content
|
||||||
else str(ai_message.content)
|
if isinstance(ai_message.content, str)
|
||||||
)
|
else str(ai_message.content)
|
||||||
return {"final_answer": clean_thinking_content(final_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)
|
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.ai.provision import provision_langchain_model
|
||||||
from open_notebook.config import LANGGRAPH_CHECKPOINT_FILE
|
from open_notebook.config import LANGGRAPH_CHECKPOINT_FILE
|
||||||
from open_notebook.domain.notebook import Notebook
|
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 import clean_thinking_content
|
||||||
|
from open_notebook.utils.error_classifier import classify_error
|
||||||
|
|
||||||
|
|
||||||
class ThreadState(TypedDict):
|
class ThreadState(TypedDict):
|
||||||
|
|
@ -25,59 +27,65 @@ class ThreadState(TypedDict):
|
||||||
|
|
||||||
|
|
||||||
def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict:
|
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:
|
||||||
# Try to get the current event loop
|
system_prompt = Prompter(prompt_template="chat/system").render(data=state) # type: ignore[arg-type]
|
||||||
asyncio.get_running_loop()
|
payload = [SystemMessage(content=system_prompt)] + state.get("messages", [])
|
||||||
# If we're in an event loop, run in a thread with a new loop
|
model_id = config.get("configurable", {}).get("model_id") or state.get(
|
||||||
import concurrent.futures
|
"model_override"
|
||||||
|
|
||||||
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)
|
# 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)
|
try:
|
||||||
content = (
|
# Try to get the current event loop
|
||||||
ai_message.content
|
asyncio.get_running_loop()
|
||||||
if isinstance(ai_message.content, str)
|
# If we're in an event loop, run in a thread with a new loop
|
||||||
else str(ai_message.content)
|
import concurrent.futures
|
||||||
)
|
|
||||||
cleaned_content = clean_thinking_content(content)
|
|
||||||
cleaned_message = ai_message.model_copy(update={"content": cleaned_content})
|
|
||||||
|
|
||||||
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(
|
conn = sqlite3.connect(
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ from typing_extensions import TypedDict
|
||||||
from open_notebook.ai.provision import provision_langchain_model
|
from open_notebook.ai.provision import provision_langchain_model
|
||||||
from open_notebook.config import LANGGRAPH_CHECKPOINT_FILE
|
from open_notebook.config import LANGGRAPH_CHECKPOINT_FILE
|
||||||
from open_notebook.domain.notebook import Source, SourceInsight
|
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 import clean_thinking_content
|
||||||
from open_notebook.utils.context_builder import ContextBuilder
|
from open_notebook.utils.context_builder import ContextBuilder
|
||||||
|
from open_notebook.utils.error_classifier import classify_error
|
||||||
|
|
||||||
|
|
||||||
class SourceChatState(TypedDict):
|
class SourceChatState(TypedDict):
|
||||||
|
|
@ -39,6 +41,18 @@ def call_model_with_source_context(
|
||||||
3. Handles model provisioning with override support
|
3. Handles model provisioning with override support
|
||||||
4. Tracks context indicators for referenced insights/content
|
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")
|
source_id = state.get("source_id")
|
||||||
if not source_id:
|
if not source_id:
|
||||||
raise ValueError("source_id is required in state")
|
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.ai.provision import provision_langchain_model
|
||||||
from open_notebook.domain.notebook import Source
|
from open_notebook.domain.notebook import Source
|
||||||
from open_notebook.domain.transformation import DefaultPrompts, Transformation
|
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 import clean_thinking_content
|
||||||
|
from open_notebook.utils.error_classifier import classify_error
|
||||||
|
|
||||||
|
|
||||||
class TransformationState(TypedDict):
|
class TransformationState(TypedDict):
|
||||||
|
|
@ -23,41 +25,48 @@ async def run_transformation(state: dict, config: RunnableConfig) -> dict:
|
||||||
content = state.get("input_text")
|
content = state.get("input_text")
|
||||||
assert source or content, "No content to transform"
|
assert source or content, "No content to transform"
|
||||||
transformation: Transformation = state["transformation"]
|
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(
|
transformation_template_text = f"{transformation_template_text}\n\n# INPUT"
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
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 = await chain.ainvoke(payload)
|
||||||
response_content = (
|
|
||||||
response.content if isinstance(response.content, str) else str(response.content)
|
|
||||||
)
|
|
||||||
cleaned_content = clean_thinking_content(response_content)
|
|
||||||
|
|
||||||
if source:
|
# Clean thinking content from the response
|
||||||
await source.add_insight(transformation.title, cleaned_content)
|
response_content = (
|
||||||
|
response.content if isinstance(response.content, str) else str(response.content)
|
||||||
|
)
|
||||||
|
cleaned_content = clean_thinking_content(response_content)
|
||||||
|
|
||||||
return {
|
if source:
|
||||||
"output": cleaned_content,
|
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)
|
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]
|
[project]
|
||||||
name = "open-notebook"
|
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"
|
description = "An open source implementation of a research assistant, inspired by Google Notebook LM"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Luis Novo", email = "lfnovo@gmail.com"}
|
{name = "Luis Novo", email = "lfnovo@gmail.com"}
|
||||||
|
|
|
||||||
2
uv.lock
2
uv.lock
|
|
@ -2095,7 +2095,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open-notebook"
|
name = "open-notebook"
|
||||||
version = "1.7.1"
|
version = "1.7.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "ai-prompter" },
|
{ name = "ai-prompter" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue