Compare commits

...
3 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 6d5760c297 release: v1.4.7 - Paperless custom field fix
Build and Push / build (release) Successful in 29s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 16:40:28 +01:00
jpmschweitzerandClaude Opus 4.5 867de65354 fix: use field ID for Paperless custom field updates
Paperless API requires field ID (integer) not field name (string)
when updating custom fields. Now looks up field ID by name before
updating library_indexed custom field.

Also includes webhook debugging endpoint for development.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 16:37:26 +01:00
jpmschweitzerandClaude Opus 4.5 f2b8c7d111 fix: update Paperless webhook payload to match include_document format
Build and Push / build (release) Successful in 30s
- Change model field from document_id to id (Paperless sends id)
- Add content, created, modified, added, original_file_name, owner fields
- Add extra="ignore" config to handle additional Paperless fields
- Update sync service to use content from webhook payload
- Skip Paperless API call when content already provided

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 14:38:40 +01:00
6 changed files with 224 additions and 42 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ QDRANT_PORT=6333
OLLAMA_URL=http://192.168.86.149:11434 OLLAMA_URL=http://192.168.86.149:11434
SEARXNG_URL=http://192.168.86.149:8080 SEARXNG_URL=http://192.168.86.149:8080
REDIS_HOST=192.168.86.149 REDIS_HOST=192.168.86.149
PAPERLESS_URL=http://192.168.86.149:8000 PAPERLESS_URL=http://192.168.86.149:8091
OLLAMA_MODEL=mistral-nemo-large:latest OLLAMA_MODEL=mistral-nemo-large:latest
OLLAMA_EMBEDDING_MODEL=nomic-embed-text OLLAMA_EMBEDDING_MODEL=nomic-embed-text
+23
View File
@@ -5,6 +5,29 @@ All notable changes to Library Desk will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.7] - 2025-12-25
### Fixed
- **Paperless Custom Field Update** - Fixed 400 error when marking documents as indexed
- Paperless API requires field ID (integer) not field name (string)
- Now looks up `library_indexed` field ID before updating
- Webhook params format: `doc_url` and `title` from Jinja templates
### Added
- **Webhook Debug Endpoint** - `POST /documents/webhook-capture` for development testing
## [1.4.6] - 2025-12-25
### Fixed
- **Paperless Webhook Payload Format** - Updated model to match Paperless `include_document=true` format
- Paperless sends `id` instead of `document_id`
- Paperless sends full document data including `content`, `title`, `tags`, etc.
- Webhook now uses content from payload, skipping extra Paperless API call
- Added `extra = "ignore"` to handle additional Paperless fields
## [1.4.5] - 2025-12-25 ## [1.4.5] - 2025-12-25
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "library-desk" name = "library-desk"
version = "1.4.5" version = "1.4.7"
description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation" description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+20 -6
View File
@@ -86,13 +86,27 @@ class DocumentUploadResponse(BaseModel):
class PaperlessWebhookPayload(BaseModel): class PaperlessWebhookPayload(BaseModel):
"""Payload from Paperless-ngx webhook.""" """
document_id: int = Field(..., description="Paperless document ID") Payload from Paperless-ngx webhook.
event: str = Field(..., description="Event type (document_added, document_updated)")
Supports Jinja template format:
- doc_url: Contains document ID in URL path (e.g., http://paperless:8000/documents/123/)
- title: Document title from {{ doc_title }}
"""
doc_url: str = Field(..., description="Paperless document URL containing ID")
title: Optional[str] = Field(None, description="Document title") title: Optional[str] = Field(None, description="Document title")
correspondent: Optional[int] = Field(None, description="Correspondent ID")
document_type: Optional[int] = Field(None, description="Document type ID") class Config:
tags: List[int] = Field(default_factory=list, description="Tag IDs") extra = "ignore" # Ignore extra fields
@property
def document_id(self) -> int:
"""Extract document ID from doc_url."""
import re
match = re.search(r'/documents/(\d+)/?', self.doc_url)
if match:
return int(match.group(1))
raise ValueError(f"Cannot extract document ID from URL: {self.doc_url}")
class WebhookResponse(BaseModel): class WebhookResponse(BaseModel):
+134 -8
View File
@@ -7,7 +7,7 @@ Event-driven integration with Paperless-ngx:
- Search across indexed documents - Search across indexed documents
""" """
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Request
from typing import Optional from typing import Optional
import logging import logging
import time import time
@@ -64,12 +64,138 @@ async def receive_webhook(
""" """
from src.services.document_sync_service import DocumentSyncService from src.services.document_sync_service import DocumentSyncService
logger.info(f"Webhook received: document_id={payload.document_id}, event={payload.event}") doc_id = payload.document_id
logger.info(f"Webhook received: document_id={doc_id}, title={payload.title}")
settings = get_settings() settings = get_settings()
if not settings.document_store_enabled: if not settings.document_store_enabled:
return WebhookResponse( return WebhookResponse(
document_id=payload.document_id, document_id=doc_id,
status="skipped",
indexed=False,
message="Document store is disabled"
)
try:
sync_service = DocumentSyncService(
paperless_client=paperless,
qdrant_client=qdrant,
ollama_client=ollama,
neo4j_client=neo4j,
wiki_client=wiki,
settings=settings
)
# Fetch content from Paperless (template only provides doc_url and title)
result = await sync_service.index_document(
document_id=doc_id,
user=user,
)
return WebhookResponse(
document_id=doc_id,
status="indexed" if result.success else "failed",
indexed=result.success,
message=result.error if not result.success else f"Indexed: {result.title}"
)
except Exception as e:
logger.error(f"Webhook processing failed for document {doc_id}: {e}", exc_info=True)
return WebhookResponse(
document_id=doc_id,
status="error",
indexed=False,
message=str(e)
)
# =============================================================================
# Debug Capture Endpoint
# =============================================================================
@router.post("/webhook-capture")
async def capture_webhook(request: Request):
"""Capture raw webhook payload for debugging."""
import json
from pathlib import Path
from datetime import datetime
# Get raw body
body = await request.body()
headers = dict(request.headers)
query_params = dict(request.query_params)
# Build capture data
capture = {
"timestamp": datetime.now().isoformat(),
"method": request.method,
"url": str(request.url),
"query_params": query_params,
"headers": headers,
"content_type": headers.get("content-type", "unknown"),
"body_raw": body.decode("utf-8", errors="replace"),
}
# Try to parse as JSON
try:
capture["body_json"] = json.loads(body)
except:
capture["body_json"] = None
# Write to file
capture_file = Path("logs/webhook_capture.json")
capture_file.parent.mkdir(exist_ok=True)
with open(capture_file, "w") as f:
json.dump(capture, f, indent=2, default=str)
logger.info(f"Captured webhook: {capture['body_raw'][:200]}")
return {"status": "captured", "file": str(capture_file)}
# =============================================================================
# Simple Webhook (URL parameters only)
# =============================================================================
@router.post("/webhook-simple", response_model=WebhookResponse)
async def receive_webhook_simple(
doc_url: str = Query(..., description="Paperless document URL containing ID"),
title: str = Query(default="", description="Document title"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
paperless: PaperlessDep = None,
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
neo4j: Neo4jDep = None,
wiki: WikiJSDep = None,
):
"""
Simple webhook endpoint accepting URL parameters.
Used when Paperless Jinja templates don't work with JSON body.
URL format: /webhook-simple?doc_url=http://...&title=...&user=...
"""
from src.services.document_sync_service import DocumentSyncService
import re
# Extract document ID from URL
match = re.search(r'/documents/(\d+)/?', doc_url)
if not match:
return WebhookResponse(
document_id=0,
status="error",
indexed=False,
message=f"Cannot extract document ID from URL: {doc_url}"
)
doc_id = int(match.group(1))
logger.info(f"Webhook-simple received: document_id={doc_id}, title={title}")
settings = get_settings()
if not settings.document_store_enabled:
return WebhookResponse(
document_id=doc_id,
status="skipped", status="skipped",
indexed=False, indexed=False,
message="Document store is disabled" message="Document store is disabled"
@@ -86,21 +212,21 @@ async def receive_webhook(
) )
result = await sync_service.index_document( result = await sync_service.index_document(
document_id=payload.document_id, document_id=doc_id,
user=user user=user,
) )
return WebhookResponse( return WebhookResponse(
document_id=payload.document_id, document_id=doc_id,
status="indexed" if result.success else "failed", status="indexed" if result.success else "failed",
indexed=result.success, indexed=result.success,
message=result.error if not result.success else f"Indexed: {result.title}" message=result.error if not result.success else f"Indexed: {result.title}"
) )
except Exception as e: except Exception as e:
logger.error(f"Webhook processing failed for document {payload.document_id}: {e}", exc_info=True) logger.error(f"Webhook-simple failed for document {doc_id}: {e}", exc_info=True)
return WebhookResponse( return WebhookResponse(
document_id=payload.document_id, document_id=doc_id,
status="error", status="error",
indexed=False, indexed=False,
message=str(e) message=str(e)
+45 -26
View File
@@ -86,6 +86,8 @@ class DocumentSyncService:
self, self,
document_id: int, document_id: int,
user: str, user: str,
content: Optional[str] = None,
title: Optional[str] = None,
) -> IndexResult: ) -> IndexResult:
""" """
Index a single document from Paperless into vectors and graph. Index a single document from Paperless into vectors and graph.
@@ -93,6 +95,8 @@ class DocumentSyncService:
Args: Args:
document_id: Paperless document ID document_id: Paperless document ID
user: User identifier for multi-tenancy user: User identifier for multi-tenancy
content: Optional document content (if provided, skip Paperless API call)
title: Optional document title (if provided, skip Paperless API call)
Returns: Returns:
IndexResult with success status and details IndexResult with success status and details
@@ -100,24 +104,36 @@ class DocumentSyncService:
logger.info(f"Indexing document {document_id} for user {user}") logger.info(f"Indexing document {document_id} for user {user}")
try: try:
# Fetch document from Paperless # If content and title provided (from webhook), skip API call
doc = await self.paperless.get_document(document_id) if content is not None and title is not None:
if not doc: doc_title = title
return IndexResult( doc_content = content
success=False, original_filename = None
document_id=document_id, correspondent = None
error="Document not found in Paperless" document_type = None
) tags = []
else:
# Fetch document from Paperless
doc = await self.paperless.get_document(document_id)
if not doc:
return IndexResult(
success=False,
document_id=document_id,
error="Document not found in Paperless"
)
doc_title = doc.title
doc_content = doc.content or ""
original_filename = doc.original_file_name
correspondent = doc.correspondent
document_type = doc.document_type
tags = doc.tags
title = doc.title if not doc_content.strip():
content = doc.content or ""
if not content.strip():
logger.warning(f"Document {document_id} has no text content") logger.warning(f"Document {document_id} has no text content")
return IndexResult( return IndexResult(
success=True, success=True,
document_id=document_id, document_id=document_id,
title=title, title=doc_title,
chunks_created=0, chunks_created=0,
error="No text content (possibly image/video only)" error="No text content (possibly image/video only)"
) )
@@ -125,23 +141,23 @@ class DocumentSyncService:
# Index vectors # Index vectors
chunks_created = await self._index_vectors( chunks_created = await self._index_vectors(
document_id=document_id, document_id=document_id,
title=title, title=doc_title,
content=content, content=doc_content,
user=user, user=user,
metadata={ metadata={
"paperless_id": document_id, "paperless_id": document_id,
"original_filename": doc.original_file_name, "original_filename": original_filename,
"correspondent": doc.correspondent, "correspondent": correspondent,
"document_type": doc.document_type, "document_type": document_type,
"tags": doc.tags, "tags": tags,
} }
) )
# Index graph node # Index graph node
await self._index_graph( await self._index_graph(
document_id=document_id, document_id=document_id,
title=title, title=doc_title,
content=content, content=doc_content,
user=user, user=user,
) )
@@ -156,7 +172,7 @@ class DocumentSyncService:
return IndexResult( return IndexResult(
success=True, success=True,
document_id=document_id, document_id=document_id,
title=title, title=doc_title,
chunks_created=chunks_created chunks_created=chunks_created
) )
@@ -265,10 +281,13 @@ class DocumentSyncService:
"""Mark document as indexed in Paperless custom field.""" """Mark document as indexed in Paperless custom field."""
# Try to update library_indexed custom field if it exists # Try to update library_indexed custom field if it exists
try: try:
await self.paperless.update_document( # Look up field ID by name (Paperless requires ID, not name)
document_id=document_id, field = await self.paperless.get_custom_field_by_name("library_indexed")
custom_fields=[{"field": "library_indexed", "value": True}] if field:
) await self.paperless.update_document(
document_id=document_id,
custom_fields=[{"field": field["id"], "value": True}]
)
except Exception: except Exception:
# Field might not exist, that's OK # Field might not exist, that's OK
pass pass