Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5760c297 | ||
|
|
867de65354 | ||
|
|
f2b8c7d111 |
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user