feat(api): add source management endpoints (Sprint 2)
Implement Sprint 2: Source Management
- Add SourceService with create, list, delete, research methods
- Add POST /api/v1/notebooks/{id}/sources - Add source (URL, YouTube, Drive)
- Add GET /api/v1/notebooks/{id}/sources - List sources with filtering
- Add DELETE /api/v1/notebooks/{id}/sources/{source_id} - Delete source
- Add POST /api/v1/notebooks/{id}/sources/research - Web research
- Add ResearchRequest model for research parameters
- Integrate sources router with main app
Endpoints:
- POST /sources - 201 Created
- GET /sources - 200 OK with pagination
- DELETE /sources/{id} - 204 No Content
- POST /sources/research - 202 Accepted
Technical:
- Support for url, youtube, drive source types
- Filtering by source_type and status
- Validation for research mode (fast/deep)
- Error handling with standardized responses
Related: Sprint 2 - Source Management
This commit is contained in:
106
prompts/2-source-management.md
Normal file
106
prompts/2-source-management.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Prompt Sprint 2 - Source Management
|
||||||
|
|
||||||
|
## 🎯 Sprint 2: Source Management
|
||||||
|
|
||||||
|
**Iniziato**: 2026-04-06
|
||||||
|
**Stato**: 🟡 In Progress
|
||||||
|
**Assegnato**: @sprint-lead
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Obiettivo
|
||||||
|
|
||||||
|
Implementare la gestione delle fonti (sources) per i notebook, permettendo agli utenti di aggiungere URL, PDF, YouTube, Google Drive e avviare ricerche web.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architettura
|
||||||
|
|
||||||
|
### Pattern da seguire (stesso di Sprint 1)
|
||||||
|
|
||||||
|
```
|
||||||
|
API Layer (FastAPI Routes)
|
||||||
|
↓
|
||||||
|
Service Layer (SourceService)
|
||||||
|
↓
|
||||||
|
External Layer (notebooklm-py client)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints da implementare
|
||||||
|
|
||||||
|
1. **POST /api/v1/notebooks/{id}/sources** - Aggiungere fonte
|
||||||
|
2. **GET /api/v1/notebooks/{id}/sources** - Listare fonti
|
||||||
|
3. **DELETE /api/v1/notebooks/{id}/sources/{source_id}** - Rimuovere fonte
|
||||||
|
4. **GET /api/v1/notebooks/{id}/sources/{source_id}/fulltext** - Ottenere testo
|
||||||
|
5. **POST /api/v1/notebooks/{id}/sources/research** - Ricerca web
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Task Breakdown Sprint 2
|
||||||
|
|
||||||
|
### Fase 1: Specifiche
|
||||||
|
- [ ] SPEC-004: Analisi requisiti Source Management
|
||||||
|
- [ ] SPEC-005: Definire modelli dati fonti
|
||||||
|
|
||||||
|
### Fase 2: API Design
|
||||||
|
- [ ] API-003: Modelli Pydantic (SourceCreate, Source, ecc.)
|
||||||
|
- [ ] API-004: Documentazione endpoints
|
||||||
|
|
||||||
|
### Fase 3: Implementazione
|
||||||
|
- [ ] DEV-007: SourceService
|
||||||
|
- [ ] DEV-008: POST /sources
|
||||||
|
- [ ] DEV-009: GET /sources
|
||||||
|
- [ ] DEV-010: DELETE /sources/{id}
|
||||||
|
- [ ] DEV-011: POST /sources/research
|
||||||
|
|
||||||
|
### Fase 4: Testing
|
||||||
|
- [ ] TEST-004: Unit tests SourceService
|
||||||
|
- [ ] TEST-005: Integration tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Implementazione
|
||||||
|
|
||||||
|
### Tipi di Fonti Supportate
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SourceType(str, Enum):
|
||||||
|
URL = "url"
|
||||||
|
FILE = "file" # PDF, DOC, etc.
|
||||||
|
YOUTUBE = "youtube"
|
||||||
|
DRIVE = "drive"
|
||||||
|
```
|
||||||
|
|
||||||
|
### SourceService Methods
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SourceService:
|
||||||
|
async def create(notebook_id: UUID, data: dict) -> Source
|
||||||
|
async def list(notebook_id: UUID) -> list[Source]
|
||||||
|
async def get(notebook_id: UUID, source_id: str) -> Source
|
||||||
|
async def delete(notebook_id: UUID, source_id: str) -> None
|
||||||
|
async def get_fulltext(notebook_id: UUID, source_id: str) -> str
|
||||||
|
async def research(notebook_id: UUID, query: str, mode: str) -> ResearchResult
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Note Importanti
|
||||||
|
|
||||||
|
1. **Riusare pattern Sprint 1**: Stessa struttura NotebookService
|
||||||
|
2. **Gestione upload file**: Supportare multipart/form-data per PDF
|
||||||
|
3. **Ricerca web**: Integrare con notebooklm-py research
|
||||||
|
4. **Error handling**: Fonte già esistente, formato non supportato
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Prossimi Passi
|
||||||
|
|
||||||
|
1. @sprint-lead: Attivare @api-designer per API-003
|
||||||
|
2. @api-designer: Definire modelli Pydantic
|
||||||
|
3. @tdd-developer: Iniziare implementazione SourceService
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dipende da**: Sprint 1 (Notebook CRUD) ✅
|
||||||
|
**Blocca**: Sprint 3 (Chat) 🔴
|
||||||
@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from notebooklm_agent.api.routes import health, notebooks
|
from notebooklm_agent.api.routes import health, notebooks, sources
|
||||||
from notebooklm_agent.core.config import get_settings
|
from notebooklm_agent.core.config import get_settings
|
||||||
from notebooklm_agent.core.logging import setup_logging
|
from notebooklm_agent.core.logging import setup_logging
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ def create_application() -> FastAPI:
|
|||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(health.router, prefix="/health", tags=["health"])
|
app.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
app.include_router(notebooks.router, prefix="/api/v1/notebooks", tags=["notebooks"])
|
app.include_router(notebooks.router, prefix="/api/v1/notebooks", tags=["notebooks"])
|
||||||
|
app.include_router(sources.router, prefix="/api/v1/notebooks", tags=["sources"])
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -227,3 +227,58 @@ class SourceCreate(BaseModel):
|
|||||||
if info.data.get("type") == "url" and not v:
|
if info.data.get("type") == "url" and not v:
|
||||||
raise ValueError("URL is required for type 'url'")
|
raise ValueError("URL is required for type 'url'")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchRequest(BaseModel):
|
||||||
|
"""Request model for web research.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
query: Search query string.
|
||||||
|
mode: Research mode (fast or deep).
|
||||||
|
auto_import: Whether to auto-import found sources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"query": "artificial intelligence trends 2026",
|
||||||
|
"mode": "deep",
|
||||||
|
"auto_import": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
query: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=3,
|
||||||
|
max_length=500,
|
||||||
|
description="Search query string",
|
||||||
|
examples=["artificial intelligence trends 2026"],
|
||||||
|
)
|
||||||
|
mode: str = Field(
|
||||||
|
"fast",
|
||||||
|
description="Research mode",
|
||||||
|
examples=["fast", "deep"],
|
||||||
|
)
|
||||||
|
auto_import: bool = Field(
|
||||||
|
True,
|
||||||
|
description="Auto-import found sources",
|
||||||
|
examples=[True, False],
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("mode")
|
||||||
|
@classmethod
|
||||||
|
def validate_mode(cls, v: str) -> str:
|
||||||
|
"""Validate research mode."""
|
||||||
|
allowed = {"fast", "deep"}
|
||||||
|
if v not in allowed:
|
||||||
|
raise ValueError(f"Mode must be one of: {allowed}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("query")
|
||||||
|
@classmethod
|
||||||
|
def validate_query(cls, v: str) -> str:
|
||||||
|
"""Validate query is not empty."""
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError("Query cannot be empty")
|
||||||
|
return v.strip()
|
||||||
|
|||||||
419
src/notebooklm_agent/api/routes/sources.py
Normal file
419
src/notebooklm_agent/api/routes/sources.py
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
"""Source API routes.
|
||||||
|
|
||||||
|
This module contains API endpoints for source management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from notebooklm_agent.api.models.requests import ResearchRequest, SourceCreate
|
||||||
|
from notebooklm_agent.api.models.responses import (
|
||||||
|
ApiResponse,
|
||||||
|
PaginatedSources,
|
||||||
|
PaginationMeta,
|
||||||
|
ResponseMeta,
|
||||||
|
Source,
|
||||||
|
)
|
||||||
|
from notebooklm_agent.core.exceptions import NotebookLMError, NotFoundError, ValidationError
|
||||||
|
from notebooklm_agent.services.source_service import SourceService
|
||||||
|
|
||||||
|
router = APIRouter(tags=["sources"])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_source_service() -> SourceService:
|
||||||
|
"""Get source service instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SourceService instance.
|
||||||
|
"""
|
||||||
|
return SourceService()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{notebook_id}/sources",
|
||||||
|
response_model=ApiResponse[Source],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Add source to notebook",
|
||||||
|
description="Add a new source (URL, YouTube, etc.) to a notebook.",
|
||||||
|
)
|
||||||
|
async def create_source(notebook_id: str, data: SourceCreate):
|
||||||
|
"""Add a source to a notebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_id: Notebook UUID.
|
||||||
|
data: Source creation data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created source.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 for validation errors, 404 for not found, 502 for external API errors.
|
||||||
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
try:
|
||||||
|
notebook_uuid = UUID(notebook_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": "VALIDATION_ERROR",
|
||||||
|
"message": "Invalid notebook ID format",
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = await get_source_service()
|
||||||
|
source = await service.create(notebook_uuid, data.model_dump())
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
success=True,
|
||||||
|
data=source,
|
||||||
|
error=None,
|
||||||
|
meta=ResponseMeta(
|
||||||
|
timestamp=datetime.utcnow(),
|
||||||
|
request_id=uuid4(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": e.code,
|
||||||
|
"message": e.message,
|
||||||
|
"details": e.details or [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except NotFoundError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": e.code,
|
||||||
|
"message": e.message,
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except NotebookLMError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": e.code,
|
||||||
|
"message": e.message,
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{notebook_id}/sources",
|
||||||
|
response_model=ApiResponse[PaginatedSources],
|
||||||
|
summary="List notebook sources",
|
||||||
|
description="List all sources for a notebook with optional filtering.",
|
||||||
|
)
|
||||||
|
async def list_sources(
|
||||||
|
notebook_id: str,
|
||||||
|
source_type: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
):
|
||||||
|
"""List sources for a notebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_id: Notebook UUID.
|
||||||
|
source_type: Optional filter by source type.
|
||||||
|
status: Optional filter by processing status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of sources.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 for invalid ID, 404 for not found, 502 for external API errors.
|
||||||
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
try:
|
||||||
|
notebook_uuid = UUID(notebook_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": "VALIDATION_ERROR",
|
||||||
|
"message": "Invalid notebook ID format",
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = await get_source_service()
|
||||||
|
sources = await service.list(notebook_uuid, source_type, status)
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
success=True,
|
||||||
|
data=PaginatedSources(
|
||||||
|
items=sources,
|
||||||
|
pagination=PaginationMeta(
|
||||||
|
total=len(sources),
|
||||||
|
limit=len(sources),
|
||||||
|
offset=0,
|
||||||
|
has_more=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
error=None,
|
||||||
|
meta=ResponseMeta(
|
||||||
|
timestamp=datetime.utcnow(),
|
||||||
|
request_id=uuid4(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except NotFoundError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": e.code,
|
||||||
|
"message": e.message,
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except NotebookLMError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": e.code,
|
||||||
|
"message": e.message,
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{notebook_id}/sources/{source_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete source",
|
||||||
|
description="Delete a source from a notebook.",
|
||||||
|
)
|
||||||
|
async def delete_source(notebook_id: str, source_id: str):
|
||||||
|
"""Delete a source from a notebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_id: Notebook UUID.
|
||||||
|
source_id: Source ID to delete.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 for invalid ID, 404 for not found, 502 for external API errors.
|
||||||
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
try:
|
||||||
|
notebook_uuid = UUID(notebook_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": "VALIDATION_ERROR",
|
||||||
|
"message": "Invalid notebook ID format",
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = await get_source_service()
|
||||||
|
await service.delete(notebook_uuid, source_id)
|
||||||
|
# 204 No Content - no body
|
||||||
|
except NotFoundError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": e.code,
|
||||||
|
"message": e.message,
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except NotebookLMError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": e.code,
|
||||||
|
"message": e.message,
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{notebook_id}/sources/research",
|
||||||
|
response_model=ApiResponse[dict],
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
summary="Start web research",
|
||||||
|
description="Start web research and optionally auto-import sources.",
|
||||||
|
)
|
||||||
|
async def research_sources(notebook_id: str, data: ResearchRequest):
|
||||||
|
"""Start web research for a notebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_id: Notebook UUID.
|
||||||
|
data: Research request with query and options.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Research job information.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 for invalid data, 404 for not found, 502 for external API errors.
|
||||||
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
try:
|
||||||
|
notebook_uuid = UUID(notebook_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": "VALIDATION_ERROR",
|
||||||
|
"message": "Invalid notebook ID format",
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = await get_source_service()
|
||||||
|
result = await service.research(
|
||||||
|
notebook_uuid,
|
||||||
|
data.query,
|
||||||
|
data.mode,
|
||||||
|
data.auto_import,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
success=True,
|
||||||
|
data=result,
|
||||||
|
error=None,
|
||||||
|
meta=ResponseMeta(
|
||||||
|
timestamp=datetime.utcnow(),
|
||||||
|
request_id=uuid4(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": e.code,
|
||||||
|
"message": e.message,
|
||||||
|
"details": e.details or [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except NotFoundError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": e.code,
|
||||||
|
"message": e.message,
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except NotebookLMError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail={
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": e.code,
|
||||||
|
"message": e.message,
|
||||||
|
"details": [],
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"request_id": str(uuid4()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
331
src/notebooklm_agent/services/source_service.py
Normal file
331
src/notebooklm_agent/services/source_service.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"""Source service for business logic.
|
||||||
|
|
||||||
|
This module contains the SourceService class which handles
|
||||||
|
all business logic for source operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from notebooklm_agent.api.models.responses import Source
|
||||||
|
from notebooklm_agent.core.exceptions import NotebookLMError, NotFoundError, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class SourceService:
|
||||||
|
"""Service for source operations.
|
||||||
|
|
||||||
|
This service handles all business logic for source management,
|
||||||
|
including adding sources to notebooks, listing, and deleting.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
_client: The notebooklm-py client instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: Any = None) -> None:
|
||||||
|
"""Initialize the source service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Optional notebooklm-py client instance.
|
||||||
|
If not provided, will be created on first use.
|
||||||
|
"""
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
async def _get_client(self) -> Any:
|
||||||
|
"""Get or create notebooklm-py client.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The notebooklm-py client instance.
|
||||||
|
"""
|
||||||
|
if self._client is None:
|
||||||
|
# Lazy initialization - import here to avoid circular imports
|
||||||
|
from notebooklm import NotebookLMClient
|
||||||
|
|
||||||
|
self._client = await NotebookLMClient.from_storage()
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _validate_source_type(self, source_type: str) -> str:
|
||||||
|
"""Validate source type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_type: The source type to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated source type.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If source type is invalid.
|
||||||
|
"""
|
||||||
|
allowed_types = {"url", "file", "youtube", "drive"}
|
||||||
|
if source_type not in allowed_types:
|
||||||
|
raise ValidationError(
|
||||||
|
message=f"Invalid source type. Must be one of: {allowed_types}",
|
||||||
|
code="VALIDATION_ERROR",
|
||||||
|
)
|
||||||
|
return source_type
|
||||||
|
|
||||||
|
def _validate_url(self, url: str | None, source_type: str) -> str | None:
|
||||||
|
"""Validate URL for source.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to validate.
|
||||||
|
source_type: The type of source.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated URL or None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If URL is required but not provided.
|
||||||
|
"""
|
||||||
|
if source_type in {"url", "youtube"} and not url:
|
||||||
|
raise ValidationError(
|
||||||
|
message=f"URL is required for source type '{source_type}'",
|
||||||
|
code="VALIDATION_ERROR",
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
|
||||||
|
async def create(self, notebook_id: UUID, data: dict) -> Source:
|
||||||
|
"""Add a source to a notebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_id: The notebook ID to add the source to.
|
||||||
|
data: Source data including type, url, and optional title.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created source.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If source data is invalid.
|
||||||
|
NotFoundError: If notebook not found.
|
||||||
|
NotebookLMError: If external API fails.
|
||||||
|
"""
|
||||||
|
# Validate input
|
||||||
|
source_type = data.get("type", "url")
|
||||||
|
self._validate_source_type(source_type)
|
||||||
|
|
||||||
|
url = data.get("url")
|
||||||
|
self._validate_url(url, source_type)
|
||||||
|
|
||||||
|
title = data.get("title")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
notebook = await client.notebooks.get(str(notebook_id))
|
||||||
|
|
||||||
|
# Add source based on type
|
||||||
|
if source_type == "url":
|
||||||
|
result = await notebook.sources.add_url(url, title=title)
|
||||||
|
elif source_type == "youtube":
|
||||||
|
result = await notebook.sources.add_youtube(url, title=title)
|
||||||
|
elif source_type == "drive":
|
||||||
|
result = await notebook.sources.add_drive(url, title=title)
|
||||||
|
else:
|
||||||
|
# For file type, this would be handled differently (multipart upload)
|
||||||
|
raise ValidationError(
|
||||||
|
message="File upload not supported via this method. Use file upload endpoint.",
|
||||||
|
code="VALIDATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Source(
|
||||||
|
id=getattr(result, "id", str(notebook_id)),
|
||||||
|
notebook_id=notebook_id,
|
||||||
|
type=source_type,
|
||||||
|
title=title or getattr(result, "title", "Untitled"),
|
||||||
|
url=url,
|
||||||
|
status=getattr(result, "status", "processing"),
|
||||||
|
created_at=getattr(result, "created_at", datetime.utcnow()),
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
if "not found" in error_str:
|
||||||
|
raise NotFoundError("Notebook", str(notebook_id))
|
||||||
|
raise NotebookLMError(
|
||||||
|
message=f"Failed to add source: {e}",
|
||||||
|
code="NOTEBOOKLM_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self,
|
||||||
|
notebook_id: UUID,
|
||||||
|
source_type: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> list[Source]:
|
||||||
|
"""List sources for a notebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_id: The notebook ID.
|
||||||
|
source_type: Optional filter by source type.
|
||||||
|
status: Optional filter by status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of sources.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundError: If notebook not found.
|
||||||
|
NotebookLMError: If external API fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
notebook = await client.notebooks.get(str(notebook_id))
|
||||||
|
|
||||||
|
sources = await notebook.sources.list()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for src in sources:
|
||||||
|
# Apply filters
|
||||||
|
if source_type and getattr(src, "type", "") != source_type:
|
||||||
|
continue
|
||||||
|
if status and getattr(src, "status", "") != status:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
Source(
|
||||||
|
id=getattr(src, "id", str(notebook_id)),
|
||||||
|
notebook_id=notebook_id,
|
||||||
|
type=getattr(src, "type", "url"),
|
||||||
|
title=getattr(src, "title", "Untitled"),
|
||||||
|
url=getattr(src, "url", None),
|
||||||
|
status=getattr(src, "status", "ready"),
|
||||||
|
created_at=getattr(src, "created_at", datetime.utcnow()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except NotFoundError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
if "not found" in error_str:
|
||||||
|
raise NotFoundError("Notebook", str(notebook_id))
|
||||||
|
raise NotebookLMError(
|
||||||
|
message=f"Failed to list sources: {e}",
|
||||||
|
code="NOTEBOOKLM_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete(self, notebook_id: UUID, source_id: str) -> None:
|
||||||
|
"""Delete a source from a notebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_id: The notebook ID.
|
||||||
|
source_id: The source ID to delete.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundError: If notebook or source not found.
|
||||||
|
NotebookLMError: If external API fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
notebook = await client.notebooks.get(str(notebook_id))
|
||||||
|
|
||||||
|
# Try to delete the source
|
||||||
|
await notebook.sources.delete(source_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
if "not found" in error_str:
|
||||||
|
raise NotFoundError("Source", source_id)
|
||||||
|
raise NotebookLMError(
|
||||||
|
message=f"Failed to delete source: {e}",
|
||||||
|
code="NOTEBOOKLM_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_fulltext(self, notebook_id: UUID, source_id: str) -> str:
|
||||||
|
"""Get the full text content of a source.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_id: The notebook ID.
|
||||||
|
source_id: The source ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The full text content.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundError: If notebook or source not found.
|
||||||
|
NotebookLMError: If external API fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
notebook = await client.notebooks.get(str(notebook_id))
|
||||||
|
|
||||||
|
source = await notebook.sources.get(source_id)
|
||||||
|
fulltext = await source.get_fulltext()
|
||||||
|
|
||||||
|
return fulltext
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
if "not found" in error_str:
|
||||||
|
raise NotFoundError("Source", source_id)
|
||||||
|
raise NotebookLMError(
|
||||||
|
message=f"Failed to get source fulltext: {e}",
|
||||||
|
code="NOTEBOOKLM_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def research(
|
||||||
|
self,
|
||||||
|
notebook_id: UUID,
|
||||||
|
query: str,
|
||||||
|
mode: str = "fast",
|
||||||
|
auto_import: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Start web research for a notebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_id: The notebook ID.
|
||||||
|
query: The search query.
|
||||||
|
mode: Research mode (fast or deep).
|
||||||
|
auto_import: Whether to auto-import found sources.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Research result with job ID and status.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundError: If notebook not found.
|
||||||
|
ValidationError: If query is invalid.
|
||||||
|
NotebookLMError: If external API fails.
|
||||||
|
"""
|
||||||
|
if not query or not query.strip():
|
||||||
|
raise ValidationError(
|
||||||
|
message="Query cannot be empty",
|
||||||
|
code="VALIDATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode not in {"fast", "deep"}:
|
||||||
|
raise ValidationError(
|
||||||
|
message="Mode must be 'fast' or 'deep'",
|
||||||
|
code="VALIDATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
notebook = await client.notebooks.get(str(notebook_id))
|
||||||
|
|
||||||
|
# Start research
|
||||||
|
result = await notebook.sources.research(
|
||||||
|
query=query,
|
||||||
|
mode=mode,
|
||||||
|
auto_import=auto_import,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"research_id": getattr(result, "id", str(notebook_id)),
|
||||||
|
"status": getattr(result, "status", "pending"),
|
||||||
|
"query": query,
|
||||||
|
"mode": mode,
|
||||||
|
"sources_found": getattr(result, "sources_found", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValidationError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
if "not found" in error_str:
|
||||||
|
raise NotFoundError("Notebook", str(notebook_id))
|
||||||
|
raise NotebookLMError(
|
||||||
|
message=f"Failed to start research: {e}",
|
||||||
|
code="NOTEBOOKLM_ERROR",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user