Spaces:
Runtime error
Runtime error
ffreemt commited on
Commit ·
3793f68
1
Parent(s): b7ed9ce
1st try
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env +2 -2
- Dockerfile +60 -0
- api/CLAUDE.md +260 -0
- api/__init__.py +0 -0
- api/auth.py +114 -0
- api/chat_service.py +168 -0
- api/client.py +529 -0
- api/command_service.py +92 -0
- api/context_service.py +29 -0
- api/credentials_service.py +890 -0
- api/embedding_service.py +27 -0
- api/episode_profiles_service.py +112 -0
- api/insights_service.py +100 -0
- api/main.py +322 -0
- api/models.py +686 -0
- api/models_service.py +112 -0
- api/notebook_service.py +87 -0
- api/notes_service.py +103 -0
- api/podcast_api_service.py +125 -0
- api/podcast_service.py +206 -0
- api/routers/__init__.py +0 -0
- api/routers/auth.py +27 -0
- api/routers/chat.py +526 -0
- api/routers/commands.py +166 -0
- api/routers/config.py +160 -0
- api/routers/context.py +115 -0
- api/routers/credentials.py +426 -0
- api/routers/embedding.py +113 -0
- api/routers/embedding_rebuild.py +192 -0
- api/routers/episode_profiles.py +226 -0
- api/routers/insights.py +82 -0
- api/routers/languages.py +83 -0
- api/routers/models.py +776 -0
- api/routers/notebooks.py +354 -0
- api/routers/notes.py +189 -0
- api/routers/podcasts.py +299 -0
- api/routers/search.py +217 -0
- api/routers/settings.py +88 -0
- api/routers/source_chat.py +554 -0
- api/routers/sources.py +1045 -0
- api/routers/speaker_profiles.py +190 -0
- api/routers/transformations.py +252 -0
- api/search_service.py +58 -0
- api/settings_service.py +79 -0
- api/sources_service.py +324 -0
- api/transformations_service.py +141 -0
- commands/CLAUDE.md +68 -0
- commands/__init__.py +24 -0
- commands/embedding_commands.py +787 -0
- 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=
|
| 10 |
-
GROQ_API_KEY=
|
| 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 |
+
)
|