ffreemt commited on
Commit
3793f68
·
1 Parent(s): b7ed9ce
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env +2 -2
  2. Dockerfile +60 -0
  3. api/CLAUDE.md +260 -0
  4. api/__init__.py +0 -0
  5. api/auth.py +114 -0
  6. api/chat_service.py +168 -0
  7. api/client.py +529 -0
  8. api/command_service.py +92 -0
  9. api/context_service.py +29 -0
  10. api/credentials_service.py +890 -0
  11. api/embedding_service.py +27 -0
  12. api/episode_profiles_service.py +112 -0
  13. api/insights_service.py +100 -0
  14. api/main.py +322 -0
  15. api/models.py +686 -0
  16. api/models_service.py +112 -0
  17. api/notebook_service.py +87 -0
  18. api/notes_service.py +103 -0
  19. api/podcast_api_service.py +125 -0
  20. api/podcast_service.py +206 -0
  21. api/routers/__init__.py +0 -0
  22. api/routers/auth.py +27 -0
  23. api/routers/chat.py +526 -0
  24. api/routers/commands.py +166 -0
  25. api/routers/config.py +160 -0
  26. api/routers/context.py +115 -0
  27. api/routers/credentials.py +426 -0
  28. api/routers/embedding.py +113 -0
  29. api/routers/embedding_rebuild.py +192 -0
  30. api/routers/episode_profiles.py +226 -0
  31. api/routers/insights.py +82 -0
  32. api/routers/languages.py +83 -0
  33. api/routers/models.py +776 -0
  34. api/routers/notebooks.py +354 -0
  35. api/routers/notes.py +189 -0
  36. api/routers/podcasts.py +299 -0
  37. api/routers/search.py +217 -0
  38. api/routers/settings.py +88 -0
  39. api/routers/source_chat.py +554 -0
  40. api/routers/sources.py +1045 -0
  41. api/routers/speaker_profiles.py +190 -0
  42. api/routers/transformations.py +252 -0
  43. api/search_service.py +58 -0
  44. api/settings_service.py +79 -0
  45. api/sources_service.py +324 -0
  46. api/transformations_service.py +141 -0
  47. commands/CLAUDE.md +68 -0
  48. commands/__init__.py +24 -0
  49. commands/embedding_commands.py +787 -0
  50. commands/example_commands.py +142 -0
.env CHANGED
@@ -6,8 +6,8 @@ DOTENV_PUBLIC_KEY="02432e6a4895a1e967061d0588df908fba1730139bdf599d3021034d4035a
6
 
7
  # dotenvx encrypt -ek "SURREAL_*"
8
  # .env
9
- GOOGLE_API_KEY=encrypted:BEpFDkAjzT+MXhXpTo0MtlI+PCdMT2xc+qTQQGmHQifUWx4RxNG8ylRvCCB3jFe4fejMhBMBsDXrpt9OiurXDVvoStO4jl6QuVq1TfWHRrqbBYFCVRRlZnr7mx+MCwkhp8sWHWg=
10
- GROQ_API_KEY=encrypted:BGb7PzEU1NUTXUjCslpPqu9mCgwCO7G5cf2B4jp5AciPl2t6lwEhKvFLknRGzLVPu7RHoVN7Y+D78IhgJf3mDnuk9Oiz6b9KkDzXOpRjmQhO22GVnqPM8WtAgqBQx6BY5FGAaik=
11
 
12
  SURREAL_URL=ws://127.0.0.1:8000/rpc
13
  SURREAL_NAMESPACE=open_notebook
 
6
 
7
  # dotenvx encrypt -ek "SURREAL_*"
8
  # .env
9
+ GOOGLE_API_KEY=AIzaSyAFlllmL6s0l-SKezU6sZRQ7ZzNQODgQro
10
+ GROQ_API_KEY=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
11
 
12
  SURREAL_URL=ws://127.0.0.1:8000/rpc
13
  SURREAL_NAMESPACE=open_notebook
Dockerfile ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Set PYTHONPATH to include /app
6
+ ENV PYTHONPATH=/app
7
+
8
+ # Set Hugging Face cache directories (writable in HF Spaces)
9
+ ENV HF_HOME=/tmp
10
+ ENV TRANSFORMERS_CACHE=/tmp
11
+ ENV SENTENCE_TRANSFORMERS_HOME=/tmp
12
+
13
+ # Install system dependencies
14
+ RUN apt-get update && apt-get install -y \
15
+ curl \
16
+ build-essential && \
17
+ curl -sSf https://install.surrealdb.com | sh && \
18
+ curl -fsS https://dotenvx.sh | sh && \
19
+ rm -rf /var/lib/apt/lists/*
20
+
21
+ # Copy requirements.txt for dependency installation
22
+ COPY requirements.txt ./
23
+
24
+ # Install Python dependencies from requirements.txt
25
+ RUN pip install --no-cache-dir --upgrade pip && \
26
+ pip install --no-cache-dir -r requirements.txt
27
+
28
+ # Explicitly ensure surreal-commands is installed (belt-and-suspenders approach)
29
+ # RUN pip install --no-cache-dir surreal-commands>=1.2.0
30
+ # requirements.txt
31
+
32
+ # Pre-download sentence-transformers model at build time
33
+ # This will be cached in the Docker image
34
+ RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')"
35
+
36
+ # Copy application code
37
+ COPY api/ ./open_notebook/ ./commands/ ./migrations/ ./prompts/ run_api.py start.sh ./
38
+
39
+ # Make start script executable
40
+ RUN chmod +x start.sh
41
+
42
+ # Set environment variables for SurrealDB connection
43
+ ENV SURREAL_URL=ws://localhost:8000/rpc
44
+ ENV SURREAL_ADDRESS=localhost
45
+ ENV SURREAL_PORT=8000
46
+ ENV SURREAL_USER=root
47
+ ENV SURREAL_PASS=root
48
+ ENV SURREAL_NAMESPACE=open_notebook
49
+ ENV SURREAL_DATABASE=main
50
+
51
+ # Set API configuration for Hugging Face Spaces
52
+ ENV API_HOST=0.0.0.0
53
+ ENV API_PORT=7860
54
+ ENV API_RELOAD=false
55
+
56
+ # Expose Hugging Face Spaces port
57
+ EXPOSE 7860
58
+
59
+ # Run the start script
60
+ CMD ["dotenvx", "./start.sh"]
api/CLAUDE.md ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Module
2
+
3
+ FastAPI-based REST backend exposing services for notebooks, sources, notes, chat, podcasts, and AI model management.
4
+
5
+ ## Purpose
6
+
7
+ FastAPI application serving three architectural layers: routes (HTTP endpoints), services (business logic), and models (request/response schemas). Integrates LangGraph workflows (chat, ask, source_chat), SurrealDB persistence, and AI providers via Esperanto.
8
+
9
+ ## Architecture Overview
10
+
11
+ **Three layers**:
12
+ 1. **Routes** (`routers/*`): HTTP endpoints mapping to services
13
+ 2. **Services** (`*_service.py`): Business logic orchestrating domain models, database, graphs, AI providers
14
+ 3. **Models** (`models.py`): Pydantic request/response schemas with validation
15
+
16
+ **Startup flow**:
17
+ - Load .env environment variables
18
+ - Initialize CORS middleware + password auth middleware
19
+ - Run database migrations via AsyncMigrationManager on lifespan startup
20
+ - Run podcast profile data migration (legacy string to model registry conversion)
21
+ - Register all routers
22
+
23
+ **Key services**:
24
+ - `chat_service.py`: Invokes chat graph with messages, context
25
+ - `podcast_service.py`: Orchestrates outline + transcript generation
26
+ - `sources_service.py`: Content ingestion, vectorization, metadata
27
+ - `notes_service.py`: Note creation, linking to sources/insights
28
+ - `transformations_service.py`: Applies transformations to content
29
+ - `models_service.py`: Manages AI provider/model configuration
30
+ - `episode_profiles_service.py`: Manages podcast speaker/episode profiles
31
+
32
+ ## Component Catalog
33
+
34
+ ### Main Application
35
+ - **main.py**: FastAPI app initialization, CORS setup, auth middleware, lifespan event, router registration
36
+ - **Lifespan handler**: Runs AsyncMigrationManager on startup (database schema migration)
37
+ - **Auth middleware**: PasswordAuthMiddleware protects endpoints (password-based access control)
38
+
39
+ ### Services (Business Logic)
40
+ - **chat_service.py**: Invokes chat.py graph; handles message history via SqliteSaver
41
+ - **podcast_service.py**: Generates outline (outline.jinja), then transcript (transcript.jinja) for episodes
42
+ - **sources_service.py**: Ingests files/URLs (content_core), extracts text, vectorizes, saves to SurrealDB
43
+ - **transformations_service.py**: Applies transformations via transformation.py graph
44
+ - **models_service.py**: Manages ModelManager config (AI provider overrides)
45
+ - **episode_profiles_service.py**: CRUD for EpisodeProfile and SpeakerProfile models
46
+ - **insights_service.py**: Generates and retrieves source insights
47
+ - **notes_service.py**: Creates notes linked to sources/insights
48
+
49
+ ### Models (Schemas)
50
+ - **models.py**: Pydantic schemas for request/response validation
51
+ - Request bodies: ChatRequest, CreateNoteRequest, PodcastGenerationRequest, etc.
52
+ - Response bodies: ChatResponse, NoteResponse, PodcastResponse, etc.
53
+ - Custom validators for enum fields, file paths, model references
54
+
55
+ ### Routers
56
+ - **routers/chat.py**: POST /chat
57
+ - **routers/source_chat.py**: POST /source/{source_id}/chat
58
+ - **routers/podcasts.py**: POST /podcasts, GET /podcasts/{id}, POST /podcasts/episodes/{id}/retry, etc.
59
+ - **routers/notes.py**: POST /notes, GET /notes/{id}
60
+ - **routers/sources.py**: POST /sources, GET /sources/{id}, DELETE /sources/{id}
61
+ - **routers/models.py**: GET /models, POST /models/config
62
+ - **routers/credentials.py**: CRUD + test + discover + migrate for credential management
63
+ - **routers/transformations.py**: POST /transformations
64
+ - **routers/insights.py**: GET /sources/{source_id}/insights
65
+ - **routers/auth.py**: POST /auth/password (password-based auth)
66
+ - **routers/languages.py**: GET /languages (available podcast languages via pycountry+babel)
67
+ - **routers/commands.py**: GET /commands/{command_id} (job status tracking)
68
+
69
+ ## Common Patterns
70
+
71
+ - **Service injection via FastAPI**: Routers import services directly; no DI framework
72
+ - **Async/await throughout**: All DB queries, graph invocations, AI calls are async
73
+ - **SurrealDB transactions**: Services use repo_query, repo_create, repo_upsert from database layer
74
+ - **Config override pattern**: Models/config override via models_service passed to graph.ainvoke(config=...)
75
+ - **Error handling**: Custom exception hierarchy (`open_notebook.exceptions`) with global FastAPI exception handlers mapping to HTTP status codes (see Error Handling section below). LangGraph nodes use `classify_error()` to convert raw LLM provider errors into typed exceptions with user-friendly messages.
76
+ - **Logging**: loguru logger in main.py; services expected to log key operations
77
+ - **Response normalization**: All responses follow standard schema (data + metadata structure)
78
+
79
+ ## Key Dependencies
80
+
81
+ - `fastapi`: FastAPI app, routers, HTTPException
82
+ - `pydantic`: Validation models with Field, field_validator
83
+ - `open_notebook.graphs`: chat, ask, source_chat, source, transformation graphs
84
+ - `open_notebook.database`: SurrealDB repository functions (repo_query, repo_create, repo_upsert)
85
+ - `open_notebook.domain`: Notebook, Source, Note, SourceInsight models
86
+ - `open_notebook.ai.provision`: provision_langchain_model() factory
87
+ - `ai_prompter`: Prompter for template rendering
88
+ - `content_core`: extract_content() for file/URL processing
89
+ - `esperanto`: AI provider client library (LLM, embeddings, TTS)
90
+ - `surreal_commands`: Job queue for async operations (podcast generation)
91
+ - `loguru`: Structured logging
92
+
93
+ ## Important Quirks & Gotchas
94
+
95
+ - **Migration auto-run**: Database schema migrations run on every API startup (via lifespan); no manual migration steps
96
+ - **PasswordAuthMiddleware is basic**: Uses simple password check; production deployments should replace with OAuth/JWT
97
+ - **No request rate limiting**: No built-in rate limiting; deployment must add via proxy/middleware
98
+ - **Service state is stateless**: Services don't cache results; each request re-queries database/AI models
99
+ - **Graph invocation is blocking**: chat/podcast workflows may take minutes; no timeout handling in services
100
+ - **Command job fire-and-forget**: podcast_service.py submits jobs but doesn't wait (async job queue pattern)
101
+ - **Model override scoping**: Model config override via RunnableConfig is per-request only (not persistent)
102
+ - **CORS open by default**: main.py CORS settings allow all origins (restrict before production)
103
+ - **No OpenAPI security scheme**: API docs available without auth (disable before production)
104
+ - **Services don't validate user permission**: All endpoints trust authentication layer; no per-notebook permission checks
105
+
106
+ ## Error Handling
107
+
108
+ ### Global Exception Handlers (`main.py`)
109
+
110
+ FastAPI exception handlers map custom exception types from `open_notebook.exceptions` to HTTP status codes. All error responses include CORS headers.
111
+
112
+ | Exception Class | HTTP Status | Use Case |
113
+ |----------------|-------------|----------|
114
+ | `NotFoundError` | 404 | Resource not found |
115
+ | `InvalidInputError` | 400 | Bad request data |
116
+ | `AuthenticationError` | 401 | Invalid/missing API key |
117
+ | `RateLimitError` | 429 | Provider rate limit exceeded |
118
+ | `ConfigurationError` | 422 | Wrong model name, missing config |
119
+ | `NetworkError` | 502 | Cannot reach AI provider |
120
+ | `ExternalServiceError` | 502 | Provider returned error (500/503, context length) |
121
+ | `OpenNotebookError` (base) | 500 | Any other application error |
122
+
123
+ ### Error Classification (`open_notebook.utils.error_classifier`)
124
+
125
+ The `classify_error()` function maps raw exceptions from LLM providers/Esperanto/LangChain into the typed exceptions above with user-friendly messages. Used in all LangGraph graph nodes and SSE streaming handlers.
126
+
127
+ **Flow**: Raw exception → keyword matching → `(ExceptionClass, user_message)` → raised → caught by global handler → HTTP response with descriptive message.
128
+
129
+ ### Frontend Integration
130
+
131
+ The frontend `getApiErrorMessage()` helper (`lib/utils/error-handler.ts`) tries i18n mapping first, then falls back to displaying the backend's descriptive error message directly.
132
+
133
+ ---
134
+
135
+ ## How to Add New Endpoint
136
+
137
+ 1. Create router file in `routers/` (e.g., `routers/new_feature.py`)
138
+ 2. Import router into `main.py` and register: `app.include_router(new_feature.router, tags=["new_feature"])`
139
+ 3. Create service in `new_feature_service.py` with business logic
140
+ 4. Define request/response schemas in `models.py` (or create `new_feature_models.py`)
141
+ 5. Implement router functions calling service methods
142
+ 6. Test with `uv run uvicorn api.main:app --host 0.0.0.0 --port 5055`
143
+
144
+ ## Testing Patterns
145
+
146
+ - **Interactive docs**: http://localhost:5055/docs (Swagger UI)
147
+ - **Direct service tests**: Import service, call methods directly with test data
148
+ - **Mock graphs**: Replace graph.ainvoke() with mock for testing service logic
149
+ - **Database: Use test database** (separate SurrealDB instance or mock repo_query)
150
+
151
+ ---
152
+
153
+ ## Credential Management (API Configuration UI)
154
+
155
+ The Credential Management system enables users to configure AI provider credentials through the UI instead of environment variables. Keys are stored securely in SurrealDB (encrypted via Fernet) with database-first fallback to environment variables.
156
+
157
+ ### Router: `routers/credentials.py`
158
+
159
+ **Endpoints**:
160
+
161
+ | Method | Endpoint | Description |
162
+ |--------|----------|-------------|
163
+ | GET | `/credentials` | List all credentials (optional `?provider=` filter) |
164
+ | GET | `/credentials/by-provider/{provider}` | List credentials for a provider |
165
+ | POST | `/credentials` | Create a new credential |
166
+ | GET | `/credentials/{credential_id}` | Get a specific credential |
167
+ | PUT | `/credentials/{credential_id}` | Update a credential |
168
+ | DELETE | `/credentials/{credential_id}` | Delete a credential |
169
+ | POST | `/credentials/{credential_id}/test` | Test connection using credential |
170
+ | POST | `/credentials/{credential_id}/discover` | Discover available models |
171
+ | POST | `/credentials/{credential_id}/register-models` | Register discovered models |
172
+ | POST | `/credentials/migrate-from-provider-config` | Migrate from legacy ProviderConfig |
173
+
174
+ **Supported Providers** (13 total):
175
+ - Simple API key: `openai`, `anthropic`, `google`, `groq`, `mistral`, `deepseek`, `xai`, `openrouter`, `voyage`, `elevenlabs`
176
+ - URL-based: `ollama`
177
+ - Multi-field: `azure`, `vertex`, `openai_compatible`
178
+
179
+ **Security Features**:
180
+ - NEVER returns actual API key values (only metadata)
181
+ - URL validation (SSRF protection) on all URL fields via `_validate_url()`
182
+ - Allows private IPs and localhost for self-hosted services (Ollama, LM Studio)
183
+ - Requires `OPEN_NOTEBOOK_ENCRYPTION_KEY` to be set for storing credentials
184
+
185
+ ### Domain Model: `Credential` (`open_notebook/domain/credential.py`)
186
+
187
+ Individual credential records replacing the old `ProviderConfig` singleton. Each credential stores:
188
+ - Provider name, display name, modalities
189
+ - Encrypted API key (via Fernet)
190
+ - Provider-specific config (base_url, endpoint, api_version, etc.)
191
+
192
+ ### Integration with Key Provider (`open_notebook/ai/key_provider.py`)
193
+
194
+ The `key_provider` module provisions DB-stored credentials into environment variables for Esperanto compatibility:
195
+
196
+ **Database-first Pattern**:
197
+ 1. API endpoint saves keys to `Credential` records (encrypted in SurrealDB)
198
+ 2. Before model provisioning, `provision_provider_keys(provider)` checks DB, then env vars
199
+ 3. Keys from DB are set as environment variables for Esperanto compatibility
200
+ 4. Existing env vars remain unchanged if no DB config exists
201
+
202
+ **Key Functions**:
203
+ - `get_api_key(provider)`: Get API key (DB first, env fallback)
204
+ - `provision_provider_keys(provider)`: Set env vars from DB for a provider
205
+ - `provision_all_keys()`: Load all provider keys from DB into env vars
206
+
207
+ ### Authentication
208
+
209
+ No changes to authentication. The `credentials` router uses the same `PasswordAuthMiddleware` as all other endpoints. Keys are protected by the same password-based auth.
210
+
211
+ **Auth Flow** (unchanged from `api/auth.py`):
212
+ - `PasswordAuthMiddleware`: Global middleware checking `Authorization: Bearer {password}` header
213
+ - Default password: `open-notebook-change-me` (set `OPEN_NOTEBOOK_PASSWORD` in production)
214
+ - Docker secrets support via `OPEN_NOTEBOOK_PASSWORD_FILE`
215
+
216
+ ### Connection Testing (`open_notebook/ai/connection_tester.py`)
217
+
218
+ The `/credentials/{credential_id}/test` endpoint uses minimal API calls to verify credentials:
219
+ - Loads Credential via `Credential.get(config_id)`, uses `credential.to_esperanto_config()`
220
+ - Uses cheapest/smallest models per provider (TEST_MODELS map)
221
+ - Returns success status and descriptive message
222
+ - Special handlers for ollama, openai_compatible, and azure providers
223
+
224
+ ### Migration Workflows
225
+
226
+ Two migration endpoints help users transition to the credential system:
227
+
228
+ **From environment variables** (`POST /credentials/migrate-from-env`):
229
+ 1. Checks each provider for env var presence
230
+ 2. Creates Credential records from env var values
231
+ 3. Returns summary: migrated, skipped, errors
232
+
233
+ **From legacy ProviderConfig** (`POST /credentials/migrate-from-provider-config`):
234
+ 1. Reads old ProviderConfig records from database
235
+ 2. Converts each to individual Credential records
236
+ 3. Returns summary: migrated, skipped, errors
237
+
238
+ ### Example Usage
239
+
240
+ ```python
241
+ # Check status
242
+ GET /credentials/status
243
+ # Response: {"configured": {"openai": true, "anthropic": false}, "source": {"openai": "database", "anthropic": "none"}, "encryption_configured": true}
244
+
245
+ # Create credential
246
+ POST /credentials
247
+ {"name": "My OpenAI Key", "provider": "openai", "modalities": ["language", "embedding"], "api_key": "sk-proj-..."}
248
+
249
+ # Test connection
250
+ POST /credentials/{credential_id}/test
251
+ # Response: {"provider": "openai", "success": true, "message": "Connection successful"}
252
+
253
+ # Discover models
254
+ POST /credentials/{credential_id}/discover
255
+ # Response: {"provider": "openai", "models": [{"model_id": "gpt-4", "name": "gpt-4", ...}], "credential_id": "..."}
256
+
257
+ # Migrate from env
258
+ POST /credentials/migrate-from-env
259
+ # Response: {"message": "Migration complete. Migrated 3 providers.", "migrated": ["openai", "anthropic", "groq"], "skipped": [], "errors": []}
260
+ ```
api/__init__.py ADDED
File without changes
api/auth.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+
3
+ from fastapi import Depends, HTTPException, Request
4
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
5
+ from loguru import logger
6
+ from starlette.middleware.base import BaseHTTPMiddleware
7
+ from starlette.responses import JSONResponse
8
+
9
+ from open_notebook.utils.encryption import get_secret_from_env
10
+
11
+
12
+ class PasswordAuthMiddleware(BaseHTTPMiddleware):
13
+ """
14
+ Middleware to check password authentication for all API requests.
15
+ Always active with default password if OPEN_NOTEBOOK_PASSWORD is not set.
16
+ Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
17
+ """
18
+
19
+ def __init__(self, app, excluded_paths: Optional[list] = None):
20
+ super().__init__(app)
21
+ self.password = get_secret_from_env("OPEN_NOTEBOOK_PASSWORD")
22
+ self.excluded_paths = excluded_paths or [
23
+ "/",
24
+ "/health",
25
+ "/docs",
26
+ "/openapi.json",
27
+ "/redoc",
28
+ ]
29
+
30
+ async def dispatch(self, request: Request, call_next):
31
+ # Skip authentication if no password is set
32
+ if not self.password:
33
+ return await call_next(request)
34
+
35
+ # Skip authentication for excluded paths
36
+ if request.url.path in self.excluded_paths:
37
+ return await call_next(request)
38
+
39
+ # Skip authentication for CORS preflight requests (OPTIONS)
40
+ if request.method == "OPTIONS":
41
+ return await call_next(request)
42
+
43
+ # Check authorization header
44
+ auth_header = request.headers.get("Authorization")
45
+
46
+ if not auth_header:
47
+ return JSONResponse(
48
+ status_code=401,
49
+ content={"detail": "Missing authorization header"},
50
+ headers={"WWW-Authenticate": "Bearer"},
51
+ )
52
+
53
+ # Expected format: "Bearer {password}"
54
+ try:
55
+ scheme, credentials = auth_header.split(" ", 1)
56
+ if scheme.lower() != "bearer":
57
+ raise ValueError("Invalid authentication scheme")
58
+ except ValueError:
59
+ return JSONResponse(
60
+ status_code=401,
61
+ content={"detail": "Invalid authorization header format"},
62
+ headers={"WWW-Authenticate": "Bearer"},
63
+ )
64
+
65
+ # Check password
66
+ if credentials != self.password:
67
+ return JSONResponse(
68
+ status_code=401,
69
+ content={"detail": "Invalid password"},
70
+ headers={"WWW-Authenticate": "Bearer"},
71
+ )
72
+
73
+ # Password is correct, proceed with the request
74
+ response = await call_next(request)
75
+ return response
76
+
77
+
78
+ # Optional: HTTPBearer security scheme for OpenAPI documentation
79
+ security = HTTPBearer(auto_error=False)
80
+
81
+
82
+ def check_api_password(
83
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
84
+ ) -> bool:
85
+ """
86
+ Utility function to check API password.
87
+ Can be used as a dependency in individual routes if needed.
88
+ Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
89
+ Returns True without checking credentials if OPEN_NOTEBOOK_PASSWORD is not configured.
90
+ Raises 401 if credentials are missing or don't match the configured password.
91
+ """
92
+ password = get_secret_from_env("OPEN_NOTEBOOK_PASSWORD")
93
+
94
+ # No password configured - skip authentication
95
+ if not password:
96
+ return True
97
+
98
+ # No credentials provided
99
+ if not credentials:
100
+ raise HTTPException(
101
+ status_code=401,
102
+ detail="Missing authorization",
103
+ headers={"WWW-Authenticate": "Bearer"},
104
+ )
105
+
106
+ # Check password
107
+ if credentials.credentials != password:
108
+ raise HTTPException(
109
+ status_code=401,
110
+ detail="Invalid password",
111
+ headers={"WWW-Authenticate": "Bearer"},
112
+ )
113
+
114
+ return True
api/chat_service.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat service for API operations.
3
+ Provides async interface for chat functionality.
4
+ """
5
+
6
+ import os
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import httpx
10
+ from loguru import logger
11
+
12
+
13
+ class ChatService:
14
+ """Service for chat-related API operations"""
15
+
16
+ def __init__(self):
17
+ self.base_url = os.getenv("API_BASE_URL", "http://127.0.0.1:5055")
18
+ # Add authentication header if password is set
19
+ self.headers = {}
20
+ password = os.getenv("OPEN_NOTEBOOK_PASSWORD")
21
+ if password:
22
+ self.headers["Authorization"] = f"Bearer {password}"
23
+
24
+ async def get_sessions(self, notebook_id: str) -> List[Dict[str, Any]]:
25
+ """Get all chat sessions for a notebook"""
26
+ try:
27
+ async with httpx.AsyncClient() as client:
28
+ response = await client.get(
29
+ f"{self.base_url}/api/chat/sessions",
30
+ params={"notebook_id": notebook_id},
31
+ headers=self.headers,
32
+ )
33
+ response.raise_for_status()
34
+ return response.json()
35
+ except Exception as e:
36
+ logger.error(f"Error fetching chat sessions: {str(e)}")
37
+ raise
38
+
39
+ async def create_session(
40
+ self,
41
+ notebook_id: str,
42
+ title: Optional[str] = None,
43
+ model_override: Optional[str] = None,
44
+ ) -> Dict[str, Any]:
45
+ """Create a new chat session"""
46
+ try:
47
+ data: Dict[str, Any] = {"notebook_id": notebook_id}
48
+ if title is not None:
49
+ data["title"] = title
50
+ if model_override is not None:
51
+ data["model_override"] = model_override
52
+
53
+ async with httpx.AsyncClient() as client:
54
+ response = await client.post(
55
+ f"{self.base_url}/api/chat/sessions",
56
+ json=data,
57
+ headers=self.headers,
58
+ )
59
+ response.raise_for_status()
60
+ return response.json()
61
+ except Exception as e:
62
+ logger.error(f"Error creating chat session: {str(e)}")
63
+ raise
64
+
65
+ async def get_session(self, session_id: str) -> Dict[str, Any]:
66
+ """Get a specific session with messages"""
67
+ try:
68
+ async with httpx.AsyncClient() as client:
69
+ response = await client.get(
70
+ f"{self.base_url}/api/chat/sessions/{session_id}",
71
+ headers=self.headers,
72
+ )
73
+ response.raise_for_status()
74
+ return response.json()
75
+ except Exception as e:
76
+ logger.error(f"Error fetching session: {str(e)}")
77
+ raise
78
+
79
+ async def update_session(
80
+ self,
81
+ session_id: str,
82
+ title: Optional[str] = None,
83
+ model_override: Optional[str] = None,
84
+ ) -> Dict[str, Any]:
85
+ """Update session properties"""
86
+ try:
87
+ data: Dict[str, Any] = {}
88
+ if title is not None:
89
+ data["title"] = title
90
+ if model_override is not None:
91
+ data["model_override"] = model_override
92
+
93
+ if not data:
94
+ raise ValueError(
95
+ "At least one field must be provided to update a session"
96
+ )
97
+
98
+ async with httpx.AsyncClient() as client:
99
+ response = await client.put(
100
+ f"{self.base_url}/api/chat/sessions/{session_id}",
101
+ json=data,
102
+ headers=self.headers,
103
+ )
104
+ response.raise_for_status()
105
+ return response.json()
106
+ except Exception as e:
107
+ logger.error(f"Error updating session: {str(e)}")
108
+ raise
109
+
110
+ async def delete_session(self, session_id: str) -> Dict[str, Any]:
111
+ """Delete a chat session"""
112
+ try:
113
+ async with httpx.AsyncClient() as client:
114
+ response = await client.delete(
115
+ f"{self.base_url}/api/chat/sessions/{session_id}",
116
+ headers=self.headers,
117
+ )
118
+ response.raise_for_status()
119
+ return response.json()
120
+ except Exception as e:
121
+ logger.error(f"Error deleting session: {str(e)}")
122
+ raise
123
+
124
+ async def execute_chat(
125
+ self,
126
+ session_id: str,
127
+ message: str,
128
+ context: Dict[str, Any],
129
+ model_override: Optional[str] = None,
130
+ ) -> Dict[str, Any]:
131
+ """Execute a chat request"""
132
+ try:
133
+ data = {"session_id": session_id, "message": message, "context": context}
134
+ if model_override is not None:
135
+ data["model_override"] = model_override
136
+
137
+ # Short connect timeout (10s), long read timeout (10 min) for Ollama/local LLMs
138
+ timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0)
139
+ async with httpx.AsyncClient(timeout=timeout) as client:
140
+ response = await client.post(
141
+ f"{self.base_url}/api/chat/execute", json=data, headers=self.headers
142
+ )
143
+ response.raise_for_status()
144
+ return response.json()
145
+ except Exception as e:
146
+ logger.error(f"Error executing chat: {str(e)}")
147
+ raise
148
+
149
+ async def build_context(
150
+ self, notebook_id: str, context_config: Dict[str, Any]
151
+ ) -> Dict[str, Any]:
152
+ """Build context for a notebook"""
153
+ try:
154
+ data = {"notebook_id": notebook_id, "context_config": context_config}
155
+
156
+ async with httpx.AsyncClient() as client:
157
+ response = await client.post(
158
+ f"{self.base_url}/api/chat/context", json=data, headers=self.headers
159
+ )
160
+ response.raise_for_status()
161
+ return response.json()
162
+ except Exception as e:
163
+ logger.error(f"Error building context: {str(e)}")
164
+ raise
165
+
166
+
167
+ # Global instance
168
+ chat_service = ChatService()
api/client.py ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API client for Open Notebook API.
3
+ This module provides a client interface to interact with the Open Notebook API.
4
+ """
5
+
6
+ import os
7
+ from typing import Any, Dict, List, Optional, Union
8
+
9
+ import httpx
10
+ from loguru import logger
11
+
12
+
13
+ class APIClient:
14
+ """Client for Open Notebook API."""
15
+
16
+ def __init__(self, base_url: Optional[str] = None):
17
+ self.base_url = base_url or os.getenv("API_BASE_URL", "http://127.0.0.1:5055")
18
+ # Timeout increased to 5 minutes (300s) to accommodate slow LLM operations
19
+ # (transformations, insights) on slower hardware (Ollama, LM Studio, remote APIs)
20
+ # Configurable via API_CLIENT_TIMEOUT environment variable (in seconds)
21
+ timeout_str = os.getenv("API_CLIENT_TIMEOUT", "300.0")
22
+ try:
23
+ timeout_value = float(timeout_str)
24
+ # Validate timeout is within reasonable bounds (30s - 3600s / 1 hour)
25
+ if timeout_value < 30:
26
+ logger.warning(
27
+ f"API_CLIENT_TIMEOUT={timeout_value}s is too low, using minimum of 30s"
28
+ )
29
+ timeout_value = 30.0
30
+ elif timeout_value > 3600:
31
+ logger.warning(
32
+ f"API_CLIENT_TIMEOUT={timeout_value}s is too high, using maximum of 3600s"
33
+ )
34
+ timeout_value = 3600.0
35
+ self.timeout = timeout_value
36
+ except ValueError:
37
+ logger.error(
38
+ f"Invalid API_CLIENT_TIMEOUT value '{timeout_str}', using default 300s"
39
+ )
40
+ self.timeout = 300.0
41
+
42
+ # Add authentication header if password is set
43
+ self.headers = {}
44
+ password = os.getenv("OPEN_NOTEBOOK_PASSWORD")
45
+ if password:
46
+ self.headers["Authorization"] = f"Bearer {password}"
47
+
48
+ def _make_request(
49
+ self, method: str, endpoint: str, timeout: Optional[float] = None, **kwargs
50
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
51
+ """Make HTTP request to the API."""
52
+ url = f"{self.base_url}{endpoint}"
53
+ request_timeout = timeout if timeout is not None else self.timeout
54
+
55
+ # Merge headers
56
+ headers = kwargs.get("headers", {})
57
+ headers.update(self.headers)
58
+ kwargs["headers"] = headers
59
+
60
+ try:
61
+ with httpx.Client(timeout=request_timeout) as client:
62
+ response = client.request(method, url, **kwargs)
63
+ response.raise_for_status()
64
+ return response.json()
65
+ except httpx.RequestError as e:
66
+ logger.error(f"Request error for {method} {url}: {str(e)}")
67
+ raise ConnectionError(f"Failed to connect to API: {str(e)}")
68
+ except httpx.HTTPStatusError as e:
69
+ logger.error(
70
+ f"HTTP error {e.response.status_code} for {method} {url}: {e.response.text}"
71
+ )
72
+ raise RuntimeError(
73
+ f"API request failed: {e.response.status_code} - {e.response.text}"
74
+ )
75
+ except Exception as e:
76
+ logger.error(f"Unexpected error for {method} {url}: {str(e)}")
77
+ raise
78
+
79
+ # Notebooks API methods
80
+ def get_notebooks(
81
+ self, archived: Optional[bool] = None, order_by: str = "updated desc"
82
+ ) -> List[Dict[Any, Any]]:
83
+ """Get all notebooks."""
84
+ params: Dict[str, Any] = {"order_by": order_by}
85
+ if archived is not None:
86
+ params["archived"] = str(archived).lower()
87
+
88
+ result = self._make_request("GET", "/api/notebooks", params=params)
89
+ return result if isinstance(result, list) else [result]
90
+
91
+ def create_notebook(
92
+ self, name: str, description: str = ""
93
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
94
+ """Create a new notebook."""
95
+ data = {"name": name, "description": description}
96
+ return self._make_request("POST", "/api/notebooks", json=data)
97
+
98
+ def get_notebook(
99
+ self, notebook_id: str
100
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
101
+ """Get a specific notebook."""
102
+ return self._make_request("GET", f"/api/notebooks/{notebook_id}")
103
+
104
+ def update_notebook(
105
+ self, notebook_id: str, **updates
106
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
107
+ """Update a notebook."""
108
+ return self._make_request("PUT", f"/api/notebooks/{notebook_id}", json=updates)
109
+
110
+ def delete_notebook(
111
+ self, notebook_id: str
112
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
113
+ """Delete a notebook."""
114
+ return self._make_request("DELETE", f"/api/notebooks/{notebook_id}")
115
+
116
+ # Search API methods
117
+ def search(
118
+ self,
119
+ query: str,
120
+ search_type: str = "text",
121
+ limit: int = 100,
122
+ search_sources: bool = True,
123
+ search_notes: bool = True,
124
+ minimum_score: float = 0.2,
125
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
126
+ """Search the knowledge base."""
127
+ data = {
128
+ "query": query,
129
+ "type": search_type,
130
+ "limit": limit,
131
+ "search_sources": search_sources,
132
+ "search_notes": search_notes,
133
+ "minimum_score": minimum_score,
134
+ }
135
+ return self._make_request("POST", "/api/search", json=data)
136
+
137
+ def ask_simple(
138
+ self,
139
+ question: str,
140
+ strategy_model: str,
141
+ answer_model: str,
142
+ final_answer_model: str,
143
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
144
+ """Ask the knowledge base a question (simple, non-streaming)."""
145
+ data = {
146
+ "question": question,
147
+ "strategy_model": strategy_model,
148
+ "answer_model": answer_model,
149
+ "final_answer_model": final_answer_model,
150
+ }
151
+ # Use configured timeout for long-running ask operations
152
+ return self._make_request(
153
+ "POST", "/api/search/ask/simple", json=data, timeout=self.timeout
154
+ )
155
+
156
+ # Models API methods
157
+ def get_models(self, model_type: Optional[str] = None) -> List[Dict[Any, Any]]:
158
+ """Get all models with optional type filtering."""
159
+ params = {}
160
+ if model_type:
161
+ params["type"] = model_type
162
+ result = self._make_request("GET", "/api/models", params=params)
163
+ return result if isinstance(result, list) else [result]
164
+
165
+ def create_model(
166
+ self, name: str, provider: str, model_type: str
167
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
168
+ """Create a new model."""
169
+ data = {
170
+ "name": name,
171
+ "provider": provider,
172
+ "type": model_type,
173
+ }
174
+ return self._make_request("POST", "/api/models", json=data)
175
+
176
+ def delete_model(
177
+ self, model_id: str
178
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
179
+ """Delete a model."""
180
+ return self._make_request("DELETE", f"/api/models/{model_id}")
181
+
182
+ def get_default_models(self) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
183
+ """Get default model assignments."""
184
+ return self._make_request("GET", "/api/models/defaults")
185
+
186
+ def update_default_models(
187
+ self, **defaults
188
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
189
+ """Update default model assignments."""
190
+ return self._make_request("PUT", "/api/models/defaults", json=defaults)
191
+
192
+ # Transformations API methods
193
+ def get_transformations(self) -> List[Dict[Any, Any]]:
194
+ """Get all transformations."""
195
+ result = self._make_request("GET", "/api/transformations")
196
+ return result if isinstance(result, list) else [result]
197
+
198
+ def create_transformation(
199
+ self,
200
+ name: str,
201
+ title: str,
202
+ description: str,
203
+ prompt: str,
204
+ apply_default: bool = False,
205
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
206
+ """Create a new transformation."""
207
+ data = {
208
+ "name": name,
209
+ "title": title,
210
+ "description": description,
211
+ "prompt": prompt,
212
+ "apply_default": apply_default,
213
+ }
214
+ return self._make_request("POST", "/api/transformations", json=data)
215
+
216
+ def get_transformation(
217
+ self, transformation_id: str
218
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
219
+ """Get a specific transformation."""
220
+ return self._make_request("GET", f"/api/transformations/{transformation_id}")
221
+
222
+ def update_transformation(
223
+ self, transformation_id: str, **updates
224
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
225
+ """Update a transformation."""
226
+ return self._make_request(
227
+ "PUT", f"/api/transformations/{transformation_id}", json=updates
228
+ )
229
+
230
+ def delete_transformation(
231
+ self, transformation_id: str
232
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
233
+ """Delete a transformation."""
234
+ return self._make_request("DELETE", f"/api/transformations/{transformation_id}")
235
+
236
+ def execute_transformation(
237
+ self, transformation_id: str, input_text: str, model_id: str
238
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
239
+ """Execute a transformation on input text."""
240
+ data = {
241
+ "transformation_id": transformation_id,
242
+ "input_text": input_text,
243
+ "model_id": model_id,
244
+ }
245
+ # Use configured timeout for transformation operations
246
+ return self._make_request(
247
+ "POST", "/api/transformations/execute", json=data, timeout=self.timeout
248
+ )
249
+
250
+ # Notes API methods
251
+ def get_notes(self, notebook_id: Optional[str] = None) -> List[Dict[Any, Any]]:
252
+ """Get all notes with optional notebook filtering."""
253
+ params = {}
254
+ if notebook_id:
255
+ params["notebook_id"] = notebook_id
256
+ result = self._make_request("GET", "/api/notes", params=params)
257
+ return result if isinstance(result, list) else [result]
258
+
259
+ def create_note(
260
+ self,
261
+ content: str,
262
+ title: Optional[str] = None,
263
+ note_type: str = "human",
264
+ notebook_id: Optional[str] = None,
265
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
266
+ """Create a new note."""
267
+ data = {
268
+ "content": content,
269
+ "note_type": note_type,
270
+ }
271
+ if title:
272
+ data["title"] = title
273
+ if notebook_id:
274
+ data["notebook_id"] = notebook_id
275
+ return self._make_request("POST", "/api/notes", json=data)
276
+
277
+ def get_note(self, note_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
278
+ """Get a specific note."""
279
+ return self._make_request("GET", f"/api/notes/{note_id}")
280
+
281
+ def update_note(
282
+ self, note_id: str, **updates
283
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
284
+ """Update a note."""
285
+ return self._make_request("PUT", f"/api/notes/{note_id}", json=updates)
286
+
287
+ def delete_note(self, note_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
288
+ """Delete a note."""
289
+ return self._make_request("DELETE", f"/api/notes/{note_id}")
290
+
291
+ # Embedding API methods
292
+ def embed_content(
293
+ self, item_id: str, item_type: str, async_processing: bool = False
294
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
295
+ """Embed content for vector search."""
296
+ data = {
297
+ "item_id": item_id,
298
+ "item_type": item_type,
299
+ "async_processing": async_processing,
300
+ }
301
+ # Use configured timeout for embedding operations
302
+ return self._make_request("POST", "/api/embed", json=data, timeout=self.timeout)
303
+
304
+ def rebuild_embeddings(
305
+ self,
306
+ mode: str = "existing",
307
+ include_sources: bool = True,
308
+ include_notes: bool = True,
309
+ include_insights: bool = True,
310
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
311
+ """Rebuild embeddings in bulk.
312
+
313
+ Note: This operation can take a long time for large databases.
314
+ Consider increasing API_CLIENT_TIMEOUT to 600-900s for bulk rebuilds.
315
+ """
316
+ data = {
317
+ "mode": mode,
318
+ "include_sources": include_sources,
319
+ "include_notes": include_notes,
320
+ "include_insights": include_insights,
321
+ }
322
+ # Use double the configured timeout for bulk rebuild operations (or configured value if already high)
323
+ rebuild_timeout = max(self.timeout, min(self.timeout * 2, 3600.0))
324
+ return self._make_request(
325
+ "POST", "/api/embeddings/rebuild", json=data, timeout=rebuild_timeout
326
+ )
327
+
328
+ def get_rebuild_status(
329
+ self, command_id: str
330
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
331
+ """Get status of a rebuild operation."""
332
+ return self._make_request("GET", f"/api/embeddings/rebuild/{command_id}/status")
333
+
334
+ # Settings API methods
335
+ def get_settings(self) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
336
+ """Get all application settings."""
337
+ return self._make_request("GET", "/api/settings")
338
+
339
+ def update_settings(
340
+ self, **settings
341
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
342
+ """Update application settings."""
343
+ return self._make_request("PUT", "/api/settings", json=settings)
344
+
345
+ # Context API methods
346
+ def get_notebook_context(
347
+ self, notebook_id: str, context_config: Optional[Dict] = None
348
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
349
+ """Get context for a notebook."""
350
+ data: Dict[str, Any] = {"notebook_id": notebook_id}
351
+ if context_config:
352
+ data["context_config"] = context_config
353
+ result = self._make_request(
354
+ "POST", f"/api/notebooks/{notebook_id}/context", json=data
355
+ )
356
+ return result if isinstance(result, dict) else {}
357
+
358
+ # Sources API methods
359
+ def get_sources(self, notebook_id: Optional[str] = None) -> List[Dict[Any, Any]]:
360
+ """Get all sources with optional notebook filtering."""
361
+ params = {}
362
+ if notebook_id:
363
+ params["notebook_id"] = notebook_id
364
+ result = self._make_request("GET", "/api/sources", params=params)
365
+ return result if isinstance(result, list) else [result]
366
+
367
+ def create_source(
368
+ self,
369
+ notebook_id: Optional[str] = None,
370
+ notebooks: Optional[List[str]] = None,
371
+ source_type: str = "text",
372
+ url: Optional[str] = None,
373
+ file_path: Optional[str] = None,
374
+ content: Optional[str] = None,
375
+ title: Optional[str] = None,
376
+ transformations: Optional[List[str]] = None,
377
+ embed: bool = False,
378
+ delete_source: bool = False,
379
+ async_processing: bool = False,
380
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
381
+ """Create a new source."""
382
+ data = {
383
+ "type": source_type,
384
+ "embed": embed,
385
+ "delete_source": delete_source,
386
+ "async_processing": async_processing,
387
+ }
388
+
389
+ # Handle backward compatibility for notebook_id vs notebooks
390
+ if notebooks:
391
+ data["notebooks"] = notebooks
392
+ elif notebook_id:
393
+ data["notebook_id"] = notebook_id
394
+ else:
395
+ raise ValueError("Either notebook_id or notebooks must be provided")
396
+
397
+ if url:
398
+ data["url"] = url
399
+ if file_path:
400
+ data["file_path"] = file_path
401
+ if content:
402
+ data["content"] = content
403
+ if title:
404
+ data["title"] = title
405
+ if transformations:
406
+ data["transformations"] = transformations
407
+
408
+ # Use configured timeout for source creation (especially PDF processing with OCR)
409
+ return self._make_request(
410
+ "POST", "/api/sources/json", json=data, timeout=self.timeout
411
+ )
412
+
413
+ def get_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
414
+ """Get a specific source."""
415
+ return self._make_request("GET", f"/api/sources/{source_id}")
416
+
417
+ def get_source_status(
418
+ self, source_id: str
419
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
420
+ """Get processing status for a source."""
421
+ return self._make_request("GET", f"/api/sources/{source_id}/status")
422
+
423
+ def update_source(
424
+ self, source_id: str, **updates
425
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
426
+ """Update a source."""
427
+ return self._make_request("PUT", f"/api/sources/{source_id}", json=updates)
428
+
429
+ def delete_source(
430
+ self, source_id: str
431
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
432
+ """Delete a source."""
433
+ return self._make_request("DELETE", f"/api/sources/{source_id}")
434
+
435
+ # Insights API methods
436
+ def get_source_insights(self, source_id: str) -> List[Dict[Any, Any]]:
437
+ """Get all insights for a specific source."""
438
+ result = self._make_request("GET", f"/api/sources/{source_id}/insights")
439
+ return result if isinstance(result, list) else [result]
440
+
441
+ def get_insight(
442
+ self, insight_id: str
443
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
444
+ """Get a specific insight."""
445
+ return self._make_request("GET", f"/api/insights/{insight_id}")
446
+
447
+ def delete_insight(
448
+ self, insight_id: str
449
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
450
+ """Delete a specific insight."""
451
+ return self._make_request("DELETE", f"/api/insights/{insight_id}")
452
+
453
+ def save_insight_as_note(
454
+ self, insight_id: str, notebook_id: Optional[str] = None
455
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
456
+ """Convert an insight to a note."""
457
+ data = {}
458
+ if notebook_id:
459
+ data["notebook_id"] = notebook_id
460
+ return self._make_request(
461
+ "POST", f"/api/insights/{insight_id}/save-as-note", json=data
462
+ )
463
+
464
+ def create_source_insight(
465
+ self, source_id: str, transformation_id: str, model_id: Optional[str] = None
466
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
467
+ """Create a new insight for a source by running a transformation."""
468
+ data = {"transformation_id": transformation_id}
469
+ if model_id:
470
+ data["model_id"] = model_id
471
+ return self._make_request(
472
+ "POST", f"/api/sources/{source_id}/insights", json=data
473
+ )
474
+
475
+ # Episode Profiles API methods
476
+ def get_episode_profiles(self) -> List[Dict[Any, Any]]:
477
+ """Get all episode profiles."""
478
+ result = self._make_request("GET", "/api/episode-profiles")
479
+ return result if isinstance(result, list) else [result]
480
+
481
+ def get_episode_profile(
482
+ self, profile_name: str
483
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
484
+ """Get a specific episode profile by name."""
485
+ return self._make_request("GET", f"/api/episode-profiles/{profile_name}")
486
+
487
+ def create_episode_profile(
488
+ self,
489
+ name: str,
490
+ description: str = "",
491
+ speaker_config: str = "",
492
+ outline_provider: str = "",
493
+ outline_model: str = "",
494
+ transcript_provider: str = "",
495
+ transcript_model: str = "",
496
+ default_briefing: str = "",
497
+ num_segments: int = 5,
498
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
499
+ """Create a new episode profile."""
500
+ data = {
501
+ "name": name,
502
+ "description": description,
503
+ "speaker_config": speaker_config,
504
+ "outline_provider": outline_provider,
505
+ "outline_model": outline_model,
506
+ "transcript_provider": transcript_provider,
507
+ "transcript_model": transcript_model,
508
+ "default_briefing": default_briefing,
509
+ "num_segments": num_segments,
510
+ }
511
+ return self._make_request("POST", "/api/episode-profiles", json=data)
512
+
513
+ def update_episode_profile(
514
+ self, profile_id: str, **updates
515
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
516
+ """Update an episode profile."""
517
+ return self._make_request(
518
+ "PUT", f"/api/episode-profiles/{profile_id}", json=updates
519
+ )
520
+
521
+ def delete_episode_profile(
522
+ self, profile_id: str
523
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
524
+ """Delete an episode profile."""
525
+ return self._make_request("DELETE", f"/api/episode-profiles/{profile_id}")
526
+
527
+
528
+ # Global client instance
529
+ api_client = APIClient()
api/command_service.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Optional
2
+
3
+ from loguru import logger
4
+ from surreal_commands import get_command_status, submit_command
5
+
6
+
7
+ class CommandService:
8
+ """Generic service layer for command operations"""
9
+
10
+ @staticmethod
11
+ async def submit_command_job(
12
+ module_name: str, # Actually app_name for surreal-commands
13
+ command_name: str,
14
+ command_args: Dict[str, Any],
15
+ context: Optional[Dict[str, Any]] = None,
16
+ ) -> str:
17
+ """Submit a generic command job for background processing"""
18
+ try:
19
+ # Ensure command modules are imported before submitting
20
+ # This is needed because submit_command validates against local registry
21
+ try:
22
+ import commands.podcast_commands # noqa: F401
23
+ except ImportError as import_err:
24
+ logger.error(f"Failed to import command modules: {import_err}")
25
+ raise ValueError("Command modules not available")
26
+
27
+ # surreal-commands expects: submit_command(app_name, command_name, args)
28
+ cmd_id = submit_command(
29
+ module_name, # This is actually the app name (e.g., "open_notebook")
30
+ command_name, # Command name (e.g., "process_text")
31
+ command_args, # Input data
32
+ )
33
+ # Convert RecordID to string if needed
34
+ if not cmd_id:
35
+ raise ValueError("Failed to get cmd_id from submit_command")
36
+ cmd_id_str = str(cmd_id)
37
+ logger.info(
38
+ f"Submitted command job: {cmd_id_str} for {module_name}.{command_name}"
39
+ )
40
+ return cmd_id_str
41
+
42
+ except Exception as e:
43
+ logger.error(f"Failed to submit command job: {e}")
44
+ raise
45
+
46
+ @staticmethod
47
+ async def get_command_status(job_id: str) -> Dict[str, Any]:
48
+ """Get status of any command job"""
49
+ try:
50
+ status = await get_command_status(job_id)
51
+ return {
52
+ "job_id": job_id,
53
+ "status": status.status if status else "unknown",
54
+ "result": status.result if status else None,
55
+ "error_message": getattr(status, "error_message", None)
56
+ if status
57
+ else None,
58
+ "created": str(status.created)
59
+ if status and hasattr(status, "created") and status.created
60
+ else None,
61
+ "updated": str(status.updated)
62
+ if status and hasattr(status, "updated") and status.updated
63
+ else None,
64
+ "progress": getattr(status, "progress", None) if status else None,
65
+ }
66
+ except Exception as e:
67
+ logger.error(f"Failed to get command status: {e}")
68
+ raise
69
+
70
+ @staticmethod
71
+ async def list_command_jobs(
72
+ module_filter: Optional[str] = None,
73
+ command_filter: Optional[str] = None,
74
+ status_filter: Optional[str] = None,
75
+ limit: int = 50,
76
+ ) -> List[Dict[str, Any]]:
77
+ """List command jobs with optional filtering"""
78
+ # This will be implemented with proper SurrealDB queries
79
+ # For now, return empty list as this is foundation phase
80
+ return []
81
+
82
+ @staticmethod
83
+ async def cancel_command_job(job_id: str) -> bool:
84
+ """Cancel a running command job"""
85
+ try:
86
+ # Implementation depends on surreal-commands cancellation support
87
+ # For now, just log the attempt
88
+ logger.info(f"Attempting to cancel job: {job_id}")
89
+ return True
90
+ except Exception as e:
91
+ logger.error(f"Failed to cancel command job: {e}")
92
+ raise
api/context_service.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Context service layer using API.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Optional, Union
6
+
7
+ from loguru import logger
8
+
9
+ from api.client import api_client
10
+
11
+
12
+ class ContextService:
13
+ """Service layer for context operations using API."""
14
+
15
+ def __init__(self):
16
+ logger.info("Using API for context operations")
17
+
18
+ def get_notebook_context(
19
+ self, notebook_id: str, context_config: Optional[Dict] = None
20
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
21
+ """Get context for a notebook."""
22
+ result = api_client.get_notebook_context(
23
+ notebook_id=notebook_id, context_config=context_config
24
+ )
25
+ return result
26
+
27
+
28
+ # Global service instance
29
+ context_service = ContextService()
api/credentials_service.py ADDED
@@ -0,0 +1,890 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Credentials Service
3
+
4
+ Business logic for managing AI provider credentials.
5
+ Extracted from the credentials router to follow the service layer pattern.
6
+
7
+ All functions raise ValueError for business errors (router converts to HTTPException).
8
+ """
9
+
10
+ import ipaddress
11
+ import os
12
+ import socket
13
+ from typing import Dict, List, Optional
14
+ from urllib.parse import urlparse
15
+
16
+ import httpx
17
+ from loguru import logger
18
+ from pydantic import SecretStr
19
+
20
+ from api.models import CredentialResponse
21
+ from open_notebook.domain.credential import Credential
22
+ from open_notebook.utils.encryption import get_secret_from_env
23
+
24
+ # =============================================================================
25
+ # Constants
26
+ # =============================================================================
27
+
28
+ # Provider environment variable configuration.
29
+ # - "required": ALL listed env vars must be set for the provider to be considered configured.
30
+ # - "required_any": at least ONE of the listed env vars must be set.
31
+ # - "optional": additional env vars used during migration but not required.
32
+ PROVIDER_ENV_CONFIG: Dict[str, dict] = {
33
+ "openai": {"required": ["OPENAI_API_KEY"]},
34
+ "anthropic": {"required": ["ANTHROPIC_API_KEY"]},
35
+ "google": {"required_any": ["GOOGLE_API_KEY", "GEMINI_API_KEY"]},
36
+ "groq": {"required": ["GROQ_API_KEY"]},
37
+ "mistral": {"required": ["MISTRAL_API_KEY"]},
38
+ "deepseek": {"required": ["DEEPSEEK_API_KEY"]},
39
+ "xai": {"required": ["XAI_API_KEY"]},
40
+ "openrouter": {"required": ["OPENROUTER_API_KEY"]},
41
+ "voyage": {"required": ["VOYAGE_API_KEY"]},
42
+ "elevenlabs": {"required": ["ELEVENLABS_API_KEY"]},
43
+ "ollama": {"required": ["OLLAMA_API_BASE"]},
44
+ "vertex": {
45
+ "required": ["VERTEX_PROJECT", "VERTEX_LOCATION"],
46
+ "optional": ["GOOGLE_APPLICATION_CREDENTIALS"],
47
+ },
48
+ "azure": {
49
+ "required": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_API_VERSION"],
50
+ "optional": [
51
+ "AZURE_OPENAI_ENDPOINT_LLM",
52
+ "AZURE_OPENAI_ENDPOINT_EMBEDDING",
53
+ "AZURE_OPENAI_ENDPOINT_STT",
54
+ "AZURE_OPENAI_ENDPOINT_TTS",
55
+ ],
56
+ },
57
+ "openai_compatible": {
58
+ "required_any": ["OPENAI_COMPATIBLE_BASE_URL", "OPENAI_COMPATIBLE_API_KEY"],
59
+ },
60
+ "dashscope": {"required": ["DASHSCOPE_API_KEY"]},
61
+ "minimax": {"required": ["MINIMAX_API_KEY"]},
62
+ }
63
+
64
+ PROVIDER_MODALITIES: Dict[str, List[str]] = {
65
+ "openai": ["language", "embedding", "speech_to_text", "text_to_speech"],
66
+ "anthropic": ["language"],
67
+ "google": ["language", "embedding"],
68
+ "groq": ["language", "speech_to_text"],
69
+ "mistral": ["language", "embedding"],
70
+ "deepseek": ["language"],
71
+ "xai": ["language"],
72
+ "openrouter": ["language"],
73
+ "voyage": ["embedding"],
74
+ "elevenlabs": ["text_to_speech"],
75
+ "ollama": ["language", "embedding"],
76
+ "vertex": ["language", "embedding"],
77
+ "azure": ["language", "embedding", "speech_to_text", "text_to_speech"],
78
+ "openai_compatible": ["language", "embedding", "speech_to_text", "text_to_speech"],
79
+ "dashscope": ["language"],
80
+ "minimax": ["language"],
81
+ }
82
+
83
+
84
+ # =============================================================================
85
+ # URL Validation (SSRF protection)
86
+ # =============================================================================
87
+
88
+
89
+ def validate_url(url: str, provider: str) -> None:
90
+ """
91
+ Validate URL format for API endpoints.
92
+
93
+ This is a self-hosted application, so we allow:
94
+ - Private IPs (10.x, 172.16-31.x, 192.168.x) for self-hosted services
95
+ - Localhost for local services (Ollama, LM Studio, etc.)
96
+
97
+ We only block:
98
+ - Invalid schemes (must be http or https)
99
+ - Malformed URLs
100
+ - Link-local addresses (169.254.x.x) - used for cloud metadata endpoints
101
+ - Hostnames that resolve to link-local addresses
102
+
103
+ Args:
104
+ url: The URL to validate
105
+ provider: The provider name (for logging/context)
106
+
107
+ Raises:
108
+ ValueError: If the URL is invalid
109
+ """
110
+ if not url or not url.strip():
111
+ return # Empty URLs handled elsewhere
112
+
113
+ try:
114
+ parsed = urlparse(url.strip())
115
+
116
+ # Validate scheme - only http/https allowed
117
+ if parsed.scheme not in ("http", "https"):
118
+ raise ValueError(
119
+ f"Invalid URL scheme: '{parsed.scheme}'. Only http and https are allowed."
120
+ )
121
+
122
+ # Extract hostname
123
+ hostname = parsed.hostname
124
+ if not hostname:
125
+ raise ValueError("Invalid URL: hostname could not be determined.")
126
+
127
+ # Try to parse as IP address to check for dangerous addresses
128
+ try:
129
+ ip = ipaddress.ip_address(hostname)
130
+
131
+ # Block link-local addresses (169.254.x.x) - used for cloud metadata
132
+ # These are dangerous as they can expose cloud instance credentials
133
+ if ip.is_link_local:
134
+ raise ValueError(
135
+ "Link-local addresses (169.254.x.x) are not allowed for security reasons. "
136
+ "These addresses are used for cloud metadata endpoints."
137
+ )
138
+
139
+ # Block IPv4-mapped IPv6 addresses pointing to link-local
140
+ # e.g. ::ffff:169.254.169.254 bypasses IPv6 is_link_local check
141
+ if hasattr(ip, "ipv4_mapped") and ip.ipv4_mapped and ip.ipv4_mapped.is_link_local:
142
+ raise ValueError(
143
+ "Link-local addresses (169.254.x.x) are not allowed for security reasons. "
144
+ "These addresses are used for cloud metadata endpoints."
145
+ )
146
+
147
+ except ValueError as ve:
148
+ # Re-raise our own ValueErrors
149
+ if "Link-local" in str(ve) or "Invalid URL" in str(ve):
150
+ raise
151
+ # Not an IP address, it's a hostname - need to resolve and check
152
+ try:
153
+ # Resolve hostname to IP address
154
+ resolved_ips = socket.getaddrinfo(hostname, None)
155
+ for family, _, _, _, sockaddr in resolved_ips:
156
+ ip_addr = sockaddr[0]
157
+ try:
158
+ parsed_ip = ipaddress.ip_address(ip_addr)
159
+ if parsed_ip.is_link_local:
160
+ raise ValueError(
161
+ f"Hostname '{hostname}' resolves to a link-local address (169.254.x.x) which is not allowed for security reasons. "
162
+ "These addresses are used for cloud metadata endpoints."
163
+ )
164
+ # Block IPv4-mapped IPv6 addresses pointing to link-local
165
+ if (
166
+ hasattr(parsed_ip, "ipv4_mapped")
167
+ and parsed_ip.ipv4_mapped
168
+ and parsed_ip.ipv4_mapped.is_link_local
169
+ ):
170
+ raise ValueError(
171
+ f"Hostname '{hostname}' resolves to a link-local address (169.254.x.x) which is not allowed for security reasons. "
172
+ "These addresses are used for cloud metadata endpoints."
173
+ )
174
+ except ValueError as inner_ve:
175
+ if "link-local" in str(inner_ve).lower() or "Link-local" in str(inner_ve):
176
+ raise
177
+ # Skip non-IP addresses (e.g., IPv6 zones)
178
+ continue
179
+ except socket.gaierror:
180
+ # Could not resolve hostname - allow it since the URL may be
181
+ # valid in the deployment environment (e.g., Azure endpoints,
182
+ # internal DNS names). We only block link-local addresses.
183
+ pass
184
+
185
+ except ValueError:
186
+ raise
187
+ except Exception:
188
+ raise ValueError("Invalid URL format. Check server logs for details.")
189
+
190
+
191
+ # =============================================================================
192
+ # Helpers
193
+ # =============================================================================
194
+
195
+
196
+ def require_encryption_key() -> None:
197
+ """Raise ValueError if encryption key is not configured."""
198
+ if not get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"):
199
+ raise ValueError(
200
+ "Encryption key not configured. "
201
+ "Set OPEN_NOTEBOOK_ENCRYPTION_KEY to enable storing API keys."
202
+ )
203
+
204
+
205
+ def credential_to_response(cred: Credential, model_count: int = 0) -> CredentialResponse:
206
+ """Convert a Credential domain object to API response."""
207
+ return CredentialResponse(
208
+ id=cred.id or "",
209
+ name=cred.name,
210
+ provider=cred.provider,
211
+ modalities=cred.modalities,
212
+ base_url=cred.base_url,
213
+ endpoint=cred.endpoint,
214
+ api_version=cred.api_version,
215
+ endpoint_llm=cred.endpoint_llm,
216
+ endpoint_embedding=cred.endpoint_embedding,
217
+ endpoint_stt=cred.endpoint_stt,
218
+ endpoint_tts=cred.endpoint_tts,
219
+ project=cred.project,
220
+ location=cred.location,
221
+ credentials_path=cred.credentials_path,
222
+ has_api_key=cred.api_key is not None,
223
+ created=str(cred.created) if cred.created else "",
224
+ updated=str(cred.updated) if cred.updated else "",
225
+ model_count=model_count,
226
+ decryption_error=cred.decryption_error,
227
+ )
228
+
229
+
230
+ def check_env_configured(provider: str) -> bool:
231
+ """Check if a provider has sufficient env vars configured for migration."""
232
+ config = PROVIDER_ENV_CONFIG.get(provider)
233
+ if not config:
234
+ return False
235
+
236
+ if "required_any" in config:
237
+ return any(bool(os.environ.get(v, "").strip()) for v in config["required_any"])
238
+ elif "required" in config:
239
+ return all(bool(os.environ.get(v, "").strip()) for v in config["required"])
240
+ return False
241
+
242
+
243
+ def get_default_modalities(provider: str) -> List[str]:
244
+ """Get default modalities for a provider."""
245
+ return PROVIDER_MODALITIES.get(provider.lower(), ["language"])
246
+
247
+
248
+ def create_credential_from_env(provider: str) -> Credential:
249
+ """Create a Credential from environment variables for a given provider."""
250
+ modalities = get_default_modalities(provider)
251
+ name = "Default (Migrated from env)"
252
+
253
+ if provider == "ollama":
254
+ return Credential(
255
+ name=name,
256
+ provider=provider,
257
+ modalities=modalities,
258
+ base_url=os.environ.get("OLLAMA_API_BASE"),
259
+ )
260
+ elif provider == "vertex":
261
+ return Credential(
262
+ name=name,
263
+ provider=provider,
264
+ modalities=modalities,
265
+ project=os.environ.get("VERTEX_PROJECT"),
266
+ location=os.environ.get("VERTEX_LOCATION"),
267
+ credentials_path=os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"),
268
+ )
269
+ elif provider == "azure":
270
+ return Credential(
271
+ name=name,
272
+ provider=provider,
273
+ modalities=modalities,
274
+ api_key=SecretStr(os.environ["AZURE_OPENAI_API_KEY"]),
275
+ endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"),
276
+ api_version=os.environ.get("AZURE_OPENAI_API_VERSION"),
277
+ endpoint_llm=os.environ.get("AZURE_OPENAI_ENDPOINT_LLM"),
278
+ endpoint_embedding=os.environ.get("AZURE_OPENAI_ENDPOINT_EMBEDDING"),
279
+ endpoint_stt=os.environ.get("AZURE_OPENAI_ENDPOINT_STT"),
280
+ endpoint_tts=os.environ.get("AZURE_OPENAI_ENDPOINT_TTS"),
281
+ )
282
+ elif provider == "openai_compatible":
283
+ api_key = os.environ.get("OPENAI_COMPATIBLE_API_KEY")
284
+ return Credential(
285
+ name=name,
286
+ provider=provider,
287
+ modalities=modalities,
288
+ api_key=SecretStr(api_key) if api_key else None,
289
+ base_url=os.environ.get("OPENAI_COMPATIBLE_BASE_URL"),
290
+ )
291
+ elif provider == "google":
292
+ # Support both GOOGLE_API_KEY and GEMINI_API_KEY (fallback)
293
+ api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
294
+ return Credential(
295
+ name=name,
296
+ provider=provider,
297
+ modalities=modalities,
298
+ api_key=SecretStr(api_key) if api_key else None,
299
+ )
300
+ else:
301
+ # Simple API key providers
302
+ config = PROVIDER_ENV_CONFIG.get(provider, {})
303
+ required = config.get("required", [])
304
+ env_var = required[0] if required else None
305
+ api_key = os.environ.get(env_var) if env_var else None
306
+ return Credential(
307
+ name=name,
308
+ provider=provider,
309
+ modalities=modalities,
310
+ api_key=SecretStr(api_key) if api_key else None,
311
+ )
312
+
313
+
314
+ # =============================================================================
315
+ # Service Functions
316
+ # =============================================================================
317
+
318
+
319
+ async def get_provider_status() -> dict:
320
+ """
321
+ Get configuration status: encryption key status, and per-provider
322
+ configured/source information.
323
+ """
324
+ encryption_configured = bool(get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"))
325
+
326
+ configured: Dict[str, bool] = {}
327
+ source: Dict[str, str] = {}
328
+
329
+ for provider in PROVIDER_ENV_CONFIG:
330
+ env_configured = check_env_configured(provider)
331
+ try:
332
+ db_credentials = await Credential.get_by_provider(provider)
333
+ db_configured = len(db_credentials) > 0
334
+ except Exception:
335
+ db_configured = False
336
+
337
+ configured[provider] = db_configured or env_configured
338
+
339
+ if db_configured:
340
+ source[provider] = "database"
341
+ elif env_configured:
342
+ source[provider] = "environment"
343
+ else:
344
+ source[provider] = "none"
345
+
346
+ return {
347
+ "configured": configured,
348
+ "source": source,
349
+ "encryption_configured": encryption_configured,
350
+ }
351
+
352
+
353
+ async def get_env_status() -> Dict[str, bool]:
354
+ """Check what's configured via environment variables."""
355
+ env_status: Dict[str, bool] = {}
356
+ for provider in PROVIDER_ENV_CONFIG:
357
+ env_status[provider] = check_env_configured(provider)
358
+ return env_status
359
+
360
+
361
+ async def test_credential(credential_id: str) -> dict:
362
+ """
363
+ Test connection using a credential's configuration.
364
+
365
+ Returns dict with provider, success, message keys.
366
+ """
367
+ provider = "unknown"
368
+ try:
369
+ cred = await Credential.get(credential_id)
370
+ config = cred.to_esperanto_config()
371
+
372
+ from open_notebook.ai.connection_tester import (
373
+ _test_azure_connection,
374
+ _test_ollama_connection,
375
+ _test_openai_compatible_connection,
376
+ )
377
+
378
+ provider = cred.provider.lower()
379
+
380
+ # Handle special providers
381
+ if provider == "ollama":
382
+ base_url = config.get("base_url", "http://localhost:11434")
383
+ success, message = await _test_ollama_connection(base_url)
384
+ return {"provider": provider, "success": success, "message": message}
385
+
386
+ if provider == "openai_compatible":
387
+ base_url = config.get("base_url")
388
+ api_key = config.get("api_key")
389
+ if not base_url:
390
+ return {
391
+ "provider": provider,
392
+ "success": False,
393
+ "message": "No base URL configured",
394
+ }
395
+ success, message = await _test_openai_compatible_connection(
396
+ base_url, api_key
397
+ )
398
+ return {"provider": provider, "success": success, "message": message}
399
+
400
+ if provider == "azure":
401
+ success, message = await _test_azure_connection(
402
+ endpoint=config.get("endpoint"),
403
+ api_key=config.get("api_key"),
404
+ api_version=config.get("api_version"),
405
+ )
406
+ return {"provider": provider, "success": success, "message": message}
407
+
408
+ # Standard provider: use Esperanto to create and test
409
+ from esperanto.factory import AIFactory
410
+
411
+ from open_notebook.ai.connection_tester import TEST_MODELS
412
+
413
+ if provider not in TEST_MODELS:
414
+ return {
415
+ "provider": provider,
416
+ "success": False,
417
+ "message": f"Unknown provider: {provider}",
418
+ }
419
+
420
+ test_model, test_type = TEST_MODELS[provider]
421
+ if not test_model:
422
+ return {
423
+ "provider": provider,
424
+ "success": False,
425
+ "message": f"No test model configured for {provider}",
426
+ }
427
+
428
+ if test_type == "language":
429
+ model = AIFactory.create_language(
430
+ model_name=test_model, provider=provider, config=config
431
+ )
432
+ lc_model = model.to_langchain()
433
+ await lc_model.ainvoke("Hi")
434
+ return {"provider": provider, "success": True, "message": "Connection successful"}
435
+
436
+ elif test_type == "embedding":
437
+ model = AIFactory.create_embedding(
438
+ model_name=test_model, provider=provider, config=config
439
+ )
440
+ await model.aembed(["test"])
441
+ return {"provider": provider, "success": True, "message": "Connection successful"}
442
+
443
+ elif test_type == "text_to_speech":
444
+ AIFactory.create_text_to_speech(model_name=test_model, provider=provider, config=config)
445
+ return {
446
+ "provider": provider,
447
+ "success": True,
448
+ "message": "Connection successful (key format valid)",
449
+ }
450
+
451
+ return {
452
+ "provider": provider,
453
+ "success": False,
454
+ "message": f"Unsupported test type: {test_type}",
455
+ }
456
+
457
+ except Exception as e:
458
+ error_msg = str(e)
459
+ if "401" in error_msg or "unauthorized" in error_msg.lower():
460
+ return {"provider": provider, "success": False, "message": "Invalid API key"}
461
+ elif "403" in error_msg or "forbidden" in error_msg.lower():
462
+ return {"provider": provider, "success": False, "message": "API key lacks required permissions"}
463
+ elif "rate" in error_msg.lower() and "limit" in error_msg.lower():
464
+ return {"provider": provider, "success": True, "message": "Rate limited - but connection works"}
465
+ elif "not found" in error_msg.lower() and "model" in error_msg.lower():
466
+ return {"provider": provider, "success": True, "message": "API key valid (test model not available)"}
467
+ else:
468
+ logger.debug(f"Test connection error for credential {credential_id}: {e}")
469
+ truncated = error_msg[:100] + "..." if len(error_msg) > 100 else error_msg
470
+ return {"provider": provider, "success": False, "message": f"Error: {truncated}"}
471
+
472
+
473
+ async def discover_with_config(provider: str, config: dict) -> List[dict]:
474
+ """
475
+ Discover models using explicit config instead of env vars.
476
+
477
+ Returns model names only — no type classification.
478
+ The user chooses the model type when registering.
479
+ """
480
+ api_key = config.get("api_key")
481
+ base_url = config.get("base_url")
482
+
483
+ # Static model lists for providers without a listing API
484
+ STATIC_MODELS: Dict[str, List[str]] = {
485
+ "anthropic": [
486
+ "claude-opus-4-20250514",
487
+ "claude-sonnet-4-20250514",
488
+ "claude-3-5-sonnet-20241022",
489
+ "claude-3-5-haiku-20241022",
490
+ "claude-3-opus-20240229",
491
+ "claude-3-sonnet-20240229",
492
+ "claude-3-haiku-20240307",
493
+ ],
494
+ "voyage": [
495
+ "voyage-3", "voyage-3-lite", "voyage-code-3",
496
+ "voyage-finance-2", "voyage-law-2", "voyage-multilingual-2",
497
+ ],
498
+ "elevenlabs": [
499
+ "eleven_multilingual_v2", "eleven_turbo_v2_5",
500
+ "eleven_turbo_v2", "eleven_monolingual_v1",
501
+ ],
502
+ }
503
+
504
+ if provider in STATIC_MODELS:
505
+ if not api_key and provider != "ollama":
506
+ return []
507
+ return [
508
+ {"name": m, "provider": provider}
509
+ for m in STATIC_MODELS[provider]
510
+ ]
511
+
512
+ # API-based discovery URLs (OpenAI-style /models endpoints)
513
+ url_map = {
514
+ "openai": "https://api.openai.com/v1/models",
515
+ "groq": "https://api.groq.com/openai/v1/models",
516
+ "mistral": "https://api.mistral.ai/v1/models",
517
+ "deepseek": "https://api.deepseek.com/models",
518
+ "xai": "https://api.x.ai/v1/models",
519
+ "openrouter": "https://openrouter.ai/api/v1/models",
520
+ "dashscope": "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
521
+ "minimax": "https://api.minimax.io/v1/models",
522
+ }
523
+
524
+ if provider == "ollama":
525
+ ollama_url = base_url or "http://localhost:11434"
526
+ try:
527
+ async with httpx.AsyncClient() as client:
528
+ response = await client.get(f"{ollama_url}/api/tags", timeout=10.0)
529
+ response.raise_for_status()
530
+ data = response.json()
531
+ return [
532
+ {"name": m.get("name", ""), "provider": "ollama"}
533
+ for m in data.get("models", [])
534
+ if m.get("name")
535
+ ]
536
+ except Exception as e:
537
+ logger.warning(f"Failed to discover Ollama models: {e}")
538
+ return []
539
+
540
+ if provider == "openai_compatible":
541
+ if not base_url:
542
+ return []
543
+ try:
544
+ headers = {}
545
+ if api_key:
546
+ headers["Authorization"] = f"Bearer {api_key}"
547
+ async with httpx.AsyncClient() as client:
548
+ response = await client.get(
549
+ f"{base_url.rstrip('/')}/models", headers=headers, timeout=30.0,
550
+ )
551
+ response.raise_for_status()
552
+ data = response.json()
553
+ return [
554
+ {"name": m.get("id", ""), "provider": "openai_compatible"}
555
+ for m in data.get("data", [])
556
+ if m.get("id")
557
+ ]
558
+ except Exception as e:
559
+ logger.warning(f"Failed to discover openai_compatible models: {e}")
560
+ return []
561
+
562
+ if provider == "azure":
563
+ endpoint = config.get("endpoint")
564
+ api_version = config.get("api_version", "2024-10-21")
565
+ if not endpoint or not api_key:
566
+ return []
567
+ try:
568
+ url = f"{endpoint.rstrip('/')}/openai/models?api-version={api_version}"
569
+ headers = {"api-key": api_key}
570
+ async with httpx.AsyncClient() as client:
571
+ response = await client.get(url, headers=headers, timeout=30.0)
572
+ response.raise_for_status()
573
+ data = response.json()
574
+ return [
575
+ {"name": m.get("id", ""), "provider": "azure"}
576
+ for m in data.get("data", [])
577
+ if m.get("id")
578
+ ]
579
+ except Exception as e:
580
+ logger.warning(f"Failed to discover Azure models: {e}")
581
+ return []
582
+
583
+ if provider == "vertex":
584
+ # Vertex AI requires service-account OAuth2 for model listing.
585
+ # Return a curated static list of well-known Vertex models instead.
586
+ VERTEX_MODELS = [
587
+ "gemini-2.0-flash",
588
+ "gemini-2.0-flash-lite",
589
+ "gemini-1.5-pro",
590
+ "gemini-1.5-flash",
591
+ "text-embedding-005",
592
+ ]
593
+ return [{"name": m, "provider": "vertex"} for m in VERTEX_MODELS]
594
+
595
+ if provider == "google":
596
+ try:
597
+ headers = {"X-Goog-Api-Key": api_key} if api_key else {}
598
+ async with httpx.AsyncClient() as client:
599
+ response = await client.get(
600
+ "https://generativelanguage.googleapis.com/v1/models",
601
+ headers=headers,
602
+ timeout=30.0,
603
+ )
604
+ response.raise_for_status()
605
+ data = response.json()
606
+ return [
607
+ {
608
+ "name": model.get("name", "").replace("models/", ""),
609
+ "provider": "google",
610
+ "description": model.get("displayName"),
611
+ }
612
+ for model in data.get("models", [])
613
+ if model.get("name")
614
+ ]
615
+ except Exception as e:
616
+ logger.warning(f"Failed to discover Google models: {e}")
617
+ return []
618
+
619
+ # Standard OpenAI-style API discovery
620
+ discovery_url = url_map.get(provider)
621
+ if not discovery_url or not api_key:
622
+ return []
623
+
624
+ try:
625
+ async with httpx.AsyncClient() as client:
626
+ response = await client.get(
627
+ discovery_url,
628
+ headers={"Authorization": f"Bearer {api_key}"},
629
+ timeout=30.0,
630
+ )
631
+ response.raise_for_status()
632
+ data = response.json()
633
+
634
+ return [
635
+ {
636
+ "name": m.get("id", ""),
637
+ "provider": provider,
638
+ "description": m.get("name"),
639
+ }
640
+ for m in data.get("data", [])
641
+ if m.get("id")
642
+ ]
643
+ except Exception as e:
644
+ logger.warning(f"Failed to discover {provider} models: {e}")
645
+ return []
646
+
647
+
648
+ async def register_models(credential_id: str, models_data: list) -> dict:
649
+ """
650
+ Register discovered models and link them to a credential.
651
+
652
+ Args:
653
+ credential_id: The credential ID to link models to
654
+ models_data: List of dicts with name, provider, model_type
655
+
656
+ Returns:
657
+ dict with created and existing counts
658
+ """
659
+ cred = await Credential.get(credential_id)
660
+
661
+ from open_notebook.ai.models import Model
662
+ from open_notebook.database.repository import repo_query
663
+
664
+ # Batch fetch existing models for this provider
665
+ existing_models = await repo_query(
666
+ "SELECT string::lowercase(name) as name, string::lowercase(type) as type FROM model "
667
+ "WHERE string::lowercase(provider) = $provider",
668
+ {"provider": cred.provider.lower()},
669
+ )
670
+ existing_keys = {(m["name"], m["type"]) for m in existing_models}
671
+
672
+ created = 0
673
+ existing = 0
674
+
675
+ for model_data in models_data:
676
+ key = (model_data.name.lower(), model_data.model_type.lower())
677
+ if key in existing_keys:
678
+ existing += 1
679
+ continue
680
+
681
+ new_model = Model(
682
+ name=model_data.name,
683
+ provider=model_data.provider or cred.provider,
684
+ type=model_data.model_type,
685
+ credential=cred.id,
686
+ )
687
+ await new_model.save()
688
+ created += 1
689
+
690
+ return {"created": created, "existing": existing}
691
+
692
+
693
+ async def migrate_from_provider_config() -> dict:
694
+ """
695
+ Migrate existing ProviderConfig data to individual credential records.
696
+
697
+ Returns dict with message, migrated, skipped, errors.
698
+ """
699
+ logger.info("=== Starting ProviderConfig migration ===")
700
+
701
+ require_encryption_key()
702
+ logger.info("Encryption key verified")
703
+
704
+ from open_notebook.domain.provider_config import ProviderConfig
705
+
706
+ config = await ProviderConfig.get_instance()
707
+ logger.info(
708
+ f"Found ProviderConfig with {len(config.credentials)} provider(s): "
709
+ f"{', '.join(config.credentials.keys())}"
710
+ )
711
+
712
+ migrated = []
713
+ skipped = []
714
+ errors = []
715
+
716
+ for provider, credentials_list in config.credentials.items():
717
+ for old_cred in credentials_list:
718
+ try:
719
+ # Check if a credential already exists for this provider with same name
720
+ existing = await Credential.get_by_provider(provider)
721
+ names = [c.name for c in existing]
722
+ if old_cred.name in names:
723
+ logger.info(
724
+ f"[{provider}/{old_cred.name}] Already exists in DB, skipping"
725
+ )
726
+ skipped.append(f"{provider}/{old_cred.name}")
727
+ continue
728
+
729
+ # Determine modalities from the provider type
730
+ modalities = get_default_modalities(provider)
731
+
732
+ logger.info(f"[{provider}/{old_cred.name}] Creating credential")
733
+ new_cred = Credential(
734
+ name=old_cred.name,
735
+ provider=provider,
736
+ modalities=modalities,
737
+ api_key=old_cred.api_key,
738
+ base_url=old_cred.base_url,
739
+ endpoint=old_cred.endpoint,
740
+ api_version=old_cred.api_version,
741
+ endpoint_llm=old_cred.endpoint_llm,
742
+ endpoint_embedding=old_cred.endpoint_embedding,
743
+ endpoint_stt=old_cred.endpoint_stt,
744
+ endpoint_tts=old_cred.endpoint_tts,
745
+ project=old_cred.project,
746
+ location=old_cred.location,
747
+ credentials_path=old_cred.credentials_path,
748
+ )
749
+ await new_cred.save()
750
+ logger.info(
751
+ f"[{provider}/{old_cred.name}] Credential saved (id={new_cred.id})"
752
+ )
753
+
754
+ # Link existing models for this provider to the new credential
755
+ from open_notebook.ai.models import Model
756
+ from open_notebook.database.repository import repo_query
757
+
758
+ provider_models = await repo_query(
759
+ "SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE",
760
+ {"provider": provider.lower()},
761
+ )
762
+ if provider_models:
763
+ logger.info(
764
+ f"[{provider}/{old_cred.name}] Linking {len(provider_models)} "
765
+ f"unassigned model(s)"
766
+ )
767
+ for model_data in provider_models:
768
+ model = Model(**model_data)
769
+ model.credential = new_cred.id
770
+ await model.save()
771
+
772
+ migrated.append(f"{provider}/{old_cred.name}")
773
+
774
+ except Exception as e:
775
+ logger.error(
776
+ f"[{provider}/{old_cred.name}] Migration FAILED: "
777
+ f"{type(e).__name__}: {e}",
778
+ exc_info=True,
779
+ )
780
+ errors.append(f"{provider}/{old_cred.name}: {e}")
781
+
782
+ logger.info(
783
+ f"=== ProviderConfig migration complete === "
784
+ f"migrated={len(migrated)} skipped={len(skipped)} errors={len(errors)}"
785
+ )
786
+ if migrated:
787
+ logger.info(f" Migrated: {', '.join(migrated)}")
788
+ if skipped:
789
+ logger.info(f" Skipped: {', '.join(skipped)}")
790
+ if errors:
791
+ logger.error(f" Errors: {'; '.join(errors)}")
792
+
793
+ return {
794
+ "message": f"Migration complete. Migrated {len(migrated)} credentials.",
795
+ "migrated": migrated,
796
+ "skipped": skipped,
797
+ "errors": errors,
798
+ }
799
+
800
+
801
+ async def migrate_from_env() -> dict:
802
+ """
803
+ Migrate API keys from environment variables to credential records.
804
+
805
+ Returns dict with message, migrated, skipped, not_configured, errors.
806
+ """
807
+ logger.info("=== Starting environment variable migration ===")
808
+ logger.info(
809
+ f"Checking {len(PROVIDER_ENV_CONFIG)} providers: "
810
+ f"{', '.join(PROVIDER_ENV_CONFIG.keys())}"
811
+ )
812
+
813
+ require_encryption_key()
814
+ logger.info("Encryption key verified")
815
+
816
+ from open_notebook.ai.models import Model
817
+ from open_notebook.database.repository import repo_query
818
+
819
+ migrated = []
820
+ skipped = []
821
+ not_configured = []
822
+ errors = []
823
+
824
+ for provider in PROVIDER_ENV_CONFIG:
825
+ try:
826
+ if not check_env_configured(provider):
827
+ logger.debug(f"[{provider}] No env vars configured, skipping")
828
+ not_configured.append(provider)
829
+ continue
830
+
831
+ logger.info(f"[{provider}] Env vars detected, checking for existing credentials")
832
+
833
+ existing = await Credential.get_by_provider(provider)
834
+ if existing:
835
+ logger.info(
836
+ f"[{provider}] Already has {len(existing)} credential(s) in DB, skipping"
837
+ )
838
+ skipped.append(provider)
839
+ continue
840
+
841
+ logger.info(f"[{provider}] Creating credential from env vars")
842
+ cred = create_credential_from_env(provider)
843
+ await cred.save()
844
+ logger.info(f"[{provider}] Credential saved successfully (id={cred.id})")
845
+
846
+ # Link unassigned models to this credential
847
+ provider_models = await repo_query(
848
+ "SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE",
849
+ {"provider": provider.lower()},
850
+ )
851
+ if provider_models:
852
+ logger.info(
853
+ f"[{provider}] Linking {len(provider_models)} unassigned model(s) "
854
+ f"to credential {cred.id}"
855
+ )
856
+ for model_data in provider_models:
857
+ model = Model(**model_data)
858
+ model.credential = cred.id
859
+ await model.save()
860
+ else:
861
+ logger.info(f"[{provider}] No unassigned models to link")
862
+
863
+ migrated.append(provider)
864
+
865
+ except Exception as e:
866
+ logger.error(
867
+ f"[{provider}] Migration FAILED: {type(e).__name__}: {e}",
868
+ exc_info=True,
869
+ )
870
+ errors.append(f"{provider}: {e}")
871
+
872
+ logger.info(
873
+ f"=== Environment variable migration complete === "
874
+ f"migrated={len(migrated)} skipped={len(skipped)} "
875
+ f"not_configured={len(not_configured)} errors={len(errors)}"
876
+ )
877
+ if migrated:
878
+ logger.info(f" Migrated: {', '.join(migrated)}")
879
+ if skipped:
880
+ logger.info(f" Skipped (already in DB): {', '.join(skipped)}")
881
+ if errors:
882
+ logger.error(f" Errors: {'; '.join(errors)}")
883
+
884
+ return {
885
+ "message": f"Migration complete. Migrated {len(migrated)} providers.",
886
+ "migrated": migrated,
887
+ "skipped": skipped,
888
+ "not_configured": not_configured,
889
+ "errors": errors,
890
+ }
api/embedding_service.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Embedding service layer using API.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Union
6
+
7
+ from loguru import logger
8
+
9
+ from api.client import api_client
10
+
11
+
12
+ class EmbeddingService:
13
+ """Service layer for embedding operations using API."""
14
+
15
+ def __init__(self):
16
+ logger.info("Using API for embedding operations")
17
+
18
+ def embed_content(
19
+ self, item_id: str, item_type: str
20
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
21
+ """Embed content for vector search."""
22
+ result = api_client.embed_content(item_id=item_id, item_type=item_type)
23
+ return result
24
+
25
+
26
+ # Global service instance
27
+ embedding_service = EmbeddingService()
api/episode_profiles_service.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Episode profiles service layer using API.
3
+ """
4
+
5
+ from typing import List
6
+
7
+ from loguru import logger
8
+
9
+ from api.client import api_client
10
+ from open_notebook.podcasts.models import EpisodeProfile
11
+
12
+
13
+ class EpisodeProfilesService:
14
+ """Service layer for episode profiles operations using API."""
15
+
16
+ def __init__(self):
17
+ logger.info("Using API for episode profiles operations")
18
+
19
+ def get_all_episode_profiles(self) -> List[EpisodeProfile]:
20
+ """Get all episode profiles."""
21
+ profiles_data = api_client.get_episode_profiles()
22
+ # Convert API response to EpisodeProfile objects
23
+ profiles = []
24
+ for profile_data in profiles_data:
25
+ profile = EpisodeProfile(
26
+ name=profile_data["name"],
27
+ description=profile_data.get("description", ""),
28
+ speaker_config=profile_data["speaker_config"],
29
+ outline_provider=profile_data["outline_provider"],
30
+ outline_model=profile_data["outline_model"],
31
+ transcript_provider=profile_data["transcript_provider"],
32
+ transcript_model=profile_data["transcript_model"],
33
+ default_briefing=profile_data["default_briefing"],
34
+ num_segments=profile_data["num_segments"],
35
+ )
36
+ profile.id = profile_data["id"]
37
+ profiles.append(profile)
38
+ return profiles
39
+
40
+ def get_episode_profile(self, profile_name: str) -> EpisodeProfile:
41
+ """Get a specific episode profile by name."""
42
+ profile_response = api_client.get_episode_profile(profile_name)
43
+ profile_data = (
44
+ profile_response
45
+ if isinstance(profile_response, dict)
46
+ else profile_response[0]
47
+ )
48
+ profile = EpisodeProfile(
49
+ name=profile_data["name"],
50
+ description=profile_data.get("description", ""),
51
+ speaker_config=profile_data["speaker_config"],
52
+ outline_provider=profile_data["outline_provider"],
53
+ outline_model=profile_data["outline_model"],
54
+ transcript_provider=profile_data["transcript_provider"],
55
+ transcript_model=profile_data["transcript_model"],
56
+ default_briefing=profile_data["default_briefing"],
57
+ num_segments=profile_data["num_segments"],
58
+ )
59
+ profile.id = profile_data["id"]
60
+ return profile
61
+
62
+ def create_episode_profile(
63
+ self,
64
+ name: str,
65
+ description: str = "",
66
+ speaker_config: str = "",
67
+ outline_provider: str = "",
68
+ outline_model: str = "",
69
+ transcript_provider: str = "",
70
+ transcript_model: str = "",
71
+ default_briefing: str = "",
72
+ num_segments: int = 5,
73
+ ) -> EpisodeProfile:
74
+ """Create a new episode profile."""
75
+ profile_response = api_client.create_episode_profile(
76
+ name=name,
77
+ description=description,
78
+ speaker_config=speaker_config,
79
+ outline_provider=outline_provider,
80
+ outline_model=outline_model,
81
+ transcript_provider=transcript_provider,
82
+ transcript_model=transcript_model,
83
+ default_briefing=default_briefing,
84
+ num_segments=num_segments,
85
+ )
86
+ profile_data = (
87
+ profile_response
88
+ if isinstance(profile_response, dict)
89
+ else profile_response[0]
90
+ )
91
+ profile = EpisodeProfile(
92
+ name=profile_data["name"],
93
+ description=profile_data.get("description", ""),
94
+ speaker_config=profile_data["speaker_config"],
95
+ outline_provider=profile_data["outline_provider"],
96
+ outline_model=profile_data["outline_model"],
97
+ transcript_provider=profile_data["transcript_provider"],
98
+ transcript_model=profile_data["transcript_model"],
99
+ default_briefing=profile_data["default_briefing"],
100
+ num_segments=profile_data["num_segments"],
101
+ )
102
+ profile.id = profile_data["id"]
103
+ return profile
104
+
105
+ def delete_episode_profile(self, profile_id: str) -> bool:
106
+ """Delete an episode profile."""
107
+ api_client.delete_episode_profile(profile_id)
108
+ return True
109
+
110
+
111
+ # Global service instance
112
+ episode_profiles_service = EpisodeProfilesService()
api/insights_service.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Insights service layer using API.
3
+ """
4
+
5
+ from typing import List, Optional
6
+
7
+ from loguru import logger
8
+
9
+ from api.client import api_client
10
+ from open_notebook.domain.notebook import Note, SourceInsight
11
+
12
+
13
+ class InsightsService:
14
+ """Service layer for insights operations using API."""
15
+
16
+ def __init__(self):
17
+ logger.info("Using API for insights operations")
18
+
19
+ def get_source_insights(self, source_id: str) -> List[SourceInsight]:
20
+ """Get all insights for a specific source."""
21
+ insights_data = api_client.get_source_insights(source_id)
22
+ # Convert API response to SourceInsight objects
23
+ insights = []
24
+ for insight_data in insights_data:
25
+ insight = SourceInsight(
26
+ insight_type=insight_data["insight_type"],
27
+ content=insight_data["content"],
28
+ )
29
+ insight.id = insight_data["id"]
30
+ insight.created = insight_data["created"]
31
+ insight.updated = insight_data["updated"]
32
+ insights.append(insight)
33
+ return insights
34
+
35
+ def get_insight(self, insight_id: str) -> SourceInsight:
36
+ """Get a specific insight."""
37
+ insight_response = api_client.get_insight(insight_id)
38
+ insight_data = (
39
+ insight_response
40
+ if isinstance(insight_response, dict)
41
+ else insight_response[0]
42
+ )
43
+ insight = SourceInsight(
44
+ insight_type=insight_data["insight_type"],
45
+ content=insight_data["content"],
46
+ )
47
+ insight.id = insight_data["id"]
48
+ insight.created = insight_data["created"]
49
+ insight.updated = insight_data["updated"]
50
+ # Note: source_id from API response is not stored; use await insight.get_source() if needed
51
+ return insight
52
+
53
+ def delete_insight(self, insight_id: str) -> bool:
54
+ """Delete a specific insight."""
55
+ api_client.delete_insight(insight_id)
56
+ return True
57
+
58
+ def save_insight_as_note(
59
+ self, insight_id: str, notebook_id: Optional[str] = None
60
+ ) -> Note:
61
+ """Convert an insight to a note."""
62
+ note_response = api_client.save_insight_as_note(insight_id, notebook_id)
63
+ note_data = (
64
+ note_response if isinstance(note_response, dict) else note_response[0]
65
+ )
66
+ note = Note(
67
+ title=note_data["title"],
68
+ content=note_data["content"],
69
+ note_type=note_data["note_type"],
70
+ )
71
+ note.id = note_data["id"]
72
+ note.created = note_data["created"]
73
+ note.updated = note_data["updated"]
74
+ return note
75
+
76
+ def create_source_insight(
77
+ self, source_id: str, transformation_id: str, model_id: Optional[str] = None
78
+ ) -> SourceInsight:
79
+ """Create a new insight for a source by running a transformation."""
80
+ insight_response = api_client.create_source_insight(
81
+ source_id, transformation_id, model_id
82
+ )
83
+ insight_data = (
84
+ insight_response
85
+ if isinstance(insight_response, dict)
86
+ else insight_response[0]
87
+ )
88
+ insight = SourceInsight(
89
+ insight_type=insight_data["insight_type"],
90
+ content=insight_data["content"],
91
+ )
92
+ insight.id = insight_data["id"]
93
+ insight.created = insight_data["created"]
94
+ insight.updated = insight_data["updated"]
95
+ # Note: source_id from API response is not stored; use await insight.get_source() if needed
96
+ return insight
97
+
98
+
99
+ # Global service instance
100
+ insights_service = InsightsService()
api/main.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Load environment variables
2
+ from dotenv import load_dotenv
3
+
4
+ load_dotenv()
5
+
6
+ import os
7
+ from contextlib import asynccontextmanager
8
+
9
+ from fastapi import FastAPI, Request
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import JSONResponse
12
+ from loguru import logger
13
+ from starlette.exceptions import HTTPException as StarletteHTTPException
14
+
15
+ from api.auth import PasswordAuthMiddleware
16
+ from api.routers import (
17
+ auth,
18
+ chat,
19
+ config,
20
+ context,
21
+ credentials,
22
+ embedding,
23
+ embedding_rebuild,
24
+ episode_profiles,
25
+ insights,
26
+ languages,
27
+ models,
28
+ notebooks,
29
+ notes,
30
+ podcasts,
31
+ search,
32
+ settings,
33
+ source_chat,
34
+ sources,
35
+ speaker_profiles,
36
+ transformations,
37
+ )
38
+ from api.routers import commands as commands_router
39
+ from open_notebook.database.async_migrate import AsyncMigrationManager
40
+ from open_notebook.exceptions import (
41
+ AuthenticationError,
42
+ ConfigurationError,
43
+ ExternalServiceError,
44
+ InvalidInputError,
45
+ NetworkError,
46
+ NotFoundError,
47
+ OpenNotebookError,
48
+ RateLimitError,
49
+ )
50
+ from open_notebook.utils.encryption import get_secret_from_env
51
+
52
+
53
+ def _parse_cors_origins(raw: str) -> list[str]:
54
+ """Parse CORS_ORIGINS env value into a list of origins."""
55
+ value = raw.strip()
56
+ if value == "*":
57
+ return ["*"]
58
+ return [origin.strip() for origin in value.split(",") if origin.strip()]
59
+
60
+
61
+ # Parsed once at module load; CORS_ORIGINS changes require a restart.
62
+ _cors_origins_raw = os.getenv("CORS_ORIGINS")
63
+ CORS_ALLOWED_ORIGINS = _parse_cors_origins(_cors_origins_raw or "*")
64
+ CORS_IS_DEFAULT_WILDCARD = _cors_origins_raw is None
65
+
66
+
67
+ def _cors_headers(request: Request) -> dict[str, str]:
68
+ """
69
+ Build CORS headers for error responses.
70
+
71
+ Mirrors Starlette CORSMiddleware behavior: reflects the request Origin
72
+ when the origin is allowed (or when wildcard is configured, since
73
+ browsers reject `Access-Control-Allow-Origin: *` combined with
74
+ credentials). Omits `Access-Control-Allow-Origin` for disallowed
75
+ origins so the browser blocks the error body from leaking cross-origin.
76
+ """
77
+ origin = request.headers.get("origin")
78
+ headers: dict[str, str] = {
79
+ "Access-Control-Allow-Credentials": "true",
80
+ "Access-Control-Allow-Methods": "*",
81
+ "Access-Control-Allow-Headers": "*",
82
+ }
83
+
84
+ if origin and ("*" in CORS_ALLOWED_ORIGINS or origin in CORS_ALLOWED_ORIGINS):
85
+ headers["Access-Control-Allow-Origin"] = origin
86
+ headers["Vary"] = "Origin"
87
+
88
+ return headers
89
+
90
+
91
+ # Import commands to register them in the API process
92
+ try:
93
+ logger.info("Commands imported in API process")
94
+ except Exception as e:
95
+ logger.error(f"Failed to import commands in API process: {e}")
96
+
97
+
98
+ @asynccontextmanager
99
+ async def lifespan(app: FastAPI):
100
+ """
101
+ Lifespan event handler for the FastAPI application.
102
+ Runs database migrations automatically on startup.
103
+ """
104
+ # Startup: Security checks
105
+ logger.info("Starting API initialization...")
106
+
107
+ # Security check: Encryption key
108
+ if not get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"):
109
+ logger.warning(
110
+ "OPEN_NOTEBOOK_ENCRYPTION_KEY not set. "
111
+ "API key encryption will fail until this is configured. "
112
+ "Set OPEN_NOTEBOOK_ENCRYPTION_KEY to any secret string."
113
+ )
114
+
115
+ # Run database migrations
116
+
117
+ try:
118
+ migration_manager = AsyncMigrationManager()
119
+ current_version = await migration_manager.get_current_version()
120
+ logger.info(f"Current database version: {current_version}")
121
+
122
+ if await migration_manager.needs_migration():
123
+ logger.warning("Database migrations are pending. Running migrations...")
124
+ await migration_manager.run_migration_up()
125
+ new_version = await migration_manager.get_current_version()
126
+ logger.success(
127
+ f"Migrations completed successfully. Database is now at version {new_version}"
128
+ )
129
+ else:
130
+ logger.info(
131
+ "Database is already at the latest version. No migrations needed."
132
+ )
133
+ except Exception as e:
134
+ logger.error(f"CRITICAL: Database migration failed: {str(e)}")
135
+ logger.exception(e)
136
+ # Fail fast - don't start the API with an outdated database schema
137
+ raise RuntimeError(f"Failed to run database migrations: {str(e)}") from e
138
+
139
+ # Run podcast profile data migration (legacy strings -> Model registry)
140
+ try:
141
+ from open_notebook.podcasts.migration import migrate_podcast_profiles
142
+
143
+ await migrate_podcast_profiles()
144
+ except Exception as e:
145
+ logger.warning(f"Podcast profile migration encountered errors: {e}")
146
+ # Non-fatal: profiles can be migrated manually via UI
147
+
148
+ logger.success("API initialization completed successfully")
149
+
150
+ # Yield control to the application
151
+ yield
152
+
153
+ # Shutdown: cleanup if needed
154
+ logger.info("API shutdown complete")
155
+
156
+
157
+ app = FastAPI(
158
+ title="Open Notebook API",
159
+ description="API for Open Notebook - Research Assistant",
160
+ lifespan=lifespan,
161
+ )
162
+
163
+ if CORS_IS_DEFAULT_WILDCARD:
164
+ logger.warning(
165
+ "CORS_ORIGINS is not set — API accepts cross-origin requests from any "
166
+ "origin (default: '*'). For production deployments, set CORS_ORIGINS to "
167
+ "your frontend origin(s), e.g. "
168
+ "CORS_ORIGINS=https://notebook.example.com"
169
+ )
170
+ else:
171
+ logger.info(f"CORS allowed origins: {CORS_ALLOWED_ORIGINS}")
172
+
173
+ # Add password authentication middleware first
174
+ # Exclude /api/auth/status and /api/config from authentication
175
+ app.add_middleware(
176
+ PasswordAuthMiddleware,
177
+ excluded_paths=[
178
+ "/",
179
+ "/health",
180
+ "/docs",
181
+ "/openapi.json",
182
+ "/redoc",
183
+ "/api/auth/status",
184
+ "/api/config",
185
+ ],
186
+ )
187
+
188
+ # Add CORS middleware last (so it processes first)
189
+ app.add_middleware(
190
+ CORSMiddleware,
191
+ allow_origins=CORS_ALLOWED_ORIGINS,
192
+ allow_credentials=True,
193
+ allow_methods=["*"],
194
+ allow_headers=["*"],
195
+ )
196
+
197
+
198
+ # Custom exception handler to ensure CORS headers are included in error responses
199
+ # This helps when errors occur before the CORS middleware can process them
200
+ @app.exception_handler(StarletteHTTPException)
201
+ async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
202
+ """
203
+ Custom exception handler that ensures CORS headers are included in error responses.
204
+ This is particularly important for 413 (Payload Too Large) errors during file uploads.
205
+
206
+ Note: If a reverse proxy (nginx, traefik) returns 413 before the request reaches
207
+ FastAPI, this handler won't be called. In that case, configure your reverse proxy
208
+ to add CORS headers to error responses.
209
+ """
210
+ return JSONResponse(
211
+ status_code=exc.status_code,
212
+ content={"detail": exc.detail},
213
+ headers={**(exc.headers or {}), **_cors_headers(request)},
214
+ )
215
+
216
+
217
+ @app.exception_handler(NotFoundError)
218
+ async def not_found_error_handler(request: Request, exc: NotFoundError):
219
+ return JSONResponse(
220
+ status_code=404,
221
+ content={"detail": str(exc)},
222
+ headers=_cors_headers(request),
223
+ )
224
+
225
+
226
+ @app.exception_handler(InvalidInputError)
227
+ async def invalid_input_error_handler(request: Request, exc: InvalidInputError):
228
+ return JSONResponse(
229
+ status_code=400,
230
+ content={"detail": str(exc)},
231
+ headers=_cors_headers(request),
232
+ )
233
+
234
+
235
+ @app.exception_handler(AuthenticationError)
236
+ async def authentication_error_handler(request: Request, exc: AuthenticationError):
237
+ return JSONResponse(
238
+ status_code=401,
239
+ content={"detail": str(exc)},
240
+ headers=_cors_headers(request),
241
+ )
242
+
243
+
244
+ @app.exception_handler(RateLimitError)
245
+ async def rate_limit_error_handler(request: Request, exc: RateLimitError):
246
+ return JSONResponse(
247
+ status_code=429,
248
+ content={"detail": str(exc)},
249
+ headers=_cors_headers(request),
250
+ )
251
+
252
+
253
+ @app.exception_handler(ConfigurationError)
254
+ async def configuration_error_handler(request: Request, exc: ConfigurationError):
255
+ return JSONResponse(
256
+ status_code=422,
257
+ content={"detail": str(exc)},
258
+ headers=_cors_headers(request),
259
+ )
260
+
261
+
262
+ @app.exception_handler(NetworkError)
263
+ async def network_error_handler(request: Request, exc: NetworkError):
264
+ return JSONResponse(
265
+ status_code=502,
266
+ content={"detail": str(exc)},
267
+ headers=_cors_headers(request),
268
+ )
269
+
270
+
271
+ @app.exception_handler(ExternalServiceError)
272
+ async def external_service_error_handler(request: Request, exc: ExternalServiceError):
273
+ return JSONResponse(
274
+ status_code=502,
275
+ content={"detail": str(exc)},
276
+ headers=_cors_headers(request),
277
+ )
278
+
279
+
280
+ @app.exception_handler(OpenNotebookError)
281
+ async def open_notebook_error_handler(request: Request, exc: OpenNotebookError):
282
+ return JSONResponse(
283
+ status_code=500,
284
+ content={"detail": str(exc)},
285
+ headers=_cors_headers(request),
286
+ )
287
+
288
+
289
+ # Include routers
290
+ app.include_router(auth.router, prefix="/api", tags=["auth"])
291
+ app.include_router(config.router, prefix="/api", tags=["config"])
292
+ app.include_router(notebooks.router, prefix="/api", tags=["notebooks"])
293
+ app.include_router(search.router, prefix="/api", tags=["search"])
294
+ app.include_router(models.router, prefix="/api", tags=["models"])
295
+ app.include_router(transformations.router, prefix="/api", tags=["transformations"])
296
+ app.include_router(notes.router, prefix="/api", tags=["notes"])
297
+ app.include_router(embedding.router, prefix="/api", tags=["embedding"])
298
+ app.include_router(
299
+ embedding_rebuild.router, prefix="/api/embeddings", tags=["embeddings"]
300
+ )
301
+ app.include_router(settings.router, prefix="/api", tags=["settings"])
302
+ app.include_router(context.router, prefix="/api", tags=["context"])
303
+ app.include_router(sources.router, prefix="/api", tags=["sources"])
304
+ app.include_router(insights.router, prefix="/api", tags=["insights"])
305
+ app.include_router(commands_router.router, prefix="/api", tags=["commands"])
306
+ app.include_router(podcasts.router, prefix="/api", tags=["podcasts"])
307
+ app.include_router(episode_profiles.router, prefix="/api", tags=["episode-profiles"])
308
+ app.include_router(speaker_profiles.router, prefix="/api", tags=["speaker-profiles"])
309
+ app.include_router(chat.router, prefix="/api", tags=["chat"])
310
+ app.include_router(source_chat.router, prefix="/api", tags=["source-chat"])
311
+ app.include_router(credentials.router, prefix="/api", tags=["credentials"])
312
+ app.include_router(languages.router, prefix="/api", tags=["languages"])
313
+
314
+
315
+ @app.get("/")
316
+ async def root():
317
+ return {"message": "Open Notebook API is running"}
318
+
319
+
320
+ @app.get("/health")
321
+ async def health():
322
+ return {"status": "healthy"}
api/models.py ADDED
@@ -0,0 +1,686 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Literal, Optional
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
4
+
5
+
6
+ # Notebook models
7
+ class NotebookCreate(BaseModel):
8
+ name: str = Field(..., description="Name of the notebook")
9
+ description: str = Field(default="", description="Description of the notebook")
10
+
11
+
12
+ class NotebookUpdate(BaseModel):
13
+ name: Optional[str] = Field(None, description="Name of the notebook")
14
+ description: Optional[str] = Field(None, description="Description of the notebook")
15
+ archived: Optional[bool] = Field(
16
+ None, description="Whether the notebook is archived"
17
+ )
18
+
19
+
20
+ class NotebookResponse(BaseModel):
21
+ id: str
22
+ name: str
23
+ description: str
24
+ archived: bool
25
+ created: str
26
+ updated: str
27
+ source_count: int
28
+ note_count: int
29
+
30
+
31
+ # Search models
32
+ class SearchRequest(BaseModel):
33
+ query: str = Field(..., description="Search query")
34
+ type: Literal["text", "vector"] = Field("text", description="Search type")
35
+ limit: int = Field(100, description="Maximum number of results", le=1000)
36
+ search_sources: bool = Field(True, description="Include sources in search")
37
+ search_notes: bool = Field(True, description="Include notes in search")
38
+ minimum_score: float = Field(
39
+ 0.2, description="Minimum score for vector search", ge=0, le=1
40
+ )
41
+
42
+
43
+ class SearchResponse(BaseModel):
44
+ results: List[Dict[str, Any]] = Field(..., description="Search results")
45
+ total_count: int = Field(..., description="Total number of results")
46
+ search_type: str = Field(..., description="Type of search performed")
47
+
48
+
49
+ class AskRequest(BaseModel):
50
+ question: str = Field(..., description="Question to ask the knowledge base")
51
+ strategy_model: str = Field(..., description="Model ID for query strategy")
52
+ answer_model: str = Field(..., description="Model ID for individual answers")
53
+ final_answer_model: str = Field(..., description="Model ID for final answer")
54
+
55
+
56
+ class AskResponse(BaseModel):
57
+ answer: str = Field(..., description="Final answer from the knowledge base")
58
+ question: str = Field(..., description="Original question")
59
+
60
+
61
+ # Models API models
62
+ class ModelCreate(BaseModel):
63
+ name: str = Field(..., description="Model name (e.g., gpt-5-mini, claude, gemini)")
64
+ provider: str = Field(
65
+ ..., description="Provider name (e.g., openai, anthropic, gemini)"
66
+ )
67
+ type: str = Field(
68
+ ...,
69
+ description="Model type (language, embedding, text_to_speech, speech_to_text)",
70
+ )
71
+ credential: Optional[str] = Field(
72
+ None, description="Credential ID to link this model to"
73
+ )
74
+
75
+
76
+ class ModelResponse(BaseModel):
77
+ id: str
78
+ name: str
79
+ provider: str
80
+ type: str
81
+ credential: Optional[str] = None
82
+ created: str
83
+ updated: str
84
+
85
+
86
+ class DefaultModelsResponse(BaseModel):
87
+ default_chat_model: Optional[str] = None
88
+ default_transformation_model: Optional[str] = None
89
+ large_context_model: Optional[str] = None
90
+ default_text_to_speech_model: Optional[str] = None
91
+ default_speech_to_text_model: Optional[str] = None
92
+ default_embedding_model: Optional[str] = None
93
+ default_tools_model: Optional[str] = None
94
+
95
+
96
+ class ProviderAvailabilityResponse(BaseModel):
97
+ available: List[str] = Field(..., description="List of available providers")
98
+ unavailable: List[str] = Field(..., description="List of unavailable providers")
99
+ supported_types: Dict[str, List[str]] = Field(
100
+ ..., description="Provider to supported model types mapping"
101
+ )
102
+
103
+
104
+ # Transformations API models
105
+ class TransformationCreate(BaseModel):
106
+ name: str = Field(..., description="Transformation name")
107
+ title: str = Field(..., description="Display title for the transformation")
108
+ description: str = Field(
109
+ ..., description="Description of what this transformation does"
110
+ )
111
+ prompt: str = Field(..., description="The transformation prompt")
112
+ apply_default: bool = Field(
113
+ False, description="Whether to apply this transformation by default"
114
+ )
115
+
116
+
117
+ class TransformationUpdate(BaseModel):
118
+ name: Optional[str] = Field(None, description="Transformation name")
119
+ title: Optional[str] = Field(
120
+ None, description="Display title for the transformation"
121
+ )
122
+ description: Optional[str] = Field(
123
+ None, description="Description of what this transformation does"
124
+ )
125
+ prompt: Optional[str] = Field(None, description="The transformation prompt")
126
+ apply_default: Optional[bool] = Field(
127
+ None, description="Whether to apply this transformation by default"
128
+ )
129
+
130
+
131
+ class TransformationResponse(BaseModel):
132
+ id: str
133
+ name: str
134
+ title: str
135
+ description: str
136
+ prompt: str
137
+ apply_default: bool
138
+ created: str
139
+ updated: str
140
+
141
+
142
+ class TransformationExecuteRequest(BaseModel):
143
+ model_config = ConfigDict(protected_namespaces=())
144
+
145
+ transformation_id: str = Field(
146
+ ..., description="ID of the transformation to execute"
147
+ )
148
+ input_text: str = Field(..., description="Text to transform")
149
+ model_id: str = Field(..., description="Model ID to use for the transformation")
150
+
151
+
152
+ class TransformationExecuteResponse(BaseModel):
153
+ model_config = ConfigDict(protected_namespaces=())
154
+
155
+ output: str = Field(..., description="Transformed text")
156
+ transformation_id: str = Field(..., description="ID of the transformation used")
157
+ model_id: str = Field(..., description="Model ID used")
158
+
159
+
160
+ # Default Prompt API models
161
+ class DefaultPromptResponse(BaseModel):
162
+ transformation_instructions: str = Field(
163
+ ..., description="Default transformation instructions"
164
+ )
165
+
166
+
167
+ class DefaultPromptUpdate(BaseModel):
168
+ transformation_instructions: str = Field(
169
+ ..., description="Default transformation instructions"
170
+ )
171
+
172
+
173
+ # Notes API models
174
+ class NoteCreate(BaseModel):
175
+ title: Optional[str] = Field(None, description="Note title")
176
+ content: str = Field(..., description="Note content")
177
+ note_type: Optional[str] = Field("human", description="Type of note (human, ai)")
178
+ notebook_id: Optional[str] = Field(
179
+ None, description="Notebook ID to add the note to"
180
+ )
181
+
182
+
183
+ class NoteUpdate(BaseModel):
184
+ title: Optional[str] = Field(None, description="Note title")
185
+ content: Optional[str] = Field(None, description="Note content")
186
+ note_type: Optional[str] = Field(None, description="Type of note (human, ai)")
187
+
188
+
189
+ class NoteResponse(BaseModel):
190
+ id: str
191
+ title: Optional[str]
192
+ content: Optional[str]
193
+ note_type: Optional[str]
194
+ created: str
195
+ updated: str
196
+ command_id: Optional[str] = None
197
+
198
+
199
+ # Embedding API models
200
+ class EmbedRequest(BaseModel):
201
+ item_id: str = Field(..., description="ID of the item to embed")
202
+ item_type: str = Field(..., description="Type of item (source, note)")
203
+ async_processing: bool = Field(
204
+ False, description="Process asynchronously in background"
205
+ )
206
+
207
+
208
+ class EmbedResponse(BaseModel):
209
+ success: bool = Field(..., description="Whether embedding was successful")
210
+ message: str = Field(..., description="Result message")
211
+ item_id: str = Field(..., description="ID of the item that was embedded")
212
+ item_type: str = Field(..., description="Type of item that was embedded")
213
+ command_id: Optional[str] = Field(
214
+ None, description="Command ID for async processing"
215
+ )
216
+
217
+
218
+ # Rebuild request/response models
219
+ class RebuildRequest(BaseModel):
220
+ mode: Literal["existing", "all"] = Field(
221
+ ...,
222
+ description="Rebuild mode: 'existing' only re-embeds items with embeddings, 'all' embeds everything",
223
+ )
224
+ include_sources: bool = Field(True, description="Include sources in rebuild")
225
+ include_notes: bool = Field(True, description="Include notes in rebuild")
226
+ include_insights: bool = Field(True, description="Include insights in rebuild")
227
+
228
+
229
+ class RebuildResponse(BaseModel):
230
+ command_id: str = Field(..., description="Command ID to track progress")
231
+ total_items: int = Field(..., description="Estimated number of items to process")
232
+ message: str = Field(..., description="Status message")
233
+
234
+
235
+ class RebuildProgress(BaseModel):
236
+ processed: int = Field(..., description="Number of items processed")
237
+ total: int = Field(..., description="Total items to process")
238
+ percentage: float = Field(..., description="Progress percentage")
239
+
240
+
241
+ class RebuildStats(BaseModel):
242
+ sources: int = Field(0, description="Sources processed")
243
+ notes: int = Field(0, description="Notes processed")
244
+ insights: int = Field(0, description="Insights processed")
245
+ failed: int = Field(0, description="Failed items")
246
+
247
+
248
+ class RebuildStatusResponse(BaseModel):
249
+ command_id: str = Field(..., description="Command ID")
250
+ status: str = Field(..., description="Status: queued, running, completed, failed")
251
+ progress: Optional[RebuildProgress] = None
252
+ stats: Optional[RebuildStats] = None
253
+ started_at: Optional[str] = None
254
+ completed_at: Optional[str] = None
255
+ error_message: Optional[str] = None
256
+
257
+
258
+ # Settings API models
259
+ class SettingsResponse(BaseModel):
260
+ default_content_processing_engine_doc: Optional[str] = None
261
+ default_content_processing_engine_url: Optional[str] = None
262
+ default_embedding_option: Optional[str] = None
263
+ auto_delete_files: Optional[str] = None
264
+ youtube_preferred_languages: Optional[List[str]] = None
265
+
266
+
267
+ class SettingsUpdate(BaseModel):
268
+ default_content_processing_engine_doc: Optional[str] = None
269
+ default_content_processing_engine_url: Optional[str] = None
270
+ default_embedding_option: Optional[str] = None
271
+ auto_delete_files: Optional[str] = None
272
+ youtube_preferred_languages: Optional[List[str]] = None
273
+
274
+
275
+ # Sources API models
276
+ class AssetModel(BaseModel):
277
+ file_path: Optional[str] = None
278
+ url: Optional[str] = None
279
+
280
+
281
+ class SourceCreate(BaseModel):
282
+ # Backward compatibility: support old single notebook_id
283
+ notebook_id: Optional[str] = Field(
284
+ None, description="Notebook ID to add the source to (deprecated, use notebooks)"
285
+ )
286
+ # New multi-notebook support
287
+ notebooks: Optional[List[str]] = Field(
288
+ None, description="List of notebook IDs to add the source to"
289
+ )
290
+ # Required fields
291
+ type: str = Field(..., description="Source type: link, upload, or text")
292
+ url: Optional[str] = Field(None, description="URL for link type")
293
+ file_path: Optional[str] = Field(None, description="File path for upload type")
294
+ content: Optional[str] = Field(None, description="Text content for text type")
295
+ title: Optional[str] = Field(None, description="Source title")
296
+ transformations: Optional[List[str]] = Field(
297
+ default_factory=list, description="Transformation IDs to apply"
298
+ )
299
+ embed: bool = Field(False, description="Whether to embed content for vector search")
300
+ delete_source: bool = Field(
301
+ False, description="Whether to delete uploaded file after processing"
302
+ )
303
+ # New async processing support
304
+ async_processing: bool = Field(
305
+ False, description="Whether to process source asynchronously"
306
+ )
307
+
308
+ @model_validator(mode="after")
309
+ def validate_notebook_fields(self):
310
+ # Ensure only one of notebook_id or notebooks is provided
311
+ if self.notebook_id is not None and self.notebooks is not None:
312
+ raise ValueError(
313
+ "Cannot specify both 'notebook_id' and 'notebooks'. Use 'notebooks' for multi-notebook support."
314
+ )
315
+
316
+ # Convert single notebook_id to notebooks array for internal processing
317
+ if self.notebook_id is not None:
318
+ self.notebooks = [self.notebook_id]
319
+ # Keep notebook_id for backward compatibility in response
320
+
321
+ # Set empty array if no notebooks specified (allow sources without notebooks)
322
+ if self.notebooks is None:
323
+ self.notebooks = []
324
+
325
+ return self
326
+
327
+
328
+ class SourceUpdate(BaseModel):
329
+ title: Optional[str] = Field(None, description="Source title")
330
+ topics: Optional[List[str]] = Field(None, description="Source topics")
331
+
332
+
333
+ class SourceResponse(BaseModel):
334
+ id: str
335
+ title: Optional[str]
336
+ topics: Optional[List[str]]
337
+ asset: Optional[AssetModel]
338
+ full_text: Optional[str]
339
+ embedded: bool
340
+ embedded_chunks: int
341
+ file_available: Optional[bool] = None
342
+ created: str
343
+ updated: str
344
+ # New fields for async processing
345
+ command_id: Optional[str] = None
346
+ status: Optional[str] = None
347
+ processing_info: Optional[Dict] = None
348
+ # Notebook associations
349
+ notebooks: Optional[List[str]] = None
350
+
351
+
352
+ class SourceListResponse(BaseModel):
353
+ id: str
354
+ title: Optional[str]
355
+ topics: Optional[List[str]]
356
+ asset: Optional[AssetModel]
357
+ embedded: bool # Boolean flag indicating if source has embeddings
358
+ embedded_chunks: int # Number of embedded chunks
359
+ insights_count: int
360
+ created: str
361
+ updated: str
362
+ file_available: Optional[bool] = None
363
+ # Status fields for async processing
364
+ command_id: Optional[str] = None
365
+ status: Optional[str] = None
366
+ processing_info: Optional[Dict[str, Any]] = None
367
+
368
+
369
+ # Context API models
370
+ class ContextConfig(BaseModel):
371
+ sources: Dict[str, str] = Field(
372
+ default_factory=dict, description="Source inclusion config {source_id: level}"
373
+ )
374
+ notes: Dict[str, str] = Field(
375
+ default_factory=dict, description="Note inclusion config {note_id: level}"
376
+ )
377
+
378
+
379
+ class ContextRequest(BaseModel):
380
+ notebook_id: str = Field(..., description="Notebook ID to get context for")
381
+ context_config: Optional[ContextConfig] = Field(
382
+ None, description="Context configuration"
383
+ )
384
+
385
+
386
+ class ContextResponse(BaseModel):
387
+ notebook_id: str
388
+ sources: List[Dict[str, Any]] = Field(..., description="Source context data")
389
+ notes: List[Dict[str, Any]] = Field(..., description="Note context data")
390
+ total_tokens: Optional[int] = Field(None, description="Estimated token count")
391
+
392
+
393
+ # Insights API models
394
+ class SourceInsightResponse(BaseModel):
395
+ id: str
396
+ source_id: str
397
+ insight_type: str
398
+ content: str
399
+ created: str
400
+ updated: str
401
+
402
+
403
+ class InsightCreationResponse(BaseModel):
404
+ """Response for async insight creation."""
405
+
406
+ status: Literal["pending"] = "pending"
407
+ message: str = "Insight generation started"
408
+ source_id: str
409
+ transformation_id: str
410
+ command_id: Optional[str] = None
411
+
412
+
413
+ class SaveAsNoteRequest(BaseModel):
414
+ notebook_id: Optional[str] = Field(None, description="Notebook ID to add note to")
415
+
416
+
417
+ class CreateSourceInsightRequest(BaseModel):
418
+ model_config = ConfigDict(protected_namespaces=())
419
+
420
+ transformation_id: str = Field(..., description="ID of transformation to apply")
421
+ model_id: Optional[str] = Field(
422
+ None, description="Model ID (uses default if not provided)"
423
+ )
424
+
425
+
426
+ # Source status response
427
+ class SourceStatusResponse(BaseModel):
428
+ status: Optional[str] = Field(None, description="Processing status")
429
+ message: str = Field(..., description="Descriptive message about the status")
430
+ processing_info: Optional[Dict[str, Any]] = Field(
431
+ None, description="Detailed processing information"
432
+ )
433
+ command_id: Optional[str] = Field(None, description="Command ID if available")
434
+
435
+
436
+ # Error response
437
+ class ErrorResponse(BaseModel):
438
+ error: str
439
+ message: str
440
+
441
+
442
+ # API Key Configuration models
443
+ class SetApiKeyRequest(BaseModel):
444
+ """Request to set an API key for a provider."""
445
+
446
+ api_key: Optional[str] = Field(None, description="API key for the provider")
447
+ base_url: Optional[str] = Field(
448
+ None, description="Base URL for URL-based providers (Ollama, OpenAI-compatible)"
449
+ )
450
+ endpoint: Optional[str] = Field(
451
+ None, description="Endpoint URL for Azure OpenAI"
452
+ )
453
+ api_version: Optional[str] = Field(
454
+ None, description="API version for Azure OpenAI"
455
+ )
456
+ endpoint_llm: Optional[str] = Field(
457
+ None, description="Service-specific endpoint for LLM (Azure)"
458
+ )
459
+ endpoint_embedding: Optional[str] = Field(
460
+ None, description="Service-specific endpoint for embedding (Azure)"
461
+ )
462
+ endpoint_stt: Optional[str] = Field(
463
+ None, description="Service-specific endpoint for STT (Azure)"
464
+ )
465
+ endpoint_tts: Optional[str] = Field(
466
+ None, description="Service-specific endpoint for TTS (Azure)"
467
+ )
468
+ service_type: Optional[Literal["llm", "embedding", "stt", "tts"]] = Field(
469
+ None,
470
+ description="Service type for OpenAI-compatible providers (llm, embedding, stt, tts)",
471
+ )
472
+ # Vertex AI specific fields
473
+ vertex_project: Optional[str] = Field(
474
+ None, description="Google Cloud Project ID for Vertex AI"
475
+ )
476
+ vertex_location: Optional[str] = Field(
477
+ None, description="Google Cloud Region for Vertex AI (e.g., us-central1)"
478
+ )
479
+ vertex_credentials_path: Optional[str] = Field(
480
+ None, description="Path to Google Cloud service account JSON file"
481
+ )
482
+
483
+ @field_validator(
484
+ "api_key",
485
+ "base_url",
486
+ "endpoint",
487
+ "api_version",
488
+ "endpoint_llm",
489
+ "endpoint_embedding",
490
+ "endpoint_stt",
491
+ "endpoint_tts",
492
+ "vertex_project",
493
+ "vertex_location",
494
+ "vertex_credentials_path",
495
+ mode="before",
496
+ )
497
+ @classmethod
498
+ def validate_not_empty_string(cls, v: Optional[str]) -> Optional[str]:
499
+ """Reject empty strings - convert to None or raise error."""
500
+ if v is not None:
501
+ stripped = v.strip()
502
+ if not stripped:
503
+ return None # Treat empty/whitespace-only as None
504
+ return stripped
505
+ return v
506
+
507
+
508
+ class ApiKeyStatusResponse(BaseModel):
509
+ """Response showing which providers are configured and their source."""
510
+
511
+ configured: Dict[str, bool] = Field(
512
+ ..., description="Map of provider name to whether it is configured"
513
+ )
514
+ source: Dict[str, Literal["database", "environment", "none"]] = Field(
515
+ ...,
516
+ description="Map of provider name to configuration source (database, environment, or none)",
517
+ )
518
+ encryption_configured: bool = Field(
519
+ ...,
520
+ description="Whether OPEN_NOTEBOOK_ENCRYPTION_KEY is set (required to store keys in database)",
521
+ )
522
+
523
+
524
+ class TestConnectionResponse(BaseModel):
525
+ """Response from testing a provider connection."""
526
+
527
+ provider: str = Field(..., description="Provider name that was tested")
528
+ success: bool = Field(..., description="Whether connection test succeeded")
529
+ message: str = Field(..., description="Result message with details")
530
+
531
+
532
+ class MigrateFromEnvRequest(BaseModel):
533
+ """Request to migrate API keys from environment variables to database."""
534
+
535
+ force: bool = Field(
536
+ False, description="Force overwrite existing database configurations"
537
+ )
538
+
539
+
540
+ class MigrationResult(BaseModel):
541
+ """Response from migrating API keys from environment to database."""
542
+
543
+ message: str = Field(..., description="Summary message")
544
+ migrated: List[str] = Field(
545
+ default_factory=list, description="Providers successfully migrated"
546
+ )
547
+ skipped: List[str] = Field(
548
+ default_factory=list, description="Providers skipped (already in DB)"
549
+ )
550
+ errors: List[str] = Field(
551
+ default_factory=list, description="Migration errors by provider"
552
+ )
553
+
554
+
555
+ # Notebook delete cascade models
556
+ # Credential models
557
+ class CreateCredentialRequest(BaseModel):
558
+ """Request to create a new credential."""
559
+
560
+ name: str = Field(..., description="Credential name")
561
+ provider: str = Field(..., description="Provider name (openai, anthropic, etc.)")
562
+ modalities: List[str] = Field(
563
+ default_factory=list,
564
+ description="Supported modalities (language, embedding, text_to_speech, speech_to_text)",
565
+ )
566
+ api_key: Optional[str] = Field(None, description="API key (stored encrypted)")
567
+ base_url: Optional[str] = Field(None, description="Base URL")
568
+ endpoint: Optional[str] = Field(None, description="Endpoint URL (Azure)")
569
+ api_version: Optional[str] = Field(None, description="API version (Azure)")
570
+ endpoint_llm: Optional[str] = Field(None, description="LLM endpoint")
571
+ endpoint_embedding: Optional[str] = Field(None, description="Embedding endpoint")
572
+ endpoint_stt: Optional[str] = Field(None, description="STT endpoint")
573
+ endpoint_tts: Optional[str] = Field(None, description="TTS endpoint")
574
+ project: Optional[str] = Field(None, description="Project ID (Vertex)")
575
+ location: Optional[str] = Field(None, description="Location (Vertex)")
576
+ credentials_path: Optional[str] = Field(
577
+ None, description="Credentials file path (Vertex)"
578
+ )
579
+
580
+
581
+ class UpdateCredentialRequest(BaseModel):
582
+ """Request to update an existing credential."""
583
+
584
+ name: Optional[str] = Field(None, description="Credential name")
585
+ modalities: Optional[List[str]] = Field(None, description="Supported modalities")
586
+ api_key: Optional[str] = Field(None, description="API key (stored encrypted)")
587
+ base_url: Optional[str] = Field(None, description="Base URL")
588
+ endpoint: Optional[str] = Field(None, description="Endpoint URL")
589
+ api_version: Optional[str] = Field(None, description="API version")
590
+ endpoint_llm: Optional[str] = Field(None, description="LLM endpoint")
591
+ endpoint_embedding: Optional[str] = Field(None, description="Embedding endpoint")
592
+ endpoint_stt: Optional[str] = Field(None, description="STT endpoint")
593
+ endpoint_tts: Optional[str] = Field(None, description="TTS endpoint")
594
+ project: Optional[str] = Field(None, description="Project ID")
595
+ location: Optional[str] = Field(None, description="Location")
596
+ credentials_path: Optional[str] = Field(None, description="Credentials path")
597
+
598
+
599
+ class CredentialResponse(BaseModel):
600
+ """Response for a credential (never includes api_key)."""
601
+
602
+ id: str
603
+ name: str
604
+ provider: str
605
+ modalities: List[str]
606
+ base_url: Optional[str] = None
607
+ endpoint: Optional[str] = None
608
+ api_version: Optional[str] = None
609
+ endpoint_llm: Optional[str] = None
610
+ endpoint_embedding: Optional[str] = None
611
+ endpoint_stt: Optional[str] = None
612
+ endpoint_tts: Optional[str] = None
613
+ project: Optional[str] = None
614
+ location: Optional[str] = None
615
+ credentials_path: Optional[str] = None
616
+ has_api_key: bool = False
617
+ created: str
618
+ updated: str
619
+ model_count: int = 0
620
+ decryption_error: Optional[str] = None
621
+
622
+
623
+ class CredentialDeleteResponse(BaseModel):
624
+ """Response for credential deletion."""
625
+
626
+ message: str
627
+ deleted_models: int = 0
628
+
629
+
630
+ class DiscoveredModelResponse(BaseModel):
631
+ """A model discovered from a provider."""
632
+
633
+ name: str
634
+ provider: str
635
+ model_type: Optional[str] = None
636
+ description: Optional[str] = None
637
+
638
+
639
+ class DiscoverModelsResponse(BaseModel):
640
+ """Response from model discovery."""
641
+
642
+ credential_id: str
643
+ provider: str
644
+ discovered: List[DiscoveredModelResponse]
645
+
646
+
647
+ class RegisterModelData(BaseModel):
648
+ """A model to register with user-specified type."""
649
+
650
+ name: str
651
+ provider: str
652
+ model_type: str # Required: user specifies the type
653
+
654
+
655
+ class RegisterModelsRequest(BaseModel):
656
+ """Request to register discovered models."""
657
+
658
+ models: List[RegisterModelData]
659
+
660
+
661
+ class RegisterModelsResponse(BaseModel):
662
+ """Response from model registration."""
663
+
664
+ created: int
665
+ existing: int
666
+
667
+
668
+ class NotebookDeletePreview(BaseModel):
669
+ notebook_id: str = Field(..., description="ID of the notebook")
670
+ notebook_name: str = Field(..., description="Name of the notebook")
671
+ note_count: int = Field(..., description="Number of notes that will be deleted")
672
+ exclusive_source_count: int = Field(
673
+ ..., description="Number of sources only in this notebook"
674
+ )
675
+ shared_source_count: int = Field(
676
+ ..., description="Number of sources shared with other notebooks"
677
+ )
678
+
679
+
680
+ class NotebookDeleteResponse(BaseModel):
681
+ message: str = Field(..., description="Success message")
682
+ deleted_notes: int = Field(..., description="Number of notes deleted")
683
+ deleted_sources: int = Field(..., description="Number of exclusive sources deleted")
684
+ unlinked_sources: int = Field(
685
+ ..., description="Number of sources unlinked from notebook"
686
+ )
api/models_service.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Models service layer using API.
3
+ """
4
+
5
+ from typing import List, Optional
6
+
7
+ from loguru import logger
8
+
9
+ from api.client import api_client
10
+ from open_notebook.ai.models import DefaultModels, Model
11
+
12
+
13
+ class ModelsService:
14
+ """Service layer for models operations using API."""
15
+
16
+ def __init__(self):
17
+ logger.info("Using API for models operations")
18
+
19
+ def get_all_models(self, model_type: Optional[str] = None) -> List[Model]:
20
+ """Get all models with optional type filtering."""
21
+ models_data = api_client.get_models(model_type=model_type)
22
+ # Convert API response to Model objects
23
+ models = []
24
+ for model_data in models_data:
25
+ model = Model(
26
+ name=model_data["name"],
27
+ provider=model_data["provider"],
28
+ type=model_data["type"],
29
+ )
30
+ model.id = model_data["id"]
31
+ model.created = model_data["created"]
32
+ model.updated = model_data["updated"]
33
+ models.append(model)
34
+ return models
35
+
36
+ def create_model(self, name: str, provider: str, model_type: str) -> Model:
37
+ """Create a new model."""
38
+ response = api_client.create_model(name, provider, model_type)
39
+ model_data = response if isinstance(response, dict) else response[0]
40
+ model = Model(
41
+ name=model_data["name"],
42
+ provider=model_data["provider"],
43
+ type=model_data["type"],
44
+ )
45
+ model.id = model_data["id"]
46
+ model.created = model_data["created"]
47
+ model.updated = model_data["updated"]
48
+ return model
49
+
50
+ def delete_model(self, model_id: str) -> bool:
51
+ """Delete a model."""
52
+ api_client.delete_model(model_id)
53
+ return True
54
+
55
+ def get_default_models(self) -> DefaultModels:
56
+ """Get default model assignments."""
57
+ response = api_client.get_default_models()
58
+ defaults_data = response if isinstance(response, dict) else response[0]
59
+ defaults = DefaultModels()
60
+
61
+ # Set the values from API response
62
+ defaults.default_chat_model = defaults_data.get("default_chat_model")
63
+ defaults.default_transformation_model = defaults_data.get(
64
+ "default_transformation_model"
65
+ )
66
+ defaults.large_context_model = defaults_data.get("large_context_model")
67
+ defaults.default_text_to_speech_model = defaults_data.get(
68
+ "default_text_to_speech_model"
69
+ )
70
+ defaults.default_speech_to_text_model = defaults_data.get(
71
+ "default_speech_to_text_model"
72
+ )
73
+ defaults.default_embedding_model = defaults_data.get("default_embedding_model")
74
+ defaults.default_tools_model = defaults_data.get("default_tools_model")
75
+
76
+ return defaults
77
+
78
+ def update_default_models(self, defaults: DefaultModels) -> DefaultModels:
79
+ """Update default model assignments."""
80
+ updates = {
81
+ "default_chat_model": defaults.default_chat_model,
82
+ "default_transformation_model": defaults.default_transformation_model,
83
+ "large_context_model": defaults.large_context_model,
84
+ "default_text_to_speech_model": defaults.default_text_to_speech_model,
85
+ "default_speech_to_text_model": defaults.default_speech_to_text_model,
86
+ "default_embedding_model": defaults.default_embedding_model,
87
+ "default_tools_model": defaults.default_tools_model,
88
+ }
89
+
90
+ response = api_client.update_default_models(**updates)
91
+ defaults_data = response if isinstance(response, dict) else response[0]
92
+
93
+ # Update the defaults object with the response
94
+ defaults.default_chat_model = defaults_data.get("default_chat_model")
95
+ defaults.default_transformation_model = defaults_data.get(
96
+ "default_transformation_model"
97
+ )
98
+ defaults.large_context_model = defaults_data.get("large_context_model")
99
+ defaults.default_text_to_speech_model = defaults_data.get(
100
+ "default_text_to_speech_model"
101
+ )
102
+ defaults.default_speech_to_text_model = defaults_data.get(
103
+ "default_speech_to_text_model"
104
+ )
105
+ defaults.default_embedding_model = defaults_data.get("default_embedding_model")
106
+ defaults.default_tools_model = defaults_data.get("default_tools_model")
107
+
108
+ return defaults
109
+
110
+
111
+ # Global service instance
112
+ models_service = ModelsService()
api/notebook_service.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Notebook service layer using API.
3
+ """
4
+
5
+ from typing import List, Optional
6
+
7
+ from loguru import logger
8
+
9
+ from api.client import api_client
10
+ from open_notebook.domain.notebook import Notebook
11
+
12
+
13
+ class NotebookService:
14
+ """Service layer for notebook operations using API."""
15
+
16
+ def __init__(self):
17
+ logger.info("Using API for notebook operations")
18
+
19
+ def get_all_notebooks(self, order_by: str = "updated desc") -> List[Notebook]:
20
+ """Get all notebooks."""
21
+ notebooks_data = api_client.get_notebooks(order_by=order_by)
22
+ # Convert API response to Notebook objects
23
+ notebooks = []
24
+ for nb_data in notebooks_data:
25
+ nb = Notebook(
26
+ name=nb_data["name"],
27
+ description=nb_data["description"],
28
+ archived=nb_data["archived"],
29
+ )
30
+ nb.id = nb_data["id"]
31
+ nb.created = nb_data["created"]
32
+ nb.updated = nb_data["updated"]
33
+ notebooks.append(nb)
34
+ return notebooks
35
+
36
+ def get_notebook(self, notebook_id: str) -> Optional[Notebook]:
37
+ """Get a specific notebook."""
38
+ response = api_client.get_notebook(notebook_id)
39
+ nb_data = response if isinstance(response, dict) else response[0]
40
+ nb = Notebook(
41
+ name=nb_data["name"],
42
+ description=nb_data["description"],
43
+ archived=nb_data["archived"],
44
+ )
45
+ nb.id = nb_data["id"]
46
+ nb.created = nb_data["created"]
47
+ nb.updated = nb_data["updated"]
48
+ return nb
49
+
50
+ def create_notebook(self, name: str, description: str = "") -> Notebook:
51
+ """Create a new notebook."""
52
+ response = api_client.create_notebook(name, description)
53
+ nb_data = response if isinstance(response, dict) else response[0]
54
+ nb = Notebook(
55
+ name=nb_data["name"],
56
+ description=nb_data["description"],
57
+ archived=nb_data["archived"],
58
+ )
59
+ nb.id = nb_data["id"]
60
+ nb.created = nb_data["created"]
61
+ nb.updated = nb_data["updated"]
62
+ return nb
63
+
64
+ def update_notebook(self, notebook: Notebook) -> Notebook:
65
+ """Update a notebook."""
66
+ updates = {
67
+ "name": notebook.name,
68
+ "description": notebook.description,
69
+ "archived": notebook.archived,
70
+ }
71
+ response = api_client.update_notebook(notebook.id or "", **updates)
72
+ nb_data = response if isinstance(response, dict) else response[0]
73
+ # Update the notebook object with the response
74
+ notebook.name = nb_data["name"]
75
+ notebook.description = nb_data["description"]
76
+ notebook.archived = nb_data["archived"]
77
+ notebook.updated = nb_data["updated"]
78
+ return notebook
79
+
80
+ def delete_notebook(self, notebook: Notebook) -> bool:
81
+ """Delete a notebook."""
82
+ api_client.delete_notebook(notebook.id or "")
83
+ return True
84
+
85
+
86
+ # Global service instance
87
+ notebook_service = NotebookService()
api/notes_service.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Notes service layer using API.
3
+ """
4
+
5
+ from typing import List, Optional
6
+
7
+ from loguru import logger
8
+
9
+ from api.client import api_client
10
+ from open_notebook.domain.notebook import Note
11
+
12
+
13
+ class NotesService:
14
+ """Service layer for notes operations using API."""
15
+
16
+ def __init__(self):
17
+ logger.info("Using API for notes operations")
18
+
19
+ def get_all_notes(self, notebook_id: Optional[str] = None) -> List[Note]:
20
+ """Get all notes with optional notebook filtering."""
21
+ notes_data = api_client.get_notes(notebook_id=notebook_id)
22
+ # Convert API response to Note objects
23
+ notes = []
24
+ for note_data in notes_data:
25
+ note = Note(
26
+ title=note_data["title"],
27
+ content=note_data["content"],
28
+ note_type=note_data["note_type"],
29
+ )
30
+ note.id = note_data["id"]
31
+ note.created = note_data["created"]
32
+ note.updated = note_data["updated"]
33
+ notes.append(note)
34
+ return notes
35
+
36
+ def get_note(self, note_id: str) -> Note:
37
+ """Get a specific note."""
38
+ note_response = api_client.get_note(note_id)
39
+ note_data = (
40
+ note_response if isinstance(note_response, dict) else note_response[0]
41
+ )
42
+ note = Note(
43
+ title=note_data["title"],
44
+ content=note_data["content"],
45
+ note_type=note_data["note_type"],
46
+ )
47
+ note.id = note_data["id"]
48
+ note.created = note_data["created"]
49
+ note.updated = note_data["updated"]
50
+ return note
51
+
52
+ def create_note(
53
+ self,
54
+ content: str,
55
+ title: Optional[str] = None,
56
+ note_type: str = "human",
57
+ notebook_id: Optional[str] = None,
58
+ ) -> Note:
59
+ """Create a new note."""
60
+ note_response = api_client.create_note(
61
+ content=content, title=title, note_type=note_type, notebook_id=notebook_id
62
+ )
63
+ note_data = (
64
+ note_response if isinstance(note_response, dict) else note_response[0]
65
+ )
66
+ note = Note(
67
+ title=note_data["title"],
68
+ content=note_data["content"],
69
+ note_type=note_data["note_type"],
70
+ )
71
+ note.id = note_data["id"]
72
+ note.created = note_data["created"]
73
+ note.updated = note_data["updated"]
74
+ return note
75
+
76
+ def update_note(self, note: Note) -> Note:
77
+ """Update a note."""
78
+ updates = {
79
+ "title": note.title,
80
+ "content": note.content,
81
+ "note_type": note.note_type,
82
+ }
83
+ note_response = api_client.update_note(note.id or "", **updates)
84
+ note_data = (
85
+ note_response if isinstance(note_response, dict) else note_response[0]
86
+ )
87
+
88
+ # Update the note object with the response
89
+ note.title = note_data["title"]
90
+ note.content = note_data["content"]
91
+ note.note_type = note_data["note_type"]
92
+ note.updated = note_data["updated"]
93
+
94
+ return note
95
+
96
+ def delete_note(self, note_id: str) -> bool:
97
+ """Delete a note."""
98
+ api_client.delete_note(note_id)
99
+ return True
100
+
101
+
102
+ # Global service instance
103
+ notes_service = NotesService()
api/podcast_api_service.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Podcast service layer using API client.
3
+ This replaces direct httpx calls in the Streamlit pages.
4
+ """
5
+
6
+ from typing import Any, Dict, List
7
+
8
+ from loguru import logger
9
+
10
+ from api.client import api_client
11
+
12
+
13
+ class PodcastAPIService:
14
+ """Service layer for podcast operations using API client."""
15
+
16
+ def __init__(self):
17
+ logger.info("Using API client for podcast operations")
18
+
19
+ # Episode methods
20
+ def get_episodes(self) -> List[Dict[Any, Any]]:
21
+ """Get all podcast episodes."""
22
+ result = api_client._make_request("GET", "/api/podcasts/episodes")
23
+ return result if isinstance(result, list) else [result]
24
+
25
+ def delete_episode(self, episode_id: str) -> bool:
26
+ """Delete a podcast episode."""
27
+ try:
28
+ api_client._make_request("DELETE", f"/api/podcasts/episodes/{episode_id}")
29
+ return True
30
+ except Exception as e:
31
+ logger.error(f"Failed to delete episode: {e}")
32
+ return False
33
+
34
+ # Episode Profile methods
35
+ def get_episode_profiles(self) -> List[Dict]:
36
+ """Get all episode profiles."""
37
+ return api_client.get_episode_profiles()
38
+
39
+ def create_episode_profile(self, profile_data: Dict) -> bool:
40
+ """Create a new episode profile."""
41
+ try:
42
+ api_client.create_episode_profile(**profile_data)
43
+ return True
44
+ except Exception as e:
45
+ logger.error(f"Failed to create episode profile: {e}")
46
+ return False
47
+
48
+ def update_episode_profile(self, profile_id: str, profile_data: Dict) -> bool:
49
+ """Update an episode profile."""
50
+ try:
51
+ api_client.update_episode_profile(profile_id, **profile_data)
52
+ return True
53
+ except Exception as e:
54
+ logger.error(f"Failed to update episode profile: {e}")
55
+ return False
56
+
57
+ def delete_episode_profile(self, profile_id: str) -> bool:
58
+ """Delete an episode profile."""
59
+ try:
60
+ api_client.delete_episode_profile(profile_id)
61
+ return True
62
+ except Exception as e:
63
+ logger.error(f"Failed to delete episode profile: {e}")
64
+ return False
65
+
66
+ def duplicate_episode_profile(self, profile_id: str) -> bool:
67
+ """Duplicate an episode profile."""
68
+ try:
69
+ api_client._make_request(
70
+ "POST", f"/api/episode-profiles/{profile_id}/duplicate"
71
+ )
72
+ return True
73
+ except Exception as e:
74
+ logger.error(f"Failed to duplicate episode profile: {e}")
75
+ return False
76
+
77
+ # Speaker Profile methods
78
+ def get_speaker_profiles(self) -> List[Dict[Any, Any]]:
79
+ """Get all speaker profiles."""
80
+ result = api_client._make_request("GET", "/api/speaker-profiles")
81
+ return result if isinstance(result, list) else [result]
82
+
83
+ def create_speaker_profile(self, profile_data: Dict) -> bool:
84
+ """Create a new speaker profile."""
85
+ try:
86
+ api_client._make_request("POST", "/api/speaker-profiles", json=profile_data)
87
+ return True
88
+ except Exception as e:
89
+ logger.error(f"Failed to create speaker profile: {e}")
90
+ return False
91
+
92
+ def update_speaker_profile(self, profile_id: str, profile_data: Dict) -> bool:
93
+ """Update a speaker profile."""
94
+ try:
95
+ api_client._make_request(
96
+ "PUT", f"/api/speaker-profiles/{profile_id}", json=profile_data
97
+ )
98
+ return True
99
+ except Exception as e:
100
+ logger.error(f"Failed to update speaker profile: {e}")
101
+ return False
102
+
103
+ def delete_speaker_profile(self, profile_id: str) -> bool:
104
+ """Delete a speaker profile."""
105
+ try:
106
+ api_client._make_request("DELETE", f"/api/speaker-profiles/{profile_id}")
107
+ return True
108
+ except Exception as e:
109
+ logger.error(f"Failed to delete speaker profile: {e}")
110
+ return False
111
+
112
+ def duplicate_speaker_profile(self, profile_id: str) -> bool:
113
+ """Duplicate a speaker profile."""
114
+ try:
115
+ api_client._make_request(
116
+ "POST", f"/api/speaker-profiles/{profile_id}/duplicate"
117
+ )
118
+ return True
119
+ except Exception as e:
120
+ logger.error(f"Failed to duplicate speaker profile: {e}")
121
+ return False
122
+
123
+
124
+ # Global service instance
125
+ podcast_api_service = PodcastAPIService()
api/podcast_service.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, Optional
2
+
3
+ from fastapi import HTTPException
4
+ from loguru import logger
5
+ from pydantic import BaseModel
6
+ from surreal_commands import get_command_status, submit_command
7
+
8
+ from open_notebook.domain.notebook import Notebook
9
+ from open_notebook.podcasts.models import EpisodeProfile, PodcastEpisode, SpeakerProfile
10
+
11
+
12
+ class PodcastGenerationRequest(BaseModel):
13
+ """Request model for podcast generation"""
14
+
15
+ episode_profile: str
16
+ speaker_profile: str
17
+ episode_name: str
18
+ content: Optional[str] = None
19
+ notebook_id: Optional[str] = None
20
+ briefing_suffix: Optional[str] = None
21
+
22
+
23
+ class PodcastGenerationResponse(BaseModel):
24
+ """Response model for podcast generation"""
25
+
26
+ job_id: str
27
+ status: str
28
+ message: str
29
+ episode_profile: str
30
+ episode_name: str
31
+
32
+
33
+ class PodcastService:
34
+ """Service layer for podcast operations"""
35
+
36
+ @staticmethod
37
+ async def submit_generation_job(
38
+ episode_profile_name: str,
39
+ speaker_profile_name: str,
40
+ episode_name: str,
41
+ notebook_id: Optional[str] = None,
42
+ content: Optional[str] = None,
43
+ briefing_suffix: Optional[str] = None,
44
+ ) -> str:
45
+ """Submit a podcast generation job for background processing"""
46
+ try:
47
+ # Validate episode profile exists
48
+ episode_profile = await EpisodeProfile.get_by_name(episode_profile_name)
49
+ if not episode_profile:
50
+ raise ValueError(f"Episode profile '{episode_profile_name}' not found")
51
+
52
+ # Validate speaker profile exists
53
+ speaker_profile = await SpeakerProfile.get_by_name(speaker_profile_name)
54
+ if not speaker_profile:
55
+ raise ValueError(f"Speaker profile '{speaker_profile_name}' not found")
56
+
57
+ # Get content from notebook if not provided directly
58
+ if not content and notebook_id:
59
+ try:
60
+ notebook = await Notebook.get(notebook_id)
61
+ # Get notebook context (this may need to be adjusted based on actual Notebook implementation)
62
+ content = (
63
+ await notebook.get_context()
64
+ if hasattr(notebook, "get_context")
65
+ else str(notebook)
66
+ )
67
+ except Exception as e:
68
+ logger.warning(
69
+ f"Failed to get notebook content, using notebook_id as content: {e}"
70
+ )
71
+ content = f"Notebook ID: {notebook_id}"
72
+
73
+ if not content:
74
+ raise ValueError(
75
+ "Content is required - provide either content or notebook_id"
76
+ )
77
+
78
+ # Prepare command arguments
79
+ command_args = {
80
+ "episode_profile": episode_profile_name,
81
+ "speaker_profile": speaker_profile_name,
82
+ "episode_name": episode_name,
83
+ "content": str(content),
84
+ "briefing_suffix": briefing_suffix,
85
+ }
86
+
87
+ # Ensure command modules are imported before submitting
88
+ # This is needed because submit_command validates against local registry
89
+ try:
90
+ import commands.podcast_commands # noqa: F401
91
+ except ImportError as import_err:
92
+ logger.error(f"Failed to import podcast commands: {import_err}")
93
+ raise ValueError("Podcast commands not available")
94
+
95
+ # Submit command to surreal-commands
96
+ job_id = submit_command("open_notebook", "generate_podcast", command_args)
97
+
98
+ # Convert RecordID to string if needed
99
+ if not job_id:
100
+ raise ValueError("Failed to get job_id from submit_command")
101
+ job_id_str = str(job_id)
102
+ logger.info(
103
+ f"Submitted podcast generation job: {job_id_str} for episode '{episode_name}'"
104
+ )
105
+ return job_id_str
106
+
107
+ except Exception as e:
108
+ logger.error(f"Failed to submit podcast generation job: {e}")
109
+ raise HTTPException(
110
+ status_code=500,
111
+ detail=f"Failed to submit podcast generation job: {str(e)}",
112
+ )
113
+
114
+ @staticmethod
115
+ async def get_job_status(job_id: str) -> Dict[str, Any]:
116
+ """Get status of a podcast generation job"""
117
+ try:
118
+ status = await get_command_status(job_id)
119
+ return {
120
+ "job_id": job_id,
121
+ "status": status.status if status else "unknown",
122
+ "result": status.result if status else None,
123
+ "error_message": getattr(status, "error_message", None)
124
+ if status
125
+ else None,
126
+ "created": str(status.created)
127
+ if status and hasattr(status, "created") and status.created
128
+ else None,
129
+ "updated": str(status.updated)
130
+ if status and hasattr(status, "updated") and status.updated
131
+ else None,
132
+ "progress": getattr(status, "progress", None) if status else None,
133
+ }
134
+ except Exception as e:
135
+ logger.error(f"Failed to get podcast job status: {e}")
136
+ raise HTTPException(
137
+ status_code=500, detail=f"Failed to get job status: {str(e)}"
138
+ )
139
+
140
+ @staticmethod
141
+ async def list_episodes() -> list:
142
+ """List all podcast episodes"""
143
+ try:
144
+ episodes = await PodcastEpisode.get_all(order_by="created desc")
145
+ return episodes
146
+ except Exception as e:
147
+ logger.error(f"Failed to list podcast episodes: {e}")
148
+ raise HTTPException(
149
+ status_code=500, detail=f"Failed to list episodes: {str(e)}"
150
+ )
151
+
152
+ @staticmethod
153
+ async def get_episode(episode_id: str) -> PodcastEpisode:
154
+ """Get a specific podcast episode"""
155
+ try:
156
+ episode = await PodcastEpisode.get(episode_id)
157
+ return episode
158
+ except Exception as e:
159
+ logger.error(f"Failed to get podcast episode {episode_id}: {e}")
160
+ raise HTTPException(status_code=404, detail=f"Episode not found: {str(e)}")
161
+
162
+
163
+ class DefaultProfiles:
164
+ """Utility class for creating default profiles (if needed beyond migration data)"""
165
+
166
+ @staticmethod
167
+ async def create_default_episode_profiles():
168
+ """Create default episode profiles if they don't exist"""
169
+ try:
170
+ # Check if profiles already exist
171
+ existing = await EpisodeProfile.get_all()
172
+ if existing:
173
+ logger.info(f"Episode profiles already exist: {len(existing)} found")
174
+ return existing
175
+
176
+ # This would create profiles, but since we have migration data,
177
+ # this is mainly for future extensibility
178
+ logger.info(
179
+ "Default episode profiles should be created via database migration"
180
+ )
181
+ return []
182
+
183
+ except Exception as e:
184
+ logger.error(f"Failed to create default episode profiles: {e}")
185
+ raise
186
+
187
+ @staticmethod
188
+ async def create_default_speaker_profiles():
189
+ """Create default speaker profiles if they don't exist"""
190
+ try:
191
+ # Check if profiles already exist
192
+ existing = await SpeakerProfile.get_all()
193
+ if existing:
194
+ logger.info(f"Speaker profiles already exist: {len(existing)} found")
195
+ return existing
196
+
197
+ # This would create profiles, but since we have migration data,
198
+ # this is mainly for future extensibility
199
+ logger.info(
200
+ "Default speaker profiles should be created via database migration"
201
+ )
202
+ return []
203
+
204
+ except Exception as e:
205
+ logger.error(f"Failed to create default speaker profiles: {e}")
206
+ raise
api/routers/__init__.py ADDED
File without changes
api/routers/auth.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication router for Open Notebook API.
3
+ Provides endpoints to check authentication status.
4
+ """
5
+
6
+ from fastapi import APIRouter
7
+
8
+ from open_notebook.utils.encryption import get_secret_from_env
9
+
10
+ router = APIRouter(prefix="/auth", tags=["auth"])
11
+
12
+
13
+ @router.get("/status")
14
+ async def get_auth_status():
15
+ """
16
+ Check if authentication is enabled.
17
+ Returns whether a password is required to access the API.
18
+ Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
19
+ """
20
+ auth_enabled = bool(get_secret_from_env("OPEN_NOTEBOOK_PASSWORD"))
21
+
22
+ return {
23
+ "auth_enabled": auth_enabled,
24
+ "message": "Authentication is required"
25
+ if auth_enabled
26
+ else "Authentication is disabled",
27
+ }
api/routers/chat.py ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import traceback
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from fastapi import APIRouter, HTTPException, Query
6
+ from langchain_core.runnables import RunnableConfig
7
+ from loguru import logger
8
+ from pydantic import BaseModel, Field
9
+
10
+ from open_notebook.database.repository import ensure_record_id, repo_query
11
+ from open_notebook.domain.notebook import ChatSession, Note, Notebook, Source
12
+ from open_notebook.exceptions import (
13
+ NotFoundError,
14
+ )
15
+ from open_notebook.graphs.chat import graph as chat_graph
16
+ from open_notebook.utils.graph_utils import get_session_message_count
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ # Request/Response models
22
+ class CreateSessionRequest(BaseModel):
23
+ notebook_id: str = Field(..., description="Notebook ID to create session for")
24
+ title: Optional[str] = Field(None, description="Optional session title")
25
+ model_override: Optional[str] = Field(
26
+ None, description="Optional model override for this session"
27
+ )
28
+
29
+
30
+ class UpdateSessionRequest(BaseModel):
31
+ title: Optional[str] = Field(None, description="New session title")
32
+ model_override: Optional[str] = Field(
33
+ None, description="Model override for this session"
34
+ )
35
+
36
+
37
+ class ChatMessage(BaseModel):
38
+ id: str = Field(..., description="Message ID")
39
+ type: str = Field(..., description="Message type (human|ai)")
40
+ content: str = Field(..., description="Message content")
41
+ timestamp: Optional[str] = Field(None, description="Message timestamp")
42
+
43
+
44
+ class ChatSessionResponse(BaseModel):
45
+ id: str = Field(..., description="Session ID")
46
+ title: str = Field(..., description="Session title")
47
+ notebook_id: Optional[str] = Field(None, description="Notebook ID")
48
+ created: str = Field(..., description="Creation timestamp")
49
+ updated: str = Field(..., description="Last update timestamp")
50
+ message_count: Optional[int] = Field(
51
+ None, description="Number of messages in session"
52
+ )
53
+ model_override: Optional[str] = Field(
54
+ None, description="Model override for this session"
55
+ )
56
+
57
+
58
+ class ChatSessionWithMessagesResponse(ChatSessionResponse):
59
+ messages: List[ChatMessage] = Field(
60
+ default_factory=list, description="Session messages"
61
+ )
62
+
63
+
64
+ class ExecuteChatRequest(BaseModel):
65
+ session_id: str = Field(..., description="Chat session ID")
66
+ message: str = Field(..., description="User message content")
67
+ context: Dict[str, Any] = Field(
68
+ ..., description="Chat context with sources and notes"
69
+ )
70
+ model_override: Optional[str] = Field(
71
+ None, description="Optional model override for this message"
72
+ )
73
+
74
+
75
+ class ExecuteChatResponse(BaseModel):
76
+ session_id: str = Field(..., description="Session ID")
77
+ messages: List[ChatMessage] = Field(..., description="Updated message list")
78
+
79
+
80
+ class BuildContextRequest(BaseModel):
81
+ notebook_id: str = Field(..., description="Notebook ID")
82
+ context_config: Dict[str, Any] = Field(..., description="Context configuration")
83
+
84
+
85
+ class BuildContextResponse(BaseModel):
86
+ context: Dict[str, Any] = Field(..., description="Built context data")
87
+ token_count: int = Field(..., description="Estimated token count")
88
+ char_count: int = Field(..., description="Character count")
89
+
90
+
91
+ class SuccessResponse(BaseModel):
92
+ success: bool = Field(True, description="Operation success status")
93
+ message: str = Field(..., description="Success message")
94
+
95
+
96
+ @router.get("/chat/sessions", response_model=List[ChatSessionResponse])
97
+ async def get_sessions(notebook_id: str = Query(..., description="Notebook ID")):
98
+ """Get all chat sessions for a notebook."""
99
+ try:
100
+ # Get notebook to verify it exists
101
+ notebook = await Notebook.get(notebook_id)
102
+ if not notebook:
103
+ raise HTTPException(status_code=404, detail="Notebook not found")
104
+
105
+ # Get sessions for this notebook
106
+ sessions_list = await notebook.get_chat_sessions()
107
+
108
+ results = []
109
+ for session in sessions_list:
110
+ session_id = str(session.id)
111
+
112
+ # Get message count from LangGraph state
113
+ msg_count = await get_session_message_count(chat_graph, session_id)
114
+
115
+ results.append(
116
+ ChatSessionResponse(
117
+ id=session.id or "",
118
+ title=session.title or "Untitled Session",
119
+ notebook_id=notebook_id,
120
+ created=str(session.created),
121
+ updated=str(session.updated),
122
+ message_count=msg_count,
123
+ model_override=getattr(session, "model_override", None),
124
+ )
125
+ )
126
+
127
+ return results
128
+ except NotFoundError:
129
+ raise HTTPException(status_code=404, detail="Notebook not found")
130
+ except Exception as e:
131
+ logger.error(f"Error fetching chat sessions: {str(e)}")
132
+ raise HTTPException(
133
+ status_code=500, detail=f"Error fetching chat sessions: {str(e)}"
134
+ )
135
+
136
+
137
+ @router.post("/chat/sessions", response_model=ChatSessionResponse)
138
+ async def create_session(request: CreateSessionRequest):
139
+ """Create a new chat session."""
140
+ try:
141
+ # Verify notebook exists
142
+ notebook = await Notebook.get(request.notebook_id)
143
+ if not notebook:
144
+ raise HTTPException(status_code=404, detail="Notebook not found")
145
+
146
+ # Create new session
147
+ session = ChatSession(
148
+ title=request.title
149
+ or f"Chat Session {asyncio.get_event_loop().time():.0f}",
150
+ model_override=request.model_override,
151
+ )
152
+ await session.save()
153
+
154
+ # Relate session to notebook
155
+ await session.relate_to_notebook(request.notebook_id)
156
+
157
+ return ChatSessionResponse(
158
+ id=session.id or "",
159
+ title=session.title or "",
160
+ notebook_id=request.notebook_id,
161
+ created=str(session.created),
162
+ updated=str(session.updated),
163
+ message_count=0,
164
+ model_override=session.model_override,
165
+ )
166
+ except NotFoundError:
167
+ raise HTTPException(status_code=404, detail="Notebook not found")
168
+ except Exception as e:
169
+ logger.error(f"Error creating chat session: {str(e)}")
170
+ raise HTTPException(
171
+ status_code=500, detail=f"Error creating chat session: {str(e)}"
172
+ )
173
+
174
+
175
+ @router.get(
176
+ "/chat/sessions/{session_id}", response_model=ChatSessionWithMessagesResponse
177
+ )
178
+ async def get_session(session_id: str):
179
+ """Get a specific session with its messages."""
180
+ try:
181
+ # Get session
182
+ # Ensure session_id has proper table prefix
183
+ full_session_id = (
184
+ session_id
185
+ if session_id.startswith("chat_session:")
186
+ else f"chat_session:{session_id}"
187
+ )
188
+ session = await ChatSession.get(full_session_id)
189
+ if not session:
190
+ raise HTTPException(status_code=404, detail="Session not found")
191
+
192
+ # Get session state from LangGraph to retrieve messages
193
+ # Use sync get_state() in a thread since SqliteSaver doesn't support async
194
+ thread_state = await asyncio.to_thread(
195
+ chat_graph.get_state,
196
+ config=RunnableConfig(configurable={"thread_id": full_session_id}),
197
+ )
198
+
199
+ # Extract messages from state
200
+ messages: list[ChatMessage] = []
201
+ if thread_state and thread_state.values and "messages" in thread_state.values:
202
+ for msg in thread_state.values["messages"]:
203
+ messages.append(
204
+ ChatMessage(
205
+ id=getattr(msg, "id", f"msg_{len(messages)}"),
206
+ type=msg.type if hasattr(msg, "type") else "unknown",
207
+ content=msg.content if hasattr(msg, "content") else str(msg),
208
+ timestamp=None, # LangChain messages don't have timestamps by default
209
+ )
210
+ )
211
+
212
+ # Find notebook_id (we need to query the relationship)
213
+ # Ensure session_id has proper table prefix
214
+ full_session_id = (
215
+ session_id
216
+ if session_id.startswith("chat_session:")
217
+ else f"chat_session:{session_id}"
218
+ )
219
+
220
+ notebook_query = await repo_query(
221
+ "SELECT out FROM refers_to WHERE in = $session_id",
222
+ {"session_id": ensure_record_id(full_session_id)},
223
+ )
224
+
225
+ notebook_id = notebook_query[0]["out"] if notebook_query else None
226
+
227
+ if not notebook_id:
228
+ # This might be an old session created before API migration
229
+ logger.warning(
230
+ f"No notebook relationship found for session {session_id} - may be an orphaned session"
231
+ )
232
+
233
+ return ChatSessionWithMessagesResponse(
234
+ id=session.id or "",
235
+ title=session.title or "Untitled Session",
236
+ notebook_id=notebook_id,
237
+ created=str(session.created),
238
+ updated=str(session.updated),
239
+ message_count=len(messages),
240
+ messages=messages,
241
+ model_override=getattr(session, "model_override", None),
242
+ )
243
+ except NotFoundError:
244
+ raise HTTPException(status_code=404, detail="Session not found")
245
+ except Exception as e:
246
+ logger.error(f"Error fetching session: {str(e)}")
247
+ raise HTTPException(status_code=500, detail=f"Error fetching session: {str(e)}")
248
+
249
+
250
+ @router.put("/chat/sessions/{session_id}", response_model=ChatSessionResponse)
251
+ async def update_session(session_id: str, request: UpdateSessionRequest):
252
+ """Update session title."""
253
+ try:
254
+ # Ensure session_id has proper table prefix
255
+ full_session_id = (
256
+ session_id
257
+ if session_id.startswith("chat_session:")
258
+ else f"chat_session:{session_id}"
259
+ )
260
+ session = await ChatSession.get(full_session_id)
261
+ if not session:
262
+ raise HTTPException(status_code=404, detail="Session not found")
263
+
264
+ update_data = request.model_dump(exclude_unset=True)
265
+
266
+ if "title" in update_data:
267
+ session.title = update_data["title"]
268
+
269
+ if "model_override" in update_data:
270
+ session.model_override = update_data["model_override"]
271
+
272
+ await session.save()
273
+
274
+ # Find notebook_id
275
+ # Ensure session_id has proper table prefix
276
+ full_session_id = (
277
+ session_id
278
+ if session_id.startswith("chat_session:")
279
+ else f"chat_session:{session_id}"
280
+ )
281
+ notebook_query = await repo_query(
282
+ "SELECT out FROM refers_to WHERE in = $session_id",
283
+ {"session_id": ensure_record_id(full_session_id)},
284
+ )
285
+ notebook_id = notebook_query[0]["out"] if notebook_query else None
286
+
287
+ # Get message count from LangGraph state
288
+ msg_count = await get_session_message_count(chat_graph, full_session_id)
289
+
290
+ return ChatSessionResponse(
291
+ id=session.id or "",
292
+ title=session.title or "",
293
+ notebook_id=notebook_id,
294
+ created=str(session.created),
295
+ updated=str(session.updated),
296
+ message_count=msg_count,
297
+ model_override=session.model_override,
298
+ )
299
+ except NotFoundError:
300
+ raise HTTPException(status_code=404, detail="Session not found")
301
+ except Exception as e:
302
+ logger.error(f"Error updating session: {str(e)}")
303
+ raise HTTPException(status_code=500, detail=f"Error updating session: {str(e)}")
304
+
305
+
306
+ @router.delete("/chat/sessions/{session_id}", response_model=SuccessResponse)
307
+ async def delete_session(session_id: str):
308
+ """Delete a chat session."""
309
+ try:
310
+ # Ensure session_id has proper table prefix
311
+ full_session_id = (
312
+ session_id
313
+ if session_id.startswith("chat_session:")
314
+ else f"chat_session:{session_id}"
315
+ )
316
+ session = await ChatSession.get(full_session_id)
317
+ if not session:
318
+ raise HTTPException(status_code=404, detail="Session not found")
319
+
320
+ await session.delete()
321
+
322
+ return SuccessResponse(success=True, message="Session deleted successfully")
323
+ except NotFoundError:
324
+ raise HTTPException(status_code=404, detail="Session not found")
325
+ except Exception as e:
326
+ logger.error(f"Error deleting session: {str(e)}")
327
+ raise HTTPException(status_code=500, detail=f"Error deleting session: {str(e)}")
328
+
329
+
330
+ @router.post("/chat/execute", response_model=ExecuteChatResponse)
331
+ async def execute_chat(request: ExecuteChatRequest):
332
+ """Execute a chat request and get AI response."""
333
+ try:
334
+ # Verify session exists
335
+ # Ensure session_id has proper table prefix
336
+ full_session_id = (
337
+ request.session_id
338
+ if request.session_id.startswith("chat_session:")
339
+ else f"chat_session:{request.session_id}"
340
+ )
341
+ session = await ChatSession.get(full_session_id)
342
+ if not session:
343
+ raise HTTPException(status_code=404, detail="Session not found")
344
+
345
+ # Fetch notebook linked to this session
346
+ notebook_query = await repo_query(
347
+ "SELECT out FROM refers_to WHERE in = $session_id",
348
+ {"session_id": ensure_record_id(full_session_id)},
349
+ )
350
+ notebook = None
351
+ if notebook_query:
352
+ notebook = await Notebook.get(notebook_query[0]["out"])
353
+
354
+ # Determine model override (per-request override takes precedence over session-level)
355
+ model_override = (
356
+ request.model_override
357
+ if request.model_override is not None
358
+ else getattr(session, "model_override", None)
359
+ )
360
+
361
+ # Get current state
362
+ # Use sync get_state() in a thread since SqliteSaver doesn't support async
363
+ current_state = await asyncio.to_thread(
364
+ chat_graph.get_state,
365
+ config=RunnableConfig(configurable={"thread_id": full_session_id}),
366
+ )
367
+
368
+ # Prepare state for execution
369
+ state_values = current_state.values if current_state else {}
370
+ state_values["messages"] = state_values.get("messages", [])
371
+ state_values["context"] = request.context
372
+ state_values["notebook"] = notebook
373
+ state_values["model_override"] = model_override
374
+
375
+ # Add user message to state
376
+ from langchain_core.messages import HumanMessage
377
+
378
+ user_message = HumanMessage(content=request.message)
379
+ state_values["messages"].append(user_message)
380
+
381
+ # Execute chat graph
382
+ result = chat_graph.invoke(
383
+ input=state_values, # type: ignore[arg-type]
384
+ config=RunnableConfig(
385
+ configurable={
386
+ "thread_id": full_session_id,
387
+ "model_id": model_override,
388
+ }
389
+ ),
390
+ )
391
+
392
+ # Update session timestamp
393
+ await session.save()
394
+
395
+ # Convert messages to response format
396
+ messages: list[ChatMessage] = []
397
+ for msg in result.get("messages", []):
398
+ messages.append(
399
+ ChatMessage(
400
+ id=getattr(msg, "id", f"msg_{len(messages)}"),
401
+ type=msg.type if hasattr(msg, "type") else "unknown",
402
+ content=msg.content if hasattr(msg, "content") else str(msg),
403
+ timestamp=None,
404
+ )
405
+ )
406
+
407
+ return ExecuteChatResponse(session_id=request.session_id, messages=messages)
408
+ except NotFoundError:
409
+ raise HTTPException(status_code=404, detail="Session not found")
410
+ except Exception as e:
411
+ # Log detailed error with context for debugging
412
+ logger.error(
413
+ f"Error executing chat: {str(e)}\n"
414
+ f" Session ID: {request.session_id}\n"
415
+ f" Model override: {request.model_override}\n"
416
+ f" Traceback:\n{traceback.format_exc()}"
417
+ )
418
+ raise HTTPException(status_code=500, detail=f"Error executing chat: {str(e)}")
419
+
420
+
421
+ @router.post("/chat/context", response_model=BuildContextResponse)
422
+ async def build_context(request: BuildContextRequest):
423
+ """Build context for a notebook based on context configuration."""
424
+ try:
425
+ # Verify notebook exists
426
+ notebook = await Notebook.get(request.notebook_id)
427
+ if not notebook:
428
+ raise HTTPException(status_code=404, detail="Notebook not found")
429
+
430
+ context_data: dict[str, list[dict[str, str]]] = {"sources": [], "notes": []}
431
+ total_content = ""
432
+
433
+ # Process context configuration if provided
434
+ if request.context_config:
435
+ # Process sources
436
+ for source_id, status in request.context_config.get("sources", {}).items():
437
+ if "not in" in status:
438
+ continue
439
+
440
+ try:
441
+ # Add table prefix if not present
442
+ full_source_id = (
443
+ source_id
444
+ if source_id.startswith("source:")
445
+ else f"source:{source_id}"
446
+ )
447
+
448
+ try:
449
+ source = await Source.get(full_source_id)
450
+ except Exception:
451
+ continue
452
+
453
+ if "insights" in status:
454
+ source_context = await source.get_context(context_size="short")
455
+ context_data["sources"].append(source_context)
456
+ total_content += str(source_context)
457
+ elif "full content" in status:
458
+ source_context = await source.get_context(context_size="long")
459
+ context_data["sources"].append(source_context)
460
+ total_content += str(source_context)
461
+ except Exception as e:
462
+ logger.warning(f"Error processing source {source_id}: {str(e)}")
463
+ continue
464
+
465
+ # Process notes
466
+ for note_id, status in request.context_config.get("notes", {}).items():
467
+ if "not in" in status:
468
+ continue
469
+
470
+ try:
471
+ # Add table prefix if not present
472
+ full_note_id = (
473
+ note_id if note_id.startswith("note:") else f"note:{note_id}"
474
+ )
475
+ note = await Note.get(full_note_id)
476
+ if not note:
477
+ continue
478
+
479
+ if "full content" in status:
480
+ note_context = note.get_context(context_size="long")
481
+ context_data["notes"].append(note_context)
482
+ total_content += str(note_context)
483
+ except Exception as e:
484
+ logger.warning(f"Error processing note {note_id}: {str(e)}")
485
+ continue
486
+ else:
487
+ # Default behavior - include all sources and notes with short context
488
+ sources = await notebook.get_sources()
489
+ for source in sources:
490
+ try:
491
+ source_context = await source.get_context(context_size="short")
492
+ context_data["sources"].append(source_context)
493
+ total_content += str(source_context)
494
+ except Exception as e:
495
+ logger.warning(f"Error processing source {source.id}: {str(e)}")
496
+ continue
497
+
498
+ notes = await notebook.get_notes()
499
+ for note in notes:
500
+ try:
501
+ note_context = note.get_context(context_size="short")
502
+ context_data["notes"].append(note_context)
503
+ total_content += str(note_context)
504
+ except Exception as e:
505
+ logger.warning(f"Error processing note {note.id}: {str(e)}")
506
+ continue
507
+
508
+ # Calculate character and token counts
509
+ char_count = len(total_content)
510
+ # Use token count utility if available
511
+ try:
512
+ from open_notebook.utils import token_count
513
+
514
+ estimated_tokens = token_count(total_content) if total_content else 0
515
+ except ImportError:
516
+ # Fallback to simple estimation
517
+ estimated_tokens = char_count // 4
518
+
519
+ return BuildContextResponse(
520
+ context=context_data, token_count=estimated_tokens, char_count=char_count
521
+ )
522
+ except HTTPException:
523
+ raise
524
+ except Exception as e:
525
+ logger.error(f"Error building context: {str(e)}")
526
+ raise HTTPException(status_code=500, detail=f"Error building context: {str(e)}")
api/routers/commands.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Optional
2
+
3
+ from fastapi import APIRouter, HTTPException, Query
4
+ from loguru import logger
5
+ from pydantic import BaseModel, Field
6
+ from surreal_commands import registry
7
+
8
+ from api.command_service import CommandService
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ class CommandExecutionRequest(BaseModel):
14
+ command: str = Field(
15
+ ..., description="Command function name (e.g., 'process_text')"
16
+ )
17
+ app: str = Field(..., description="Application name (e.g., 'open_notebook')")
18
+ input: Dict[str, Any] = Field(..., description="Arguments to pass to the command")
19
+
20
+
21
+ class CommandJobResponse(BaseModel):
22
+ job_id: str
23
+ status: str
24
+ message: str
25
+
26
+
27
+ class CommandJobStatusResponse(BaseModel):
28
+ job_id: str
29
+ status: str
30
+ result: Optional[Dict[str, Any]] = None
31
+ error_message: Optional[str] = None
32
+ created: Optional[str] = None
33
+ updated: Optional[str] = None
34
+ progress: Optional[Dict[str, Any]] = None
35
+
36
+
37
+ @router.post("/commands/jobs", response_model=CommandJobResponse)
38
+ async def execute_command(request: CommandExecutionRequest):
39
+ """
40
+ Submit a command for background processing.
41
+ Returns immediately with job ID for status tracking.
42
+
43
+ Example request:
44
+ {
45
+ "command": "process_text",
46
+ "app": "open_notebook",
47
+ "input": {
48
+ "text": "Hello world",
49
+ "operation": "uppercase"
50
+ }
51
+ }
52
+ """
53
+ try:
54
+ # Submit command using app name (not module name)
55
+ job_id = await CommandService.submit_command_job(
56
+ module_name=request.app, # This should be "open_notebook"
57
+ command_name=request.command,
58
+ command_args=request.input,
59
+ )
60
+
61
+ return CommandJobResponse(
62
+ job_id=job_id,
63
+ status="submitted",
64
+ message=f"Command '{request.command}' submitted successfully",
65
+ )
66
+
67
+ except Exception as e:
68
+ logger.error(f"Error submitting command: {str(e)}")
69
+ raise HTTPException(
70
+ status_code=500, detail="Failed to submit command"
71
+ )
72
+
73
+
74
+ @router.get("/commands/jobs/{job_id}", response_model=CommandJobStatusResponse)
75
+ async def get_command_job_status(job_id: str):
76
+ """Get the status of a specific command job"""
77
+ try:
78
+ status_data = await CommandService.get_command_status(job_id)
79
+ return CommandJobStatusResponse(**status_data)
80
+
81
+ except Exception as e:
82
+ logger.error(f"Error fetching job status: {str(e)}")
83
+ raise HTTPException(
84
+ status_code=500, detail="Failed to fetch job status"
85
+ )
86
+
87
+
88
+ @router.get("/commands/jobs", response_model=List[Dict[str, Any]])
89
+ async def list_command_jobs(
90
+ command_filter: Optional[str] = Query(None, description="Filter by command name"),
91
+ status_filter: Optional[str] = Query(None, description="Filter by status"),
92
+ limit: int = Query(50, description="Maximum number of jobs to return"),
93
+ ):
94
+ """List command jobs with optional filtering"""
95
+ try:
96
+ jobs = await CommandService.list_command_jobs(
97
+ command_filter=command_filter, status_filter=status_filter, limit=limit
98
+ )
99
+ return jobs
100
+
101
+ except Exception as e:
102
+ logger.error(f"Error listing command jobs: {str(e)}")
103
+ raise HTTPException(
104
+ status_code=500, detail="Failed to list command jobs"
105
+ )
106
+
107
+
108
+ @router.delete("/commands/jobs/{job_id}")
109
+ async def cancel_command_job(job_id: str):
110
+ """Cancel a running command job"""
111
+ try:
112
+ success = await CommandService.cancel_command_job(job_id)
113
+ return {"job_id": job_id, "cancelled": success}
114
+
115
+ except Exception as e:
116
+ logger.error(f"Error cancelling command job: {str(e)}")
117
+ raise HTTPException(
118
+ status_code=500, detail="Failed to cancel command job"
119
+ )
120
+
121
+
122
+ @router.get("/commands/registry/debug")
123
+ async def debug_registry():
124
+ """Debug endpoint to see what commands are registered"""
125
+ try:
126
+ # Get all registered commands
127
+ all_items = registry.get_all_commands()
128
+
129
+ # Create JSON-serializable data
130
+ command_items = []
131
+ for item in all_items:
132
+ try:
133
+ command_items.append(
134
+ {
135
+ "app_id": item.app_id,
136
+ "name": item.name,
137
+ "full_id": f"{item.app_id}.{item.name}",
138
+ }
139
+ )
140
+ except Exception as item_error:
141
+ logger.error(f"Error processing item: {item_error}")
142
+
143
+ # Get the basic command structure
144
+ try:
145
+ commands_dict: dict[str, list[str]] = {}
146
+ for item in all_items:
147
+ if item.app_id not in commands_dict:
148
+ commands_dict[item.app_id] = []
149
+ commands_dict[item.app_id].append(item.name)
150
+ except Exception:
151
+ commands_dict = {}
152
+
153
+ return {
154
+ "total_commands": len(all_items),
155
+ "commands_by_app": commands_dict,
156
+ "command_items": command_items,
157
+ }
158
+
159
+ except Exception as e:
160
+ logger.error(f"Error debugging registry: {str(e)}")
161
+ return {
162
+ "error": str(e),
163
+ "total_commands": 0,
164
+ "commands_by_app": {},
165
+ "command_items": [],
166
+ }
api/routers/config.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import time
4
+ import tomllib
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from fastapi import APIRouter, Request
9
+ from loguru import logger
10
+
11
+ from open_notebook.database.repository import repo_query
12
+ from open_notebook.utils.version_utils import (
13
+ compare_versions,
14
+ get_version_from_github_async,
15
+ )
16
+
17
+ router = APIRouter()
18
+
19
+ # In-memory cache for version check results
20
+ _version_cache: dict = {
21
+ "latest_version": None,
22
+ "has_update": False,
23
+ "timestamp": 0,
24
+ "check_failed": False,
25
+ }
26
+
27
+ # Cache TTL in seconds (24 hours)
28
+ VERSION_CACHE_TTL = 24 * 60 * 60
29
+
30
+
31
+ def get_version() -> str:
32
+ """Read version from pyproject.toml"""
33
+ try:
34
+ pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
35
+ with open(pyproject_path, "rb") as f:
36
+ pyproject = tomllib.load(f)
37
+ return pyproject.get("project", {}).get("version", "unknown")
38
+ except Exception as e:
39
+ logger.warning(f"Could not read version from pyproject.toml: {e}")
40
+ return "unknown"
41
+
42
+
43
+ async def get_latest_version_cached(current_version: str) -> tuple[Optional[str], bool]:
44
+ """
45
+ Check for the latest version from GitHub with caching.
46
+
47
+ Returns:
48
+ tuple: (latest_version, has_update)
49
+ - latest_version: str or None if check failed
50
+ - has_update: bool indicating if update is available
51
+ """
52
+ global _version_cache
53
+
54
+ # Check if cache is still valid (within TTL)
55
+ cache_age = time.time() - _version_cache["timestamp"]
56
+ if _version_cache["timestamp"] > 0 and cache_age < VERSION_CACHE_TTL:
57
+ logger.debug(f"Using cached version check result (age: {cache_age:.0f}s)")
58
+ return _version_cache["latest_version"], _version_cache["has_update"]
59
+
60
+ # Cache expired or not yet set
61
+ if _version_cache["timestamp"] > 0:
62
+ logger.info(f"Version cache expired (age: {cache_age:.0f}s), refreshing...")
63
+
64
+ # Perform version check with strict error handling
65
+ try:
66
+ logger.info("Checking for latest version from GitHub...")
67
+
68
+ # Fetch latest version from GitHub with 10-second timeout
69
+ latest_version = await get_version_from_github_async(
70
+ "https://github.com/lfnovo/open-notebook", "main"
71
+ )
72
+
73
+ logger.info(
74
+ f"Latest version from GitHub: {latest_version}, Current version: {current_version}"
75
+ )
76
+
77
+ # Compare versions
78
+ has_update = compare_versions(current_version, latest_version) < 0
79
+
80
+ # Cache the result
81
+ _version_cache["latest_version"] = latest_version
82
+ _version_cache["has_update"] = has_update
83
+ _version_cache["timestamp"] = time.time()
84
+ _version_cache["check_failed"] = False
85
+
86
+ logger.info(f"Version check complete. Update available: {has_update}")
87
+
88
+ return latest_version, has_update
89
+
90
+ except Exception as e:
91
+ logger.warning(f"Version check failed: {e}")
92
+
93
+ # Cache the failure to avoid repeated attempts
94
+ _version_cache["latest_version"] = None
95
+ _version_cache["has_update"] = False
96
+ _version_cache["timestamp"] = time.time()
97
+ _version_cache["check_failed"] = True
98
+
99
+ return None, False
100
+
101
+
102
+ async def check_database_health() -> dict:
103
+ """
104
+ Check if database is reachable using a lightweight query.
105
+
106
+ Returns:
107
+ dict with 'status' ("online" | "offline") and optional 'error'
108
+ """
109
+ try:
110
+ # 2-second timeout for database health check
111
+ result = await asyncio.wait_for(repo_query("RETURN 1"), timeout=2.0)
112
+ if result:
113
+ return {"status": "online"}
114
+ return {"status": "offline", "error": "Empty result"}
115
+ except asyncio.TimeoutError:
116
+ logger.warning("Database health check timed out after 2 seconds")
117
+ return {"status": "offline", "error": "Health check timeout"}
118
+ except Exception as e:
119
+ logger.warning(f"Database health check failed: {e}")
120
+ return {"status": "offline", "error": str(e)}
121
+
122
+
123
+ @router.get("/config")
124
+ async def get_config(request: Request):
125
+ """
126
+ Get frontend configuration.
127
+
128
+ Returns version information and health status.
129
+ Note: The frontend determines the API URL via its own runtime-config endpoint,
130
+ so this endpoint no longer returns apiUrl.
131
+
132
+ Also checks for version updates from GitHub (with caching and error handling).
133
+ """
134
+ # Get current version
135
+ current_version = get_version()
136
+
137
+ # Check for updates (with caching and error handling)
138
+ # This MUST NOT break the endpoint - wrapped in try-except as extra safety
139
+ latest_version = None
140
+ has_update = False
141
+
142
+ try:
143
+ latest_version, has_update = await get_latest_version_cached(current_version)
144
+ except Exception as e:
145
+ # Extra safety: ensure version check never breaks the config endpoint
146
+ logger.error(f"Unexpected error during version check: {e}")
147
+
148
+ # Check database health
149
+ db_health = await check_database_health()
150
+ db_status = db_health["status"]
151
+
152
+ if db_status == "offline":
153
+ logger.warning(f"Database offline: {db_health.get('error', 'Unknown error')}")
154
+
155
+ return {
156
+ "version": current_version,
157
+ "latestVersion": latest_version,
158
+ "hasUpdate": has_update,
159
+ "dbStatus": db_status,
160
+ }
api/routers/context.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from loguru import logger
3
+
4
+ from api.models import ContextRequest, ContextResponse
5
+ from open_notebook.domain.notebook import Note, Notebook, Source
6
+ from open_notebook.exceptions import InvalidInputError
7
+ from open_notebook.utils import token_count
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.post("/notebooks/{notebook_id}/context", response_model=ContextResponse)
13
+ async def get_notebook_context(notebook_id: str, context_request: ContextRequest):
14
+ """Get context for a notebook based on configuration."""
15
+ try:
16
+ # Verify notebook exists
17
+ notebook = await Notebook.get(notebook_id)
18
+ if not notebook:
19
+ raise HTTPException(status_code=404, detail="Notebook not found")
20
+
21
+ context_data: dict[str, list[dict[str, str]]] = {"note": [], "source": []}
22
+ total_content = ""
23
+
24
+ # Process context configuration if provided
25
+ if context_request.context_config:
26
+ # Process sources
27
+ for source_id, status in context_request.context_config.sources.items():
28
+ if "not in" in status:
29
+ continue
30
+
31
+ try:
32
+ # Add table prefix if not present
33
+ full_source_id = (
34
+ source_id
35
+ if source_id.startswith("source:")
36
+ else f"source:{source_id}"
37
+ )
38
+
39
+ try:
40
+ source = await Source.get(full_source_id)
41
+ except Exception:
42
+ continue
43
+
44
+ if "insights" in status:
45
+ source_context = await source.get_context(context_size="short")
46
+ context_data["source"].append(source_context)
47
+ total_content += str(source_context)
48
+ elif "full content" in status:
49
+ source_context = await source.get_context(context_size="long")
50
+ context_data["source"].append(source_context)
51
+ total_content += str(source_context)
52
+ except Exception as e:
53
+ logger.warning(f"Error processing source {source_id}: {str(e)}")
54
+ continue
55
+
56
+ # Process notes
57
+ for note_id, status in context_request.context_config.notes.items():
58
+ if "not in" in status:
59
+ continue
60
+
61
+ try:
62
+ # Add table prefix if not present
63
+ full_note_id = (
64
+ note_id if note_id.startswith("note:") else f"note:{note_id}"
65
+ )
66
+ note = await Note.get(full_note_id)
67
+ if not note:
68
+ continue
69
+
70
+ if "full content" in status:
71
+ note_context = note.get_context(context_size="long")
72
+ context_data["note"].append(note_context)
73
+ total_content += str(note_context)
74
+ except Exception as e:
75
+ logger.warning(f"Error processing note {note_id}: {str(e)}")
76
+ continue
77
+ else:
78
+ # Default behavior - include all sources and notes with short context
79
+ sources = await notebook.get_sources()
80
+ for source in sources:
81
+ try:
82
+ source_context = await source.get_context(context_size="short")
83
+ context_data["source"].append(source_context)
84
+ total_content += str(source_context)
85
+ except Exception as e:
86
+ logger.warning(f"Error processing source {source.id}: {str(e)}")
87
+ continue
88
+
89
+ notes = await notebook.get_notes()
90
+ for note in notes:
91
+ try:
92
+ note_context = note.get_context(context_size="short")
93
+ context_data["note"].append(note_context)
94
+ total_content += str(note_context)
95
+ except Exception as e:
96
+ logger.warning(f"Error processing note {note.id}: {str(e)}")
97
+ continue
98
+
99
+ # Calculate estimated token count
100
+ estimated_tokens = token_count(total_content) if total_content else 0
101
+
102
+ return ContextResponse(
103
+ notebook_id=notebook_id,
104
+ sources=context_data["source"],
105
+ notes=context_data["note"],
106
+ total_tokens=estimated_tokens,
107
+ )
108
+
109
+ except HTTPException:
110
+ raise
111
+ except InvalidInputError as e:
112
+ raise HTTPException(status_code=400, detail=str(e))
113
+ except Exception as e:
114
+ logger.error(f"Error getting context for notebook {notebook_id}: {str(e)}")
115
+ raise HTTPException(status_code=500, detail=f"Error getting context: {str(e)}")
api/routers/credentials.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Credentials Router
3
+
4
+ Thin HTTP layer for managing individual AI provider credentials.
5
+ Business logic lives in api.credentials_service.
6
+
7
+ Endpoints:
8
+ - GET /credentials - List all credentials
9
+ - GET /credentials/by-provider/{provider} - List credentials for a provider
10
+ - POST /credentials - Create a new credential
11
+ - GET /credentials/{credential_id} - Get a specific credential
12
+ - PUT /credentials/{credential_id} - Update a credential
13
+ - DELETE /credentials/{credential_id} - Delete a credential
14
+ - POST /credentials/{credential_id}/test - Test connection
15
+ - POST /credentials/{credential_id}/discover - Discover models
16
+ - POST /credentials/{credential_id}/register-models - Register models
17
+
18
+ NEVER returns actual API key values - only metadata.
19
+ """
20
+
21
+ from typing import List, Optional
22
+
23
+ from fastapi import APIRouter, HTTPException, Query
24
+ from loguru import logger
25
+ from pydantic import SecretStr
26
+
27
+ from api.credentials_service import (
28
+ credential_to_response,
29
+ discover_with_config,
30
+ get_provider_status,
31
+ register_models,
32
+ require_encryption_key,
33
+ validate_url,
34
+ )
35
+ from api.credentials_service import (
36
+ get_env_status as svc_get_env_status,
37
+ )
38
+ from api.credentials_service import (
39
+ migrate_from_env as svc_migrate_from_env,
40
+ )
41
+ from api.credentials_service import (
42
+ migrate_from_provider_config as svc_migrate_from_provider_config,
43
+ )
44
+ from api.credentials_service import (
45
+ test_credential as svc_test_credential,
46
+ )
47
+ from api.models import (
48
+ CreateCredentialRequest,
49
+ CredentialDeleteResponse,
50
+ CredentialResponse,
51
+ DiscoveredModelResponse,
52
+ DiscoverModelsResponse,
53
+ RegisterModelsRequest,
54
+ RegisterModelsResponse,
55
+ UpdateCredentialRequest,
56
+ )
57
+ from open_notebook.database.repository import ensure_record_id, repo_delete, repo_query
58
+ from open_notebook.domain.credential import Credential
59
+
60
+ router = APIRouter(prefix="/credentials", tags=["credentials"])
61
+
62
+
63
+ def _handle_value_error(e: ValueError, status_code: int = 400) -> HTTPException:
64
+ """Convert a ValueError from the service layer to an HTTPException."""
65
+ return HTTPException(status_code=status_code, detail=str(e))
66
+
67
+
68
+ # =============================================================================
69
+ # Status endpoints
70
+ # =============================================================================
71
+
72
+
73
+ @router.get("/status")
74
+ async def get_status():
75
+ """
76
+ Get configuration status: encryption key status, and per-provider
77
+ configured/source information.
78
+ """
79
+ try:
80
+ return await get_provider_status()
81
+ except Exception as e:
82
+ logger.error(f"Error fetching status: {e}")
83
+ raise HTTPException(status_code=500, detail="Failed to fetch credential status")
84
+
85
+
86
+ @router.get("/env-status")
87
+ async def get_env_status():
88
+ """Check what's configured via environment variables."""
89
+ try:
90
+ return await svc_get_env_status()
91
+ except Exception as e:
92
+ logger.error(f"Error checking env status: {e}")
93
+ raise HTTPException(status_code=500, detail="Failed to check environment status")
94
+
95
+
96
+ # =============================================================================
97
+ # CRUD endpoints
98
+ # =============================================================================
99
+
100
+
101
+ @router.get("", response_model=List[CredentialResponse])
102
+ async def list_credentials(
103
+ provider: Optional[str] = Query(None, description="Filter by provider"),
104
+ ):
105
+ """List all credentials, optionally filtered by provider."""
106
+ try:
107
+ if provider:
108
+ credentials = await Credential.get_by_provider(provider)
109
+ else:
110
+ credentials = await Credential.get_all(order_by="provider, created")
111
+
112
+ result = []
113
+ for cred in credentials:
114
+ models = await cred.get_linked_models()
115
+ result.append(credential_to_response(cred, len(models)))
116
+
117
+ return result
118
+
119
+ except Exception as e:
120
+ logger.error(f"Error listing credentials: {e}")
121
+ raise HTTPException(status_code=500, detail="Failed to list credentials")
122
+
123
+
124
+ @router.get("/by-provider/{provider}", response_model=List[CredentialResponse])
125
+ async def list_credentials_by_provider(provider: str):
126
+ """List all credentials for a specific provider."""
127
+ try:
128
+ credentials = await Credential.get_by_provider(provider.lower())
129
+ result = []
130
+ for cred in credentials:
131
+ models = await cred.get_linked_models()
132
+ result.append(credential_to_response(cred, len(models)))
133
+ return result
134
+ except Exception as e:
135
+ logger.error(f"Error listing credentials for {provider}: {e}")
136
+ raise HTTPException(status_code=500, detail="Failed to list credentials for provider")
137
+
138
+
139
+ @router.post("", response_model=CredentialResponse, status_code=201)
140
+ async def create_credential(request: CreateCredentialRequest):
141
+ """Create a new credential."""
142
+ try:
143
+ require_encryption_key()
144
+ except ValueError as e:
145
+ raise _handle_value_error(e)
146
+
147
+ # Validate all URL fields
148
+ for url_field in [
149
+ request.base_url, request.endpoint, request.endpoint_llm,
150
+ request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,
151
+ ]:
152
+ if url_field:
153
+ try:
154
+ validate_url(url_field, request.provider)
155
+ except ValueError as e:
156
+ raise _handle_value_error(e)
157
+
158
+ try:
159
+ cred = Credential(
160
+ name=request.name,
161
+ provider=request.provider.lower(),
162
+ modalities=request.modalities,
163
+ api_key=SecretStr(request.api_key) if request.api_key else None,
164
+ base_url=request.base_url,
165
+ endpoint=request.endpoint,
166
+ api_version=request.api_version,
167
+ endpoint_llm=request.endpoint_llm,
168
+ endpoint_embedding=request.endpoint_embedding,
169
+ endpoint_stt=request.endpoint_stt,
170
+ endpoint_tts=request.endpoint_tts,
171
+ project=request.project,
172
+ location=request.location,
173
+ credentials_path=request.credentials_path,
174
+ )
175
+ await cred.save()
176
+ return credential_to_response(cred, 0)
177
+
178
+ except Exception as e:
179
+ logger.error(f"Error creating credential: {e}")
180
+ raise HTTPException(status_code=500, detail="Failed to create credential")
181
+
182
+
183
+ @router.get("/{credential_id}", response_model=CredentialResponse)
184
+ async def get_credential(credential_id: str):
185
+ """Get a specific credential by ID. Never returns api_key."""
186
+ try:
187
+ cred = await Credential.get(credential_id)
188
+ models = await cred.get_linked_models()
189
+ return credential_to_response(cred, len(models))
190
+ except Exception as e:
191
+ logger.error(f"Error fetching credential {credential_id}: {e}")
192
+ raise HTTPException(status_code=404, detail="Credential not found")
193
+
194
+
195
+ @router.put("/{credential_id}", response_model=CredentialResponse)
196
+ async def update_credential(credential_id: str, request: UpdateCredentialRequest):
197
+ """Update an existing credential."""
198
+ try:
199
+ require_encryption_key()
200
+ except ValueError as e:
201
+ raise _handle_value_error(e)
202
+
203
+ # Validate all URL fields being updated
204
+ for url_field in [
205
+ request.base_url, request.endpoint, request.endpoint_llm,
206
+ request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,
207
+ ]:
208
+ if url_field:
209
+ try:
210
+ validate_url(url_field, "update")
211
+ except ValueError as e:
212
+ raise _handle_value_error(e)
213
+
214
+ try:
215
+ cred = await Credential.get(credential_id)
216
+
217
+ if request.name is not None:
218
+ cred.name = request.name
219
+ if request.modalities is not None:
220
+ cred.modalities = request.modalities
221
+ if request.api_key is not None:
222
+ cred.api_key = SecretStr(request.api_key)
223
+ if request.base_url is not None:
224
+ cred.base_url = request.base_url or None
225
+ if request.endpoint is not None:
226
+ cred.endpoint = request.endpoint or None
227
+ if request.api_version is not None:
228
+ cred.api_version = request.api_version or None
229
+ if request.endpoint_llm is not None:
230
+ cred.endpoint_llm = request.endpoint_llm or None
231
+ if request.endpoint_embedding is not None:
232
+ cred.endpoint_embedding = request.endpoint_embedding or None
233
+ if request.endpoint_stt is not None:
234
+ cred.endpoint_stt = request.endpoint_stt or None
235
+ if request.endpoint_tts is not None:
236
+ cred.endpoint_tts = request.endpoint_tts or None
237
+ if request.project is not None:
238
+ cred.project = request.project or None
239
+ if request.location is not None:
240
+ cred.location = request.location or None
241
+ if request.credentials_path is not None:
242
+ cred.credentials_path = request.credentials_path or None
243
+
244
+ await cred.save()
245
+ models = await cred.get_linked_models()
246
+ return credential_to_response(cred, len(models))
247
+
248
+ except HTTPException:
249
+ raise
250
+ except Exception as e:
251
+ logger.error(f"Error updating credential {credential_id}: {e}")
252
+ raise HTTPException(status_code=500, detail="Failed to update credential")
253
+
254
+
255
+ @router.delete("/{credential_id}", response_model=CredentialDeleteResponse)
256
+ async def delete_credential(
257
+ credential_id: str,
258
+ migrate_to: Optional[str] = Query(
259
+ None, description="Migrate linked models to this credential ID"
260
+ ),
261
+ ):
262
+ """
263
+ Delete a credential.
264
+
265
+ If the credential has linked models:
266
+ - Pass migrate_to=<credential_id> to reassign them to another credential
267
+ - Otherwise, linked models are cascade-deleted automatically
268
+ """
269
+ try:
270
+ try:
271
+ cred = await Credential.get(credential_id)
272
+ except ValueError as decrypt_err:
273
+ # Credential exists but can't be decrypted (wrong encryption key).
274
+ # Fall back to direct DB operations for deletion.
275
+ logger.warning(
276
+ f"Cannot decrypt credential {credential_id}, "
277
+ f"falling back to direct delete: {decrypt_err}"
278
+ )
279
+
280
+ # Query linked models
281
+ linked = await repo_query(
282
+ "SELECT * FROM model WHERE credential = $cred_id",
283
+ {"cred_id": ensure_record_id(credential_id)},
284
+ )
285
+ deleted_models = 0
286
+
287
+ if linked and migrate_to:
288
+ # Migrate models to another credential
289
+ target_cred = await Credential.get(migrate_to)
290
+ for model_row in linked:
291
+ model_id = str(model_row.get("id", ""))
292
+ if model_id:
293
+ await repo_query(
294
+ "UPDATE $model_id SET credential = $target_id",
295
+ {
296
+ "model_id": ensure_record_id(model_id),
297
+ "target_id": ensure_record_id(target_cred.id),
298
+ },
299
+ )
300
+ elif linked:
301
+ # Cascade-delete linked models
302
+ for model_row in linked:
303
+ model_id = str(model_row.get("id", ""))
304
+ if model_id:
305
+ await repo_delete(model_id)
306
+ deleted_models += 1
307
+
308
+ # Delete the credential itself
309
+ await repo_delete(credential_id)
310
+
311
+ return CredentialDeleteResponse(
312
+ message="Credential deleted successfully",
313
+ deleted_models=deleted_models,
314
+ )
315
+
316
+ linked_models = await cred.get_linked_models()
317
+
318
+ deleted_models = 0
319
+
320
+ if linked_models and migrate_to:
321
+ # Migrate models to another credential
322
+ target_cred = await Credential.get(migrate_to)
323
+ for model in linked_models:
324
+ model.credential = target_cred.id
325
+ await model.save()
326
+
327
+ elif linked_models:
328
+ # Cascade-delete linked models (default behavior when no migrate_to)
329
+ for model in linked_models:
330
+ await model.delete()
331
+ deleted_models += 1
332
+
333
+ # Delete the credential
334
+ await cred.delete()
335
+
336
+ return CredentialDeleteResponse(
337
+ message="Credential deleted successfully",
338
+ deleted_models=deleted_models,
339
+ )
340
+
341
+ except HTTPException:
342
+ raise
343
+ except Exception as e:
344
+ logger.error(f"Error deleting credential {credential_id}: {e}")
345
+ raise HTTPException(status_code=500, detail="Failed to delete credential")
346
+
347
+
348
+ # =============================================================================
349
+ # Test / Discover / Register endpoints
350
+ # =============================================================================
351
+
352
+
353
+ @router.post("/{credential_id}/test")
354
+ async def test_credential(credential_id: str):
355
+ """Test connection using this credential's configuration."""
356
+ return await svc_test_credential(credential_id)
357
+
358
+
359
+ @router.post("/{credential_id}/discover", response_model=DiscoverModelsResponse)
360
+ async def discover_models_for_credential(credential_id: str):
361
+ """Discover available models using this credential's API key."""
362
+ try:
363
+ cred = await Credential.get(credential_id)
364
+ config = cred.to_esperanto_config()
365
+ provider = cred.provider.lower()
366
+
367
+ discovered = await discover_with_config(provider, config)
368
+
369
+ return DiscoverModelsResponse(
370
+ credential_id=cred.id or "",
371
+ provider=provider,
372
+ discovered=[
373
+ DiscoveredModelResponse(
374
+ name=d["name"],
375
+ provider=d["provider"],
376
+ description=d.get("description"),
377
+ )
378
+ for d in discovered
379
+ ],
380
+ )
381
+
382
+ except Exception as e:
383
+ logger.error(f"Error discovering models for credential {credential_id}: {e}")
384
+ raise HTTPException(status_code=500, detail="Failed to discover models")
385
+
386
+
387
+ @router.post("/{credential_id}/register-models", response_model=RegisterModelsResponse)
388
+ async def register_models_for_credential(
389
+ credential_id: str, request: RegisterModelsRequest
390
+ ):
391
+ """Register discovered models and link them to this credential."""
392
+ try:
393
+ result = await register_models(credential_id, request.models)
394
+ return RegisterModelsResponse(**result)
395
+ except Exception as e:
396
+ logger.error(f"Error registering models for credential {credential_id}: {e}")
397
+ raise HTTPException(status_code=500, detail="Failed to register models")
398
+
399
+
400
+ # =============================================================================
401
+ # Migration endpoints
402
+ # =============================================================================
403
+
404
+
405
+ @router.post("/migrate-from-provider-config")
406
+ async def migrate_from_provider_config():
407
+ """Migrate existing ProviderConfig data to individual credential records."""
408
+ try:
409
+ return await svc_migrate_from_provider_config()
410
+ except ValueError as e:
411
+ raise _handle_value_error(e)
412
+ except Exception as e:
413
+ logger.error(f"ProviderConfig migration FAILED: {type(e).__name__}: {e}", exc_info=True)
414
+ raise HTTPException(status_code=500, detail="Migration from provider config failed")
415
+
416
+
417
+ @router.post("/migrate-from-env")
418
+ async def migrate_from_env():
419
+ """Migrate API keys from environment variables to credential records."""
420
+ try:
421
+ return await svc_migrate_from_env()
422
+ except ValueError as e:
423
+ raise _handle_value_error(e)
424
+ except Exception as e:
425
+ logger.error(f"Env migration FAILED: {type(e).__name__}: {e}", exc_info=True)
426
+ raise HTTPException(status_code=500, detail="Migration from environment variables failed")
api/routers/embedding.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from loguru import logger
3
+
4
+ from api.command_service import CommandService
5
+ from api.models import EmbedRequest, EmbedResponse
6
+ from open_notebook.ai.models import model_manager
7
+ from open_notebook.domain.notebook import Note, Source
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.post("/embed", response_model=EmbedResponse)
13
+ async def embed_content(embed_request: EmbedRequest):
14
+ """Embed content for vector search."""
15
+ try:
16
+ # Check if embedding model is available
17
+ if not await model_manager.get_embedding_model():
18
+ raise HTTPException(
19
+ status_code=400,
20
+ detail="No embedding model configured. Please configure one in the Models section.",
21
+ )
22
+
23
+ item_id = embed_request.item_id
24
+ item_type = embed_request.item_type.lower()
25
+
26
+ # Validate item type
27
+ if item_type not in ["source", "note"]:
28
+ raise HTTPException(
29
+ status_code=400, detail="Item type must be either 'source' or 'note'"
30
+ )
31
+
32
+ # Branch based on processing mode
33
+ if embed_request.async_processing:
34
+ # ASYNC PATH: Submit command for background processing
35
+ logger.info(f"Using async processing for {item_type} {item_id}")
36
+
37
+ try:
38
+ # Import commands to ensure they're registered
39
+ import commands.embedding_commands # noqa: F401
40
+
41
+ # Submit type-specific command
42
+ if item_type == "source":
43
+ command_name = "embed_source"
44
+ command_input = {"source_id": item_id}
45
+ else: # note
46
+ command_name = "embed_note"
47
+ command_input = {"note_id": item_id}
48
+
49
+ command_id = await CommandService.submit_command_job(
50
+ "open_notebook",
51
+ command_name,
52
+ command_input,
53
+ )
54
+
55
+ logger.info(f"Submitted async {command_name} command: {command_id}")
56
+
57
+ return EmbedResponse(
58
+ success=True,
59
+ message="Embedding queued for background processing",
60
+ item_id=item_id,
61
+ item_type=item_type,
62
+ command_id=command_id,
63
+ )
64
+
65
+ except Exception as e:
66
+ logger.error(f"Failed to submit async embedding command: {e}")
67
+ raise HTTPException(
68
+ status_code=500, detail=f"Failed to queue embedding: {str(e)}"
69
+ )
70
+
71
+ else:
72
+ # DOMAIN MODEL PATH: Submit job via domain model convenience methods
73
+ # These methods internally call submit_command() - still fire-and-forget
74
+ logger.info(f"Using domain model path for {item_type} {item_id}")
75
+
76
+ command_id = None
77
+
78
+ # Get the item and submit embedding job
79
+ if item_type == "source":
80
+ source_item = await Source.get(item_id)
81
+ if not source_item:
82
+ raise HTTPException(status_code=404, detail="Source not found")
83
+
84
+ # Submit embed_source job (returns command_id for tracking)
85
+ command_id = await source_item.vectorize()
86
+ message = "Source embedding job submitted"
87
+
88
+ elif item_type == "note":
89
+ note_item = await Note.get(item_id)
90
+ if not note_item:
91
+ raise HTTPException(status_code=404, detail="Note not found")
92
+
93
+ # Note.save() internally submits embed_note command and returns command_id
94
+ command_id = await note_item.save()
95
+ message = "Note embedding job submitted"
96
+
97
+ return EmbedResponse(
98
+ success=True,
99
+ message=message,
100
+ item_id=item_id,
101
+ item_type=item_type,
102
+ command_id=command_id,
103
+ )
104
+
105
+ except HTTPException:
106
+ raise
107
+ except Exception as e:
108
+ logger.error(
109
+ f"Error embedding {embed_request.item_type} {embed_request.item_id}: {str(e)}"
110
+ )
111
+ raise HTTPException(
112
+ status_code=500, detail=f"Error embedding content: {str(e)}"
113
+ )
api/routers/embedding_rebuild.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from loguru import logger
3
+ from surreal_commands import get_command_status
4
+
5
+ from api.command_service import CommandService
6
+ from api.models import (
7
+ RebuildProgress,
8
+ RebuildRequest,
9
+ RebuildResponse,
10
+ RebuildStats,
11
+ RebuildStatusResponse,
12
+ )
13
+ from open_notebook.database.repository import repo_query
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ @router.post("/rebuild", response_model=RebuildResponse)
19
+ async def start_rebuild(request: RebuildRequest):
20
+ """
21
+ Start a background job to rebuild embeddings.
22
+
23
+ - **mode**: "existing" (re-embed items with embeddings) or "all" (embed everything)
24
+ - **include_sources**: Include sources in rebuild (default: true)
25
+ - **include_notes**: Include notes in rebuild (default: true)
26
+ - **include_insights**: Include insights in rebuild (default: true)
27
+
28
+ Returns command ID to track progress and estimated item count.
29
+ """
30
+ try:
31
+ logger.info(f"Starting rebuild request: mode={request.mode}")
32
+
33
+ # Import commands to ensure they're registered
34
+ import commands.embedding_commands # noqa: F401
35
+
36
+ # Estimate total items (quick count query)
37
+ # This is a rough estimate before the command runs
38
+ total_estimate = 0
39
+
40
+ if request.include_sources:
41
+ if request.mode == "existing":
42
+ # Count sources with embeddings
43
+ result = await repo_query(
44
+ """
45
+ SELECT VALUE count(array::distinct(
46
+ SELECT VALUE source.id
47
+ FROM source_embedding
48
+ WHERE embedding != none AND array::len(embedding) > 0
49
+ )) as count FROM {}
50
+ """
51
+ )
52
+ else:
53
+ # Count all sources with content
54
+ result = await repo_query(
55
+ "SELECT VALUE count() as count FROM source WHERE full_text != none GROUP ALL"
56
+ )
57
+
58
+ if result and isinstance(result[0], dict):
59
+ total_estimate += result[0].get("count", 0)
60
+ elif result:
61
+ total_estimate += result[0] if isinstance(result[0], int) else 0
62
+
63
+ if request.include_notes:
64
+ if request.mode == "existing":
65
+ result = await repo_query(
66
+ "SELECT VALUE count() as count FROM note WHERE embedding != none AND array::len(embedding) > 0 GROUP ALL"
67
+ )
68
+ else:
69
+ result = await repo_query(
70
+ "SELECT VALUE count() as count FROM note WHERE content != none GROUP ALL"
71
+ )
72
+
73
+ if result and isinstance(result[0], dict):
74
+ total_estimate += result[0].get("count", 0)
75
+ elif result:
76
+ total_estimate += result[0] if isinstance(result[0], int) else 0
77
+
78
+ if request.include_insights:
79
+ if request.mode == "existing":
80
+ result = await repo_query(
81
+ "SELECT VALUE count() as count FROM source_insight WHERE embedding != none AND array::len(embedding) > 0 GROUP ALL"
82
+ )
83
+ else:
84
+ result = await repo_query(
85
+ "SELECT VALUE count() as count FROM source_insight GROUP ALL"
86
+ )
87
+
88
+ if result and isinstance(result[0], dict):
89
+ total_estimate += result[0].get("count", 0)
90
+ elif result:
91
+ total_estimate += result[0] if isinstance(result[0], int) else 0
92
+
93
+ logger.info(f"Estimated {total_estimate} items to process")
94
+
95
+ # Submit command
96
+ command_id = await CommandService.submit_command_job(
97
+ "open_notebook",
98
+ "rebuild_embeddings",
99
+ {
100
+ "mode": request.mode,
101
+ "include_sources": request.include_sources,
102
+ "include_notes": request.include_notes,
103
+ "include_insights": request.include_insights,
104
+ },
105
+ )
106
+
107
+ logger.info(f"Submitted rebuild command: {command_id}")
108
+
109
+ return RebuildResponse(
110
+ command_id=command_id,
111
+ total_items=total_estimate,
112
+ message=f"Rebuild operation started. Estimated {total_estimate} items to process.",
113
+ )
114
+
115
+ except Exception as e:
116
+ logger.error(f"Failed to start rebuild: {e}")
117
+ logger.exception(e)
118
+ raise HTTPException(
119
+ status_code=500, detail=f"Failed to start rebuild operation: {str(e)}"
120
+ )
121
+
122
+
123
+ @router.get("/rebuild/{command_id}/status", response_model=RebuildStatusResponse)
124
+ async def get_rebuild_status(command_id: str):
125
+ """
126
+ Get the status of a rebuild operation.
127
+
128
+ Returns:
129
+ - **status**: queued, running, completed, failed
130
+ - **progress**: processed count, total count, percentage
131
+ - **stats**: breakdown by type (sources, notes, insights, failed)
132
+ - **timestamps**: started_at, completed_at
133
+ """
134
+ try:
135
+ # Get command status from surreal_commands
136
+ status = await get_command_status(command_id)
137
+
138
+ if not status:
139
+ raise HTTPException(status_code=404, detail="Rebuild command not found")
140
+
141
+ # Build response based on status
142
+ response = RebuildStatusResponse(
143
+ command_id=command_id,
144
+ status=status.status,
145
+ )
146
+
147
+ # Extract metadata from command result
148
+ if status.result and isinstance(status.result, dict):
149
+ result = status.result
150
+
151
+ # Build progress info
152
+ if "total_items" in result and "jobs_submitted" in result:
153
+ total = result["total_items"]
154
+ submitted = result["jobs_submitted"]
155
+ response.progress = RebuildProgress(
156
+ processed=submitted,
157
+ total=total,
158
+ percentage=round((submitted / total * 100) if total > 0 else 0, 2),
159
+ )
160
+
161
+ # Build stats
162
+ response.stats = RebuildStats(
163
+ sources=result.get("sources_submitted", 0),
164
+ notes=result.get("notes_submitted", 0),
165
+ insights=result.get("insights_submitted", 0),
166
+ failed=result.get("failed_submissions", 0),
167
+ )
168
+
169
+ # Add timestamps
170
+ if hasattr(status, "created") and status.created:
171
+ response.started_at = str(status.created)
172
+ if hasattr(status, "updated") and status.updated:
173
+ response.completed_at = str(status.updated)
174
+
175
+ # Add error message if failed
176
+ if (
177
+ status.status == "failed"
178
+ and status.result
179
+ and isinstance(status.result, dict)
180
+ ):
181
+ response.error_message = status.result.get("error_message", "Unknown error")
182
+
183
+ return response
184
+
185
+ except HTTPException:
186
+ raise
187
+ except Exception as e:
188
+ logger.error(f"Failed to get rebuild status: {e}")
189
+ logger.exception(e)
190
+ raise HTTPException(
191
+ status_code=500, detail=f"Failed to get rebuild status: {str(e)}"
192
+ )
api/routers/episode_profiles.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from loguru import logger
5
+ from pydantic import BaseModel, Field
6
+
7
+ from open_notebook.podcasts.models import EpisodeProfile
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ class EpisodeProfileResponse(BaseModel):
13
+ id: str
14
+ name: str
15
+ description: str
16
+ speaker_config: str
17
+ outline_llm: Optional[str] = None
18
+ transcript_llm: Optional[str] = None
19
+ language: Optional[str] = None
20
+ default_briefing: str
21
+ num_segments: int
22
+ # Legacy fields (for display/migration awareness)
23
+ outline_provider: Optional[str] = None
24
+ outline_model: Optional[str] = None
25
+ transcript_provider: Optional[str] = None
26
+ transcript_model: Optional[str] = None
27
+
28
+
29
+ def _profile_to_response(profile: EpisodeProfile) -> EpisodeProfileResponse:
30
+ return EpisodeProfileResponse(
31
+ id=str(profile.id),
32
+ name=profile.name,
33
+ description=profile.description or "",
34
+ speaker_config=profile.speaker_config,
35
+ outline_llm=profile.outline_llm,
36
+ transcript_llm=profile.transcript_llm,
37
+ language=profile.language,
38
+ default_briefing=profile.default_briefing,
39
+ num_segments=profile.num_segments,
40
+ outline_provider=profile.outline_provider,
41
+ outline_model=profile.outline_model,
42
+ transcript_provider=profile.transcript_provider,
43
+ transcript_model=profile.transcript_model,
44
+ )
45
+
46
+
47
+ @router.get("/episode-profiles", response_model=List[EpisodeProfileResponse])
48
+ async def list_episode_profiles():
49
+ """List all available episode profiles"""
50
+ try:
51
+ profiles = await EpisodeProfile.get_all(order_by="name asc")
52
+ return [_profile_to_response(p) for p in profiles]
53
+ except Exception as e:
54
+ logger.error(f"Failed to fetch episode profiles: {e}")
55
+ raise HTTPException(
56
+ status_code=500, detail="Failed to fetch episode profiles"
57
+ )
58
+
59
+
60
+ @router.get("/episode-profiles/{profile_name}", response_model=EpisodeProfileResponse)
61
+ async def get_episode_profile(profile_name: str):
62
+ """Get a specific episode profile by name"""
63
+ try:
64
+ profile = await EpisodeProfile.get_by_name(profile_name)
65
+
66
+ if not profile:
67
+ raise HTTPException(
68
+ status_code=404, detail=f"Episode profile '{profile_name}' not found"
69
+ )
70
+
71
+ return _profile_to_response(profile)
72
+
73
+ except HTTPException:
74
+ raise
75
+ except Exception as e:
76
+ logger.error(f"Failed to fetch episode profile '{profile_name}': {e}")
77
+ raise HTTPException(
78
+ status_code=500, detail="Failed to fetch episode profile"
79
+ )
80
+
81
+
82
+ class EpisodeProfileCreate(BaseModel):
83
+ name: str = Field(..., description="Unique profile name")
84
+ description: str = Field("", description="Profile description")
85
+ speaker_config: str = Field(..., description="Reference to speaker profile name")
86
+ outline_llm: Optional[str] = Field(None, description="Model record ID for outline")
87
+ transcript_llm: Optional[str] = Field(
88
+ None, description="Model record ID for transcript"
89
+ )
90
+ language: Optional[str] = Field(None, description="Podcast language code")
91
+ default_briefing: str = Field(..., description="Default briefing template")
92
+ num_segments: int = Field(default=5, description="Number of podcast segments")
93
+ # Legacy fields (accepted but not required)
94
+ outline_provider: Optional[str] = None
95
+ outline_model: Optional[str] = None
96
+ transcript_provider: Optional[str] = None
97
+ transcript_model: Optional[str] = None
98
+
99
+
100
+ @router.post("/episode-profiles", response_model=EpisodeProfileResponse)
101
+ async def create_episode_profile(profile_data: EpisodeProfileCreate):
102
+ """Create a new episode profile"""
103
+ try:
104
+ profile = EpisodeProfile(
105
+ name=profile_data.name,
106
+ description=profile_data.description,
107
+ speaker_config=profile_data.speaker_config,
108
+ outline_llm=profile_data.outline_llm,
109
+ transcript_llm=profile_data.transcript_llm,
110
+ language=profile_data.language,
111
+ default_briefing=profile_data.default_briefing,
112
+ num_segments=profile_data.num_segments,
113
+ outline_provider=profile_data.outline_provider,
114
+ outline_model=profile_data.outline_model,
115
+ transcript_provider=profile_data.transcript_provider,
116
+ transcript_model=profile_data.transcript_model,
117
+ )
118
+
119
+ await profile.save()
120
+ return _profile_to_response(profile)
121
+
122
+ except Exception as e:
123
+ logger.error(f"Failed to create episode profile: {e}")
124
+ raise HTTPException(
125
+ status_code=500, detail="Failed to create episode profile"
126
+ )
127
+
128
+
129
+ @router.put("/episode-profiles/{profile_id}", response_model=EpisodeProfileResponse)
130
+ async def update_episode_profile(profile_id: str, profile_data: EpisodeProfileCreate):
131
+ """Update an existing episode profile"""
132
+ try:
133
+ profile = await EpisodeProfile.get(profile_id)
134
+
135
+ if not profile:
136
+ raise HTTPException(
137
+ status_code=404, detail=f"Episode profile '{profile_id}' not found"
138
+ )
139
+
140
+ profile.name = profile_data.name
141
+ profile.description = profile_data.description
142
+ profile.speaker_config = profile_data.speaker_config
143
+ profile.outline_llm = profile_data.outline_llm
144
+ profile.transcript_llm = profile_data.transcript_llm
145
+ profile.language = profile_data.language
146
+ profile.default_briefing = profile_data.default_briefing
147
+ profile.num_segments = profile_data.num_segments
148
+ profile.outline_provider = profile_data.outline_provider
149
+ profile.outline_model = profile_data.outline_model
150
+ profile.transcript_provider = profile_data.transcript_provider
151
+ profile.transcript_model = profile_data.transcript_model
152
+
153
+ await profile.save()
154
+ return _profile_to_response(profile)
155
+
156
+ except HTTPException:
157
+ raise
158
+ except Exception as e:
159
+ logger.error(f"Failed to update episode profile: {e}")
160
+ raise HTTPException(
161
+ status_code=500, detail="Failed to update episode profile"
162
+ )
163
+
164
+
165
+ @router.delete("/episode-profiles/{profile_id}")
166
+ async def delete_episode_profile(profile_id: str):
167
+ """Delete an episode profile"""
168
+ try:
169
+ profile = await EpisodeProfile.get(profile_id)
170
+
171
+ if not profile:
172
+ raise HTTPException(
173
+ status_code=404, detail=f"Episode profile '{profile_id}' not found"
174
+ )
175
+
176
+ await profile.delete()
177
+
178
+ return {"message": "Episode profile deleted successfully"}
179
+
180
+ except HTTPException:
181
+ raise
182
+ except Exception as e:
183
+ logger.error(f"Failed to delete episode profile: {e}")
184
+ raise HTTPException(
185
+ status_code=500, detail="Failed to delete episode profile"
186
+ )
187
+
188
+
189
+ @router.post(
190
+ "/episode-profiles/{profile_id}/duplicate", response_model=EpisodeProfileResponse
191
+ )
192
+ async def duplicate_episode_profile(profile_id: str):
193
+ """Duplicate an episode profile"""
194
+ try:
195
+ original = await EpisodeProfile.get(profile_id)
196
+
197
+ if not original:
198
+ raise HTTPException(
199
+ status_code=404, detail=f"Episode profile '{profile_id}' not found"
200
+ )
201
+
202
+ duplicate = EpisodeProfile(
203
+ name=f"{original.name} - Copy",
204
+ description=original.description,
205
+ speaker_config=original.speaker_config,
206
+ outline_llm=original.outline_llm,
207
+ transcript_llm=original.transcript_llm,
208
+ language=original.language,
209
+ default_briefing=original.default_briefing,
210
+ num_segments=original.num_segments,
211
+ outline_provider=original.outline_provider,
212
+ outline_model=original.outline_model,
213
+ transcript_provider=original.transcript_provider,
214
+ transcript_model=original.transcript_model,
215
+ )
216
+
217
+ await duplicate.save()
218
+ return _profile_to_response(duplicate)
219
+
220
+ except HTTPException:
221
+ raise
222
+ except Exception as e:
223
+ logger.error(f"Failed to duplicate episode profile: {e}")
224
+ raise HTTPException(
225
+ status_code=500, detail="Failed to duplicate episode profile"
226
+ )
api/routers/insights.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from loguru import logger
3
+
4
+ from api.models import NoteResponse, SaveAsNoteRequest, SourceInsightResponse
5
+ from open_notebook.domain.notebook import SourceInsight
6
+ from open_notebook.exceptions import InvalidInputError
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ @router.get("/insights/{insight_id}", response_model=SourceInsightResponse)
12
+ async def get_insight(insight_id: str):
13
+ """Get a specific insight by ID."""
14
+ try:
15
+ insight = await SourceInsight.get(insight_id)
16
+ if not insight:
17
+ raise HTTPException(status_code=404, detail="Insight not found")
18
+
19
+ # Get source ID from the insight relationship
20
+ source = await insight.get_source()
21
+
22
+ return SourceInsightResponse(
23
+ id=insight.id or "",
24
+ source_id=source.id or "",
25
+ insight_type=insight.insight_type,
26
+ content=insight.content,
27
+ created=str(insight.created),
28
+ updated=str(insight.updated),
29
+ )
30
+ except HTTPException:
31
+ raise
32
+ except Exception as e:
33
+ logger.error(f"Error fetching insight {insight_id}: {str(e)}")
34
+ raise HTTPException(status_code=500, detail="Error fetching insight")
35
+
36
+
37
+ @router.delete("/insights/{insight_id}")
38
+ async def delete_insight(insight_id: str):
39
+ """Delete a specific insight."""
40
+ try:
41
+ insight = await SourceInsight.get(insight_id)
42
+ if not insight:
43
+ raise HTTPException(status_code=404, detail="Insight not found")
44
+
45
+ await insight.delete()
46
+
47
+ return {"message": "Insight deleted successfully"}
48
+ except HTTPException:
49
+ raise
50
+ except Exception as e:
51
+ logger.error(f"Error deleting insight {insight_id}: {str(e)}")
52
+ raise HTTPException(status_code=500, detail="Error deleting insight")
53
+
54
+
55
+ @router.post("/insights/{insight_id}/save-as-note", response_model=NoteResponse)
56
+ async def save_insight_as_note(insight_id: str, request: SaveAsNoteRequest):
57
+ """Convert an insight to a note."""
58
+ try:
59
+ insight = await SourceInsight.get(insight_id)
60
+ if not insight:
61
+ raise HTTPException(status_code=404, detail="Insight not found")
62
+
63
+ # Use the existing save_as_note method from the domain model
64
+ note = await insight.save_as_note(request.notebook_id)
65
+
66
+ return NoteResponse(
67
+ id=note.id or "",
68
+ title=note.title,
69
+ content=note.content,
70
+ note_type=note.note_type,
71
+ created=str(note.created),
72
+ updated=str(note.updated),
73
+ )
74
+ except HTTPException:
75
+ raise
76
+ except InvalidInputError as e:
77
+ raise HTTPException(status_code=400, detail=str(e))
78
+ except Exception as e:
79
+ logger.error(f"Error saving insight {insight_id} as note: {str(e)}")
80
+ raise HTTPException(
81
+ status_code=500, detail="Error saving insight as note"
82
+ )
api/routers/languages.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+
3
+ import pycountry
4
+ from babel import Locale
5
+ from babel.core import get_global
6
+ from fastapi import APIRouter
7
+ from pydantic import BaseModel
8
+
9
+ router = APIRouter()
10
+
11
+ # Additional regional variants for languages where the distinction matters
12
+ # (TTS accent, vocabulary, spelling differences)
13
+ _EXTRA_VARIANTS = [
14
+ "pt_PT",
15
+ "en_GB",
16
+ "en_AU",
17
+ "en_IN",
18
+ "es_MX",
19
+ "es_AR",
20
+ "es_CO",
21
+ "fr_CA",
22
+ "fr_CH",
23
+ "zh_TW",
24
+ "zh_HK",
25
+ "de_AT",
26
+ "de_CH",
27
+ "ar_SA",
28
+ "nl_BE",
29
+ ]
30
+
31
+
32
+ class LanguageResponse(BaseModel):
33
+ code: str
34
+ name: str
35
+
36
+
37
+ @router.get("/languages", response_model=List[LanguageResponse])
38
+ async def list_languages():
39
+ """List available languages as BCP 47 locale codes (e.g. pt-BR, en-US)."""
40
+ likely_subtags = get_global("likely_subtags")
41
+ languages = []
42
+ seen = set()
43
+
44
+ # 1. For each language, resolve its default locale via CLDR likely subtags
45
+ for lang in pycountry.languages:
46
+ if not hasattr(lang, "alpha_2"):
47
+ continue
48
+
49
+ code = lang.alpha_2
50
+ likely = likely_subtags.get(code)
51
+
52
+ if likely:
53
+ try:
54
+ loc = Locale.parse(likely)
55
+ if loc.territory:
56
+ bcp47 = f"{loc.language}-{loc.territory}"
57
+ display = loc.get_display_name("en")
58
+ if bcp47 not in seen:
59
+ seen.add(bcp47)
60
+ languages.append(LanguageResponse(code=bcp47, name=display))
61
+ continue
62
+ except Exception:
63
+ pass
64
+
65
+ # Fallback: bare language code
66
+ if code not in seen:
67
+ seen.add(code)
68
+ languages.append(LanguageResponse(code=code, name=lang.name))
69
+
70
+ # 2. Add important regional variants
71
+ for locale_str in _EXTRA_VARIANTS:
72
+ try:
73
+ loc = Locale.parse(locale_str)
74
+ bcp47 = f"{loc.language}-{loc.territory}"
75
+ if bcp47 not in seen:
76
+ seen.add(bcp47)
77
+ display = loc.get_display_name("en")
78
+ languages.append(LanguageResponse(code=bcp47, name=display))
79
+ except Exception:
80
+ pass
81
+
82
+ languages.sort(key=lambda x: x.name)
83
+ return languages
api/routers/models.py ADDED
@@ -0,0 +1,776 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import traceback
3
+ from typing import Dict, List, Optional
4
+
5
+ from esperanto import AIFactory
6
+ from fastapi import APIRouter, HTTPException, Query
7
+ from loguru import logger
8
+ from pydantic import BaseModel
9
+
10
+ from api.models import (
11
+ DefaultModelsResponse,
12
+ ModelCreate,
13
+ ModelResponse,
14
+ ProviderAvailabilityResponse,
15
+ )
16
+ from open_notebook.domain.credential import Credential
17
+ from open_notebook.ai.connection_tester import test_individual_model
18
+ from open_notebook.ai.key_provider import provision_provider_keys
19
+ from open_notebook.ai.model_discovery import (
20
+ discover_provider_models,
21
+ get_provider_model_count,
22
+ sync_all_providers,
23
+ sync_provider_models,
24
+ )
25
+ from open_notebook.ai.models import DefaultModels, Model
26
+ from open_notebook.exceptions import InvalidInputError
27
+
28
+ router = APIRouter()
29
+
30
+
31
+ # =============================================================================
32
+ # Model Discovery Response Models
33
+ # =============================================================================
34
+
35
+
36
+ class DiscoveredModelResponse(BaseModel):
37
+ """Response model for a discovered model."""
38
+
39
+ name: str
40
+ provider: str
41
+ model_type: str
42
+ description: Optional[str] = None
43
+
44
+
45
+ class ProviderSyncResponse(BaseModel):
46
+ """Response model for provider sync operation."""
47
+
48
+ provider: str
49
+ discovered: int
50
+ new: int
51
+ existing: int
52
+
53
+
54
+ class AllProvidersSyncResponse(BaseModel):
55
+ """Response model for syncing all providers."""
56
+
57
+ results: Dict[str, ProviderSyncResponse]
58
+ total_discovered: int
59
+ total_new: int
60
+
61
+
62
+ class ProviderModelCountResponse(BaseModel):
63
+ """Response model for provider model counts."""
64
+
65
+ provider: str
66
+ counts: Dict[str, int]
67
+ total: int
68
+
69
+
70
+ class AutoAssignResult(BaseModel):
71
+ """Response model for auto-assign operation."""
72
+
73
+ assigned: Dict[str, str] # slot_name -> model_id
74
+ skipped: List[str] # slots already assigned
75
+ missing: List[str] # slots with no available models
76
+
77
+
78
+ class ModelTestResponse(BaseModel):
79
+ """Response model for individual model test."""
80
+
81
+ success: bool
82
+ message: str
83
+ details: Optional[str] = None
84
+
85
+
86
+ # Provider priority for auto-assignment (higher priority first)
87
+ PROVIDER_PRIORITY = [
88
+ "openai",
89
+ "anthropic",
90
+ "google",
91
+ "mistral",
92
+ "groq",
93
+ "deepseek",
94
+ "xai",
95
+ "openrouter",
96
+ "ollama",
97
+ "azure",
98
+ "openai_compatible",
99
+ "dashscope",
100
+ "minimax",
101
+ ]
102
+
103
+ # Model preference patterns (preferred models within each provider)
104
+ MODEL_PREFERENCES = {
105
+ "openai": ["gpt-4o", "gpt-4", "gpt-3.5-turbo"],
106
+ "anthropic": ["claude-3-5-sonnet", "claude-3-opus", "claude-3-sonnet"],
107
+ "google": ["gemini-2.0", "gemini-1.5-pro", "gemini-pro"],
108
+ "mistral": ["mistral-large", "mixtral"],
109
+ "groq": ["llama-3.3", "llama-3.1", "mixtral"],
110
+ "dashscope": ["qwen-max", "qwen-plus", "qwen-turbo"],
111
+ "minimax": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed"],
112
+ }
113
+
114
+
115
+ async def _check_provider_has_credential(provider: str) -> bool:
116
+ """Check if a provider has any credentials configured in the database."""
117
+ try:
118
+ credentials = await Credential.get_by_provider(provider)
119
+ return len(credentials) > 0
120
+ except Exception:
121
+ pass
122
+ return False
123
+
124
+
125
+ def _check_azure_support(mode: str) -> bool:
126
+ """
127
+ Check if Azure OpenAI provider is available for a specific mode.
128
+
129
+ Args:
130
+ mode: One of 'LLM', 'EMBEDDING', 'STT', 'TTS'
131
+
132
+ Returns:
133
+ bool: True if either generic or mode-specific env vars are set
134
+ """
135
+ # Check generic configuration (applies to all modes)
136
+ generic = (
137
+ os.environ.get("AZURE_OPENAI_API_KEY") is not None
138
+ and os.environ.get("AZURE_OPENAI_ENDPOINT") is not None
139
+ and os.environ.get("AZURE_OPENAI_API_VERSION") is not None
140
+ )
141
+
142
+ # Check mode-specific configuration (takes precedence)
143
+ specific = (
144
+ os.environ.get(f"AZURE_OPENAI_API_KEY_{mode}") is not None
145
+ and os.environ.get(f"AZURE_OPENAI_ENDPOINT_{mode}") is not None
146
+ and os.environ.get(f"AZURE_OPENAI_API_VERSION_{mode}") is not None
147
+ )
148
+
149
+ return generic or specific
150
+
151
+
152
+ def _check_openai_compatible_support(mode: str) -> bool:
153
+ """
154
+ Check if OpenAI-compatible provider is available for a specific mode.
155
+
156
+ Args:
157
+ mode: One of 'LLM', 'EMBEDDING', 'STT', 'TTS'
158
+
159
+ Returns:
160
+ bool: True if either generic or mode-specific env var is set
161
+ """
162
+ generic = os.environ.get("OPENAI_COMPATIBLE_BASE_URL") is not None
163
+ specific = os.environ.get(f"OPENAI_COMPATIBLE_BASE_URL_{mode}") is not None
164
+ generic_key = os.environ.get("OPENAI_COMPATIBLE_API_KEY") is not None
165
+ specific_key = os.environ.get(f"OPENAI_COMPATIBLE_API_KEY_{mode}") is not None
166
+ return generic or specific or generic_key or specific_key
167
+
168
+
169
+ @router.get("/models", response_model=List[ModelResponse])
170
+ async def get_models(
171
+ type: Optional[str] = Query(None, description="Filter by model type"),
172
+ ):
173
+ """Get all configured models with optional type filtering."""
174
+ try:
175
+ if type:
176
+ models = await Model.get_models_by_type(type)
177
+ else:
178
+ models = await Model.get_all()
179
+
180
+ return [
181
+ ModelResponse(
182
+ id=model.id,
183
+ name=model.name,
184
+ provider=model.provider,
185
+ type=model.type,
186
+ credential=model.credential,
187
+ created=str(model.created),
188
+ updated=str(model.updated),
189
+ )
190
+ for model in models
191
+ ]
192
+ except Exception as e:
193
+ logger.error(f"Error fetching models: {str(e)}")
194
+ raise HTTPException(status_code=500, detail=f"Error fetching models: {str(e)}")
195
+
196
+
197
+ @router.post("/models", response_model=ModelResponse)
198
+ async def create_model(model_data: ModelCreate):
199
+ """Create a new model configuration."""
200
+ try:
201
+ # Validate model type
202
+ valid_types = ["language", "embedding", "text_to_speech", "speech_to_text"]
203
+ if model_data.type not in valid_types:
204
+ raise HTTPException(
205
+ status_code=400,
206
+ detail=f"Invalid model type. Must be one of: {valid_types}",
207
+ )
208
+
209
+ # Check for duplicate model name under the same provider and type (case-insensitive)
210
+ from open_notebook.database.repository import repo_query
211
+
212
+ existing = await repo_query(
213
+ "SELECT * FROM model WHERE string::lowercase(provider) = $provider AND string::lowercase(name) = $name AND string::lowercase(type) = $type LIMIT 1",
214
+ {
215
+ "provider": model_data.provider.lower(),
216
+ "name": model_data.name.lower(),
217
+ "type": model_data.type.lower(),
218
+ },
219
+ )
220
+ if existing:
221
+ raise HTTPException(
222
+ status_code=400,
223
+ detail=f"Model '{model_data.name}' already exists for provider '{model_data.provider}' with type '{model_data.type}'",
224
+ )
225
+
226
+ new_model = Model(
227
+ name=model_data.name,
228
+ provider=model_data.provider,
229
+ type=model_data.type,
230
+ credential=model_data.credential,
231
+ )
232
+ await new_model.save()
233
+
234
+ return ModelResponse(
235
+ id=new_model.id or "",
236
+ name=new_model.name,
237
+ provider=new_model.provider,
238
+ type=new_model.type,
239
+ credential=new_model.credential,
240
+ created=str(new_model.created),
241
+ updated=str(new_model.updated),
242
+ )
243
+ except HTTPException:
244
+ raise
245
+ except InvalidInputError as e:
246
+ raise HTTPException(status_code=400, detail=str(e))
247
+ except Exception as e:
248
+ logger.error(f"Error creating model: {str(e)}")
249
+ raise HTTPException(status_code=500, detail=f"Error creating model: {str(e)}")
250
+
251
+
252
+ @router.delete("/models/{model_id}")
253
+ async def delete_model(model_id: str):
254
+ """Delete a model configuration."""
255
+ try:
256
+ model = await Model.get(model_id)
257
+ if not model:
258
+ raise HTTPException(status_code=404, detail="Model not found")
259
+
260
+ await model.delete()
261
+
262
+ return {"message": "Model deleted successfully"}
263
+ except HTTPException:
264
+ raise
265
+ except Exception as e:
266
+ logger.error(f"Error deleting model {model_id}: {str(e)}")
267
+ raise HTTPException(status_code=500, detail=f"Error deleting model: {str(e)}")
268
+
269
+
270
+ @router.post("/models/{model_id}/test", response_model=ModelTestResponse)
271
+ async def test_model(model_id: str):
272
+ """Test if a specific model is correctly configured and functional."""
273
+ try:
274
+ model = await Model.get(model_id)
275
+ if not model:
276
+ raise HTTPException(status_code=404, detail="Model not found")
277
+ except HTTPException:
278
+ raise
279
+ except Exception:
280
+ raise HTTPException(status_code=404, detail="Model not found")
281
+
282
+ try:
283
+ success, message = await test_individual_model(model)
284
+ return ModelTestResponse(success=success, message=message)
285
+ except Exception as e:
286
+ logger.error(f"Error testing model {model_id}: {traceback.format_exc()}")
287
+ return ModelTestResponse(
288
+ success=False,
289
+ message=str(e)[:200],
290
+ )
291
+
292
+
293
+ @router.get("/models/defaults", response_model=DefaultModelsResponse)
294
+ async def get_default_models():
295
+ """Get default model assignments."""
296
+ try:
297
+ defaults = await DefaultModels.get_instance()
298
+
299
+ return DefaultModelsResponse(
300
+ default_chat_model=defaults.default_chat_model, # type: ignore[attr-defined]
301
+ default_transformation_model=defaults.default_transformation_model, # type: ignore[attr-defined]
302
+ large_context_model=defaults.large_context_model, # type: ignore[attr-defined]
303
+ default_text_to_speech_model=defaults.default_text_to_speech_model, # type: ignore[attr-defined]
304
+ default_speech_to_text_model=defaults.default_speech_to_text_model, # type: ignore[attr-defined]
305
+ default_embedding_model=defaults.default_embedding_model, # type: ignore[attr-defined]
306
+ default_tools_model=defaults.default_tools_model, # type: ignore[attr-defined]
307
+ )
308
+ except Exception as e:
309
+ logger.error(f"Error fetching default models: {str(e)}")
310
+ raise HTTPException(
311
+ status_code=500, detail=f"Error fetching default models: {str(e)}"
312
+ )
313
+
314
+
315
+ @router.put("/models/defaults", response_model=DefaultModelsResponse)
316
+ async def update_default_models(defaults_data: DefaultModelsResponse):
317
+ """Update default model assignments."""
318
+ try:
319
+ defaults = await DefaultModels.get_instance()
320
+
321
+ # Update only provided fields
322
+ if defaults_data.default_chat_model is not None:
323
+ defaults.default_chat_model = defaults_data.default_chat_model # type: ignore[attr-defined]
324
+ if defaults_data.default_transformation_model is not None:
325
+ defaults.default_transformation_model = (
326
+ defaults_data.default_transformation_model
327
+ ) # type: ignore[attr-defined]
328
+ if defaults_data.large_context_model is not None:
329
+ defaults.large_context_model = defaults_data.large_context_model # type: ignore[attr-defined]
330
+ if defaults_data.default_text_to_speech_model is not None:
331
+ defaults.default_text_to_speech_model = (
332
+ defaults_data.default_text_to_speech_model
333
+ ) # type: ignore[attr-defined]
334
+ if defaults_data.default_speech_to_text_model is not None:
335
+ defaults.default_speech_to_text_model = (
336
+ defaults_data.default_speech_to_text_model
337
+ ) # type: ignore[attr-defined]
338
+ if defaults_data.default_embedding_model is not None:
339
+ defaults.default_embedding_model = defaults_data.default_embedding_model # type: ignore[attr-defined]
340
+ if defaults_data.default_tools_model is not None:
341
+ defaults.default_tools_model = defaults_data.default_tools_model # type: ignore[attr-defined]
342
+
343
+ await defaults.update()
344
+
345
+ # No cache refresh needed - next access will fetch fresh data from DB
346
+
347
+ return DefaultModelsResponse(
348
+ default_chat_model=defaults.default_chat_model, # type: ignore[attr-defined]
349
+ default_transformation_model=defaults.default_transformation_model, # type: ignore[attr-defined]
350
+ large_context_model=defaults.large_context_model, # type: ignore[attr-defined]
351
+ default_text_to_speech_model=defaults.default_text_to_speech_model, # type: ignore[attr-defined]
352
+ default_speech_to_text_model=defaults.default_speech_to_text_model, # type: ignore[attr-defined]
353
+ default_embedding_model=defaults.default_embedding_model, # type: ignore[attr-defined]
354
+ default_tools_model=defaults.default_tools_model, # type: ignore[attr-defined]
355
+ )
356
+ except HTTPException:
357
+ raise
358
+ except Exception as e:
359
+ logger.error(f"Error updating default models: {str(e)}")
360
+ raise HTTPException(
361
+ status_code=500, detail=f"Error updating default models: {str(e)}"
362
+ )
363
+
364
+
365
+ @router.get("/models/providers", response_model=ProviderAvailabilityResponse)
366
+ async def get_provider_availability():
367
+ """Get provider availability based on database config and environment variables."""
368
+ try:
369
+ # Check which providers have credentials in the database or env vars
370
+ # For each provider, check DB credentials first, then env vars as fallback
371
+
372
+ # Simple env var mapping for backward compatibility
373
+ env_var_map = {
374
+ "openai": "OPENAI_API_KEY",
375
+ "anthropic": "ANTHROPIC_API_KEY",
376
+ "google": "GOOGLE_API_KEY",
377
+ "groq": "GROQ_API_KEY",
378
+ "mistral": "MISTRAL_API_KEY",
379
+ "deepseek": "DEEPSEEK_API_KEY",
380
+ "xai": "XAI_API_KEY",
381
+ "openrouter": "OPENROUTER_API_KEY",
382
+ "voyage": "VOYAGE_API_KEY",
383
+ "elevenlabs": "ELEVENLABS_API_KEY",
384
+ "ollama": "OLLAMA_API_BASE",
385
+ "dashscope": "DASHSCOPE_API_KEY",
386
+ "minimax": "MINIMAX_API_KEY",
387
+ }
388
+
389
+ provider_status = {}
390
+
391
+ # Check simple providers: credential in DB or env var
392
+ for provider, env_var in env_var_map.items():
393
+ has_cred = await _check_provider_has_credential(provider)
394
+ has_env = os.environ.get(env_var) is not None
395
+ provider_status[provider] = has_cred or has_env
396
+
397
+ # Google also supports GEMINI_API_KEY
398
+ if not provider_status.get("google"):
399
+ provider_status["google"] = os.environ.get("GEMINI_API_KEY") is not None
400
+
401
+ # Vertex: DB credential or env vars
402
+ provider_status["vertex"] = (
403
+ await _check_provider_has_credential("vertex")
404
+ or os.environ.get("VERTEX_PROJECT") is not None
405
+ )
406
+
407
+ # Azure: DB credential or env vars
408
+ provider_status["azure"] = (
409
+ await _check_provider_has_credential("azure")
410
+ or _check_azure_support("LLM")
411
+ or _check_azure_support("EMBEDDING")
412
+ or _check_azure_support("STT")
413
+ or _check_azure_support("TTS")
414
+ )
415
+
416
+ # OpenAI-compatible: DB credential or env vars
417
+ provider_status["openai-compatible"] = (
418
+ await _check_provider_has_credential("openai_compatible")
419
+ or _check_openai_compatible_support("LLM")
420
+ or _check_openai_compatible_support("EMBEDDING")
421
+ or _check_openai_compatible_support("STT")
422
+ or _check_openai_compatible_support("TTS")
423
+ )
424
+
425
+ available_providers = [k for k, v in provider_status.items() if v]
426
+ unavailable_providers = [k for k, v in provider_status.items() if not v]
427
+
428
+ # Get supported model types from Esperanto
429
+ esperanto_available = AIFactory.get_available_providers()
430
+
431
+ # Build supported types mapping only for available providers
432
+ supported_types: dict[str, list[str]] = {}
433
+ for provider in available_providers:
434
+ supported_types[provider] = []
435
+
436
+ # Map Esperanto model types to our environment variable modes
437
+ mode_mapping = {
438
+ "language": "LLM",
439
+ "embedding": "EMBEDDING",
440
+ "speech_to_text": "STT",
441
+ "text_to_speech": "TTS",
442
+ }
443
+
444
+ # Special handling for openai-compatible to check mode-specific availability
445
+ if provider == "openai-compatible":
446
+ has_db_cred = await _check_provider_has_credential("openai_compatible")
447
+ for model_type, mode in mode_mapping.items():
448
+ if (
449
+ model_type in esperanto_available
450
+ and provider in esperanto_available[model_type]
451
+ ):
452
+ if has_db_cred or _check_openai_compatible_support(mode):
453
+ supported_types[provider].append(model_type)
454
+ # Special handling for azure to check mode-specific availability
455
+ elif provider == "azure":
456
+ has_db_cred = await _check_provider_has_credential("azure")
457
+ for model_type, mode in mode_mapping.items():
458
+ if (
459
+ model_type in esperanto_available
460
+ and provider in esperanto_available[model_type]
461
+ ):
462
+ if has_db_cred or _check_azure_support(mode):
463
+ supported_types[provider].append(model_type)
464
+ else:
465
+ # Standard provider detection
466
+ for model_type, providers in esperanto_available.items():
467
+ if provider in providers:
468
+ supported_types[provider].append(model_type)
469
+
470
+ return ProviderAvailabilityResponse(
471
+ available=available_providers,
472
+ unavailable=unavailable_providers,
473
+ supported_types=supported_types,
474
+ )
475
+ except Exception as e:
476
+ logger.error(f"Error checking provider availability: {str(e)}")
477
+ raise HTTPException(
478
+ status_code=500, detail=f"Error checking provider availability: {str(e)}"
479
+ )
480
+
481
+
482
+ # =============================================================================
483
+ # Model Discovery Endpoints
484
+ # =============================================================================
485
+
486
+
487
+ @router.get(
488
+ "/models/discover/{provider}", response_model=List[DiscoveredModelResponse]
489
+ )
490
+ async def discover_models(provider: str):
491
+ """
492
+ Discover available models from a provider without registering them.
493
+
494
+ This endpoint queries the provider's API to list available models
495
+ but does not save them to the database. Use the sync endpoint
496
+ to both discover and register models.
497
+ """
498
+ try:
499
+ # Provision DB-stored credentials into env vars before discovery
500
+ await provision_provider_keys(provider)
501
+ discovered = await discover_provider_models(provider)
502
+ return [
503
+ DiscoveredModelResponse(
504
+ name=m.name,
505
+ provider=m.provider,
506
+ model_type=m.model_type,
507
+ description=m.description,
508
+ )
509
+ for m in discovered
510
+ ]
511
+ except Exception as e:
512
+ logger.error(f"Error discovering models for {provider}: {str(e)}")
513
+ raise HTTPException(
514
+ status_code=500, detail="Error discovering models. Check server logs for details."
515
+ )
516
+
517
+
518
+ @router.post("/models/sync/{provider}", response_model=ProviderSyncResponse)
519
+ async def sync_models(provider: str):
520
+ """
521
+ Sync models for a specific provider.
522
+
523
+ Discovers available models from the provider's API and registers
524
+ any new models in the database. Existing models are skipped.
525
+
526
+ Returns counts of discovered, new, and existing models.
527
+ """
528
+ try:
529
+ # Provision DB-stored credentials into env vars before discovery
530
+ await provision_provider_keys(provider)
531
+ discovered, new, existing = await sync_provider_models(
532
+ provider, auto_register=True
533
+ )
534
+ return ProviderSyncResponse(
535
+ provider=provider,
536
+ discovered=discovered,
537
+ new=new,
538
+ existing=existing,
539
+ )
540
+ except Exception as e:
541
+ logger.error(f"Error syncing models for {provider}: {str(e)}")
542
+ raise HTTPException(status_code=500, detail="Error syncing models. Check server logs for details.")
543
+
544
+
545
+ @router.post("/models/sync", response_model=AllProvidersSyncResponse)
546
+ async def sync_all_models():
547
+ """
548
+ Sync models for all configured providers.
549
+
550
+ Discovers and registers models from all providers that have
551
+ valid API keys configured. This is useful for initial setup
552
+ or periodic refresh of available models.
553
+ """
554
+ try:
555
+ results = await sync_all_providers()
556
+
557
+ response_results = {}
558
+ total_discovered = 0
559
+ total_new = 0
560
+
561
+ for provider, (discovered, new, existing) in results.items():
562
+ response_results[provider] = ProviderSyncResponse(
563
+ provider=provider,
564
+ discovered=discovered,
565
+ new=new,
566
+ existing=existing,
567
+ )
568
+ total_discovered += discovered
569
+ total_new += new
570
+
571
+ return AllProvidersSyncResponse(
572
+ results=response_results,
573
+ total_discovered=total_discovered,
574
+ total_new=total_new,
575
+ )
576
+ except Exception as e:
577
+ logger.error(f"Error syncing all models: {str(e)}")
578
+ raise HTTPException(
579
+ status_code=500, detail=f"Error syncing all models: {str(e)}"
580
+ )
581
+
582
+
583
+ @router.get("/models/count/{provider}", response_model=ProviderModelCountResponse)
584
+ async def get_model_count(provider: str):
585
+ """
586
+ Get count of registered models for a provider, grouped by type.
587
+
588
+ Returns counts for each model type (language, embedding,
589
+ speech_to_text, text_to_speech) as well as total count.
590
+ """
591
+ try:
592
+ counts = await get_provider_model_count(provider)
593
+ total = sum(counts.values())
594
+ return ProviderModelCountResponse(
595
+ provider=provider,
596
+ counts=counts,
597
+ total=total,
598
+ )
599
+ except Exception as e:
600
+ logger.error(f"Error getting model count for {provider}: {str(e)}")
601
+ raise HTTPException(
602
+ status_code=500, detail=f"Error getting model count: {str(e)}"
603
+ )
604
+
605
+
606
+ @router.get("/models/by-provider/{provider}", response_model=List[ModelResponse])
607
+ async def get_models_by_provider(provider: str):
608
+ """
609
+ Get all registered models for a specific provider.
610
+
611
+ Returns models from the database that belong to the specified provider.
612
+ """
613
+ try:
614
+ from open_notebook.database.repository import repo_query
615
+
616
+ models = await repo_query(
617
+ "SELECT * FROM model WHERE provider = $provider ORDER BY type, name",
618
+ {"provider": provider},
619
+ )
620
+
621
+ return [
622
+ ModelResponse(
623
+ id=model.get("id", ""),
624
+ name=model.get("name", ""),
625
+ provider=model.get("provider", ""),
626
+ type=model.get("type", ""),
627
+ credential=model.get("credential"),
628
+ created=str(model.get("created", "")),
629
+ updated=str(model.get("updated", "")),
630
+ )
631
+ for model in models
632
+ ]
633
+ except Exception as e:
634
+ logger.error(f"Error fetching models for {provider}: {str(e)}")
635
+ raise HTTPException(
636
+ status_code=500, detail=f"Error fetching models: {str(e)}"
637
+ )
638
+
639
+
640
+ def _get_preferred_model(
641
+ models: List[Dict], provider_priority: List[str], model_preferences: Dict
642
+ ) -> Optional[Dict]:
643
+ """
644
+ Select the best model from a list based on provider priority and model preferences.
645
+
646
+ Args:
647
+ models: List of model dictionaries with 'provider', 'name', 'id' keys
648
+ provider_priority: List of providers in preference order
649
+ model_preferences: Dict mapping provider to list of preferred model name patterns
650
+
651
+ Returns:
652
+ The best model dict, or None if no models available
653
+ """
654
+ if not models:
655
+ return None
656
+
657
+ # Group models by provider
658
+ by_provider: Dict[str, List[Dict]] = {}
659
+ for model in models:
660
+ provider = model.get("provider", "")
661
+ if provider not in by_provider:
662
+ by_provider[provider] = []
663
+ by_provider[provider].append(model)
664
+
665
+ # Find first provider with models (in priority order)
666
+ for provider in provider_priority:
667
+ if provider in by_provider:
668
+ provider_models = by_provider[provider]
669
+
670
+ # Check for preferred models within this provider
671
+ if provider in model_preferences:
672
+ for preference in model_preferences[provider]:
673
+ for model in provider_models:
674
+ if preference.lower() in model.get("name", "").lower():
675
+ return model
676
+
677
+ # Fall back to first model from this provider
678
+ return provider_models[0]
679
+
680
+ # Fall back to first model from any provider
681
+ return models[0] if models else None
682
+
683
+
684
+ @router.post("/models/auto-assign", response_model=AutoAssignResult)
685
+ async def auto_assign_defaults():
686
+ """
687
+ Auto-assign default models based on available models.
688
+
689
+ This endpoint intelligently assigns the first available model of each
690
+ required type to the corresponding default slot. It uses provider
691
+ priority (preferring premium providers like OpenAI, Anthropic) and
692
+ model preferences within each provider.
693
+
694
+ Returns:
695
+ - assigned: Dict of slot names to assigned model IDs
696
+ - skipped: List of slots that already have models assigned
697
+ - missing: List of slots with no available models
698
+ """
699
+ try:
700
+ from open_notebook.database.repository import repo_query
701
+
702
+ # Get current defaults
703
+ defaults = await DefaultModels.get_instance()
704
+
705
+ # Get all models grouped by type
706
+ all_models = await repo_query(
707
+ "SELECT * FROM model ORDER BY provider, name",
708
+ {},
709
+ )
710
+
711
+ # Group models by type
712
+ models_by_type: Dict[str, List[Dict]] = {
713
+ "language": [],
714
+ "embedding": [],
715
+ "text_to_speech": [],
716
+ "speech_to_text": [],
717
+ }
718
+
719
+ for model in all_models:
720
+ model_type = model.get("type", "")
721
+ if model_type in models_by_type:
722
+ models_by_type[model_type].append(model)
723
+
724
+ # Define slot configuration: (slot_name, model_type, current_value)
725
+ slot_configs = [
726
+ ("default_chat_model", "language", defaults.default_chat_model), # type: ignore[attr-defined]
727
+ ("default_transformation_model", "language", defaults.default_transformation_model), # type: ignore[attr-defined]
728
+ ("default_tools_model", "language", defaults.default_tools_model), # type: ignore[attr-defined]
729
+ ("large_context_model", "language", defaults.large_context_model), # type: ignore[attr-defined]
730
+ ("default_embedding_model", "embedding", defaults.default_embedding_model), # type: ignore[attr-defined]
731
+ ("default_text_to_speech_model", "text_to_speech", defaults.default_text_to_speech_model), # type: ignore[attr-defined]
732
+ ("default_speech_to_text_model", "speech_to_text", defaults.default_speech_to_text_model), # type: ignore[attr-defined]
733
+ ]
734
+
735
+ assigned: Dict[str, str] = {}
736
+ skipped: List[str] = []
737
+ missing: List[str] = []
738
+
739
+ for slot_name, model_type, current_value in slot_configs:
740
+ if current_value:
741
+ # Slot already has a value
742
+ skipped.append(slot_name)
743
+ continue
744
+
745
+ available_models = models_by_type.get(model_type, [])
746
+ if not available_models:
747
+ # No models of this type available
748
+ missing.append(slot_name)
749
+ continue
750
+
751
+ # Select best model for this slot
752
+ best_model = _get_preferred_model(
753
+ available_models, PROVIDER_PRIORITY, MODEL_PREFERENCES
754
+ )
755
+
756
+ if best_model:
757
+ model_id = best_model.get("id", "")
758
+ assigned[slot_name] = model_id
759
+ # Update the defaults object
760
+ setattr(defaults, slot_name, model_id)
761
+
762
+ # Save updated defaults if any assignments were made
763
+ if assigned:
764
+ await defaults.update()
765
+
766
+ return AutoAssignResult(
767
+ assigned=assigned,
768
+ skipped=skipped,
769
+ missing=missing,
770
+ )
771
+
772
+ except Exception as e:
773
+ logger.error(f"Error auto-assigning defaults: {str(e)}")
774
+ raise HTTPException(
775
+ status_code=500, detail=f"Error auto-assigning defaults: {str(e)}"
776
+ )
api/routers/notebooks.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+
3
+ from fastapi import APIRouter, HTTPException, Query
4
+ from loguru import logger
5
+
6
+ from api.models import (
7
+ NotebookCreate,
8
+ NotebookDeletePreview,
9
+ NotebookDeleteResponse,
10
+ NotebookResponse,
11
+ NotebookUpdate,
12
+ )
13
+ from open_notebook.database.repository import ensure_record_id, repo_query
14
+ from open_notebook.domain.notebook import Notebook, Source
15
+ from open_notebook.exceptions import InvalidInputError
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ @router.get("/notebooks", response_model=List[NotebookResponse])
21
+ async def get_notebooks(
22
+ archived: Optional[bool] = Query(None, description="Filter by archived status"),
23
+ order_by: str = Query("updated desc", description="Order by field and direction"),
24
+ ):
25
+ """Get all notebooks with optional filtering and ordering."""
26
+ try:
27
+ # Validate order_by against allowlist to prevent SurrealQL injection
28
+ allowed_fields = {"name", "created", "updated"}
29
+ allowed_directions = {"asc", "desc"}
30
+
31
+ parts = order_by.strip().lower().split()
32
+ if len(parts) == 1:
33
+ if parts[0] not in allowed_fields:
34
+ raise HTTPException(
35
+ status_code=400,
36
+ detail=f"Invalid order_by field: '{order_by}'. Allowed fields: {', '.join(sorted(allowed_fields))}",
37
+ )
38
+ validated_order_by = parts[0]
39
+ elif len(parts) == 2:
40
+ if parts[0] not in allowed_fields or parts[1] not in allowed_directions:
41
+ raise HTTPException(
42
+ status_code=400,
43
+ detail=f"Invalid order_by: '{order_by}'. Allowed fields: {', '.join(sorted(allowed_fields))}. Allowed directions: asc, desc",
44
+ )
45
+ validated_order_by = f"{parts[0]} {parts[1]}"
46
+ else:
47
+ raise HTTPException(
48
+ status_code=400,
49
+ detail=f"Invalid order_by format: '{order_by}'. Expected 'field' or 'field direction'",
50
+ )
51
+
52
+ # Build the query with counts
53
+ query = f"""
54
+ SELECT *,
55
+ count(<-reference.in) as source_count,
56
+ count(<-artifact.in) as note_count
57
+ FROM notebook
58
+ ORDER BY {validated_order_by}
59
+ """
60
+
61
+ result = await repo_query(query)
62
+
63
+ # Filter by archived status if specified
64
+ if archived is not None:
65
+ result = [nb for nb in result if nb.get("archived") == archived]
66
+
67
+ return [
68
+ NotebookResponse(
69
+ id=str(nb.get("id", "")),
70
+ name=nb.get("name", ""),
71
+ description=nb.get("description", ""),
72
+ archived=nb.get("archived", False),
73
+ created=str(nb.get("created", "")),
74
+ updated=str(nb.get("updated", "")),
75
+ source_count=nb.get("source_count", 0),
76
+ note_count=nb.get("note_count", 0),
77
+ )
78
+ for nb in result
79
+ ]
80
+ except HTTPException:
81
+ raise
82
+ except Exception as e:
83
+ logger.error(f"Error fetching notebooks: {str(e)}")
84
+ raise HTTPException(
85
+ status_code=500, detail=f"Error fetching notebooks: {str(e)}"
86
+ )
87
+
88
+
89
+ @router.post("/notebooks", response_model=NotebookResponse)
90
+ async def create_notebook(notebook: NotebookCreate):
91
+ """Create a new notebook."""
92
+ try:
93
+ new_notebook = Notebook(
94
+ name=notebook.name,
95
+ description=notebook.description,
96
+ )
97
+ await new_notebook.save()
98
+
99
+ return NotebookResponse(
100
+ id=new_notebook.id or "",
101
+ name=new_notebook.name,
102
+ description=new_notebook.description,
103
+ archived=new_notebook.archived or False,
104
+ created=str(new_notebook.created),
105
+ updated=str(new_notebook.updated),
106
+ source_count=0, # New notebook has no sources
107
+ note_count=0, # New notebook has no notes
108
+ )
109
+ except InvalidInputError as e:
110
+ raise HTTPException(status_code=400, detail=str(e))
111
+ except Exception as e:
112
+ logger.error(f"Error creating notebook: {str(e)}")
113
+ raise HTTPException(
114
+ status_code=500, detail=f"Error creating notebook: {str(e)}"
115
+ )
116
+
117
+
118
+ @router.get(
119
+ "/notebooks/{notebook_id}/delete-preview", response_model=NotebookDeletePreview
120
+ )
121
+ async def get_notebook_delete_preview(notebook_id: str):
122
+ """Get a preview of what will be deleted when this notebook is deleted."""
123
+ try:
124
+ notebook = await Notebook.get(notebook_id)
125
+ if not notebook:
126
+ raise HTTPException(status_code=404, detail="Notebook not found")
127
+
128
+ preview = await notebook.get_delete_preview()
129
+
130
+ return NotebookDeletePreview(
131
+ notebook_id=str(notebook.id),
132
+ notebook_name=notebook.name,
133
+ note_count=preview["note_count"],
134
+ exclusive_source_count=preview["exclusive_source_count"],
135
+ shared_source_count=preview["shared_source_count"],
136
+ )
137
+ except HTTPException:
138
+ raise
139
+ except Exception as e:
140
+ logger.error(f"Error getting delete preview for notebook {notebook_id}: {e}")
141
+ raise HTTPException(
142
+ status_code=500,
143
+ detail=f"Error fetching notebook deletion preview: {str(e)}",
144
+ )
145
+
146
+
147
+ @router.get("/notebooks/{notebook_id}", response_model=NotebookResponse)
148
+ async def get_notebook(notebook_id: str):
149
+ """Get a specific notebook by ID."""
150
+ try:
151
+ # Query with counts for single notebook
152
+ query = """
153
+ SELECT *,
154
+ count(<-reference.in) as source_count,
155
+ count(<-artifact.in) as note_count
156
+ FROM $notebook_id
157
+ """
158
+ result = await repo_query(query, {"notebook_id": ensure_record_id(notebook_id)})
159
+
160
+ if not result:
161
+ raise HTTPException(status_code=404, detail="Notebook not found")
162
+
163
+ nb = result[0]
164
+ return NotebookResponse(
165
+ id=str(nb.get("id", "")),
166
+ name=nb.get("name", ""),
167
+ description=nb.get("description", ""),
168
+ archived=nb.get("archived", False),
169
+ created=str(nb.get("created", "")),
170
+ updated=str(nb.get("updated", "")),
171
+ source_count=nb.get("source_count", 0),
172
+ note_count=nb.get("note_count", 0),
173
+ )
174
+ except HTTPException:
175
+ raise
176
+ except Exception as e:
177
+ logger.error(f"Error fetching notebook {notebook_id}: {str(e)}")
178
+ raise HTTPException(
179
+ status_code=500, detail=f"Error fetching notebook: {str(e)}"
180
+ )
181
+
182
+
183
+ @router.put("/notebooks/{notebook_id}", response_model=NotebookResponse)
184
+ async def update_notebook(notebook_id: str, notebook_update: NotebookUpdate):
185
+ """Update a notebook."""
186
+ try:
187
+ notebook = await Notebook.get(notebook_id)
188
+ if not notebook:
189
+ raise HTTPException(status_code=404, detail="Notebook not found")
190
+
191
+ # Update only provided fields
192
+ if notebook_update.name is not None:
193
+ notebook.name = notebook_update.name
194
+ if notebook_update.description is not None:
195
+ notebook.description = notebook_update.description
196
+ if notebook_update.archived is not None:
197
+ notebook.archived = notebook_update.archived
198
+
199
+ await notebook.save()
200
+
201
+ # Query with counts after update
202
+ query = """
203
+ SELECT *,
204
+ count(<-reference.in) as source_count,
205
+ count(<-artifact.in) as note_count
206
+ FROM $notebook_id
207
+ """
208
+ result = await repo_query(query, {"notebook_id": ensure_record_id(notebook_id)})
209
+
210
+ if result:
211
+ nb = result[0]
212
+ return NotebookResponse(
213
+ id=str(nb.get("id", "")),
214
+ name=nb.get("name", ""),
215
+ description=nb.get("description", ""),
216
+ archived=nb.get("archived", False),
217
+ created=str(nb.get("created", "")),
218
+ updated=str(nb.get("updated", "")),
219
+ source_count=nb.get("source_count", 0),
220
+ note_count=nb.get("note_count", 0),
221
+ )
222
+
223
+ # Fallback if query fails
224
+ return NotebookResponse(
225
+ id=notebook.id or "",
226
+ name=notebook.name,
227
+ description=notebook.description,
228
+ archived=notebook.archived or False,
229
+ created=str(notebook.created),
230
+ updated=str(notebook.updated),
231
+ source_count=0,
232
+ note_count=0,
233
+ )
234
+ except HTTPException:
235
+ raise
236
+ except InvalidInputError as e:
237
+ raise HTTPException(status_code=400, detail=str(e))
238
+ except Exception as e:
239
+ logger.error(f"Error updating notebook {notebook_id}: {str(e)}")
240
+ raise HTTPException(
241
+ status_code=500, detail=f"Error updating notebook: {str(e)}"
242
+ )
243
+
244
+
245
+ @router.post("/notebooks/{notebook_id}/sources/{source_id}")
246
+ async def add_source_to_notebook(notebook_id: str, source_id: str):
247
+ """Add an existing source to a notebook (create the reference)."""
248
+ try:
249
+ # Check if notebook exists
250
+ notebook = await Notebook.get(notebook_id)
251
+ if not notebook:
252
+ raise HTTPException(status_code=404, detail="Notebook not found")
253
+
254
+ # Check if source exists
255
+ source = await Source.get(source_id)
256
+ if not source:
257
+ raise HTTPException(status_code=404, detail="Source not found")
258
+
259
+ # Check if reference already exists (idempotency)
260
+ existing_ref = await repo_query(
261
+ "SELECT * FROM reference WHERE out = $source_id AND in = $notebook_id",
262
+ {
263
+ "notebook_id": ensure_record_id(notebook_id),
264
+ "source_id": ensure_record_id(source_id),
265
+ },
266
+ )
267
+
268
+ # If reference doesn't exist, create it
269
+ if not existing_ref:
270
+ await repo_query(
271
+ "RELATE $source_id->reference->$notebook_id",
272
+ {
273
+ "notebook_id": ensure_record_id(notebook_id),
274
+ "source_id": ensure_record_id(source_id),
275
+ },
276
+ )
277
+
278
+ return {"message": "Source linked to notebook successfully"}
279
+ except HTTPException:
280
+ raise
281
+ except Exception as e:
282
+ logger.error(
283
+ f"Error linking source {source_id} to notebook {notebook_id}: {str(e)}"
284
+ )
285
+ raise HTTPException(
286
+ status_code=500, detail=f"Error linking source to notebook: {str(e)}"
287
+ )
288
+
289
+
290
+ @router.delete("/notebooks/{notebook_id}/sources/{source_id}")
291
+ async def remove_source_from_notebook(notebook_id: str, source_id: str):
292
+ """Remove a source from a notebook (delete the reference)."""
293
+ try:
294
+ # Check if notebook exists
295
+ notebook = await Notebook.get(notebook_id)
296
+ if not notebook:
297
+ raise HTTPException(status_code=404, detail="Notebook not found")
298
+
299
+ # Delete the reference record linking source to notebook
300
+ await repo_query(
301
+ "DELETE FROM reference WHERE out = $notebook_id AND in = $source_id",
302
+ {
303
+ "notebook_id": ensure_record_id(notebook_id),
304
+ "source_id": ensure_record_id(source_id),
305
+ },
306
+ )
307
+
308
+ return {"message": "Source removed from notebook successfully"}
309
+ except HTTPException:
310
+ raise
311
+ except Exception as e:
312
+ logger.error(
313
+ f"Error removing source {source_id} from notebook {notebook_id}: {str(e)}"
314
+ )
315
+ raise HTTPException(
316
+ status_code=500, detail=f"Error removing source from notebook: {str(e)}"
317
+ )
318
+
319
+
320
+ @router.delete("/notebooks/{notebook_id}", response_model=NotebookDeleteResponse)
321
+ async def delete_notebook(
322
+ notebook_id: str,
323
+ delete_exclusive_sources: bool = Query(
324
+ False,
325
+ description="Whether to delete sources that belong only to this notebook",
326
+ ),
327
+ ):
328
+ """
329
+ Delete a notebook with cascade deletion.
330
+
331
+ Always deletes all notes associated with the notebook.
332
+ If delete_exclusive_sources is True, also deletes sources that belong only
333
+ to this notebook (not linked to any other notebooks).
334
+ """
335
+ try:
336
+ notebook = await Notebook.get(notebook_id)
337
+ if not notebook:
338
+ raise HTTPException(status_code=404, detail="Notebook not found")
339
+
340
+ result = await notebook.delete(delete_exclusive_sources=delete_exclusive_sources)
341
+
342
+ return NotebookDeleteResponse(
343
+ message="Notebook deleted successfully",
344
+ deleted_notes=result["deleted_notes"],
345
+ deleted_sources=result["deleted_sources"],
346
+ unlinked_sources=result["unlinked_sources"],
347
+ )
348
+ except HTTPException:
349
+ raise
350
+ except Exception as e:
351
+ logger.error(f"Error deleting notebook {notebook_id}: {str(e)}")
352
+ raise HTTPException(
353
+ status_code=500, detail=f"Error deleting notebook: {str(e)}"
354
+ )
api/routers/notes.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Literal, Optional
2
+
3
+ from fastapi import APIRouter, HTTPException, Query
4
+ from loguru import logger
5
+
6
+ from api.models import NoteCreate, NoteResponse, NoteUpdate
7
+ from open_notebook.domain.notebook import Note
8
+ from open_notebook.exceptions import InvalidInputError
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("/notes", response_model=List[NoteResponse])
14
+ async def get_notes(
15
+ notebook_id: Optional[str] = Query(None, description="Filter by notebook ID"),
16
+ ):
17
+ """Get all notes with optional notebook filtering."""
18
+ try:
19
+ if notebook_id:
20
+ # Get notes for a specific notebook
21
+ from open_notebook.domain.notebook import Notebook
22
+
23
+ notebook = await Notebook.get(notebook_id)
24
+ if not notebook:
25
+ raise HTTPException(status_code=404, detail="Notebook not found")
26
+ notes = await notebook.get_notes()
27
+ else:
28
+ # Get all notes
29
+ notes = await Note.get_all(order_by="updated desc")
30
+
31
+ return [
32
+ NoteResponse(
33
+ id=note.id or "",
34
+ title=note.title,
35
+ content=note.content,
36
+ note_type=note.note_type,
37
+ created=str(note.created),
38
+ updated=str(note.updated),
39
+ )
40
+ for note in notes
41
+ ]
42
+ except HTTPException:
43
+ raise
44
+ except Exception as e:
45
+ logger.error(f"Error fetching notes: {str(e)}")
46
+ raise HTTPException(status_code=500, detail=f"Error fetching notes: {str(e)}")
47
+
48
+
49
+ @router.post("/notes", response_model=NoteResponse)
50
+ async def create_note(note_data: NoteCreate):
51
+ """Create a new note."""
52
+ try:
53
+ # Auto-generate title if not provided and it's an AI note
54
+ title = note_data.title
55
+ if not title and note_data.note_type == "ai" and note_data.content:
56
+ from open_notebook.graphs.prompt import graph as prompt_graph
57
+
58
+ prompt = "Based on the Note below, please provide a Title for this content, with max 15 words"
59
+ result = await prompt_graph.ainvoke(
60
+ { # type: ignore[arg-type]
61
+ "input_text": note_data.content,
62
+ "prompt": prompt,
63
+ }
64
+ )
65
+ title = result.get("output", "Untitled Note")
66
+
67
+ # Validate note_type
68
+ note_type: Optional[Literal["human", "ai"]] = None
69
+ if note_data.note_type in ("human", "ai"):
70
+ note_type = note_data.note_type # type: ignore[assignment]
71
+ elif note_data.note_type is not None:
72
+ raise HTTPException(
73
+ status_code=400, detail="note_type must be 'human' or 'ai'"
74
+ )
75
+
76
+ new_note = Note(
77
+ title=title,
78
+ content=note_data.content,
79
+ note_type=note_type,
80
+ )
81
+ command_id = await new_note.save()
82
+
83
+ # Add to notebook if specified
84
+ if note_data.notebook_id:
85
+ from open_notebook.domain.notebook import Notebook
86
+
87
+ notebook = await Notebook.get(note_data.notebook_id)
88
+ if not notebook:
89
+ raise HTTPException(status_code=404, detail="Notebook not found")
90
+ await new_note.add_to_notebook(note_data.notebook_id)
91
+
92
+ return NoteResponse(
93
+ id=new_note.id or "",
94
+ title=new_note.title,
95
+ content=new_note.content,
96
+ note_type=new_note.note_type,
97
+ created=str(new_note.created),
98
+ updated=str(new_note.updated),
99
+ command_id=str(command_id) if command_id else None,
100
+ )
101
+ except HTTPException:
102
+ raise
103
+ except InvalidInputError as e:
104
+ raise HTTPException(status_code=400, detail=str(e))
105
+ except Exception as e:
106
+ logger.error(f"Error creating note: {str(e)}")
107
+ raise HTTPException(status_code=500, detail=f"Error creating note: {str(e)}")
108
+
109
+
110
+ @router.get("/notes/{note_id}", response_model=NoteResponse)
111
+ async def get_note(note_id: str):
112
+ """Get a specific note by ID."""
113
+ try:
114
+ note = await Note.get(note_id)
115
+ if not note:
116
+ raise HTTPException(status_code=404, detail="Note not found")
117
+
118
+ return NoteResponse(
119
+ id=note.id or "",
120
+ title=note.title,
121
+ content=note.content,
122
+ note_type=note.note_type,
123
+ created=str(note.created),
124
+ updated=str(note.updated),
125
+ )
126
+ except HTTPException:
127
+ raise
128
+ except Exception as e:
129
+ logger.error(f"Error fetching note {note_id}: {str(e)}")
130
+ raise HTTPException(status_code=500, detail=f"Error fetching note: {str(e)}")
131
+
132
+
133
+ @router.put("/notes/{note_id}", response_model=NoteResponse)
134
+ async def update_note(note_id: str, note_update: NoteUpdate):
135
+ """Update a note."""
136
+ try:
137
+ note = await Note.get(note_id)
138
+ if not note:
139
+ raise HTTPException(status_code=404, detail="Note not found")
140
+
141
+ # Update only provided fields
142
+ if note_update.title is not None:
143
+ note.title = note_update.title
144
+ if note_update.content is not None:
145
+ note.content = note_update.content
146
+ if note_update.note_type is not None:
147
+ if note_update.note_type in ("human", "ai"):
148
+ note.note_type = note_update.note_type # type: ignore[assignment]
149
+ else:
150
+ raise HTTPException(
151
+ status_code=400, detail="note_type must be 'human' or 'ai'"
152
+ )
153
+
154
+ command_id = await note.save()
155
+
156
+ return NoteResponse(
157
+ id=note.id or "",
158
+ title=note.title,
159
+ content=note.content,
160
+ note_type=note.note_type,
161
+ created=str(note.created),
162
+ updated=str(note.updated),
163
+ command_id=str(command_id) if command_id else None,
164
+ )
165
+ except HTTPException:
166
+ raise
167
+ except InvalidInputError as e:
168
+ raise HTTPException(status_code=400, detail=str(e))
169
+ except Exception as e:
170
+ logger.error(f"Error updating note {note_id}: {str(e)}")
171
+ raise HTTPException(status_code=500, detail=f"Error updating note: {str(e)}")
172
+
173
+
174
+ @router.delete("/notes/{note_id}")
175
+ async def delete_note(note_id: str):
176
+ """Delete a note."""
177
+ try:
178
+ note = await Note.get(note_id)
179
+ if not note:
180
+ raise HTTPException(status_code=404, detail="Note not found")
181
+
182
+ await note.delete()
183
+
184
+ return {"message": "Note deleted successfully"}
185
+ except HTTPException:
186
+ raise
187
+ except Exception as e:
188
+ logger.error(f"Error deleting note {note_id}: {str(e)}")
189
+ raise HTTPException(status_code=500, detail=f"Error deleting note: {str(e)}")
api/routers/podcasts.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from typing import List, Optional
3
+ from urllib.parse import unquote, urlparse
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from fastapi.responses import FileResponse
7
+ from loguru import logger
8
+ from pydantic import BaseModel
9
+
10
+ from api.podcast_service import (
11
+ PodcastGenerationRequest,
12
+ PodcastGenerationResponse,
13
+ PodcastService,
14
+ )
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ class PodcastEpisodeResponse(BaseModel):
20
+ id: str
21
+ name: str
22
+ episode_profile: dict
23
+ speaker_profile: dict
24
+ briefing: str
25
+ audio_file: Optional[str] = None
26
+ audio_url: Optional[str] = None
27
+ transcript: Optional[dict] = None
28
+ outline: Optional[dict] = None
29
+ created: Optional[str] = None
30
+ job_status: Optional[str] = None
31
+ error_message: Optional[str] = None
32
+
33
+
34
+ def _resolve_audio_path(audio_file: str) -> Path:
35
+ if audio_file.startswith("file://"):
36
+ parsed = urlparse(audio_file)
37
+ return Path(unquote(parsed.path))
38
+ return Path(audio_file)
39
+
40
+
41
+ @router.post("/podcasts/generate", response_model=PodcastGenerationResponse)
42
+ async def generate_podcast(request: PodcastGenerationRequest):
43
+ """
44
+ Generate a podcast episode using Episode Profiles.
45
+ Returns immediately with job ID for status tracking.
46
+ """
47
+ try:
48
+ job_id = await PodcastService.submit_generation_job(
49
+ episode_profile_name=request.episode_profile,
50
+ speaker_profile_name=request.speaker_profile,
51
+ episode_name=request.episode_name,
52
+ notebook_id=request.notebook_id,
53
+ content=request.content,
54
+ briefing_suffix=request.briefing_suffix,
55
+ )
56
+
57
+ return PodcastGenerationResponse(
58
+ job_id=job_id,
59
+ status="submitted",
60
+ message=f"Podcast generation started for episode '{request.episode_name}'",
61
+ episode_profile=request.episode_profile,
62
+ episode_name=request.episode_name,
63
+ )
64
+
65
+ except Exception as e:
66
+ logger.error(f"Error generating podcast: {str(e)}")
67
+ raise HTTPException(
68
+ status_code=500, detail="Failed to generate podcast"
69
+ )
70
+
71
+
72
+ @router.get("/podcasts/jobs/{job_id}")
73
+ async def get_podcast_job_status(job_id: str):
74
+ """Get the status of a podcast generation job"""
75
+ try:
76
+ status_data = await PodcastService.get_job_status(job_id)
77
+ return status_data
78
+
79
+ except Exception as e:
80
+ logger.error(f"Error fetching podcast job status: {str(e)}")
81
+ raise HTTPException(
82
+ status_code=500, detail="Failed to fetch job status"
83
+ )
84
+
85
+
86
+ @router.get("/podcasts/episodes", response_model=List[PodcastEpisodeResponse])
87
+ async def list_podcast_episodes():
88
+ """List all podcast episodes"""
89
+ try:
90
+ episodes = await PodcastService.list_episodes()
91
+
92
+ response_episodes = []
93
+ for episode in episodes:
94
+ # Skip incomplete episodes without command or audio
95
+ if not episode.command and not episode.audio_file:
96
+ continue
97
+
98
+ # Get job status and error message if available
99
+ job_status = None
100
+ error_message = None
101
+ if episode.command:
102
+ try:
103
+ detail = await episode.get_job_detail()
104
+ job_status = detail["status"]
105
+ error_message = detail["error_message"]
106
+ except Exception:
107
+ job_status = "unknown"
108
+ else:
109
+ # No command but has audio file = completed import
110
+ job_status = "completed"
111
+
112
+ audio_url = None
113
+ if episode.audio_file:
114
+ audio_path = _resolve_audio_path(episode.audio_file)
115
+ if audio_path.exists():
116
+ audio_url = f"/api/podcasts/episodes/{episode.id}/audio"
117
+
118
+ response_episodes.append(
119
+ PodcastEpisodeResponse(
120
+ id=str(episode.id),
121
+ name=episode.name,
122
+ episode_profile=episode.episode_profile,
123
+ speaker_profile=episode.speaker_profile,
124
+ briefing=episode.briefing,
125
+ audio_file=episode.audio_file,
126
+ audio_url=audio_url,
127
+ transcript=episode.transcript,
128
+ outline=episode.outline,
129
+ created=str(episode.created) if episode.created else None,
130
+ job_status=job_status,
131
+ error_message=error_message,
132
+ )
133
+ )
134
+
135
+ return response_episodes
136
+
137
+ except Exception as e:
138
+ logger.error(f"Error listing podcast episodes: {str(e)}")
139
+ raise HTTPException(
140
+ status_code=500, detail="Failed to list podcast episodes"
141
+ )
142
+
143
+
144
+ @router.get("/podcasts/episodes/{episode_id}", response_model=PodcastEpisodeResponse)
145
+ async def get_podcast_episode(episode_id: str):
146
+ """Get a specific podcast episode"""
147
+ try:
148
+ episode = await PodcastService.get_episode(episode_id)
149
+
150
+ # Get job status and error message if available
151
+ job_status = None
152
+ error_message = None
153
+ if episode.command:
154
+ try:
155
+ detail = await episode.get_job_detail()
156
+ job_status = detail["status"]
157
+ error_message = detail["error_message"]
158
+ except Exception:
159
+ job_status = "unknown"
160
+ else:
161
+ # No command but has audio file = completed import
162
+ job_status = "completed" if episode.audio_file else "unknown"
163
+
164
+ audio_url = None
165
+ if episode.audio_file:
166
+ audio_path = _resolve_audio_path(episode.audio_file)
167
+ if audio_path.exists():
168
+ audio_url = f"/api/podcasts/episodes/{episode.id}/audio"
169
+
170
+ return PodcastEpisodeResponse(
171
+ id=str(episode.id),
172
+ name=episode.name,
173
+ episode_profile=episode.episode_profile,
174
+ speaker_profile=episode.speaker_profile,
175
+ briefing=episode.briefing,
176
+ audio_file=episode.audio_file,
177
+ audio_url=audio_url,
178
+ transcript=episode.transcript,
179
+ outline=episode.outline,
180
+ created=str(episode.created) if episode.created else None,
181
+ job_status=job_status,
182
+ error_message=error_message,
183
+ )
184
+
185
+ except Exception as e:
186
+ logger.error(f"Error fetching podcast episode: {str(e)}")
187
+ raise HTTPException(status_code=404, detail="Episode not found")
188
+
189
+
190
+ @router.get("/podcasts/episodes/{episode_id}/audio")
191
+ async def stream_podcast_episode_audio(episode_id: str):
192
+ """Stream the audio file associated with a podcast episode"""
193
+ try:
194
+ episode = await PodcastService.get_episode(episode_id)
195
+ except HTTPException:
196
+ raise
197
+ except Exception as e:
198
+ logger.error(f"Error fetching podcast episode for audio: {str(e)}")
199
+ raise HTTPException(status_code=404, detail="Episode not found")
200
+
201
+ if not episode.audio_file:
202
+ raise HTTPException(status_code=404, detail="Episode has no audio file")
203
+
204
+ audio_path = _resolve_audio_path(episode.audio_file)
205
+ if not audio_path.exists():
206
+ raise HTTPException(status_code=404, detail="Audio file not found on disk")
207
+
208
+ return FileResponse(
209
+ audio_path,
210
+ media_type="audio/mpeg",
211
+ filename=audio_path.name,
212
+ )
213
+
214
+
215
+ @router.post("/podcasts/episodes/{episode_id}/retry")
216
+ async def retry_podcast_episode(episode_id: str):
217
+ """Retry a failed podcast episode by deleting it and submitting a new job"""
218
+ try:
219
+ episode = await PodcastService.get_episode(episode_id)
220
+
221
+ # Validate episode is in a failed state
222
+ detail = await episode.get_job_detail()
223
+ if detail["status"] not in ("failed", "error"):
224
+ raise HTTPException(
225
+ status_code=400,
226
+ detail=f"Episode is not in a failed state (current: {detail['status']})",
227
+ )
228
+
229
+ # Extract params for re-submission
230
+ ep_profile_name = episode.episode_profile.get("name")
231
+ sp_profile_name = episode.speaker_profile.get("name")
232
+ episode_name = episode.name
233
+ content = episode.content
234
+
235
+ if not ep_profile_name or not sp_profile_name:
236
+ raise HTTPException(
237
+ status_code=400,
238
+ detail="Cannot retry: episode or speaker profile name missing from stored data",
239
+ )
240
+
241
+ # Delete audio file if any
242
+ if episode.audio_file:
243
+ audio_path = _resolve_audio_path(episode.audio_file)
244
+ if audio_path.exists():
245
+ try:
246
+ audio_path.unlink()
247
+ except Exception as e:
248
+ logger.warning(f"Failed to delete audio file {audio_path}: {e}")
249
+
250
+ # Delete the failed episode
251
+ await episode.delete()
252
+
253
+ # Submit a new job
254
+ job_id = await PodcastService.submit_generation_job(
255
+ episode_profile_name=ep_profile_name,
256
+ speaker_profile_name=sp_profile_name,
257
+ episode_name=episode_name,
258
+ content=content,
259
+ )
260
+
261
+ return {"job_id": job_id, "message": "Retry submitted successfully"}
262
+
263
+ except HTTPException:
264
+ raise
265
+ except Exception as e:
266
+ logger.error(f"Error retrying podcast episode: {str(e)}")
267
+ raise HTTPException(
268
+ status_code=500, detail="Failed to retry episode"
269
+ )
270
+
271
+
272
+ @router.delete("/podcasts/episodes/{episode_id}")
273
+ async def delete_podcast_episode(episode_id: str):
274
+ """Delete a podcast episode and its associated audio file"""
275
+ try:
276
+ # Get the episode first to check if it exists and get the audio file path
277
+ episode = await PodcastService.get_episode(episode_id)
278
+
279
+ # Delete the physical audio file if it exists
280
+ if episode.audio_file:
281
+ audio_path = _resolve_audio_path(episode.audio_file)
282
+ if audio_path.exists():
283
+ try:
284
+ audio_path.unlink()
285
+ logger.info(f"Deleted audio file: {audio_path}")
286
+ except Exception as e:
287
+ logger.warning(f"Failed to delete audio file {audio_path}: {e}")
288
+
289
+ # Delete the episode from the database
290
+ await episode.delete()
291
+
292
+ logger.info(f"Deleted podcast episode: {episode_id}")
293
+ return {"message": "Episode deleted successfully", "episode_id": episode_id}
294
+
295
+ except Exception as e:
296
+ logger.error(f"Error deleting podcast episode: {str(e)}")
297
+ raise HTTPException(
298
+ status_code=500, detail="Failed to delete episode"
299
+ )
api/routers/search.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import AsyncGenerator
3
+
4
+ from fastapi import APIRouter, HTTPException
5
+ from fastapi.responses import StreamingResponse
6
+ from loguru import logger
7
+
8
+ from api.models import AskRequest, AskResponse, SearchRequest, SearchResponse
9
+ from open_notebook.ai.models import Model, model_manager
10
+ from open_notebook.domain.notebook import text_search, vector_search
11
+ from open_notebook.exceptions import DatabaseOperationError, InvalidInputError
12
+ from open_notebook.graphs.ask import graph as ask_graph
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ @router.post("/search", response_model=SearchResponse)
18
+ async def search_knowledge_base(search_request: SearchRequest):
19
+ """Search the knowledge base using text or vector search."""
20
+ try:
21
+ if search_request.type == "vector":
22
+ # Check if embedding model is available for vector search
23
+ if not await model_manager.get_embedding_model():
24
+ raise HTTPException(
25
+ status_code=400,
26
+ detail="Vector search requires an embedding model. Please configure one in the Models section.",
27
+ )
28
+
29
+ results = await vector_search(
30
+ keyword=search_request.query,
31
+ results=search_request.limit,
32
+ source=search_request.search_sources,
33
+ note=search_request.search_notes,
34
+ minimum_score=search_request.minimum_score,
35
+ )
36
+ else:
37
+ # Text search
38
+ results = await text_search(
39
+ keyword=search_request.query,
40
+ results=search_request.limit,
41
+ source=search_request.search_sources,
42
+ note=search_request.search_notes,
43
+ )
44
+
45
+ return SearchResponse(
46
+ results=results or [],
47
+ total_count=len(results) if results else 0,
48
+ search_type=search_request.type,
49
+ )
50
+
51
+ except InvalidInputError as e:
52
+ raise HTTPException(status_code=400, detail=str(e))
53
+ except DatabaseOperationError as e:
54
+ logger.error(f"Database error during search: {str(e)}")
55
+ raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
56
+ except Exception as e:
57
+ logger.error(f"Unexpected error during search: {str(e)}")
58
+ raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
59
+
60
+
61
+ async def stream_ask_response(
62
+ question: str, strategy_model: Model, answer_model: Model, final_answer_model: Model
63
+ ) -> AsyncGenerator[str, None]:
64
+ """Stream the ask response as Server-Sent Events."""
65
+ try:
66
+ final_answer = None
67
+
68
+ async for chunk in ask_graph.astream(
69
+ input=dict(question=question), # type: ignore[arg-type]
70
+ config=dict(
71
+ configurable=dict(
72
+ strategy_model=strategy_model.id,
73
+ answer_model=answer_model.id,
74
+ final_answer_model=final_answer_model.id,
75
+ )
76
+ ),
77
+ stream_mode="updates",
78
+ ):
79
+ if "agent" in chunk:
80
+ strategy_data = {
81
+ "type": "strategy",
82
+ "reasoning": chunk["agent"]["strategy"].reasoning,
83
+ "searches": [
84
+ {"term": search.term, "instructions": search.instructions}
85
+ for search in chunk["agent"]["strategy"].searches
86
+ ],
87
+ }
88
+ yield f"data: {json.dumps(strategy_data)}\n\n"
89
+
90
+ elif "provide_answer" in chunk:
91
+ for answer in chunk["provide_answer"]["answers"]:
92
+ answer_data = {"type": "answer", "content": answer}
93
+ yield f"data: {json.dumps(answer_data)}\n\n"
94
+
95
+ elif "write_final_answer" in chunk:
96
+ final_answer = chunk["write_final_answer"]["final_answer"]
97
+ final_data = {"type": "final_answer", "content": final_answer}
98
+ yield f"data: {json.dumps(final_data)}\n\n"
99
+
100
+ # Send completion signal
101
+ completion_data = {"type": "complete", "final_answer": final_answer}
102
+ yield f"data: {json.dumps(completion_data)}\n\n"
103
+
104
+ except Exception as e:
105
+ from open_notebook.utils.error_classifier import classify_error
106
+
107
+ _, user_message = classify_error(e)
108
+ logger.error(f"Error in ask streaming: {str(e)}")
109
+ error_data = {"type": "error", "message": user_message}
110
+ yield f"data: {json.dumps(error_data)}\n\n"
111
+
112
+
113
+ @router.post("/search/ask")
114
+ async def ask_knowledge_base(ask_request: AskRequest):
115
+ """Ask the knowledge base a question using AI models."""
116
+ try:
117
+ # Validate models exist
118
+ strategy_model = await Model.get(ask_request.strategy_model)
119
+ answer_model = await Model.get(ask_request.answer_model)
120
+ final_answer_model = await Model.get(ask_request.final_answer_model)
121
+
122
+ if not strategy_model:
123
+ raise HTTPException(
124
+ status_code=400,
125
+ detail=f"Strategy model {ask_request.strategy_model} not found",
126
+ )
127
+ if not answer_model:
128
+ raise HTTPException(
129
+ status_code=400,
130
+ detail=f"Answer model {ask_request.answer_model} not found",
131
+ )
132
+ if not final_answer_model:
133
+ raise HTTPException(
134
+ status_code=400,
135
+ detail=f"Final answer model {ask_request.final_answer_model} not found",
136
+ )
137
+
138
+ # Check if embedding model is available
139
+ if not await model_manager.get_embedding_model():
140
+ raise HTTPException(
141
+ status_code=400,
142
+ detail="Ask feature requires an embedding model. Please configure one in the Models section.",
143
+ )
144
+
145
+ # For streaming response
146
+ return StreamingResponse(
147
+ stream_ask_response(
148
+ ask_request.question, strategy_model, answer_model, final_answer_model
149
+ ),
150
+ media_type="text/plain",
151
+ )
152
+
153
+ except HTTPException:
154
+ raise
155
+ except Exception as e:
156
+ logger.error(f"Error in ask endpoint: {str(e)}")
157
+ raise HTTPException(status_code=500, detail=f"Ask operation failed: {str(e)}")
158
+
159
+
160
+ @router.post("/search/ask/simple", response_model=AskResponse)
161
+ async def ask_knowledge_base_simple(ask_request: AskRequest):
162
+ """Ask the knowledge base a question and return a simple response (non-streaming)."""
163
+ try:
164
+ # Validate models exist
165
+ strategy_model = await Model.get(ask_request.strategy_model)
166
+ answer_model = await Model.get(ask_request.answer_model)
167
+ final_answer_model = await Model.get(ask_request.final_answer_model)
168
+
169
+ if not strategy_model:
170
+ raise HTTPException(
171
+ status_code=400,
172
+ detail=f"Strategy model {ask_request.strategy_model} not found",
173
+ )
174
+ if not answer_model:
175
+ raise HTTPException(
176
+ status_code=400,
177
+ detail=f"Answer model {ask_request.answer_model} not found",
178
+ )
179
+ if not final_answer_model:
180
+ raise HTTPException(
181
+ status_code=400,
182
+ detail=f"Final answer model {ask_request.final_answer_model} not found",
183
+ )
184
+
185
+ # Check if embedding model is available
186
+ if not await model_manager.get_embedding_model():
187
+ raise HTTPException(
188
+ status_code=400,
189
+ detail="Ask feature requires an embedding model. Please configure one in the Models section.",
190
+ )
191
+
192
+ # Run the ask graph and get final result
193
+ final_answer = None
194
+ async for chunk in ask_graph.astream(
195
+ input=dict(question=ask_request.question), # type: ignore[arg-type]
196
+ config=dict(
197
+ configurable=dict(
198
+ strategy_model=strategy_model.id,
199
+ answer_model=answer_model.id,
200
+ final_answer_model=final_answer_model.id,
201
+ )
202
+ ),
203
+ stream_mode="updates",
204
+ ):
205
+ if "write_final_answer" in chunk:
206
+ final_answer = chunk["write_final_answer"]["final_answer"]
207
+
208
+ if not final_answer:
209
+ raise HTTPException(status_code=500, detail="No answer generated")
210
+
211
+ return AskResponse(answer=final_answer, question=ask_request.question)
212
+
213
+ except HTTPException:
214
+ raise
215
+ except Exception as e:
216
+ logger.error(f"Error in ask simple endpoint: {str(e)}")
217
+ raise HTTPException(status_code=500, detail=f"Ask operation failed: {str(e)}")
api/routers/settings.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from loguru import logger
3
+
4
+ from api.models import SettingsResponse, SettingsUpdate
5
+ from open_notebook.domain.content_settings import ContentSettings
6
+ from open_notebook.exceptions import InvalidInputError
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ @router.get("/settings", response_model=SettingsResponse)
12
+ async def get_settings():
13
+ """Get all application settings."""
14
+ try:
15
+ settings: ContentSettings = await ContentSettings.get_instance() # type: ignore[assignment]
16
+
17
+ return SettingsResponse(
18
+ default_content_processing_engine_doc=settings.default_content_processing_engine_doc,
19
+ default_content_processing_engine_url=settings.default_content_processing_engine_url,
20
+ default_embedding_option=settings.default_embedding_option,
21
+ auto_delete_files=settings.auto_delete_files,
22
+ youtube_preferred_languages=settings.youtube_preferred_languages,
23
+ )
24
+ except Exception as e:
25
+ logger.error(f"Error fetching settings: {str(e)}")
26
+ raise HTTPException(
27
+ status_code=500, detail="Error fetching settings"
28
+ )
29
+
30
+
31
+ @router.put("/settings", response_model=SettingsResponse)
32
+ async def update_settings(settings_update: SettingsUpdate):
33
+ """Update application settings."""
34
+ try:
35
+ settings: ContentSettings = await ContentSettings.get_instance() # type: ignore[assignment]
36
+
37
+ # Update only provided fields
38
+ if settings_update.default_content_processing_engine_doc is not None:
39
+ # Cast to proper literal type
40
+ from typing import Literal, cast
41
+
42
+ settings.default_content_processing_engine_doc = cast(
43
+ Literal["auto", "docling", "simple"],
44
+ settings_update.default_content_processing_engine_doc,
45
+ )
46
+ if settings_update.default_content_processing_engine_url is not None:
47
+ from typing import Literal, cast
48
+
49
+ settings.default_content_processing_engine_url = cast(
50
+ Literal["auto", "firecrawl", "jina", "simple"],
51
+ settings_update.default_content_processing_engine_url,
52
+ )
53
+ if settings_update.default_embedding_option is not None:
54
+ from typing import Literal, cast
55
+
56
+ settings.default_embedding_option = cast(
57
+ Literal["ask", "always", "never"],
58
+ settings_update.default_embedding_option,
59
+ )
60
+ if settings_update.auto_delete_files is not None:
61
+ from typing import Literal, cast
62
+
63
+ settings.auto_delete_files = cast(
64
+ Literal["yes", "no"], settings_update.auto_delete_files
65
+ )
66
+ if settings_update.youtube_preferred_languages is not None:
67
+ settings.youtube_preferred_languages = (
68
+ settings_update.youtube_preferred_languages
69
+ )
70
+
71
+ await settings.update()
72
+
73
+ return SettingsResponse(
74
+ default_content_processing_engine_doc=settings.default_content_processing_engine_doc,
75
+ default_content_processing_engine_url=settings.default_content_processing_engine_url,
76
+ default_embedding_option=settings.default_embedding_option,
77
+ auto_delete_files=settings.auto_delete_files,
78
+ youtube_preferred_languages=settings.youtube_preferred_languages,
79
+ )
80
+ except HTTPException:
81
+ raise
82
+ except InvalidInputError as e:
83
+ raise HTTPException(status_code=400, detail=str(e))
84
+ except Exception as e:
85
+ logger.error(f"Error updating settings: {str(e)}")
86
+ raise HTTPException(
87
+ status_code=500, detail="Error updating settings"
88
+ )
api/routers/source_chat.py ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ from typing import AsyncGenerator, List, Optional
4
+
5
+ from fastapi import APIRouter, HTTPException, Path
6
+ from fastapi.responses import StreamingResponse
7
+ from langchain_core.messages import HumanMessage
8
+ from langchain_core.runnables import RunnableConfig
9
+ from loguru import logger
10
+ from pydantic import BaseModel, Field
11
+
12
+ from open_notebook.database.repository import ensure_record_id, repo_query
13
+ from open_notebook.domain.notebook import ChatSession, Source
14
+ from open_notebook.exceptions import (
15
+ NotFoundError,
16
+ )
17
+ from open_notebook.graphs.source_chat import source_chat_graph as source_chat_graph
18
+ from open_notebook.utils.graph_utils import get_session_message_count
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ # Request/Response models
24
+ class CreateSourceChatSessionRequest(BaseModel):
25
+ source_id: str = Field(..., description="Source ID to create chat session for")
26
+ title: Optional[str] = Field(None, description="Optional session title")
27
+ model_override: Optional[str] = Field(
28
+ None, description="Optional model override for this session"
29
+ )
30
+
31
+ class UpdateSourceChatSessionRequest(BaseModel):
32
+ title: Optional[str] = Field(None, description="New session title")
33
+ model_override: Optional[str] = Field(
34
+ None, description="Model override for this session"
35
+ )
36
+
37
+ class ChatMessage(BaseModel):
38
+ id: str = Field(..., description="Message ID")
39
+ type: str = Field(..., description="Message type (human|ai)")
40
+ content: str = Field(..., description="Message content")
41
+ timestamp: Optional[str] = Field(None, description="Message timestamp")
42
+
43
+
44
+ class ContextIndicator(BaseModel):
45
+ sources: List[str] = Field(
46
+ default_factory=list, description="Source IDs used in context"
47
+ )
48
+ insights: List[str] = Field(
49
+ default_factory=list, description="Insight IDs used in context"
50
+ )
51
+ notes: List[str] = Field(
52
+ default_factory=list, description="Note IDs used in context"
53
+ )
54
+
55
+ class SourceChatSessionResponse(BaseModel):
56
+ id: str = Field(..., description="Session ID")
57
+ title: str = Field(..., description="Session title")
58
+ source_id: str = Field(..., description="Source ID")
59
+ model_override: Optional[str] = Field(
60
+ None, description="Model override for this session"
61
+ )
62
+ created: str = Field(..., description="Creation timestamp")
63
+ updated: str = Field(..., description="Last update timestamp")
64
+ message_count: Optional[int] = Field(
65
+ None, description="Number of messages in session"
66
+ )
67
+
68
+ class SourceChatSessionWithMessagesResponse(SourceChatSessionResponse):
69
+ messages: List[ChatMessage] = Field(
70
+ default_factory=list, description="Session messages"
71
+ )
72
+ context_indicators: Optional[ContextIndicator] = Field(
73
+ None, description="Context indicators from last response"
74
+ )
75
+
76
+ class SendMessageRequest(BaseModel):
77
+ message: str = Field(..., description="User message content")
78
+ model_override: Optional[str] = Field(
79
+ None, description="Optional model override for this message"
80
+ )
81
+
82
+ class SuccessResponse(BaseModel):
83
+ success: bool = Field(True, description="Operation success status")
84
+ message: str = Field(..., description="Success message")
85
+
86
+
87
+ @router.post(
88
+ "/sources/{source_id}/chat/sessions", response_model=SourceChatSessionResponse
89
+ )
90
+ async def create_source_chat_session(
91
+ request: CreateSourceChatSessionRequest,
92
+ source_id: str = Path(..., description="Source ID"),
93
+ ):
94
+ """Create a new chat session for a source."""
95
+ try:
96
+ # Verify source exists
97
+ full_source_id = (
98
+ source_id if source_id.startswith("source:") else f"source:{source_id}"
99
+ )
100
+ source = await Source.get(full_source_id)
101
+ if not source:
102
+ raise HTTPException(status_code=404, detail="Source not found")
103
+
104
+ # Create new session with model_override support
105
+ session = ChatSession(
106
+ title=request.title or f"Source Chat {asyncio.get_event_loop().time():.0f}",
107
+ model_override=request.model_override,
108
+ )
109
+ await session.save()
110
+
111
+ # Relate session to source using "refers_to" relation
112
+ await session.relate("refers_to", full_source_id)
113
+
114
+ return SourceChatSessionResponse(
115
+ id=session.id or "",
116
+ title=session.title or "Untitled Session",
117
+ source_id=source_id,
118
+ model_override=session.model_override,
119
+ created=str(session.created),
120
+ updated=str(session.updated),
121
+ message_count=0,
122
+ )
123
+ except NotFoundError:
124
+ raise HTTPException(status_code=404, detail="Source not found")
125
+ except Exception as e:
126
+ logger.error(f"Error creating source chat session: {str(e)}")
127
+ raise HTTPException(
128
+ status_code=500, detail=f"Error creating source chat session: {str(e)}"
129
+ )
130
+
131
+
132
+ @router.get(
133
+ "/sources/{source_id}/chat/sessions", response_model=List[SourceChatSessionResponse]
134
+ )
135
+ async def get_source_chat_sessions(source_id: str = Path(..., description="Source ID")):
136
+ """Get all chat sessions for a source."""
137
+ try:
138
+ # Verify source exists
139
+ full_source_id = (
140
+ source_id if source_id.startswith("source:") else f"source:{source_id}"
141
+ )
142
+ source = await Source.get(full_source_id)
143
+ if not source:
144
+ raise HTTPException(status_code=404, detail="Source not found")
145
+
146
+ # Get sessions that refer to this source - first get relations, then sessions
147
+ relations = await repo_query(
148
+ "SELECT in FROM refers_to WHERE out = $source_id",
149
+ {"source_id": ensure_record_id(full_source_id)},
150
+ )
151
+
152
+ sessions = []
153
+ for relation in relations:
154
+ session_id_raw = relation.get("in")
155
+ if session_id_raw:
156
+ session_id = str(session_id_raw)
157
+
158
+ session_result = await repo_query(
159
+ "SELECT * FROM $id", {"id": ensure_record_id(session_id)}
160
+ )
161
+ if session_result and len(session_result) > 0:
162
+ session_data = session_result[0]
163
+
164
+ # Get message count from LangGraph state
165
+ msg_count = await get_session_message_count(
166
+ source_chat_graph, session_id
167
+ )
168
+
169
+ sessions.append(
170
+ SourceChatSessionResponse(
171
+ id=session_data.get("id") or "",
172
+ title=session_data.get("title") or "Untitled Session",
173
+ source_id=source_id,
174
+ model_override=session_data.get("model_override"),
175
+ created=str(session_data.get("created")),
176
+ updated=str(session_data.get("updated")),
177
+ message_count=msg_count,
178
+ )
179
+ )
180
+
181
+ # Sort sessions by created date (newest first)
182
+ sessions.sort(key=lambda x: x.created, reverse=True)
183
+ return sessions
184
+ except NotFoundError:
185
+ raise HTTPException(status_code=404, detail="Source not found")
186
+ except Exception as e:
187
+ logger.error(f"Error fetching source chat sessions: {str(e)}")
188
+ raise HTTPException(
189
+ status_code=500, detail=f"Error fetching source chat sessions: {str(e)}"
190
+ )
191
+
192
+
193
+ @router.get(
194
+ "/sources/{source_id}/chat/sessions/{session_id}",
195
+ response_model=SourceChatSessionWithMessagesResponse,
196
+ )
197
+ async def get_source_chat_session(
198
+ source_id: str = Path(..., description="Source ID"),
199
+ session_id: str = Path(..., description="Session ID"),
200
+ ):
201
+ """Get a specific source chat session with its messages."""
202
+ try:
203
+ # Verify source exists
204
+ full_source_id = (
205
+ source_id if source_id.startswith("source:") else f"source:{source_id}"
206
+ )
207
+ source = await Source.get(full_source_id)
208
+ if not source:
209
+ raise HTTPException(status_code=404, detail="Source not found")
210
+
211
+ # Get session
212
+ full_session_id = (
213
+ session_id
214
+ if session_id.startswith("chat_session:")
215
+ else f"chat_session:{session_id}"
216
+ )
217
+ session = await ChatSession.get(full_session_id)
218
+ if not session:
219
+ raise HTTPException(status_code=404, detail="Session not found")
220
+
221
+ # Verify session is related to this source
222
+ relation_query = await repo_query(
223
+ "SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
224
+ {
225
+ "session_id": ensure_record_id(full_session_id),
226
+ "source_id": ensure_record_id(full_source_id),
227
+ },
228
+ )
229
+
230
+ if not relation_query:
231
+ raise HTTPException(
232
+ status_code=404, detail="Session not found for this source"
233
+ )
234
+
235
+ # Get session state from LangGraph to retrieve messages
236
+ # Use sync get_state() in a thread since SqliteSaver doesn't support async
237
+ thread_state = await asyncio.to_thread(
238
+ source_chat_graph.get_state,
239
+ config=RunnableConfig(configurable={"thread_id": full_session_id}),
240
+ )
241
+
242
+ # Extract messages from state
243
+ messages: list[ChatMessage] = []
244
+ context_indicators = None
245
+
246
+ if thread_state and thread_state.values:
247
+ # Extract messages
248
+ if "messages" in thread_state.values:
249
+ for msg in thread_state.values["messages"]:
250
+ messages.append(
251
+ ChatMessage(
252
+ id=getattr(msg, "id", f"msg_{len(messages)}"),
253
+ type=msg.type if hasattr(msg, "type") else "unknown",
254
+ content=msg.content
255
+ if hasattr(msg, "content")
256
+ else str(msg),
257
+ timestamp=None, # LangChain messages don't have timestamps by default
258
+ )
259
+ )
260
+
261
+ # Extract context indicators from the last state
262
+ if "context_indicators" in thread_state.values:
263
+ context_data = thread_state.values["context_indicators"]
264
+ context_indicators = ContextIndicator(
265
+ sources=context_data.get("sources", []),
266
+ insights=context_data.get("insights", []),
267
+ notes=context_data.get("notes", []),
268
+ )
269
+
270
+ return SourceChatSessionWithMessagesResponse(
271
+ id=session.id or "",
272
+ title=session.title or "Untitled Session",
273
+ source_id=source_id,
274
+ model_override=getattr(session, "model_override", None),
275
+ created=str(session.created),
276
+ updated=str(session.updated),
277
+ message_count=len(messages),
278
+ messages=messages,
279
+ context_indicators=context_indicators,
280
+ )
281
+ except NotFoundError:
282
+ raise HTTPException(status_code=404, detail="Source or session not found")
283
+ except Exception as e:
284
+ logger.error(f"Error fetching source chat session: {str(e)}")
285
+ raise HTTPException(
286
+ status_code=500, detail=f"Error fetching source chat session: {str(e)}"
287
+ )
288
+
289
+
290
+ @router.put(
291
+ "/sources/{source_id}/chat/sessions/{session_id}",
292
+ response_model=SourceChatSessionResponse,
293
+ )
294
+ async def update_source_chat_session(
295
+ request: UpdateSourceChatSessionRequest,
296
+ source_id: str = Path(..., description="Source ID"),
297
+ session_id: str = Path(..., description="Session ID"),
298
+ ):
299
+ """Update source chat session title and/or model override."""
300
+ try:
301
+ # Verify source exists
302
+ full_source_id = (
303
+ source_id if source_id.startswith("source:") else f"source:{source_id}"
304
+ )
305
+ source = await Source.get(full_source_id)
306
+ if not source:
307
+ raise HTTPException(status_code=404, detail="Source not found")
308
+
309
+ # Get session
310
+ full_session_id = (
311
+ session_id
312
+ if session_id.startswith("chat_session:")
313
+ else f"chat_session:{session_id}"
314
+ )
315
+ session = await ChatSession.get(full_session_id)
316
+ if not session:
317
+ raise HTTPException(status_code=404, detail="Session not found")
318
+
319
+ # Verify session is related to this source
320
+ relation_query = await repo_query(
321
+ "SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
322
+ {
323
+ "session_id": ensure_record_id(full_session_id),
324
+ "source_id": ensure_record_id(full_source_id),
325
+ },
326
+ )
327
+
328
+ if not relation_query:
329
+ raise HTTPException(
330
+ status_code=404, detail="Session not found for this source"
331
+ )
332
+
333
+ # Update session fields
334
+ if request.title is not None:
335
+ session.title = request.title
336
+ if request.model_override is not None:
337
+ session.model_override = request.model_override
338
+
339
+ await session.save()
340
+
341
+ # Get message count from LangGraph state
342
+ msg_count = await get_session_message_count(source_chat_graph, full_session_id)
343
+
344
+ return SourceChatSessionResponse(
345
+ id=session.id or "",
346
+ title=session.title or "Untitled Session",
347
+ source_id=source_id,
348
+ model_override=getattr(session, "model_override", None),
349
+ created=str(session.created),
350
+ updated=str(session.updated),
351
+ message_count=msg_count,
352
+ )
353
+ except NotFoundError:
354
+ raise HTTPException(status_code=404, detail="Source or session not found")
355
+ except Exception as e:
356
+ logger.error(f"Error updating source chat session: {str(e)}")
357
+ raise HTTPException(
358
+ status_code=500, detail=f"Error updating source chat session: {str(e)}"
359
+ )
360
+
361
+
362
+ @router.delete(
363
+ "/sources/{source_id}/chat/sessions/{session_id}", response_model=SuccessResponse
364
+ )
365
+ async def delete_source_chat_session(
366
+ source_id: str = Path(..., description="Source ID"),
367
+ session_id: str = Path(..., description="Session ID"),
368
+ ):
369
+ """Delete a source chat session."""
370
+ try:
371
+ # Verify source exists
372
+ full_source_id = (
373
+ source_id if source_id.startswith("source:") else f"source:{source_id}"
374
+ )
375
+ source = await Source.get(full_source_id)
376
+ if not source:
377
+ raise HTTPException(status_code=404, detail="Source not found")
378
+
379
+ # Get session
380
+ full_session_id = (
381
+ session_id
382
+ if session_id.startswith("chat_session:")
383
+ else f"chat_session:{session_id}"
384
+ )
385
+ session = await ChatSession.get(full_session_id)
386
+ if not session:
387
+ raise HTTPException(status_code=404, detail="Session not found")
388
+
389
+ # Verify session is related to this source
390
+ relation_query = await repo_query(
391
+ "SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
392
+ {
393
+ "session_id": ensure_record_id(full_session_id),
394
+ "source_id": ensure_record_id(full_source_id),
395
+ },
396
+ )
397
+
398
+ if not relation_query:
399
+ raise HTTPException(
400
+ status_code=404, detail="Session not found for this source"
401
+ )
402
+
403
+ await session.delete()
404
+
405
+ return SuccessResponse(
406
+ success=True, message="Source chat session deleted successfully"
407
+ )
408
+ except NotFoundError:
409
+ raise HTTPException(status_code=404, detail="Source or session not found")
410
+ except Exception as e:
411
+ logger.error(f"Error deleting source chat session: {str(e)}")
412
+ raise HTTPException(
413
+ status_code=500, detail=f"Error deleting source chat session: {str(e)}"
414
+ )
415
+
416
+
417
+ async def stream_source_chat_response(
418
+ session_id: str, source_id: str, message: str, model_override: Optional[str] = None
419
+ ) -> AsyncGenerator[str, None]:
420
+ """Stream the source chat response as Server-Sent Events."""
421
+ try:
422
+ # Get current state
423
+ # Use sync get_state() in a thread since SqliteSaver doesn't support async
424
+ current_state = await asyncio.to_thread(
425
+ source_chat_graph.get_state,
426
+ config=RunnableConfig(configurable={"thread_id": session_id}),
427
+ )
428
+
429
+ # Prepare state for execution
430
+ state_values = current_state.values if current_state else {}
431
+ state_values["messages"] = state_values.get("messages", [])
432
+ state_values["source_id"] = source_id
433
+ state_values["model_override"] = model_override
434
+
435
+ # Add user message to state
436
+ user_message = HumanMessage(content=message)
437
+ state_values["messages"].append(user_message)
438
+
439
+ # Send user message event
440
+ user_event = {"type": "user_message", "content": message, "timestamp": None}
441
+ yield f"data: {json.dumps(user_event)}\n\n"
442
+
443
+ # Execute source chat graph synchronously (like notebook chat does)
444
+ result = source_chat_graph.invoke(
445
+ input=state_values, # type: ignore[arg-type]
446
+ config=RunnableConfig(
447
+ configurable={"thread_id": session_id, "model_id": model_override}
448
+ ),
449
+ )
450
+
451
+ # Stream the complete AI response
452
+ if "messages" in result:
453
+ for msg in result["messages"]:
454
+ if hasattr(msg, "type") and msg.type == "ai":
455
+ ai_event = {
456
+ "type": "ai_message",
457
+ "content": msg.content if hasattr(msg, "content") else str(msg),
458
+ "timestamp": None,
459
+ }
460
+ yield f"data: {json.dumps(ai_event)}\n\n"
461
+
462
+ # Stream context indicators
463
+ if "context_indicators" in result:
464
+ context_event = {
465
+ "type": "context_indicators",
466
+ "data": result["context_indicators"],
467
+ }
468
+ yield f"data: {json.dumps(context_event)}\n\n"
469
+
470
+ # Send completion signal
471
+ completion_event = {"type": "complete"}
472
+ yield f"data: {json.dumps(completion_event)}\n\n"
473
+
474
+ except Exception as e:
475
+ from open_notebook.utils.error_classifier import classify_error
476
+
477
+ _, user_message = classify_error(e)
478
+ logger.error(f"Error in source chat streaming: {str(e)}")
479
+ error_event = {"type": "error", "message": user_message}
480
+ yield f"data: {json.dumps(error_event)}\n\n"
481
+
482
+
483
+ @router.post("/sources/{source_id}/chat/sessions/{session_id}/messages")
484
+ async def send_message_to_source_chat(
485
+ request: SendMessageRequest,
486
+ source_id: str = Path(..., description="Source ID"),
487
+ session_id: str = Path(..., description="Session ID"),
488
+ ):
489
+ """Send a message to source chat session with SSE streaming response."""
490
+ try:
491
+ # Verify source exists
492
+ full_source_id = (
493
+ source_id if source_id.startswith("source:") else f"source:{source_id}"
494
+ )
495
+ source = await Source.get(full_source_id)
496
+ if not source:
497
+ raise HTTPException(status_code=404, detail="Source not found")
498
+
499
+ # Verify session exists and is related to source
500
+ full_session_id = (
501
+ session_id
502
+ if session_id.startswith("chat_session:")
503
+ else f"chat_session:{session_id}"
504
+ )
505
+ session = await ChatSession.get(full_session_id)
506
+ if not session:
507
+ raise HTTPException(status_code=404, detail="Session not found")
508
+
509
+ # Verify session is related to this source
510
+ relation_query = await repo_query(
511
+ "SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
512
+ {
513
+ "session_id": ensure_record_id(full_session_id),
514
+ "source_id": ensure_record_id(full_source_id),
515
+ },
516
+ )
517
+
518
+ if not relation_query:
519
+ raise HTTPException(
520
+ status_code=404, detail="Session not found for this source"
521
+ )
522
+
523
+ if not request.message:
524
+ raise HTTPException(status_code=400, detail="Message content is required")
525
+
526
+ # Determine model override (request override takes precedence over session override)
527
+ model_override = request.model_override or getattr(
528
+ session, "model_override", None
529
+ )
530
+
531
+ # Update session timestamp
532
+ await session.save()
533
+
534
+ # Return streaming response
535
+ return StreamingResponse(
536
+ stream_source_chat_response(
537
+ session_id=full_session_id,
538
+ source_id=full_source_id,
539
+ message=request.message,
540
+ model_override=model_override,
541
+ ),
542
+ media_type="text/plain",
543
+ headers={
544
+ "Cache-Control": "no-cache",
545
+ "Connection": "keep-alive",
546
+ "Content-Type": "text/plain; charset=utf-8",
547
+ },
548
+ )
549
+
550
+ except HTTPException:
551
+ raise
552
+ except Exception as e:
553
+ logger.error(f"Error sending message to source chat: {str(e)}")
554
+ raise HTTPException(status_code=500, detail=f"Error sending message: {str(e)}")
api/routers/sources.py ADDED
@@ -0,0 +1,1045 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Any, List, Optional
5
+
6
+ from fastapi import (
7
+ APIRouter,
8
+ Depends,
9
+ File,
10
+ Form,
11
+ HTTPException,
12
+ Query,
13
+ UploadFile,
14
+ )
15
+ from fastapi.responses import FileResponse, Response
16
+ from loguru import logger
17
+ from surreal_commands import execute_command_sync, submit_command
18
+
19
+ from api.command_service import CommandService
20
+ from api.models import (
21
+ AssetModel,
22
+ CreateSourceInsightRequest,
23
+ InsightCreationResponse,
24
+ SourceCreate,
25
+ SourceInsightResponse,
26
+ SourceListResponse,
27
+ SourceResponse,
28
+ SourceStatusResponse,
29
+ SourceUpdate,
30
+ )
31
+ from commands.source_commands import SourceProcessingInput
32
+ from open_notebook.config import UPLOADS_FOLDER
33
+ from open_notebook.database.repository import ensure_record_id, repo_query
34
+ from open_notebook.domain.notebook import Asset, Notebook, Source
35
+ from open_notebook.domain.transformation import Transformation
36
+ from open_notebook.exceptions import InvalidInputError
37
+
38
+ router = APIRouter()
39
+
40
+
41
+ def generate_unique_filename(original_filename: str, upload_folder: str) -> str:
42
+ """Generate unique filename like Streamlit app (append counter if file exists)."""
43
+ file_path = Path(upload_folder)
44
+ file_path.mkdir(parents=True, exist_ok=True)
45
+
46
+ # Strip directory components to prevent path traversal
47
+ safe_filename = os.path.basename(original_filename)
48
+ if not safe_filename:
49
+ raise ValueError("Invalid filename")
50
+
51
+ # Split filename and extension
52
+ stem = Path(safe_filename).stem
53
+ suffix = Path(safe_filename).suffix
54
+
55
+ # Check if file exists and generate unique name
56
+ counter = 0
57
+ while True:
58
+ if counter == 0:
59
+ new_filename = safe_filename
60
+ else:
61
+ new_filename = f"{stem} ({counter}){suffix}"
62
+
63
+ full_path = file_path / new_filename
64
+ # Verify resolved path stays within upload folder
65
+ resolved = full_path.resolve()
66
+ if not str(resolved).startswith(str(file_path.resolve()) + os.sep):
67
+ raise ValueError("Invalid filename: path traversal detected")
68
+ if not resolved.exists():
69
+ return str(resolved)
70
+ counter += 1
71
+
72
+
73
+ async def save_uploaded_file(upload_file: UploadFile) -> str:
74
+ """Save uploaded file to uploads folder and return file path."""
75
+ if not upload_file.filename:
76
+ raise ValueError("No filename provided")
77
+
78
+ # Generate unique filename
79
+ file_path = generate_unique_filename(upload_file.filename, UPLOADS_FOLDER)
80
+
81
+ try:
82
+ # Save file
83
+ with open(file_path, "wb") as f:
84
+ content = await upload_file.read()
85
+ f.write(content)
86
+
87
+ logger.info(f"Saved uploaded file to: {file_path}")
88
+ return file_path
89
+ except Exception as e:
90
+ logger.error(f"Failed to save uploaded file: {e}")
91
+ # Clean up partial file if it exists
92
+ if os.path.exists(file_path):
93
+ os.unlink(file_path)
94
+ raise
95
+
96
+
97
+ def parse_source_form_data(
98
+ type: str = Form(...),
99
+ notebook_id: Optional[str] = Form(None),
100
+ notebooks: Optional[str] = Form(None), # JSON string of notebook IDs
101
+ url: Optional[str] = Form(None),
102
+ content: Optional[str] = Form(None),
103
+ title: Optional[str] = Form(None),
104
+ transformations: Optional[str] = Form(None), # JSON string of transformation IDs
105
+ embed: str = Form("false"), # Accept as string, convert to bool
106
+ delete_source: str = Form("false"), # Accept as string, convert to bool
107
+ async_processing: str = Form("false"), # Accept as string, convert to bool
108
+ file: Optional[UploadFile] = File(None),
109
+ ) -> tuple[SourceCreate, Optional[UploadFile]]:
110
+ """Parse form data into SourceCreate model and return upload file separately."""
111
+ import json
112
+
113
+ # Convert string booleans to actual booleans
114
+ def str_to_bool(value: str) -> bool:
115
+ return value.lower() in ("true", "1", "yes", "on")
116
+
117
+ embed_bool = str_to_bool(embed)
118
+ delete_source_bool = str_to_bool(delete_source)
119
+ async_processing_bool = str_to_bool(async_processing)
120
+
121
+ # Parse JSON strings
122
+ notebooks_list = None
123
+ if notebooks:
124
+ try:
125
+ notebooks_list = json.loads(notebooks)
126
+ except json.JSONDecodeError:
127
+ logger.error(f"Invalid JSON in notebooks field: {notebooks}")
128
+ raise ValueError("Invalid JSON in notebooks field")
129
+
130
+ transformations_list = []
131
+ if transformations:
132
+ try:
133
+ transformations_list = json.loads(transformations)
134
+ except json.JSONDecodeError:
135
+ logger.error(f"Invalid JSON in transformations field: {transformations}")
136
+ raise ValueError("Invalid JSON in transformations field")
137
+
138
+ # Create SourceCreate instance
139
+ try:
140
+ source_data = SourceCreate(
141
+ type=type,
142
+ notebook_id=notebook_id,
143
+ notebooks=notebooks_list,
144
+ url=url,
145
+ content=content,
146
+ title=title,
147
+ file_path=None, # Will be set later if file is uploaded
148
+ transformations=transformations_list,
149
+ embed=embed_bool,
150
+ delete_source=delete_source_bool,
151
+ async_processing=async_processing_bool,
152
+ )
153
+ pass # SourceCreate instance created successfully
154
+ except Exception as e:
155
+ logger.error(f"Failed to create SourceCreate instance: {e}")
156
+ raise
157
+
158
+ return source_data, file
159
+
160
+
161
+ @router.get("/sources", response_model=List[SourceListResponse])
162
+ async def get_sources(
163
+ notebook_id: Optional[str] = Query(None, description="Filter by notebook ID"),
164
+ limit: int = Query(
165
+ 50, ge=1, le=100, description="Number of sources to return (1-100)"
166
+ ),
167
+ offset: int = Query(0, ge=0, description="Number of sources to skip"),
168
+ sort_by: str = Query(
169
+ "updated", description="Field to sort by (created or updated)"
170
+ ),
171
+ sort_order: str = Query("desc", description="Sort order (asc or desc)"),
172
+ ):
173
+ """Get sources with pagination and sorting support."""
174
+ try:
175
+ # Validate sort parameters
176
+ if sort_by not in ["created", "updated"]:
177
+ raise HTTPException(
178
+ status_code=400, detail="sort_by must be 'created' or 'updated'"
179
+ )
180
+ if sort_order.lower() not in ["asc", "desc"]:
181
+ raise HTTPException(
182
+ status_code=400, detail="sort_order must be 'asc' or 'desc'"
183
+ )
184
+
185
+ # Build ORDER BY clause
186
+ order_clause = f"ORDER BY {sort_by} {sort_order.upper()}"
187
+
188
+ # Build the query
189
+ if notebook_id:
190
+ # Verify notebook exists first
191
+ notebook = await Notebook.get(notebook_id)
192
+ if not notebook:
193
+ raise HTTPException(status_code=404, detail="Notebook not found")
194
+
195
+ # Query sources for specific notebook - include command field with FETCH
196
+ query = f"""
197
+ SELECT id, asset, created, title, updated, topics, command,
198
+ (SELECT VALUE count() FROM source_insight WHERE source = $parent.id GROUP ALL)[0].count OR 0 AS insights_count,
199
+ (SELECT VALUE id FROM source_embedding WHERE source = $parent.id LIMIT 1) != [] AS embedded
200
+ FROM (select value in from reference where out=$notebook_id)
201
+ {order_clause}
202
+ LIMIT $limit START $offset
203
+ FETCH command
204
+ """
205
+ result = await repo_query(
206
+ query,
207
+ {
208
+ "notebook_id": ensure_record_id(notebook_id),
209
+ "limit": limit,
210
+ "offset": offset,
211
+ },
212
+ )
213
+ else:
214
+ # Query all sources - include command field with FETCH
215
+ query = f"""
216
+ SELECT id, asset, created, title, updated, topics, command,
217
+ (SELECT VALUE count() FROM source_insight WHERE source = $parent.id GROUP ALL)[0].count OR 0 AS insights_count,
218
+ (SELECT VALUE id FROM source_embedding WHERE source = $parent.id LIMIT 1) != [] AS embedded
219
+ FROM source
220
+ {order_clause}
221
+ LIMIT $limit START $offset
222
+ FETCH command
223
+ """
224
+ result = await repo_query(query, {"limit": limit, "offset": offset})
225
+
226
+ # Convert result to response model
227
+ # Command data is already fetched via FETCH command clause
228
+ response_list = []
229
+ for row in result:
230
+ command = row.get("command")
231
+ command_id = None
232
+ status = None
233
+ processing_info = None
234
+
235
+ # Extract status from fetched command object (already resolved by FETCH)
236
+ if command and isinstance(command, dict):
237
+ command_id = str(command.get("id")) if command.get("id") else None
238
+ status = command.get("status")
239
+ # Extract execution metadata from nested result structure
240
+ result_data = command.get("result")
241
+ execution_metadata = (
242
+ result_data.get("execution_metadata", {})
243
+ if isinstance(result_data, dict)
244
+ else {}
245
+ )
246
+ processing_info = {
247
+ "started_at": execution_metadata.get("started_at"),
248
+ "completed_at": execution_metadata.get("completed_at"),
249
+ "error": command.get("error_message"),
250
+ }
251
+ elif command:
252
+ # Command exists but FETCH failed to resolve it (broken reference)
253
+ command_id = str(command)
254
+ status = "unknown"
255
+
256
+ response_list.append(
257
+ SourceListResponse(
258
+ id=row["id"],
259
+ title=row.get("title"),
260
+ topics=row.get("topics") or [],
261
+ asset=AssetModel(
262
+ file_path=row["asset"].get("file_path")
263
+ if row.get("asset")
264
+ else None,
265
+ url=row["asset"].get("url") if row.get("asset") else None,
266
+ )
267
+ if row.get("asset")
268
+ else None,
269
+ embedded=row.get("embedded", False),
270
+ embedded_chunks=0, # Not needed in list view
271
+ insights_count=row.get("insights_count", 0),
272
+ created=str(row["created"]),
273
+ updated=str(row["updated"]),
274
+ # Status fields from fetched command
275
+ command_id=command_id,
276
+ status=status,
277
+ processing_info=processing_info,
278
+ )
279
+ )
280
+
281
+ return response_list
282
+ except HTTPException:
283
+ raise
284
+ except Exception as e:
285
+ logger.error(f"Error fetching sources: {str(e)}")
286
+ raise HTTPException(status_code=500, detail=f"Error fetching sources: {str(e)}")
287
+
288
+
289
+ @router.post("/sources", response_model=SourceResponse)
290
+ async def create_source(
291
+ form_data: tuple[SourceCreate, Optional[UploadFile]] = Depends(
292
+ parse_source_form_data
293
+ ),
294
+ ):
295
+ """Create a new source with support for both JSON and multipart form data."""
296
+ source_data, upload_file = form_data
297
+
298
+ # Initialize file_path before try block so exception handlers can reference it
299
+ file_path = None
300
+
301
+ try:
302
+ # Verify all specified notebooks exist (backward compatibility support)
303
+ for notebook_id in source_data.notebooks or []:
304
+ notebook = await Notebook.get(notebook_id)
305
+ if not notebook:
306
+ raise HTTPException(
307
+ status_code=404, detail=f"Notebook {notebook_id} not found"
308
+ )
309
+
310
+ # Handle file upload if provided
311
+ if upload_file and source_data.type == "upload":
312
+ try:
313
+ file_path = await save_uploaded_file(upload_file)
314
+ except Exception as e:
315
+ logger.error(f"File upload failed: {e}")
316
+ raise HTTPException(
317
+ status_code=400, detail=f"File upload failed: {str(e)}"
318
+ )
319
+
320
+ # Prepare content_state for processing
321
+ content_state: dict[str, Any] = {}
322
+
323
+ if source_data.type == "link":
324
+ if not source_data.url:
325
+ raise HTTPException(
326
+ status_code=400, detail="URL is required for link type"
327
+ )
328
+ content_state["url"] = source_data.url
329
+ elif source_data.type == "upload":
330
+ # Use uploaded file path or provided file_path (backward compatibility)
331
+ final_file_path = file_path or source_data.file_path
332
+ if not final_file_path:
333
+ raise HTTPException(
334
+ status_code=400,
335
+ detail="File upload or file_path is required for upload type",
336
+ )
337
+ # Validate file_path is within the uploads directory to prevent LFI
338
+ uploads_resolved = Path(UPLOADS_FOLDER).resolve()
339
+ file_resolved = Path(final_file_path).resolve()
340
+ if not str(file_resolved).startswith(str(uploads_resolved) + os.sep):
341
+ raise HTTPException(
342
+ status_code=400,
343
+ detail="Invalid file path: must be within the uploads directory",
344
+ )
345
+ content_state["file_path"] = final_file_path
346
+ content_state["delete_source"] = source_data.delete_source
347
+ elif source_data.type == "text":
348
+ if not source_data.content:
349
+ raise HTTPException(
350
+ status_code=400, detail="Content is required for text type"
351
+ )
352
+ content_state["content"] = source_data.content
353
+ else:
354
+ raise HTTPException(
355
+ status_code=400,
356
+ detail="Invalid source type. Must be link, upload, or text",
357
+ )
358
+
359
+ # Validate transformations exist
360
+ transformation_ids = source_data.transformations or []
361
+ for trans_id in transformation_ids:
362
+ transformation = await Transformation.get(trans_id)
363
+ if not transformation:
364
+ raise HTTPException(
365
+ status_code=404, detail=f"Transformation {trans_id} not found"
366
+ )
367
+
368
+ # Branch based on processing mode
369
+ if source_data.async_processing:
370
+ # ASYNC PATH: Create source record first, then queue command
371
+ logger.info("Using async processing path")
372
+
373
+ # Create source record with asset - let SurrealDB generate the ID
374
+ # Persist asset before save so it's available for retry if processing fails
375
+ if source_data.type == "link":
376
+ source_asset = Asset(url=source_data.url)
377
+ elif source_data.type == "upload":
378
+ source_asset = Asset(file_path=file_path or source_data.file_path)
379
+ else:
380
+ source_asset = None
381
+
382
+ source = Source(
383
+ title=source_data.title or "Processing...",
384
+ topics=[],
385
+ asset=source_asset,
386
+ )
387
+ await source.save()
388
+
389
+ # Add source to notebooks immediately so it appears in the UI
390
+ # The source_graph will skip adding duplicates
391
+ for notebook_id in source_data.notebooks or []:
392
+ await source.add_to_notebook(notebook_id)
393
+
394
+ try:
395
+ # Import command modules to ensure they're registered
396
+ import commands.source_commands # noqa: F401
397
+
398
+ # Submit command for background processing
399
+ command_input = SourceProcessingInput(
400
+ source_id=str(source.id),
401
+ content_state=content_state,
402
+ notebook_ids=source_data.notebooks,
403
+ transformations=transformation_ids,
404
+ embed=source_data.embed,
405
+ )
406
+
407
+ command_id = await CommandService.submit_command_job(
408
+ "open_notebook", # app name
409
+ "process_source", # command name
410
+ command_input.model_dump(),
411
+ )
412
+
413
+ logger.info(f"Submitted async processing command: {command_id}")
414
+
415
+ # Update source with command reference immediately
416
+ # command_id already includes 'command:' prefix
417
+ source.command = ensure_record_id(command_id)
418
+ await source.save()
419
+
420
+ # Return source with command info
421
+ return SourceResponse(
422
+ id=source.id or "",
423
+ title=source.title,
424
+ topics=source.topics or [],
425
+ asset=None, # Will be populated after processing
426
+ full_text=None, # Will be populated after processing
427
+ embedded=False, # Will be updated after processing
428
+ embedded_chunks=0,
429
+ created=str(source.created),
430
+ updated=str(source.updated),
431
+ command_id=command_id,
432
+ status="new",
433
+ processing_info={"async": True, "queued": True},
434
+ )
435
+
436
+ except Exception as e:
437
+ logger.error(f"Failed to submit async processing command: {e}")
438
+ # Clean up source record on command submission failure
439
+ try:
440
+ await source.delete()
441
+ except Exception:
442
+ pass
443
+ # Clean up uploaded file if we created it
444
+ if file_path and upload_file:
445
+ try:
446
+ os.unlink(file_path)
447
+ except Exception:
448
+ pass
449
+ raise HTTPException(
450
+ status_code=500, detail=f"Failed to queue processing: {str(e)}"
451
+ )
452
+
453
+ else:
454
+ # SYNC PATH: Execute synchronously using execute_command_sync
455
+ logger.info("Using sync processing path")
456
+
457
+ try:
458
+ # Import command modules to ensure they're registered
459
+ import commands.source_commands # noqa: F401
460
+
461
+ # Create source record - let SurrealDB generate the ID
462
+ source = Source(
463
+ title=source_data.title or "Processing...",
464
+ topics=[],
465
+ )
466
+ await source.save()
467
+
468
+ # Add source to notebooks immediately so it appears in the UI
469
+ # The source_graph will skip adding duplicates
470
+ for notebook_id in source_data.notebooks or []:
471
+ await source.add_to_notebook(notebook_id)
472
+
473
+ # Execute command synchronously
474
+ command_input = SourceProcessingInput(
475
+ source_id=str(source.id),
476
+ content_state=content_state,
477
+ notebook_ids=source_data.notebooks,
478
+ transformations=transformation_ids,
479
+ embed=source_data.embed,
480
+ )
481
+
482
+ # Run in thread pool to avoid blocking the event loop
483
+ # execute_command_sync uses asyncio.run() internally which can't
484
+ # be called from an already-running event loop (FastAPI)
485
+ result = await asyncio.to_thread(
486
+ execute_command_sync,
487
+ "open_notebook", # app name
488
+ "process_source", # command name
489
+ command_input.model_dump(),
490
+ timeout=300, # 5 minute timeout for sync processing
491
+ )
492
+
493
+ if not result.is_success():
494
+ logger.error(f"Sync processing failed: {result.error_message}")
495
+ # Clean up source record
496
+ try:
497
+ await source.delete()
498
+ except Exception:
499
+ pass
500
+ # Clean up uploaded file if we created it
501
+ if file_path and upload_file:
502
+ try:
503
+ os.unlink(file_path)
504
+ except Exception:
505
+ pass
506
+ raise HTTPException(
507
+ status_code=500,
508
+ detail=f"Processing failed: {result.error_message}",
509
+ )
510
+
511
+ # Get the processed source
512
+ if not source.id:
513
+ raise HTTPException(status_code=500, detail="Source ID is missing")
514
+ processed_source = await Source.get(source.id)
515
+ if not processed_source:
516
+ raise HTTPException(
517
+ status_code=500, detail="Processed source not found"
518
+ )
519
+
520
+ embedded_chunks = await processed_source.get_embedded_chunks()
521
+ return SourceResponse(
522
+ id=processed_source.id or "",
523
+ title=processed_source.title,
524
+ topics=processed_source.topics or [],
525
+ asset=AssetModel(
526
+ file_path=processed_source.asset.file_path
527
+ if processed_source.asset
528
+ else None,
529
+ url=processed_source.asset.url
530
+ if processed_source.asset
531
+ else None,
532
+ )
533
+ if processed_source.asset
534
+ else None,
535
+ full_text=processed_source.full_text,
536
+ embedded=embedded_chunks > 0,
537
+ embedded_chunks=embedded_chunks,
538
+ created=str(processed_source.created),
539
+ updated=str(processed_source.updated),
540
+ # No command_id or status for sync processing (legacy behavior)
541
+ )
542
+
543
+ except Exception as e:
544
+ logger.error(f"Sync processing failed: {e}")
545
+ # Clean up uploaded file if we created it
546
+ if file_path and upload_file:
547
+ try:
548
+ os.unlink(file_path)
549
+ except Exception:
550
+ pass
551
+ raise
552
+
553
+ except HTTPException:
554
+ # Clean up uploaded file on HTTP exceptions if we created it
555
+ if file_path and upload_file:
556
+ try:
557
+ os.unlink(file_path)
558
+ except Exception:
559
+ pass
560
+ raise
561
+ except InvalidInputError as e:
562
+ # Clean up uploaded file on validation errors if we created it
563
+ if file_path and upload_file:
564
+ try:
565
+ os.unlink(file_path)
566
+ except Exception:
567
+ pass
568
+ raise HTTPException(status_code=400, detail=str(e))
569
+ except Exception as e:
570
+ logger.error(f"Error creating source: {str(e)}")
571
+ # Clean up uploaded file on unexpected errors if we created it
572
+ if file_path and upload_file:
573
+ try:
574
+ os.unlink(file_path)
575
+ except Exception:
576
+ pass
577
+ raise HTTPException(status_code=500, detail=f"Error creating source: {str(e)}")
578
+
579
+
580
+ @router.post("/sources/json", response_model=SourceResponse)
581
+ async def create_source_json(source_data: SourceCreate):
582
+ """Create a new source using JSON payload (legacy endpoint for backward compatibility)."""
583
+ # Convert to form data format and call main endpoint
584
+ form_data = (source_data, None)
585
+ return await create_source(form_data)
586
+
587
+
588
+ async def _resolve_source_file(source_id: str) -> tuple[str, str]:
589
+ source = await Source.get(source_id)
590
+ if not source:
591
+ raise HTTPException(status_code=404, detail="Source not found")
592
+
593
+ file_path = source.asset.file_path if source.asset else None
594
+ if not file_path:
595
+ raise HTTPException(status_code=404, detail="Source has no file to download")
596
+
597
+ safe_root = os.path.realpath(UPLOADS_FOLDER)
598
+ resolved_path = os.path.realpath(file_path)
599
+
600
+ if not resolved_path.startswith(safe_root):
601
+ logger.warning(
602
+ f"Blocked download outside uploads directory for source {source_id}: {resolved_path}"
603
+ )
604
+ raise HTTPException(status_code=403, detail="Access to file denied")
605
+
606
+ if not os.path.exists(resolved_path):
607
+ raise HTTPException(status_code=404, detail="File not found on server")
608
+
609
+ filename = os.path.basename(resolved_path)
610
+ return resolved_path, filename
611
+
612
+
613
+ def _is_source_file_available(source: Source) -> Optional[bool]:
614
+ if not source or not source.asset or not source.asset.file_path:
615
+ return None
616
+
617
+ file_path = source.asset.file_path
618
+ safe_root = os.path.realpath(UPLOADS_FOLDER)
619
+ resolved_path = os.path.realpath(file_path)
620
+
621
+ if not resolved_path.startswith(safe_root):
622
+ return False
623
+
624
+ return os.path.exists(resolved_path)
625
+
626
+
627
+ @router.get("/sources/{source_id}", response_model=SourceResponse)
628
+ async def get_source(source_id: str):
629
+ """Get a specific source by ID."""
630
+ try:
631
+ source = await Source.get(source_id)
632
+ if not source:
633
+ raise HTTPException(status_code=404, detail="Source not found")
634
+
635
+ # Get status information if command exists
636
+ status = None
637
+ processing_info = None
638
+ if source.command:
639
+ try:
640
+ status = await source.get_status()
641
+ processing_info = await source.get_processing_progress()
642
+ except Exception as e:
643
+ logger.warning(f"Failed to get status for source {source_id}: {e}")
644
+ status = "unknown"
645
+
646
+ embedded_chunks = await source.get_embedded_chunks()
647
+
648
+ # Get associated notebooks
649
+ notebooks_query = await repo_query(
650
+ "SELECT VALUE out FROM reference WHERE in = $source_id",
651
+ {"source_id": ensure_record_id(source.id or source_id)},
652
+ )
653
+ notebook_ids = (
654
+ [str(nb_id) for nb_id in notebooks_query] if notebooks_query else []
655
+ )
656
+
657
+ return SourceResponse(
658
+ id=source.id or "",
659
+ title=source.title,
660
+ topics=source.topics or [],
661
+ asset=AssetModel(
662
+ file_path=source.asset.file_path if source.asset else None,
663
+ url=source.asset.url if source.asset else None,
664
+ )
665
+ if source.asset
666
+ else None,
667
+ full_text=source.full_text,
668
+ embedded=embedded_chunks > 0,
669
+ embedded_chunks=embedded_chunks,
670
+ file_available=_is_source_file_available(source),
671
+ created=str(source.created),
672
+ updated=str(source.updated),
673
+ # Status fields
674
+ command_id=str(source.command) if source.command else None,
675
+ status=status,
676
+ processing_info=processing_info,
677
+ # Notebook associations
678
+ notebooks=notebook_ids,
679
+ )
680
+ except HTTPException:
681
+ raise
682
+ except Exception as e:
683
+ logger.error(f"Error fetching source {source_id}: {str(e)}")
684
+ raise HTTPException(status_code=500, detail=f"Error fetching source: {str(e)}")
685
+
686
+
687
+ @router.head("/sources/{source_id}/download")
688
+ async def check_source_file(source_id: str):
689
+ """Check if a source has a downloadable file."""
690
+ try:
691
+ await _resolve_source_file(source_id)
692
+ return Response(status_code=200)
693
+ except HTTPException:
694
+ raise
695
+ except Exception as e:
696
+ logger.error(f"Error checking file for source {source_id}: {str(e)}")
697
+ raise HTTPException(status_code=500, detail="Failed to verify file")
698
+
699
+
700
+ @router.get("/sources/{source_id}/download")
701
+ async def download_source_file(source_id: str):
702
+ """Download the original file associated with an uploaded source."""
703
+ try:
704
+ resolved_path, filename = await _resolve_source_file(source_id)
705
+ return FileResponse(
706
+ path=resolved_path,
707
+ filename=filename,
708
+ media_type="application/octet-stream",
709
+ )
710
+ except HTTPException:
711
+ raise
712
+ except Exception as e:
713
+ logger.error(f"Error downloading file for source {source_id}: {str(e)}")
714
+ raise HTTPException(status_code=500, detail="Failed to download source file")
715
+
716
+
717
+ @router.get("/sources/{source_id}/status", response_model=SourceStatusResponse)
718
+ async def get_source_status(source_id: str):
719
+ """Get processing status for a source."""
720
+ try:
721
+ # First, verify source exists
722
+ source = await Source.get(source_id)
723
+ if not source:
724
+ raise HTTPException(status_code=404, detail="Source not found")
725
+
726
+ # Check if this is a legacy source (no command)
727
+ if not source.command:
728
+ return SourceStatusResponse(
729
+ status=None,
730
+ message="Legacy source (completed before async processing)",
731
+ processing_info=None,
732
+ command_id=None,
733
+ )
734
+
735
+ # Get command status and processing info
736
+ try:
737
+ status = await source.get_status()
738
+ processing_info = await source.get_processing_progress()
739
+
740
+ # Generate descriptive message based on status
741
+ if status == "completed":
742
+ message = "Source processing completed successfully"
743
+ elif status == "failed":
744
+ message = "Source processing failed"
745
+ elif status == "running":
746
+ message = "Source processing in progress"
747
+ elif status == "queued":
748
+ message = "Source processing queued"
749
+ elif status == "unknown":
750
+ message = "Source processing status unknown"
751
+ else:
752
+ message = f"Source processing status: {status}"
753
+
754
+ return SourceStatusResponse(
755
+ status=status,
756
+ message=message,
757
+ processing_info=processing_info,
758
+ command_id=str(source.command) if source.command else None,
759
+ )
760
+
761
+ except Exception as e:
762
+ logger.warning(f"Failed to get status for source {source_id}: {e}")
763
+ return SourceStatusResponse(
764
+ status="unknown",
765
+ message="Failed to retrieve processing status",
766
+ processing_info=None,
767
+ command_id=str(source.command) if source.command else None,
768
+ )
769
+
770
+ except HTTPException:
771
+ raise
772
+ except Exception as e:
773
+ logger.error(f"Error fetching status for source {source_id}: {str(e)}")
774
+ raise HTTPException(
775
+ status_code=500, detail=f"Error fetching source status: {str(e)}"
776
+ )
777
+
778
+
779
+ @router.put("/sources/{source_id}", response_model=SourceResponse)
780
+ async def update_source(source_id: str, source_update: SourceUpdate):
781
+ """Update a source."""
782
+ try:
783
+ source = await Source.get(source_id)
784
+ if not source:
785
+ raise HTTPException(status_code=404, detail="Source not found")
786
+
787
+ # Update only provided fields
788
+ if source_update.title is not None:
789
+ source.title = source_update.title
790
+ if source_update.topics is not None:
791
+ source.topics = source_update.topics
792
+
793
+ await source.save()
794
+
795
+ embedded_chunks = await source.get_embedded_chunks()
796
+ return SourceResponse(
797
+ id=source.id or "",
798
+ title=source.title,
799
+ topics=source.topics or [],
800
+ asset=AssetModel(
801
+ file_path=source.asset.file_path if source.asset else None,
802
+ url=source.asset.url if source.asset else None,
803
+ )
804
+ if source.asset
805
+ else None,
806
+ full_text=source.full_text,
807
+ embedded=embedded_chunks > 0,
808
+ embedded_chunks=embedded_chunks,
809
+ created=str(source.created),
810
+ updated=str(source.updated),
811
+ )
812
+ except HTTPException:
813
+ raise
814
+ except InvalidInputError as e:
815
+ raise HTTPException(status_code=400, detail=str(e))
816
+ except Exception as e:
817
+ logger.error(f"Error updating source {source_id}: {str(e)}")
818
+ raise HTTPException(status_code=500, detail=f"Error updating source: {str(e)}")
819
+
820
+
821
+ @router.post("/sources/{source_id}/retry", response_model=SourceResponse)
822
+ async def retry_source_processing(source_id: str):
823
+ """Retry processing for a failed or stuck source."""
824
+ try:
825
+ # First, verify source exists
826
+ source = await Source.get(source_id)
827
+ if not source:
828
+ raise HTTPException(status_code=404, detail="Source not found")
829
+
830
+ # Check if source already has a running command
831
+ if source.command:
832
+ try:
833
+ status = await source.get_status()
834
+ if status in ["running", "queued"]:
835
+ raise HTTPException(
836
+ status_code=400,
837
+ detail="Source is already processing. Cannot retry while processing is active.",
838
+ )
839
+ except Exception as e:
840
+ logger.warning(
841
+ f"Failed to check current status for source {source_id}: {e}"
842
+ )
843
+ # Continue with retry if we can't check status
844
+
845
+ # Get notebooks that this source belongs to
846
+ query = "SELECT notebook FROM reference WHERE source = $source_id"
847
+ references = await repo_query(query, {"source_id": source_id})
848
+ notebook_ids = [str(ref["notebook"]) for ref in references]
849
+
850
+ if not notebook_ids:
851
+ raise HTTPException(
852
+ status_code=400, detail="Source is not associated with any notebooks"
853
+ )
854
+
855
+ # Prepare content_state based on source asset
856
+ content_state = {}
857
+ if source.asset:
858
+ if source.asset.file_path:
859
+ content_state = {
860
+ "file_path": source.asset.file_path,
861
+ "delete_source": False, # Don't delete on retry
862
+ }
863
+ elif source.asset.url:
864
+ content_state = {"url": source.asset.url}
865
+ else:
866
+ raise HTTPException(
867
+ status_code=400, detail="Source asset has no file_path or url"
868
+ )
869
+ else:
870
+ # Check if it's a text source by trying to get full_text
871
+ if source.full_text:
872
+ content_state = {"content": source.full_text}
873
+ else:
874
+ raise HTTPException(
875
+ status_code=400, detail="Cannot determine source content for retry"
876
+ )
877
+
878
+ try:
879
+ # Import command modules to ensure they're registered
880
+ import commands.source_commands # noqa: F401
881
+
882
+ # Submit new command for background processing
883
+ command_input = SourceProcessingInput(
884
+ source_id=str(source.id),
885
+ content_state=content_state,
886
+ notebook_ids=notebook_ids,
887
+ transformations=[], # Use default transformations on retry
888
+ embed=True, # Always embed on retry
889
+ )
890
+
891
+ command_id = await CommandService.submit_command_job(
892
+ "open_notebook", # app name
893
+ "process_source", # command name
894
+ command_input.model_dump(),
895
+ )
896
+
897
+ logger.info(
898
+ f"Submitted retry processing command: {command_id} for source {source_id}"
899
+ )
900
+
901
+ # Update source with new command ID
902
+ source.command = ensure_record_id(f"command:{command_id}")
903
+ await source.save()
904
+
905
+ # Get current embedded chunks count
906
+ embedded_chunks = await source.get_embedded_chunks()
907
+
908
+ # Return updated source response
909
+ return SourceResponse(
910
+ id=source.id or "",
911
+ title=source.title,
912
+ topics=source.topics or [],
913
+ asset=AssetModel(
914
+ file_path=source.asset.file_path if source.asset else None,
915
+ url=source.asset.url if source.asset else None,
916
+ )
917
+ if source.asset
918
+ else None,
919
+ full_text=source.full_text,
920
+ embedded=embedded_chunks > 0,
921
+ embedded_chunks=embedded_chunks,
922
+ created=str(source.created),
923
+ updated=str(source.updated),
924
+ command_id=command_id,
925
+ status="queued",
926
+ processing_info={"retry": True, "queued": True},
927
+ )
928
+
929
+ except Exception as e:
930
+ logger.error(
931
+ f"Failed to submit retry processing command for source {source_id}: {e}"
932
+ )
933
+ raise HTTPException(
934
+ status_code=500, detail=f"Failed to queue retry processing: {str(e)}"
935
+ )
936
+
937
+ except HTTPException:
938
+ raise
939
+ except Exception as e:
940
+ logger.error(f"Error retrying source processing for {source_id}: {str(e)}")
941
+ raise HTTPException(
942
+ status_code=500, detail=f"Error retrying source processing: {str(e)}"
943
+ )
944
+
945
+
946
+ @router.delete("/sources/{source_id}")
947
+ async def delete_source(source_id: str):
948
+ """Delete a source."""
949
+ try:
950
+ source = await Source.get(source_id)
951
+ if not source:
952
+ raise HTTPException(status_code=404, detail="Source not found")
953
+
954
+ await source.delete()
955
+
956
+ return {"message": "Source deleted successfully"}
957
+ except HTTPException:
958
+ raise
959
+ except Exception as e:
960
+ logger.error(f"Error deleting source {source_id}: {str(e)}")
961
+ raise HTTPException(status_code=500, detail=f"Error deleting source: {str(e)}")
962
+
963
+
964
+ @router.get("/sources/{source_id}/insights", response_model=List[SourceInsightResponse])
965
+ async def get_source_insights(source_id: str):
966
+ """Get all insights for a specific source."""
967
+ try:
968
+ source = await Source.get(source_id)
969
+ if not source:
970
+ raise HTTPException(status_code=404, detail="Source not found")
971
+
972
+ insights = await source.get_insights()
973
+ return [
974
+ SourceInsightResponse(
975
+ id=insight.id or "",
976
+ source_id=source_id,
977
+ insight_type=insight.insight_type,
978
+ content=insight.content,
979
+ created=str(insight.created),
980
+ updated=str(insight.updated),
981
+ )
982
+ for insight in insights
983
+ ]
984
+ except HTTPException:
985
+ raise
986
+ except Exception as e:
987
+ logger.error(f"Error fetching insights for source {source_id}: {str(e)}")
988
+ raise HTTPException(
989
+ status_code=500, detail=f"Error fetching insights: {str(e)}"
990
+ )
991
+
992
+
993
+ @router.post(
994
+ "/sources/{source_id}/insights",
995
+ response_model=InsightCreationResponse,
996
+ status_code=202,
997
+ )
998
+ async def create_source_insight(source_id: str, request: CreateSourceInsightRequest):
999
+ """
1000
+ Start insight generation for a source by running a transformation.
1001
+
1002
+ This endpoint returns immediately with a 202 Accepted status.
1003
+ The transformation runs asynchronously in the background via the job queue.
1004
+ Poll GET /sources/{source_id}/insights to see when the insight is ready.
1005
+ """
1006
+ try:
1007
+ # Validate source exists
1008
+ source = await Source.get(source_id)
1009
+ if not source:
1010
+ raise HTTPException(status_code=404, detail="Source not found")
1011
+
1012
+ # Validate transformation exists
1013
+ transformation = await Transformation.get(request.transformation_id)
1014
+ if not transformation:
1015
+ raise HTTPException(status_code=404, detail="Transformation not found")
1016
+
1017
+ # Submit transformation as background job (fire-and-forget)
1018
+ command_id = submit_command(
1019
+ "open_notebook",
1020
+ "run_transformation",
1021
+ {
1022
+ "source_id": source_id,
1023
+ "transformation_id": request.transformation_id,
1024
+ },
1025
+ )
1026
+ logger.info(
1027
+ f"Submitted run_transformation command {command_id} for source {source_id}"
1028
+ )
1029
+
1030
+ # Return immediately with command_id for status tracking
1031
+ return InsightCreationResponse(
1032
+ status="pending",
1033
+ message="Insight generation started",
1034
+ source_id=source_id,
1035
+ transformation_id=request.transformation_id,
1036
+ command_id=str(command_id),
1037
+ )
1038
+
1039
+ except HTTPException:
1040
+ raise
1041
+ except Exception as e:
1042
+ logger.error(f"Error starting insight generation for source {source_id}: {e}")
1043
+ raise HTTPException(
1044
+ status_code=500, detail=f"Error starting insight generation: {str(e)}"
1045
+ )
api/routers/speaker_profiles.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Optional
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from loguru import logger
5
+ from pydantic import BaseModel, Field
6
+
7
+ from open_notebook.podcasts.models import SpeakerProfile
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ class SpeakerProfileResponse(BaseModel):
13
+ id: str
14
+ name: str
15
+ description: str
16
+ voice_model: Optional[str] = None
17
+ speakers: List[Dict[str, Any]]
18
+ # Legacy fields (for display/migration awareness)
19
+ tts_provider: Optional[str] = None
20
+ tts_model: Optional[str] = None
21
+
22
+
23
+ def _profile_to_response(profile: SpeakerProfile) -> SpeakerProfileResponse:
24
+ return SpeakerProfileResponse(
25
+ id=str(profile.id),
26
+ name=profile.name,
27
+ description=profile.description or "",
28
+ voice_model=profile.voice_model,
29
+ speakers=profile.speakers,
30
+ tts_provider=profile.tts_provider,
31
+ tts_model=profile.tts_model,
32
+ )
33
+
34
+
35
+ @router.get("/speaker-profiles", response_model=List[SpeakerProfileResponse])
36
+ async def list_speaker_profiles():
37
+ """List all available speaker profiles"""
38
+ try:
39
+ profiles = await SpeakerProfile.get_all(order_by="name asc")
40
+ return [_profile_to_response(p) for p in profiles]
41
+ except Exception as e:
42
+ logger.error(f"Failed to fetch speaker profiles: {e}")
43
+ raise HTTPException(
44
+ status_code=500, detail="Failed to fetch speaker profiles"
45
+ )
46
+
47
+
48
+ @router.get("/speaker-profiles/{profile_name}", response_model=SpeakerProfileResponse)
49
+ async def get_speaker_profile(profile_name: str):
50
+ """Get a specific speaker profile by name"""
51
+ try:
52
+ profile = await SpeakerProfile.get_by_name(profile_name)
53
+
54
+ if not profile:
55
+ raise HTTPException(
56
+ status_code=404, detail=f"Speaker profile '{profile_name}' not found"
57
+ )
58
+
59
+ return _profile_to_response(profile)
60
+
61
+ except HTTPException:
62
+ raise
63
+ except Exception as e:
64
+ logger.error(f"Failed to fetch speaker profile '{profile_name}': {e}")
65
+ raise HTTPException(
66
+ status_code=500, detail="Failed to fetch speaker profile"
67
+ )
68
+
69
+
70
+ class SpeakerProfileCreate(BaseModel):
71
+ name: str = Field(..., description="Unique profile name")
72
+ description: str = Field("", description="Profile description")
73
+ voice_model: Optional[str] = Field(None, description="Model record ID for TTS")
74
+ speakers: List[Dict[str, Any]] = Field(
75
+ ..., description="Array of speaker configurations"
76
+ )
77
+ # Legacy fields (accepted but not required)
78
+ tts_provider: Optional[str] = None
79
+ tts_model: Optional[str] = None
80
+
81
+
82
+ @router.post("/speaker-profiles", response_model=SpeakerProfileResponse)
83
+ async def create_speaker_profile(profile_data: SpeakerProfileCreate):
84
+ """Create a new speaker profile"""
85
+ try:
86
+ profile = SpeakerProfile(
87
+ name=profile_data.name,
88
+ description=profile_data.description,
89
+ voice_model=profile_data.voice_model,
90
+ speakers=profile_data.speakers,
91
+ tts_provider=profile_data.tts_provider,
92
+ tts_model=profile_data.tts_model,
93
+ )
94
+
95
+ await profile.save()
96
+ return _profile_to_response(profile)
97
+
98
+ except Exception as e:
99
+ logger.error(f"Failed to create speaker profile: {e}")
100
+ raise HTTPException(
101
+ status_code=500, detail="Failed to create speaker profile"
102
+ )
103
+
104
+
105
+ @router.put("/speaker-profiles/{profile_id}", response_model=SpeakerProfileResponse)
106
+ async def update_speaker_profile(profile_id: str, profile_data: SpeakerProfileCreate):
107
+ """Update an existing speaker profile"""
108
+ try:
109
+ profile = await SpeakerProfile.get(profile_id)
110
+
111
+ if not profile:
112
+ raise HTTPException(
113
+ status_code=404, detail=f"Speaker profile '{profile_id}' not found"
114
+ )
115
+
116
+ profile.name = profile_data.name
117
+ profile.description = profile_data.description
118
+ profile.voice_model = profile_data.voice_model
119
+ profile.speakers = profile_data.speakers
120
+ profile.tts_provider = profile_data.tts_provider
121
+ profile.tts_model = profile_data.tts_model
122
+
123
+ await profile.save()
124
+ return _profile_to_response(profile)
125
+
126
+ except HTTPException:
127
+ raise
128
+ except Exception as e:
129
+ logger.error(f"Failed to update speaker profile: {e}")
130
+ raise HTTPException(
131
+ status_code=500, detail="Failed to update speaker profile"
132
+ )
133
+
134
+
135
+ @router.delete("/speaker-profiles/{profile_id}")
136
+ async def delete_speaker_profile(profile_id: str):
137
+ """Delete a speaker profile"""
138
+ try:
139
+ profile = await SpeakerProfile.get(profile_id)
140
+
141
+ if not profile:
142
+ raise HTTPException(
143
+ status_code=404, detail=f"Speaker profile '{profile_id}' not found"
144
+ )
145
+
146
+ await profile.delete()
147
+
148
+ return {"message": "Speaker profile deleted successfully"}
149
+
150
+ except HTTPException:
151
+ raise
152
+ except Exception as e:
153
+ logger.error(f"Failed to delete speaker profile: {e}")
154
+ raise HTTPException(
155
+ status_code=500, detail="Failed to delete speaker profile"
156
+ )
157
+
158
+
159
+ @router.post(
160
+ "/speaker-profiles/{profile_id}/duplicate", response_model=SpeakerProfileResponse
161
+ )
162
+ async def duplicate_speaker_profile(profile_id: str):
163
+ """Duplicate a speaker profile"""
164
+ try:
165
+ original = await SpeakerProfile.get(profile_id)
166
+
167
+ if not original:
168
+ raise HTTPException(
169
+ status_code=404, detail=f"Speaker profile '{profile_id}' not found"
170
+ )
171
+
172
+ duplicate = SpeakerProfile(
173
+ name=f"{original.name} - Copy",
174
+ description=original.description,
175
+ voice_model=original.voice_model,
176
+ speakers=original.speakers,
177
+ tts_provider=original.tts_provider,
178
+ tts_model=original.tts_model,
179
+ )
180
+
181
+ await duplicate.save()
182
+ return _profile_to_response(duplicate)
183
+
184
+ except HTTPException:
185
+ raise
186
+ except Exception as e:
187
+ logger.error(f"Failed to duplicate speaker profile: {e}")
188
+ raise HTTPException(
189
+ status_code=500, detail="Failed to duplicate speaker profile"
190
+ )
api/routers/transformations.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from loguru import logger
5
+
6
+ from api.models import (
7
+ DefaultPromptResponse,
8
+ DefaultPromptUpdate,
9
+ TransformationCreate,
10
+ TransformationExecuteRequest,
11
+ TransformationExecuteResponse,
12
+ TransformationResponse,
13
+ TransformationUpdate,
14
+ )
15
+ from open_notebook.ai.models import Model
16
+ from open_notebook.domain.transformation import DefaultPrompts, Transformation
17
+ from open_notebook.exceptions import InvalidInputError, OpenNotebookError
18
+ from open_notebook.graphs.transformation import graph as transformation_graph
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ @router.get("/transformations", response_model=List[TransformationResponse])
24
+ async def get_transformations():
25
+ """Get all transformations."""
26
+ try:
27
+ transformations = await Transformation.get_all(order_by="name asc")
28
+
29
+ return [
30
+ TransformationResponse(
31
+ id=transformation.id or "",
32
+ name=transformation.name,
33
+ title=transformation.title,
34
+ description=transformation.description,
35
+ prompt=transformation.prompt,
36
+ apply_default=transformation.apply_default,
37
+ created=str(transformation.created),
38
+ updated=str(transformation.updated),
39
+ )
40
+ for transformation in transformations
41
+ ]
42
+ except Exception as e:
43
+ logger.error(f"Error fetching transformations: {str(e)}")
44
+ raise HTTPException(
45
+ status_code=500, detail=f"Error fetching transformations: {str(e)}"
46
+ )
47
+
48
+
49
+ @router.post("/transformations", response_model=TransformationResponse)
50
+ async def create_transformation(transformation_data: TransformationCreate):
51
+ """Create a new transformation."""
52
+ try:
53
+ new_transformation = Transformation(
54
+ name=transformation_data.name,
55
+ title=transformation_data.title,
56
+ description=transformation_data.description,
57
+ prompt=transformation_data.prompt,
58
+ apply_default=transformation_data.apply_default,
59
+ )
60
+ await new_transformation.save()
61
+
62
+ return TransformationResponse(
63
+ id=new_transformation.id or "",
64
+ name=new_transformation.name,
65
+ title=new_transformation.title,
66
+ description=new_transformation.description,
67
+ prompt=new_transformation.prompt,
68
+ apply_default=new_transformation.apply_default,
69
+ created=str(new_transformation.created),
70
+ updated=str(new_transformation.updated),
71
+ )
72
+ except InvalidInputError as e:
73
+ raise HTTPException(status_code=400, detail=str(e))
74
+ except Exception as e:
75
+ logger.error(f"Error creating transformation: {str(e)}")
76
+ raise HTTPException(
77
+ status_code=500, detail=f"Error creating transformation: {str(e)}"
78
+ )
79
+
80
+
81
+ @router.post("/transformations/execute", response_model=TransformationExecuteResponse)
82
+ async def execute_transformation(execute_request: TransformationExecuteRequest):
83
+ """Execute a transformation on input text."""
84
+ try:
85
+ # Validate transformation exists
86
+ transformation = await Transformation.get(execute_request.transformation_id)
87
+ if not transformation:
88
+ raise HTTPException(status_code=404, detail="Transformation not found")
89
+
90
+ # Validate model exists
91
+ model = await Model.get(execute_request.model_id)
92
+ if not model:
93
+ raise HTTPException(status_code=404, detail="Model not found")
94
+
95
+ # Execute the transformation
96
+ result = await transformation_graph.ainvoke(
97
+ dict( # type: ignore[arg-type]
98
+ input_text=execute_request.input_text,
99
+ transformation=transformation,
100
+ ),
101
+ config=dict(configurable={"model_id": execute_request.model_id}),
102
+ )
103
+
104
+ return TransformationExecuteResponse(
105
+ output=result["output"],
106
+ transformation_id=execute_request.transformation_id,
107
+ model_id=execute_request.model_id,
108
+ )
109
+
110
+ except HTTPException:
111
+ raise
112
+ except OpenNotebookError:
113
+ raise # Let global exception handlers return proper status codes
114
+ except Exception as e:
115
+ logger.error(f"Error executing transformation: {str(e)}")
116
+ raise HTTPException(
117
+ status_code=500, detail=f"Error executing transformation: {str(e)}"
118
+ )
119
+
120
+
121
+ @router.get("/transformations/default-prompt", response_model=DefaultPromptResponse)
122
+ async def get_default_prompt():
123
+ """Get the default transformation prompt."""
124
+ try:
125
+ default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment]
126
+
127
+ return DefaultPromptResponse(
128
+ transformation_instructions=default_prompts.transformation_instructions
129
+ or ""
130
+ )
131
+ except Exception as e:
132
+ logger.error(f"Error fetching default prompt: {str(e)}")
133
+ raise HTTPException(
134
+ status_code=500, detail=f"Error fetching default prompt: {str(e)}"
135
+ )
136
+
137
+
138
+ @router.put("/transformations/default-prompt", response_model=DefaultPromptResponse)
139
+ async def update_default_prompt(prompt_update: DefaultPromptUpdate):
140
+ """Update the default transformation prompt."""
141
+ try:
142
+ default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment]
143
+
144
+ default_prompts.transformation_instructions = (
145
+ prompt_update.transformation_instructions
146
+ )
147
+ await default_prompts.update()
148
+
149
+ return DefaultPromptResponse(
150
+ transformation_instructions=default_prompts.transformation_instructions
151
+ )
152
+ except Exception as e:
153
+ logger.error(f"Error updating default prompt: {str(e)}")
154
+ raise HTTPException(
155
+ status_code=500, detail=f"Error updating default prompt: {str(e)}"
156
+ )
157
+
158
+
159
+ @router.get(
160
+ "/transformations/{transformation_id}", response_model=TransformationResponse
161
+ )
162
+ async def get_transformation(transformation_id: str):
163
+ """Get a specific transformation by ID."""
164
+ try:
165
+ transformation = await Transformation.get(transformation_id)
166
+ if not transformation:
167
+ raise HTTPException(status_code=404, detail="Transformation not found")
168
+
169
+ return TransformationResponse(
170
+ id=transformation.id or "",
171
+ name=transformation.name,
172
+ title=transformation.title,
173
+ description=transformation.description,
174
+ prompt=transformation.prompt,
175
+ apply_default=transformation.apply_default,
176
+ created=str(transformation.created),
177
+ updated=str(transformation.updated),
178
+ )
179
+ except HTTPException:
180
+ raise
181
+ except Exception as e:
182
+ logger.error(f"Error fetching transformation {transformation_id}: {str(e)}")
183
+ raise HTTPException(
184
+ status_code=500, detail=f"Error fetching transformation: {str(e)}"
185
+ )
186
+
187
+
188
+ @router.put(
189
+ "/transformations/{transformation_id}", response_model=TransformationResponse
190
+ )
191
+ async def update_transformation(
192
+ transformation_id: str, transformation_update: TransformationUpdate
193
+ ):
194
+ """Update a transformation."""
195
+ try:
196
+ transformation = await Transformation.get(transformation_id)
197
+ if not transformation:
198
+ raise HTTPException(status_code=404, detail="Transformation not found")
199
+
200
+ # Update only provided fields
201
+ if transformation_update.name is not None:
202
+ transformation.name = transformation_update.name
203
+ if transformation_update.title is not None:
204
+ transformation.title = transformation_update.title
205
+ if transformation_update.description is not None:
206
+ transformation.description = transformation_update.description
207
+ if transformation_update.prompt is not None:
208
+ transformation.prompt = transformation_update.prompt
209
+ if transformation_update.apply_default is not None:
210
+ transformation.apply_default = transformation_update.apply_default
211
+
212
+ await transformation.save()
213
+
214
+ return TransformationResponse(
215
+ id=transformation.id or "",
216
+ name=transformation.name,
217
+ title=transformation.title,
218
+ description=transformation.description,
219
+ prompt=transformation.prompt,
220
+ apply_default=transformation.apply_default,
221
+ created=str(transformation.created),
222
+ updated=str(transformation.updated),
223
+ )
224
+ except HTTPException:
225
+ raise
226
+ except InvalidInputError as e:
227
+ raise HTTPException(status_code=400, detail=str(e))
228
+ except Exception as e:
229
+ logger.error(f"Error updating transformation {transformation_id}: {str(e)}")
230
+ raise HTTPException(
231
+ status_code=500, detail=f"Error updating transformation: {str(e)}"
232
+ )
233
+
234
+
235
+ @router.delete("/transformations/{transformation_id}")
236
+ async def delete_transformation(transformation_id: str):
237
+ """Delete a transformation."""
238
+ try:
239
+ transformation = await Transformation.get(transformation_id)
240
+ if not transformation:
241
+ raise HTTPException(status_code=404, detail="Transformation not found")
242
+
243
+ await transformation.delete()
244
+
245
+ return {"message": "Transformation deleted successfully"}
246
+ except HTTPException:
247
+ raise
248
+ except Exception as e:
249
+ logger.error(f"Error deleting transformation {transformation_id}: {str(e)}")
250
+ raise HTTPException(
251
+ status_code=500, detail=f"Error deleting transformation: {str(e)}"
252
+ )
api/search_service.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Search service layer using API.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Union
6
+
7
+ from loguru import logger
8
+
9
+ from api.client import api_client
10
+
11
+
12
+ class SearchService:
13
+ """Service layer for search operations using API."""
14
+
15
+ def __init__(self):
16
+ logger.info("Using API for search operations")
17
+
18
+ def search(
19
+ self,
20
+ query: str,
21
+ search_type: str = "text",
22
+ limit: int = 100,
23
+ search_sources: bool = True,
24
+ search_notes: bool = True,
25
+ minimum_score: float = 0.2,
26
+ ) -> List[Dict[str, Any]]:
27
+ """Search the knowledge base."""
28
+ response = api_client.search(
29
+ query=query,
30
+ search_type=search_type,
31
+ limit=limit,
32
+ search_sources=search_sources,
33
+ search_notes=search_notes,
34
+ minimum_score=minimum_score,
35
+ )
36
+ if isinstance(response, dict):
37
+ return response.get("results", [])
38
+ return []
39
+
40
+ def ask_knowledge_base(
41
+ self,
42
+ question: str,
43
+ strategy_model: str,
44
+ answer_model: str,
45
+ final_answer_model: str,
46
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
47
+ """Ask the knowledge base a question."""
48
+ response = api_client.ask_simple(
49
+ question=question,
50
+ strategy_model=strategy_model,
51
+ answer_model=answer_model,
52
+ final_answer_model=final_answer_model,
53
+ )
54
+ return response
55
+
56
+
57
+ # Global service instance
58
+ search_service = SearchService()
api/settings_service.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Settings service layer using API.
3
+ """
4
+
5
+ from loguru import logger
6
+
7
+ from api.client import api_client
8
+ from open_notebook.domain.content_settings import ContentSettings
9
+
10
+
11
+ class SettingsService:
12
+ """Service layer for settings operations using API."""
13
+
14
+ def __init__(self):
15
+ logger.info("Using API for settings operations")
16
+
17
+ def get_settings(self) -> ContentSettings:
18
+ """Get application settings."""
19
+ settings_response = api_client.get_settings()
20
+ settings_data = (
21
+ settings_response
22
+ if isinstance(settings_response, dict)
23
+ else settings_response[0]
24
+ )
25
+
26
+ # Create ContentSettings object from API response
27
+ settings = ContentSettings(
28
+ default_content_processing_engine_doc=settings_data.get(
29
+ "default_content_processing_engine_doc"
30
+ ),
31
+ default_content_processing_engine_url=settings_data.get(
32
+ "default_content_processing_engine_url"
33
+ ),
34
+ default_embedding_option=settings_data.get("default_embedding_option"),
35
+ auto_delete_files=settings_data.get("auto_delete_files"),
36
+ youtube_preferred_languages=settings_data.get(
37
+ "youtube_preferred_languages"
38
+ ),
39
+ )
40
+
41
+ return settings
42
+
43
+ def update_settings(self, settings: ContentSettings) -> ContentSettings:
44
+ """Update application settings."""
45
+ updates = {
46
+ "default_content_processing_engine_doc": settings.default_content_processing_engine_doc,
47
+ "default_content_processing_engine_url": settings.default_content_processing_engine_url,
48
+ "default_embedding_option": settings.default_embedding_option,
49
+ "auto_delete_files": settings.auto_delete_files,
50
+ "youtube_preferred_languages": settings.youtube_preferred_languages,
51
+ }
52
+
53
+ settings_response = api_client.update_settings(**updates)
54
+ settings_data = (
55
+ settings_response
56
+ if isinstance(settings_response, dict)
57
+ else settings_response[0]
58
+ )
59
+
60
+ # Update the settings object with the response
61
+ settings.default_content_processing_engine_doc = settings_data.get(
62
+ "default_content_processing_engine_doc"
63
+ )
64
+ settings.default_content_processing_engine_url = settings_data.get(
65
+ "default_content_processing_engine_url"
66
+ )
67
+ settings.default_embedding_option = settings_data.get(
68
+ "default_embedding_option"
69
+ )
70
+ settings.auto_delete_files = settings_data.get("auto_delete_files")
71
+ settings.youtube_preferred_languages = settings_data.get(
72
+ "youtube_preferred_languages"
73
+ )
74
+
75
+ return settings
76
+
77
+
78
+ # Global service instance
79
+ settings_service = SettingsService()
api/sources_service.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sources service layer using API.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Dict, List, Optional, Union
7
+
8
+ from loguru import logger
9
+
10
+ from api.client import api_client
11
+ from open_notebook.domain.notebook import Asset, Source
12
+
13
+
14
+ @dataclass
15
+ class SourceProcessingResult:
16
+ """Result of source creation with optional async processing info."""
17
+
18
+ source: Source
19
+ is_async: bool = False
20
+ command_id: Optional[str] = None
21
+ status: Optional[str] = None
22
+ processing_info: Optional[Dict] = None
23
+
24
+
25
+ @dataclass
26
+ class SourceWithMetadata:
27
+ """Source object with additional metadata from API."""
28
+
29
+ source: Source
30
+ embedded_chunks: int
31
+
32
+ # Expose common source properties for easy access
33
+ @property
34
+ def id(self):
35
+ return self.source.id
36
+
37
+ @property
38
+ def title(self):
39
+ return self.source.title
40
+
41
+ @title.setter
42
+ def title(self, value):
43
+ self.source.title = value
44
+
45
+ @property
46
+ def topics(self):
47
+ return self.source.topics
48
+
49
+ @property
50
+ def asset(self):
51
+ return self.source.asset
52
+
53
+ @property
54
+ def full_text(self):
55
+ return self.source.full_text
56
+
57
+ @property
58
+ def created(self):
59
+ return self.source.created
60
+
61
+ @property
62
+ def updated(self):
63
+ return self.source.updated
64
+
65
+
66
+ class SourcesService:
67
+ """Service layer for sources operations using API."""
68
+
69
+ def __init__(self):
70
+ logger.info("Using API for sources operations")
71
+
72
+ def get_all_sources(
73
+ self, notebook_id: Optional[str] = None
74
+ ) -> List[SourceWithMetadata]:
75
+ """Get all sources with optional notebook filtering."""
76
+ sources_data = api_client.get_sources(notebook_id=notebook_id)
77
+ # Convert API response to SourceWithMetadata objects
78
+ sources = []
79
+ for source_data in sources_data:
80
+ source = Source(
81
+ title=source_data["title"],
82
+ topics=source_data["topics"],
83
+ asset=Asset(
84
+ file_path=source_data["asset"]["file_path"]
85
+ if source_data["asset"]
86
+ else None,
87
+ url=source_data["asset"]["url"] if source_data["asset"] else None,
88
+ )
89
+ if source_data["asset"]
90
+ else None,
91
+ )
92
+ source.id = source_data["id"]
93
+ source.created = source_data["created"]
94
+ source.updated = source_data["updated"]
95
+
96
+ # Wrap in SourceWithMetadata
97
+ source_with_metadata = SourceWithMetadata(
98
+ source=source, embedded_chunks=source_data.get("embedded_chunks", 0)
99
+ )
100
+ sources.append(source_with_metadata)
101
+ return sources
102
+
103
+ def get_source(self, source_id: str) -> SourceWithMetadata:
104
+ """Get a specific source."""
105
+ response = api_client.get_source(source_id)
106
+ source_data = response if isinstance(response, dict) else response[0]
107
+ source = Source(
108
+ title=source_data["title"],
109
+ topics=source_data["topics"],
110
+ full_text=source_data["full_text"],
111
+ asset=Asset(
112
+ file_path=source_data["asset"]["file_path"]
113
+ if source_data["asset"]
114
+ else None,
115
+ url=source_data["asset"]["url"] if source_data["asset"] else None,
116
+ )
117
+ if source_data["asset"]
118
+ else None,
119
+ )
120
+ source.id = source_data["id"]
121
+ source.created = source_data["created"]
122
+ source.updated = source_data["updated"]
123
+
124
+ return SourceWithMetadata(
125
+ source=source, embedded_chunks=source_data.get("embedded_chunks", 0)
126
+ )
127
+
128
+ def create_source(
129
+ self,
130
+ notebook_id: Optional[str] = None,
131
+ source_type: str = "text",
132
+ url: Optional[str] = None,
133
+ file_path: Optional[str] = None,
134
+ content: Optional[str] = None,
135
+ title: Optional[str] = None,
136
+ transformations: Optional[List[str]] = None,
137
+ embed: bool = False,
138
+ delete_source: bool = False,
139
+ notebooks: Optional[List[str]] = None,
140
+ async_processing: bool = False,
141
+ ) -> Union[Source, SourceProcessingResult]:
142
+ """
143
+ Create a new source with support for async processing.
144
+
145
+ Args:
146
+ notebook_id: Single notebook ID (deprecated, use notebooks parameter)
147
+ source_type: Type of source (link, upload, text)
148
+ url: URL for link sources
149
+ file_path: File path for upload sources
150
+ content: Text content for text sources
151
+ title: Optional source title
152
+ transformations: List of transformation IDs to apply
153
+ embed: Whether to embed content for vector search
154
+ delete_source: Whether to delete uploaded file after processing
155
+ notebooks: List of notebook IDs to add source to (preferred over notebook_id)
156
+ async_processing: Whether to process source asynchronously
157
+
158
+ Returns:
159
+ Source object for sync processing (backward compatibility)
160
+ SourceProcessingResult for async processing (contains additional metadata)
161
+ """
162
+ source_data = api_client.create_source(
163
+ notebook_id=notebook_id,
164
+ notebooks=notebooks,
165
+ source_type=source_type,
166
+ url=url,
167
+ file_path=file_path,
168
+ content=content,
169
+ title=title,
170
+ transformations=transformations,
171
+ embed=embed,
172
+ delete_source=delete_source,
173
+ async_processing=async_processing,
174
+ )
175
+
176
+ # Create Source object from response
177
+ response_data = source_data if isinstance(source_data, dict) else source_data[0]
178
+ source = Source(
179
+ title=response_data["title"],
180
+ topics=response_data.get("topics") or [],
181
+ full_text=response_data.get("full_text"),
182
+ asset=Asset(
183
+ file_path=response_data["asset"]["file_path"]
184
+ if response_data.get("asset")
185
+ else None,
186
+ url=response_data["asset"]["url"]
187
+ if response_data.get("asset")
188
+ else None,
189
+ )
190
+ if response_data.get("asset")
191
+ else None,
192
+ )
193
+ source.id = response_data["id"]
194
+ source.created = response_data["created"]
195
+ source.updated = response_data["updated"]
196
+
197
+ # Check if this is an async processing response
198
+ if (
199
+ response_data.get("command_id")
200
+ or response_data.get("status")
201
+ or response_data.get("processing_info")
202
+ ):
203
+ # Ensure source_data is a dict for accessing attributes
204
+ source_data_dict = (
205
+ source_data if isinstance(source_data, dict) else source_data[0]
206
+ )
207
+ # Return enhanced result for async processing
208
+ return SourceProcessingResult(
209
+ source=source,
210
+ is_async=True,
211
+ command_id=source_data_dict.get("command_id"),
212
+ status=source_data_dict.get("status"),
213
+ processing_info=source_data_dict.get("processing_info"),
214
+ )
215
+ else:
216
+ # Return simple Source for backward compatibility
217
+ return source
218
+
219
+ def get_source_status(self, source_id: str) -> Dict:
220
+ """Get processing status for a source."""
221
+ response = api_client.get_source_status(source_id)
222
+ return response if isinstance(response, dict) else response[0]
223
+
224
+ def create_source_async(
225
+ self,
226
+ notebook_id: Optional[str] = None,
227
+ source_type: str = "text",
228
+ url: Optional[str] = None,
229
+ file_path: Optional[str] = None,
230
+ content: Optional[str] = None,
231
+ title: Optional[str] = None,
232
+ transformations: Optional[List[str]] = None,
233
+ embed: bool = False,
234
+ delete_source: bool = False,
235
+ notebooks: Optional[List[str]] = None,
236
+ ) -> SourceProcessingResult:
237
+ """
238
+ Create a new source with async processing enabled.
239
+
240
+ This is a convenience method that always uses async processing.
241
+ Returns a SourceProcessingResult with processing status information.
242
+ """
243
+ result = self.create_source(
244
+ notebook_id=notebook_id,
245
+ notebooks=notebooks,
246
+ source_type=source_type,
247
+ url=url,
248
+ file_path=file_path,
249
+ content=content,
250
+ title=title,
251
+ transformations=transformations,
252
+ embed=embed,
253
+ delete_source=delete_source,
254
+ async_processing=True,
255
+ )
256
+
257
+ # Since we forced async_processing=True, this should always be a SourceProcessingResult
258
+ if isinstance(result, SourceProcessingResult):
259
+ return result
260
+ else:
261
+ # Fallback: wrap Source in SourceProcessingResult
262
+ return SourceProcessingResult(
263
+ source=result,
264
+ is_async=False, # This shouldn't happen, but handle it gracefully
265
+ )
266
+
267
+ def is_source_processing_complete(self, source_id: str) -> bool:
268
+ """
269
+ Check if a source's async processing is complete.
270
+
271
+ Returns True if processing is complete (success or failure),
272
+ False if still processing or queued.
273
+ """
274
+ try:
275
+ status_data = self.get_source_status(source_id)
276
+ status = status_data.get("status")
277
+ return status in [
278
+ "completed",
279
+ "failed",
280
+ None,
281
+ ] # None indicates legacy/sync source
282
+ except Exception as e:
283
+ logger.error(f"Error checking source processing status: {e}")
284
+ return True # Assume complete on error
285
+
286
+ def update_source(self, source: Source) -> Source:
287
+ """Update a source."""
288
+ if not source.id:
289
+ raise ValueError("Source ID is required for update")
290
+
291
+ updates = {
292
+ "title": source.title,
293
+ "topics": source.topics,
294
+ }
295
+ source_data = api_client.update_source(source.id, **updates)
296
+
297
+ # Ensure source_data is a dict
298
+ source_data_dict = (
299
+ source_data if isinstance(source_data, dict) else source_data[0]
300
+ )
301
+
302
+ # Update the source object with the response
303
+ source.title = source_data_dict["title"]
304
+ source.topics = source_data_dict["topics"]
305
+ source.updated = source_data_dict["updated"]
306
+
307
+ return source
308
+
309
+ def delete_source(self, source_id: str) -> bool:
310
+ """Delete a source."""
311
+ api_client.delete_source(source_id)
312
+ return True
313
+
314
+
315
+ # Global service instance
316
+ sources_service = SourcesService()
317
+
318
+ # Export important classes for easy importing
319
+ __all__ = [
320
+ "SourcesService",
321
+ "SourceWithMetadata",
322
+ "SourceProcessingResult",
323
+ "sources_service",
324
+ ]
api/transformations_service.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Transformations service layer using API.
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Any, Dict, List, Union
7
+
8
+ from loguru import logger
9
+
10
+ from api.client import api_client
11
+ from open_notebook.domain.transformation import Transformation
12
+
13
+
14
+ class TransformationsService:
15
+ """Service layer for transformations operations using API."""
16
+
17
+ def __init__(self):
18
+ logger.info("Using API for transformations operations")
19
+
20
+ def get_all_transformations(self) -> List[Transformation]:
21
+ """Get all transformations."""
22
+ transformations_data = api_client.get_transformations()
23
+ # Convert API response to Transformation objects
24
+ transformations = []
25
+ for trans_data in transformations_data:
26
+ transformation = Transformation(
27
+ name=trans_data["name"],
28
+ title=trans_data["title"],
29
+ description=trans_data["description"],
30
+ prompt=trans_data["prompt"],
31
+ apply_default=trans_data["apply_default"],
32
+ )
33
+ transformation.id = trans_data["id"]
34
+ transformation.created = datetime.fromisoformat(
35
+ trans_data["created"].replace("Z", "+00:00")
36
+ )
37
+ transformation.updated = datetime.fromisoformat(
38
+ trans_data["updated"].replace("Z", "+00:00")
39
+ )
40
+ transformations.append(transformation)
41
+ return transformations
42
+
43
+ def get_transformation(self, transformation_id: str) -> Transformation:
44
+ """Get a specific transformation."""
45
+ response = api_client.get_transformation(transformation_id)
46
+ trans_data = response if isinstance(response, dict) else response[0]
47
+ transformation = Transformation(
48
+ name=trans_data["name"],
49
+ title=trans_data["title"],
50
+ description=trans_data["description"],
51
+ prompt=trans_data["prompt"],
52
+ apply_default=trans_data["apply_default"],
53
+ )
54
+ transformation.id = trans_data["id"]
55
+ transformation.created = datetime.fromisoformat(
56
+ trans_data["created"].replace("Z", "+00:00")
57
+ )
58
+ transformation.updated = datetime.fromisoformat(
59
+ trans_data["updated"].replace("Z", "+00:00")
60
+ )
61
+ return transformation
62
+
63
+ def create_transformation(
64
+ self,
65
+ name: str,
66
+ title: str,
67
+ description: str,
68
+ prompt: str,
69
+ apply_default: bool = False,
70
+ ) -> Transformation:
71
+ """Create a new transformation."""
72
+ response = api_client.create_transformation(
73
+ name=name,
74
+ title=title,
75
+ description=description,
76
+ prompt=prompt,
77
+ apply_default=apply_default,
78
+ )
79
+ trans_data = response if isinstance(response, dict) else response[0]
80
+ transformation = Transformation(
81
+ name=trans_data["name"],
82
+ title=trans_data["title"],
83
+ description=trans_data["description"],
84
+ prompt=trans_data["prompt"],
85
+ apply_default=trans_data["apply_default"],
86
+ )
87
+ transformation.id = trans_data["id"]
88
+ transformation.created = datetime.fromisoformat(
89
+ trans_data["created"].replace("Z", "+00:00")
90
+ )
91
+ transformation.updated = datetime.fromisoformat(
92
+ trans_data["updated"].replace("Z", "+00:00")
93
+ )
94
+ return transformation
95
+
96
+ def update_transformation(self, transformation: Transformation) -> Transformation:
97
+ """Update a transformation."""
98
+ if not transformation.id:
99
+ raise ValueError("Transformation ID is required for update")
100
+
101
+ updates = {
102
+ "name": transformation.name,
103
+ "title": transformation.title,
104
+ "description": transformation.description,
105
+ "prompt": transformation.prompt,
106
+ "apply_default": transformation.apply_default,
107
+ }
108
+ response = api_client.update_transformation(transformation.id, **updates)
109
+ trans_data = response if isinstance(response, dict) else response[0]
110
+
111
+ # Update the transformation object with the response
112
+ transformation.name = trans_data["name"]
113
+ transformation.title = trans_data["title"]
114
+ transformation.description = trans_data["description"]
115
+ transformation.prompt = trans_data["prompt"]
116
+ transformation.apply_default = trans_data["apply_default"]
117
+ transformation.updated = datetime.fromisoformat(
118
+ trans_data["updated"].replace("Z", "+00:00")
119
+ )
120
+
121
+ return transformation
122
+
123
+ def delete_transformation(self, transformation_id: str) -> bool:
124
+ """Delete a transformation."""
125
+ api_client.delete_transformation(transformation_id)
126
+ return True
127
+
128
+ def execute_transformation(
129
+ self, transformation_id: str, input_text: str, model_id: str
130
+ ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
131
+ """Execute a transformation on input text."""
132
+ result = api_client.execute_transformation(
133
+ transformation_id=transformation_id,
134
+ input_text=input_text,
135
+ model_id=model_id,
136
+ )
137
+ return result
138
+
139
+
140
+ # Global service instance
141
+ transformations_service = TransformationsService()
commands/CLAUDE.md ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Commands Module
2
+
3
+ **Purpose**: Defines async command handlers for long-running operations via `surreal-commands` job queue system.
4
+
5
+ ## Key Components
6
+
7
+ ### Embedding Commands
8
+
9
+ - **`embed_note_command`**: Embeds a single note using unified embedding pipeline with content-type aware processing. Uses MARKDOWN content type detection. Retry: 5 attempts, exponential jitter 1-60s.
10
+ - **`embed_insight_command`**: Embeds a single source insight. Uses MARKDOWN content type. Retry: 5 attempts, exponential jitter 1-60s.
11
+ - **`embed_source_command`**: Embeds a source by chunking full_text with content-type aware splitters (HTML, Markdown, plain), then batch embedding all chunks (batches of 50 with per-batch retry). Retry: 5 attempts, exponential jitter 1-60s.
12
+ - **`create_insight_command`**: Creates a source insight with automatic retry on transaction conflicts. Creates the DB record, then submits `embed_insight` command (fire-and-forget). Retry: 5 attempts, exponential jitter 1-60s. Used by `Source.add_insight()`.
13
+ - **`rebuild_embeddings_command`**: Submits individual embed_* commands for all sources/notes/insights. Returns immediately; actual embedding happens async. No retry (coordinator only).
14
+
15
+ ### Other Commands
16
+
17
+ - **`process_source_command`**: Ingests content through `source_graph`, creates embeddings (optional), and generates insights. Retries on transaction conflicts (exp. jitter, max 15×, 1-120s).
18
+ - **`run_transformation_command`**: Runs a transformation on an existing source to generate an insight. Executes the transformation graph (LLM call) then creates insight via `create_insight_command`. Used by `POST /sources/{id}/insights` API endpoint. Retry: 5 attempts, exponential jitter 1-60s.
19
+ - **`generate_podcast_command`**: Creates podcasts via podcast-creator library. Resolves model registry references and credentials for all profiles before invoking podcast-creator. Validates that outline_llm, transcript_llm, and voice_model are configured.
20
+ - **`process_text_command`** (example): Test fixture for text operations (uppercase, lowercase, reverse, word_count).
21
+ - **`analyze_data_command`** (example): Test fixture for numeric aggregations.
22
+
23
+ ## Important Patterns
24
+
25
+ - **Pydantic I/O**: All commands use `CommandInput`/`CommandOutput` subclasses for type safety and serialization.
26
+ - **Error handling**: Permanent errors (ValueError) return failure output; all other exceptions auto-retry via surreal-commands.
27
+ - **Retry configuration**: Uses `stop_on: [ValueError]` (blocklist approach) - retries all exceptions EXCEPT ValueError. This is more resilient than allowlist as new exception types auto-retry.
28
+ - **Fire-and-forget embedding**: Domain models submit embed_* commands via `submit_command()` without waiting. Commands process asynchronously.
29
+ - **Content-type aware chunking**: `embed_source_command` uses `chunk_text()` with automatic content type detection (HTML, Markdown, plain text) for optimal text splitting. Default: 1500 char chunks with 225 char overlap.
30
+ - **Batch embedding**: `embed_source_command` uses `generate_embeddings()` which automatically batches texts (default 50) with per-batch retry to avoid exceeding provider payload limits.
31
+ - **Mean pooling for large content**: `embed_note_command` and `embed_insight_command` use `generate_embedding()` which handles content larger than chunk size via mean pooling.
32
+ - **Model dumping**: Recursive `full_model_dump()` utility converts Pydantic models → dicts for DB/API responses.
33
+ - **Logging**: Uses `loguru.logger` throughout; logs execution start/end and key metrics (processing time, counts).
34
+ - **Time tracking**: All commands measure `start_time` → `processing_time` for monitoring.
35
+
36
+ ## Dependencies
37
+
38
+ **External**: `surreal_commands` (command decorator, job queue, submit_command), `loguru`, `pydantic`, `podcast_creator`
39
+ **Internal**: `open_notebook.domain.notebook` (Source, Note, SourceInsight), `open_notebook.utils.chunking` (chunk_text, detect_content_type), `open_notebook.utils.embedding` (generate_embedding, generate_embeddings), `open_notebook.database.repository` (repo_query, repo_insert)
40
+
41
+ ## Quirks & Edge Cases
42
+
43
+ - **source_commands**: `ensure_record_id()` wraps command IDs for DB storage; transaction conflicts trigger exponential backoff retry. ValueError exceptions are permanent (not retried).
44
+ - **embedding_commands**: Content type detection uses file extension as primary source, heuristics as fallback. Chunks >1800 chars trigger secondary splitting. Empty/whitespace-only content returns ValueError (not retried).
45
+ - **rebuild_embeddings_command**: Returns "jobs_submitted" not "processed_items" - embedding is async. Individual commands handle failures with their own retries.
46
+ - **podcast_commands**: Profiles loaded from SurrealDB by name; model configs (credentials) resolved for ALL profiles before podcast-creator validation. Validates outline_llm/transcript_llm/voice_model are set. Episode records created mid-execution.
47
+ - **Example commands**: Accept optional `delay_seconds` for testing async behavior; not for production.
48
+
49
+ ## Code Example
50
+
51
+ ```python
52
+ @command("process_source", app="open_notebook", retry={
53
+ "max_attempts": 5,
54
+ "wait_strategy": "exponential_jitter",
55
+ "stop_on": [ValueError], # Don't retry validation errors
56
+ })
57
+ async def process_source_command(input_data: SourceProcessingInput) -> SourceProcessingOutput:
58
+ start_time = time.time()
59
+ try:
60
+ transformations = [await Transformation.get(id) for id in input_data.transformations]
61
+ source = await Source.get(input_data.source_id)
62
+ result = await source_graph.ainvoke({...})
63
+ return SourceProcessingOutput(success=True, ...)
64
+ except ValueError as e:
65
+ return SourceProcessingOutput(success=False, error_message=str(e)) # No retry
66
+ except Exception as e:
67
+ raise # Retry all other exceptions
68
+ ```
commands/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Surreal-commands integration for Open Notebook"""
2
+
3
+ from .embedding_commands import (
4
+ embed_insight_command,
5
+ embed_note_command,
6
+ embed_source_command,
7
+ rebuild_embeddings_command,
8
+ )
9
+ from .example_commands import analyze_data_command, process_text_command
10
+ from .podcast_commands import generate_podcast_command
11
+ from .source_commands import process_source_command
12
+
13
+ __all__ = [
14
+ # Embedding commands
15
+ "embed_note_command",
16
+ "embed_insight_command",
17
+ "embed_source_command",
18
+ "rebuild_embeddings_command",
19
+ # Other commands
20
+ "generate_podcast_command",
21
+ "process_source_command",
22
+ "process_text_command",
23
+ "analyze_data_command",
24
+ ]
commands/embedding_commands.py ADDED
@@ -0,0 +1,787 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from typing import Dict, List, Literal, Optional
3
+
4
+ from loguru import logger
5
+ from pydantic import BaseModel
6
+ from surreal_commands import CommandInput, CommandOutput, command, submit_command
7
+
8
+ from open_notebook.ai.models import model_manager
9
+ from open_notebook.database.repository import ensure_record_id, repo_insert, repo_query
10
+ from open_notebook.exceptions import ConfigurationError
11
+ from open_notebook.domain.notebook import Note, Source, SourceInsight
12
+ from open_notebook.utils.chunking import ContentType, chunk_text, detect_content_type
13
+ from open_notebook.utils.embedding import generate_embedding, generate_embeddings
14
+
15
+
16
+ def full_model_dump(model):
17
+ if isinstance(model, BaseModel):
18
+ return model.model_dump()
19
+ elif isinstance(model, dict):
20
+ return {k: full_model_dump(v) for k, v in model.items()}
21
+ elif isinstance(model, list):
22
+ return [full_model_dump(item) for item in model]
23
+ else:
24
+ return model
25
+
26
+
27
+ def get_command_id(input_data: CommandInput) -> str:
28
+ """Extract command_id from input_data's execution context, or return 'unknown'."""
29
+ if input_data.execution_context:
30
+ return str(input_data.execution_context.command_id)
31
+ return "unknown"
32
+
33
+
34
+ class RebuildEmbeddingsInput(CommandInput):
35
+ mode: Literal["existing", "all"]
36
+ include_sources: bool = True
37
+ include_notes: bool = True
38
+ include_insights: bool = True
39
+
40
+
41
+ class RebuildEmbeddingsOutput(CommandOutput):
42
+ success: bool
43
+ total_items: int
44
+ jobs_submitted: int # Count of embedding commands submitted
45
+ failed_submissions: int # Count of items that failed to submit
46
+ sources_submitted: int = 0
47
+ notes_submitted: int = 0
48
+ insights_submitted: int = 0
49
+ processing_time: float
50
+ error_message: Optional[str] = None
51
+
52
+
53
+ # =============================================================================
54
+ # NEW EMBEDDING COMMANDS (Phase 3)
55
+ # =============================================================================
56
+
57
+
58
+ class CreateInsightInput(CommandInput):
59
+ """Input for creating a source insight with automatic retry on conflicts."""
60
+
61
+ source_id: str
62
+ insight_type: str
63
+ content: str
64
+
65
+
66
+ class CreateInsightOutput(CommandOutput):
67
+ """Output from insight creation command."""
68
+
69
+ success: bool
70
+ insight_id: Optional[str] = None
71
+ processing_time: float
72
+ error_message: Optional[str] = None
73
+
74
+
75
+ class EmbedNoteInput(CommandInput):
76
+ """Input for embedding a single note."""
77
+
78
+ note_id: str
79
+
80
+
81
+ class EmbedNoteOutput(CommandOutput):
82
+ """Output from note embedding command."""
83
+
84
+ success: bool
85
+ note_id: str
86
+ processing_time: float
87
+ error_message: Optional[str] = None
88
+
89
+
90
+ class EmbedInsightInput(CommandInput):
91
+ """Input for embedding a single source insight."""
92
+
93
+ insight_id: str
94
+
95
+
96
+ class EmbedInsightOutput(CommandOutput):
97
+ """Output from insight embedding command."""
98
+
99
+ success: bool
100
+ insight_id: str
101
+ processing_time: float
102
+ error_message: Optional[str] = None
103
+
104
+
105
+ class EmbedSourceInput(CommandInput):
106
+ """Input for embedding a source (creates multiple chunk embeddings)."""
107
+
108
+ source_id: str
109
+
110
+
111
+ class EmbedSourceOutput(CommandOutput):
112
+ """Output from source embedding command."""
113
+
114
+ success: bool
115
+ source_id: str
116
+ chunks_created: int
117
+ processing_time: float
118
+ error_message: Optional[str] = None
119
+
120
+
121
+ @command(
122
+ "embed_note",
123
+ app="open_notebook",
124
+ retry={
125
+ "max_attempts": 5,
126
+ "wait_strategy": "exponential_jitter",
127
+ "wait_min": 1,
128
+ "wait_max": 60,
129
+ "stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
130
+ "retry_log_level": "debug",
131
+ },
132
+ )
133
+ async def embed_note_command(input_data: EmbedNoteInput) -> EmbedNoteOutput:
134
+ """
135
+ Generate and store embedding for a single note.
136
+
137
+ Uses the unified embedding pipeline with automatic chunking and mean pooling
138
+ for notes that exceed the chunk size limit.
139
+
140
+ Flow:
141
+ 1. Load Note by ID
142
+ 2. Generate embedding via generate_embedding() (auto-chunks + mean pools if needed)
143
+ 3. UPSERT note embedding in database
144
+
145
+ Retry Strategy:
146
+ - Retries up to 5 times for transient failures (network, timeout, etc.)
147
+ - Uses exponential-jitter backoff (1-60s)
148
+ - Does NOT retry permanent failures (ValueError for validation errors)
149
+ """
150
+ start_time = time.time()
151
+
152
+ try:
153
+ logger.info(f"Starting embedding for note: {input_data.note_id}")
154
+
155
+ # 1. Load note
156
+ note = await Note.get(input_data.note_id)
157
+ if not note:
158
+ raise ValueError(f"Note '{input_data.note_id}' not found")
159
+
160
+ if not note.content or not note.content.strip():
161
+ raise ValueError(f"Note '{input_data.note_id}' has no content to embed")
162
+
163
+ # 2. Generate embedding (auto-chunks + mean pools if needed)
164
+ # Notes are typically markdown content
165
+ cmd_id = get_command_id(input_data)
166
+ embedding = await generate_embedding(
167
+ note.content, content_type=ContentType.MARKDOWN, command_id=cmd_id
168
+ )
169
+
170
+ # 3. UPSERT embedding into note record
171
+ await repo_query(
172
+ "UPDATE $note_id SET embedding = $embedding",
173
+ {
174
+ "note_id": ensure_record_id(input_data.note_id),
175
+ "embedding": embedding,
176
+ },
177
+ )
178
+
179
+ processing_time = time.time() - start_time
180
+ logger.info(
181
+ f"Successfully embedded note {input_data.note_id} in {processing_time:.2f}s"
182
+ )
183
+
184
+ return EmbedNoteOutput(
185
+ success=True,
186
+ note_id=input_data.note_id,
187
+ processing_time=processing_time,
188
+ )
189
+
190
+ except ValueError as e:
191
+ # Permanent failure - don't retry
192
+ processing_time = time.time() - start_time
193
+ cmd_id = get_command_id(input_data)
194
+ logger.error(
195
+ f"Failed to embed note {input_data.note_id} (command: {cmd_id}): {e}"
196
+ )
197
+ return EmbedNoteOutput(
198
+ success=False,
199
+ note_id=input_data.note_id,
200
+ processing_time=processing_time,
201
+ error_message=str(e),
202
+ )
203
+ except Exception as e:
204
+ # Transient failure - will be retried (surreal-commands logs final failure)
205
+ cmd_id = get_command_id(input_data)
206
+ logger.debug(
207
+ f"Transient error embedding note {input_data.note_id} "
208
+ f"(command: {cmd_id}): {e}"
209
+ )
210
+ raise
211
+
212
+
213
+ @command(
214
+ "embed_insight",
215
+ app="open_notebook",
216
+ retry={
217
+ "max_attempts": 5,
218
+ "wait_strategy": "exponential_jitter",
219
+ "wait_min": 1,
220
+ "wait_max": 60,
221
+ "stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
222
+ "retry_log_level": "debug",
223
+ },
224
+ )
225
+ async def embed_insight_command(input_data: EmbedInsightInput) -> EmbedInsightOutput:
226
+ """
227
+ Generate and store embedding for a single source insight.
228
+
229
+ Uses the unified embedding pipeline with automatic chunking and mean pooling
230
+ for insights that exceed the chunk size limit.
231
+
232
+ Flow:
233
+ 1. Load SourceInsight by ID
234
+ 2. Generate embedding via generate_embedding() (auto-chunks + mean pools if needed)
235
+ 3. UPSERT insight embedding in database
236
+
237
+ Retry Strategy:
238
+ - Retries up to 5 times for transient failures (network, timeout, etc.)
239
+ - Uses exponential-jitter backoff (1-60s)
240
+ - Does NOT retry permanent failures (ValueError for validation errors)
241
+ """
242
+ start_time = time.time()
243
+
244
+ try:
245
+ logger.info(f"Starting embedding for insight: {input_data.insight_id}")
246
+
247
+ # 1. Load insight
248
+ insight = await SourceInsight.get(input_data.insight_id)
249
+ if not insight:
250
+ raise ValueError(f"Insight '{input_data.insight_id}' not found")
251
+
252
+ if not insight.content or not insight.content.strip():
253
+ raise ValueError(
254
+ f"Insight '{input_data.insight_id}' has no content to embed"
255
+ )
256
+
257
+ # 2. Generate embedding (auto-chunks + mean pools if needed)
258
+ # Insights are typically markdown content (generated by LLM)
259
+ cmd_id = get_command_id(input_data)
260
+ embedding = await generate_embedding(
261
+ insight.content, content_type=ContentType.MARKDOWN, command_id=cmd_id
262
+ )
263
+
264
+ # 3. UPSERT embedding into insight record
265
+ await repo_query(
266
+ "UPDATE $insight_id SET embedding = $embedding",
267
+ {
268
+ "insight_id": ensure_record_id(input_data.insight_id),
269
+ "embedding": embedding,
270
+ },
271
+ )
272
+
273
+ processing_time = time.time() - start_time
274
+ logger.info(
275
+ f"Successfully embedded insight {input_data.insight_id} in {processing_time:.2f}s"
276
+ )
277
+
278
+ return EmbedInsightOutput(
279
+ success=True,
280
+ insight_id=input_data.insight_id,
281
+ processing_time=processing_time,
282
+ )
283
+
284
+ except ValueError as e:
285
+ # Permanent failure - don't retry
286
+ processing_time = time.time() - start_time
287
+ cmd_id = get_command_id(input_data)
288
+ logger.error(
289
+ f"Failed to embed insight {input_data.insight_id} (command: {cmd_id}): {e}"
290
+ )
291
+ return EmbedInsightOutput(
292
+ success=False,
293
+ insight_id=input_data.insight_id,
294
+ processing_time=processing_time,
295
+ error_message=str(e),
296
+ )
297
+ except Exception as e:
298
+ # Transient failure - will be retried (surreal-commands logs final failure)
299
+ cmd_id = get_command_id(input_data)
300
+ logger.debug(
301
+ f"Transient error embedding insight {input_data.insight_id} "
302
+ f"(command: {cmd_id}): {e}"
303
+ )
304
+ raise
305
+
306
+
307
+ @command(
308
+ "embed_source",
309
+ app="open_notebook",
310
+ retry={
311
+ "max_attempts": 5,
312
+ "wait_strategy": "exponential_jitter",
313
+ "wait_min": 1,
314
+ "wait_max": 60,
315
+ "stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
316
+ "retry_log_level": "debug",
317
+ },
318
+ )
319
+ async def embed_source_command(input_data: EmbedSourceInput) -> EmbedSourceOutput:
320
+ """
321
+ Generate and store embeddings for a source document.
322
+
323
+ Creates multiple chunk embeddings stored in the source_embedding table.
324
+ Uses content-type aware chunking based on file extension or content heuristics.
325
+
326
+ Flow:
327
+ 1. Load Source by ID
328
+ 2. DELETE existing source_embedding records for this source
329
+ 3. Detect content type from file path or content
330
+ 4. Chunk text using appropriate splitter
331
+ 5. Generate embeddings for all chunks in batches
332
+ 6. Bulk INSERT source_embedding records
333
+
334
+ Retry Strategy:
335
+ - Retries up to 5 times for transient failures (network, timeout, etc.)
336
+ - Uses exponential-jitter backoff (1-60s)
337
+ - Does NOT retry permanent failures (ValueError for validation errors)
338
+ """
339
+ start_time = time.time()
340
+
341
+ try:
342
+ logger.info(f"Starting embedding for source: {input_data.source_id}")
343
+
344
+ # 1. Load source
345
+ source = await Source.get(input_data.source_id)
346
+ if not source:
347
+ raise ValueError(f"Source '{input_data.source_id}' not found")
348
+
349
+ if not source.full_text or not source.full_text.strip():
350
+ raise ValueError(f"Source '{input_data.source_id}' has no text to embed")
351
+
352
+ # 2. DELETE existing embeddings (idempotency)
353
+ logger.debug(f"Deleting existing embeddings for source {input_data.source_id}")
354
+ await repo_query(
355
+ "DELETE source_embedding WHERE source = $source_id",
356
+ {"source_id": ensure_record_id(input_data.source_id)},
357
+ )
358
+
359
+ # 3. Detect content type from file path if available
360
+ file_path = source.asset.file_path if source.asset else None
361
+ content_type = detect_content_type(source.full_text, file_path)
362
+ logger.debug(f"Detected content type: {content_type.value}")
363
+
364
+ # 4. Chunk text using appropriate splitter
365
+ chunks = chunk_text(source.full_text, content_type=content_type)
366
+ total_chunks = len(chunks)
367
+
368
+ # Log chunk statistics for debugging
369
+ chunk_sizes = [len(c) for c in chunks]
370
+ logger.info(
371
+ f"Created {total_chunks} chunks for source {input_data.source_id} "
372
+ f"(sizes: min={min(chunk_sizes) if chunk_sizes else 0}, "
373
+ f"max={max(chunk_sizes) if chunk_sizes else 0}, "
374
+ f"avg={sum(chunk_sizes)//len(chunk_sizes) if chunk_sizes else 0} chars)"
375
+ )
376
+
377
+ if total_chunks == 0:
378
+ raise ValueError("No chunks created after splitting text")
379
+
380
+ # 5. Generate embeddings for all chunks in batches
381
+ cmd_id = get_command_id(input_data)
382
+ logger.debug(f"Generating embeddings for {total_chunks} chunks")
383
+ embeddings = await generate_embeddings(chunks, command_id=cmd_id)
384
+
385
+ # Verify we got embeddings for all chunks
386
+ if len(embeddings) != len(chunks):
387
+ raise ValueError(
388
+ f"Embedding count mismatch: got {len(embeddings)} embeddings "
389
+ f"for {len(chunks)} chunks"
390
+ )
391
+
392
+ # 6. Bulk INSERT source_embedding records
393
+ records = [
394
+ {
395
+ "source": ensure_record_id(input_data.source_id),
396
+ "order": idx,
397
+ "content": chunk,
398
+ "embedding": embedding,
399
+ }
400
+ for idx, (chunk, embedding) in enumerate(zip(chunks, embeddings))
401
+ ]
402
+
403
+ logger.debug(f"Inserting {len(records)} source_embedding records")
404
+ await repo_insert("source_embedding", records)
405
+
406
+ processing_time = time.time() - start_time
407
+ logger.info(
408
+ f"Successfully embedded source {input_data.source_id}: "
409
+ f"{total_chunks} chunks in {processing_time:.2f}s"
410
+ )
411
+
412
+ return EmbedSourceOutput(
413
+ success=True,
414
+ source_id=input_data.source_id,
415
+ chunks_created=total_chunks,
416
+ processing_time=processing_time,
417
+ )
418
+
419
+ except ValueError as e:
420
+ # Permanent failure - don't retry
421
+ processing_time = time.time() - start_time
422
+ cmd_id = get_command_id(input_data)
423
+ logger.error(
424
+ f"Failed to embed source {input_data.source_id} (command: {cmd_id}): {e}"
425
+ )
426
+ return EmbedSourceOutput(
427
+ success=False,
428
+ source_id=input_data.source_id,
429
+ chunks_created=0,
430
+ processing_time=processing_time,
431
+ error_message=str(e),
432
+ )
433
+ except Exception as e:
434
+ # Transient failure - will be retried (surreal-commands logs final failure)
435
+ cmd_id = get_command_id(input_data)
436
+ logger.debug(
437
+ f"Transient error embedding source {input_data.source_id} "
438
+ f"(command: {cmd_id}): {e}"
439
+ )
440
+ raise
441
+
442
+
443
+ @command(
444
+ "create_insight",
445
+ app="open_notebook",
446
+ retry={
447
+ "max_attempts": 5,
448
+ "wait_strategy": "exponential_jitter",
449
+ "wait_min": 1,
450
+ "wait_max": 60,
451
+ "stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
452
+ "retry_log_level": "debug",
453
+ },
454
+ )
455
+ async def create_insight_command(
456
+ input_data: CreateInsightInput,
457
+ ) -> CreateInsightOutput:
458
+ """
459
+ Create a source insight with automatic retry on transaction conflicts.
460
+
461
+ This command wraps the CREATE source_insight operation with retry logic
462
+ to handle SurrealDB transaction conflicts that occur during batch imports
463
+ when multiple parallel transformations try to create insights concurrently.
464
+
465
+ Flow:
466
+ 1. CREATE source_insight record in database
467
+ 2. Submit embed_insight command (fire-and-forget) for async embedding
468
+ 3. Return the insight_id
469
+
470
+ Retry Strategy:
471
+ - Retries up to 5 times for transient failures (network, timeout, etc.)
472
+ - Uses exponential-jitter backoff (1-60s)
473
+ - Does NOT retry permanent failures (ValueError for validation errors)
474
+ """
475
+ start_time = time.time()
476
+
477
+ try:
478
+ logger.info(
479
+ f"Creating insight for source {input_data.source_id}: "
480
+ f"type={input_data.insight_type}"
481
+ )
482
+
483
+ # 1. Create insight record in database
484
+ result = await repo_query(
485
+ """
486
+ CREATE source_insight CONTENT {
487
+ "source": $source_id,
488
+ "insight_type": $insight_type,
489
+ "content": $content
490
+ };
491
+ """,
492
+ {
493
+ "source_id": ensure_record_id(input_data.source_id),
494
+ "insight_type": input_data.insight_type,
495
+ "content": input_data.content,
496
+ },
497
+ )
498
+
499
+ if not result or len(result) == 0:
500
+ raise ValueError("Failed to create insight - no result returned")
501
+
502
+ insight_id = str(result[0].get("id", ""))
503
+ if not insight_id:
504
+ raise ValueError("Failed to create insight - no ID in result")
505
+
506
+ # 2. Submit embedding command (fire-and-forget)
507
+ submit_command(
508
+ "open_notebook",
509
+ "embed_insight",
510
+ {"insight_id": insight_id},
511
+ )
512
+ logger.debug(f"Submitted embed_insight command for {insight_id}")
513
+
514
+ processing_time = time.time() - start_time
515
+ logger.info(
516
+ f"Successfully created insight {insight_id} for source "
517
+ f"{input_data.source_id} in {processing_time:.2f}s"
518
+ )
519
+
520
+ return CreateInsightOutput(
521
+ success=True,
522
+ insight_id=insight_id,
523
+ processing_time=processing_time,
524
+ )
525
+
526
+ except ValueError as e:
527
+ # Permanent failure - don't retry
528
+ processing_time = time.time() - start_time
529
+ cmd_id = get_command_id(input_data)
530
+ logger.error(
531
+ f"Failed to create insight for source {input_data.source_id} "
532
+ f"(command: {cmd_id}): {e}"
533
+ )
534
+ return CreateInsightOutput(
535
+ success=False,
536
+ processing_time=processing_time,
537
+ error_message=str(e),
538
+ )
539
+ except Exception as e:
540
+ # Transient failure - will be retried (surreal-commands logs final failure)
541
+ cmd_id = get_command_id(input_data)
542
+ logger.debug(
543
+ f"Transient error creating insight for source {input_data.source_id} "
544
+ f"(command: {cmd_id}): {e}"
545
+ )
546
+ raise
547
+
548
+
549
+ async def collect_items_for_rebuild(
550
+ mode: str,
551
+ include_sources: bool,
552
+ include_notes: bool,
553
+ include_insights: bool,
554
+ ) -> Dict[str, List[str]]:
555
+ """
556
+ Collect items to rebuild based on mode and include flags.
557
+
558
+ Returns:
559
+ Dict with keys: 'sources', 'notes', 'insights' containing lists of item IDs
560
+ """
561
+ items: Dict[str, List[str]] = {"sources": [], "notes": [], "insights": []}
562
+
563
+ if include_sources:
564
+ if mode == "existing":
565
+ # Query sources with embeddings (via source_embedding table)
566
+ result = await repo_query(
567
+ """
568
+ RETURN array::distinct(
569
+ SELECT VALUE source.id
570
+ FROM source_embedding
571
+ WHERE embedding != none AND array::len(embedding) > 0
572
+ )
573
+ """
574
+ )
575
+ # RETURN returns the array directly as the result (not nested)
576
+ if result:
577
+ items["sources"] = [str(item) for item in result]
578
+ else:
579
+ items["sources"] = []
580
+ else: # mode == "all"
581
+ # Query all sources with non-empty content
582
+ result = await repo_query(
583
+ "SELECT id FROM source WHERE full_text != none AND string::trim(full_text) != ''"
584
+ )
585
+ items["sources"] = [str(item["id"]) for item in result] if result else []
586
+
587
+ logger.info(f"Collected {len(items['sources'])} sources for rebuild")
588
+
589
+ if include_notes:
590
+ if mode == "existing":
591
+ # Query notes with embeddings
592
+ result = await repo_query(
593
+ "SELECT id FROM note WHERE embedding != none AND array::len(embedding) > 0"
594
+ )
595
+ else: # mode == "all"
596
+ # Query all notes with non-empty content
597
+ result = await repo_query(
598
+ "SELECT id FROM note WHERE content != none AND string::trim(content) != ''"
599
+ )
600
+
601
+ items["notes"] = [str(item["id"]) for item in result] if result else []
602
+ logger.info(f"Collected {len(items['notes'])} notes for rebuild")
603
+
604
+ if include_insights:
605
+ if mode == "existing":
606
+ # Query insights with embeddings
607
+ result = await repo_query(
608
+ "SELECT id FROM source_insight WHERE embedding != none AND array::len(embedding) > 0"
609
+ )
610
+ else: # mode == "all"
611
+ # Query all insights with non-empty content
612
+ result = await repo_query(
613
+ "SELECT id FROM source_insight WHERE content != none AND string::trim(content) != ''"
614
+ )
615
+
616
+ items["insights"] = [str(item["id"]) for item in result] if result else []
617
+ logger.info(f"Collected {len(items['insights'])} insights for rebuild")
618
+
619
+ return items
620
+
621
+
622
+ @command("rebuild_embeddings", app="open_notebook", retry=None)
623
+ async def rebuild_embeddings_command(
624
+ input_data: RebuildEmbeddingsInput,
625
+ ) -> RebuildEmbeddingsOutput:
626
+ """
627
+ Rebuild embeddings for sources, notes, and/or insights.
628
+
629
+ This command submits individual embedding jobs for each item:
630
+ - embed_source for sources
631
+ - embed_note for notes
632
+ - embed_insight for insights
633
+
634
+ The command returns after submitting all jobs. Actual embedding
635
+ happens asynchronously via the individual commands (which have
636
+ their own retry strategies).
637
+
638
+ Retry Strategy:
639
+ - Retries disabled (retry=None) for this coordinator command
640
+ - Individual embed_* commands handle their own retries
641
+ """
642
+ start_time = time.time()
643
+
644
+ try:
645
+ logger.info("=" * 60)
646
+ logger.info(f"Starting embedding rebuild with mode={input_data.mode}")
647
+ logger.info(
648
+ f"Include: sources={input_data.include_sources}, notes={input_data.include_notes}, insights={input_data.include_insights}"
649
+ )
650
+ logger.info("=" * 60)
651
+
652
+ # Check embedding model availability (fail fast)
653
+ EMBEDDING_MODEL = await model_manager.get_embedding_model()
654
+ if not EMBEDDING_MODEL:
655
+ raise ValueError(
656
+ "No embedding model configured. Please configure one in the Models section."
657
+ )
658
+
659
+ logger.info(f"Embedding model configured: {EMBEDDING_MODEL}")
660
+
661
+ # Collect items to process (returns IDs only)
662
+ items = await collect_items_for_rebuild(
663
+ input_data.mode,
664
+ input_data.include_sources,
665
+ input_data.include_notes,
666
+ input_data.include_insights,
667
+ )
668
+
669
+ total_items = (
670
+ len(items["sources"]) + len(items["notes"]) + len(items["insights"])
671
+ )
672
+ logger.info(f"Total items to rebuild: {total_items}")
673
+
674
+ if total_items == 0:
675
+ logger.warning("No items found to rebuild")
676
+ return RebuildEmbeddingsOutput(
677
+ success=True,
678
+ total_items=0,
679
+ jobs_submitted=0,
680
+ failed_submissions=0,
681
+ processing_time=time.time() - start_time,
682
+ )
683
+
684
+ # Initialize counters
685
+ sources_submitted = 0
686
+ notes_submitted = 0
687
+ insights_submitted = 0
688
+ failed_submissions = 0
689
+
690
+ # Submit embed_source commands for sources
691
+ logger.info(f"\nSubmitting {len(items['sources'])} source embedding jobs...")
692
+ for idx, source_id in enumerate(items["sources"], 1):
693
+ try:
694
+ submit_command(
695
+ "open_notebook",
696
+ "embed_source",
697
+ {"source_id": source_id},
698
+ )
699
+ sources_submitted += 1
700
+
701
+ if idx % 50 == 0 or idx == len(items["sources"]):
702
+ logger.info(
703
+ f" Progress: {idx}/{len(items['sources'])} source jobs submitted"
704
+ )
705
+
706
+ except Exception as e:
707
+ logger.error(f"Failed to submit embed_source for {source_id}: {e}")
708
+ failed_submissions += 1
709
+
710
+ # Submit embed_note commands for notes
711
+ logger.info(f"\nSubmitting {len(items['notes'])} note embedding jobs...")
712
+ for idx, note_id in enumerate(items["notes"], 1):
713
+ try:
714
+ submit_command(
715
+ "open_notebook",
716
+ "embed_note",
717
+ {"note_id": note_id},
718
+ )
719
+ notes_submitted += 1
720
+
721
+ if idx % 50 == 0 or idx == len(items["notes"]):
722
+ logger.info(
723
+ f" Progress: {idx}/{len(items['notes'])} note jobs submitted"
724
+ )
725
+
726
+ except Exception as e:
727
+ logger.error(f"Failed to submit embed_note for {note_id}: {e}")
728
+ failed_submissions += 1
729
+
730
+ # Submit embed_insight commands for insights
731
+ logger.info(f"\nSubmitting {len(items['insights'])} insight embedding jobs...")
732
+ for idx, insight_id in enumerate(items["insights"], 1):
733
+ try:
734
+ submit_command(
735
+ "open_notebook",
736
+ "embed_insight",
737
+ {"insight_id": insight_id},
738
+ )
739
+ insights_submitted += 1
740
+
741
+ if idx % 50 == 0 or idx == len(items["insights"]):
742
+ logger.info(
743
+ f" Progress: {idx}/{len(items['insights'])} insight jobs submitted"
744
+ )
745
+
746
+ except Exception as e:
747
+ logger.error(f"Failed to submit embed_insight for {insight_id}: {e}")
748
+ failed_submissions += 1
749
+
750
+ processing_time = time.time() - start_time
751
+ jobs_submitted = sources_submitted + notes_submitted + insights_submitted
752
+
753
+ logger.info("=" * 60)
754
+ logger.info("REBUILD JOBS SUBMITTED")
755
+ logger.info(f" Total jobs submitted: {jobs_submitted}/{total_items}")
756
+ logger.info(f" Sources: {sources_submitted}")
757
+ logger.info(f" Notes: {notes_submitted}")
758
+ logger.info(f" Insights: {insights_submitted}")
759
+ logger.info(f" Failed submissions: {failed_submissions}")
760
+ logger.info(f" Submission time: {processing_time:.2f}s")
761
+ logger.info(" Note: Actual embedding happens asynchronously")
762
+ logger.info("=" * 60)
763
+
764
+ return RebuildEmbeddingsOutput(
765
+ success=True,
766
+ total_items=total_items,
767
+ jobs_submitted=jobs_submitted,
768
+ failed_submissions=failed_submissions,
769
+ sources_submitted=sources_submitted,
770
+ notes_submitted=notes_submitted,
771
+ insights_submitted=insights_submitted,
772
+ processing_time=processing_time,
773
+ )
774
+
775
+ except Exception as e:
776
+ processing_time = time.time() - start_time
777
+ logger.error(f"Rebuild embeddings failed: {e}")
778
+ logger.exception(e)
779
+
780
+ return RebuildEmbeddingsOutput(
781
+ success=False,
782
+ total_items=0,
783
+ jobs_submitted=0,
784
+ failed_submissions=0,
785
+ processing_time=processing_time,
786
+ error_message=str(e),
787
+ )
commands/example_commands.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import time
3
+ from typing import List, Optional
4
+
5
+ from loguru import logger
6
+ from pydantic import BaseModel
7
+ from surreal_commands import command
8
+
9
+
10
+ class TextProcessingInput(BaseModel):
11
+ text: str
12
+ operation: str = "uppercase" # uppercase, lowercase, word_count, reverse
13
+ delay_seconds: Optional[int] = None # For testing async behavior
14
+
15
+
16
+ class TextProcessingOutput(BaseModel):
17
+ success: bool
18
+ original_text: str
19
+ processed_text: Optional[str] = None
20
+ word_count: Optional[int] = None
21
+ processing_time: float
22
+ error_message: Optional[str] = None
23
+
24
+
25
+ class DataAnalysisInput(BaseModel):
26
+ numbers: List[float]
27
+ analysis_type: str = "basic" # basic, detailed
28
+ delay_seconds: Optional[int] = None
29
+
30
+
31
+ class DataAnalysisOutput(BaseModel):
32
+ success: bool
33
+ analysis_type: str
34
+ count: int
35
+ sum: Optional[float] = None
36
+ average: Optional[float] = None
37
+ min_value: Optional[float] = None
38
+ max_value: Optional[float] = None
39
+ processing_time: float
40
+ error_message: Optional[str] = None
41
+
42
+
43
+ @command("process_text", app="open_notebook")
44
+ async def process_text_command(input_data: TextProcessingInput) -> TextProcessingOutput:
45
+ """
46
+ Example command for text processing. Tests basic command functionality
47
+ and demonstrates different processing types.
48
+ """
49
+ start_time = time.time()
50
+
51
+ try:
52
+ logger.info(f"Processing text with operation: {input_data.operation}")
53
+
54
+ # Simulate processing delay if specified
55
+ if input_data.delay_seconds:
56
+ await asyncio.sleep(input_data.delay_seconds)
57
+
58
+ processed_text = None
59
+ word_count = None
60
+
61
+ if input_data.operation == "uppercase":
62
+ processed_text = input_data.text.upper()
63
+ elif input_data.operation == "lowercase":
64
+ processed_text = input_data.text.lower()
65
+ elif input_data.operation == "reverse":
66
+ processed_text = input_data.text[::-1]
67
+ elif input_data.operation == "word_count":
68
+ word_count = len(input_data.text.split())
69
+ processed_text = f"Word count: {word_count}"
70
+ else:
71
+ raise ValueError(f"Unknown operation: {input_data.operation}")
72
+
73
+ processing_time = time.time() - start_time
74
+
75
+ return TextProcessingOutput(
76
+ success=True,
77
+ original_text=input_data.text,
78
+ processed_text=processed_text,
79
+ word_count=word_count,
80
+ processing_time=processing_time,
81
+ )
82
+
83
+ except Exception as e:
84
+ processing_time = time.time() - start_time
85
+ logger.error(f"Text processing failed: {e}")
86
+ return TextProcessingOutput(
87
+ success=False,
88
+ original_text=input_data.text,
89
+ processing_time=processing_time,
90
+ error_message=str(e),
91
+ )
92
+
93
+
94
+ @command("analyze_data", app="open_notebook")
95
+ async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOutput:
96
+ """
97
+ Example command for data analysis. Tests command with complex input/output
98
+ and demonstrates error handling.
99
+ """
100
+ start_time = time.time()
101
+
102
+ try:
103
+ logger.info(
104
+ f"Analyzing {len(input_data.numbers)} numbers with {input_data.analysis_type} analysis"
105
+ )
106
+
107
+ # Simulate processing delay if specified
108
+ if input_data.delay_seconds:
109
+ await asyncio.sleep(input_data.delay_seconds)
110
+
111
+ if not input_data.numbers:
112
+ raise ValueError("No numbers provided for analysis")
113
+
114
+ count = len(input_data.numbers)
115
+ sum_value = sum(input_data.numbers)
116
+ average = sum_value / count
117
+ min_value = min(input_data.numbers)
118
+ max_value = max(input_data.numbers)
119
+
120
+ processing_time = time.time() - start_time
121
+
122
+ return DataAnalysisOutput(
123
+ success=True,
124
+ analysis_type=input_data.analysis_type,
125
+ count=count,
126
+ sum=sum_value,
127
+ average=average,
128
+ min_value=min_value,
129
+ max_value=max_value,
130
+ processing_time=processing_time,
131
+ )
132
+
133
+ except Exception as e:
134
+ processing_time = time.time() - start_time
135
+ logger.error(f"Data analysis failed: {e}")
136
+ return DataAnalysisOutput(
137
+ success=False,
138
+ analysis_type=input_data.analysis_type,
139
+ count=0,
140
+ processing_time=processing_time,
141
+ error_message=str(e),
142
+ )