feat(document_query): add liteparse runtime and progressive skill

Add LiteParse as the preferred parser path with legacy parser fallbacks, centralized document fetching, generic user-facing progress, and compatibility shims for the former helper/tool imports. Install the runtime through Docker requirements for fresh images and through the _document_query plugin hook/startup migration for existing installations. Move the long document_query tool instructions into a document-query skill and leave a compact tool prompt stub that directs the model to load the skill before using document_query for documents, code-file Q&A, and document-image OCR. Also add default Agent Zero guidance for document/code/OCR Q&A routing. Tests: - PYTHONPATH=/home/eclypso/a0/agent-zero-pr-1528 conda run -n a0 pytest tests/test_document_query_plugin.py -q - python -m compileall -q plugins/_document_query helpers/document_query.py tools/document_query.py tests/test_document_query_plugin.py - git diff --check - Live Agent Zero Web UI E2E at localhost:32080: PDF Q&A, code-file Q&A through document_query skill, and W-4 document-image OCR Broader legacy pytest probe remains blocked by unrelated browser-agent, docker workflow branch expectation, and webui fixture path failures in this older PR worktree.

Alessandro committed May 29, 2026 at 11:39 UTC 6ccbae071228ee8146a62d402343f7bd67f5cf63
27 files changed +910 -1057
helpers/document_query._py.bak deleted
-774
@@ -1,774 +0,0 @@
1 -import mimetypes
2 -import os
3 -import asyncio
4 -import json
5 -
6 -from helpers.vector_db import VectorDB
7 -
8 -os.environ["USER_AGENT"] = "@mixedbread-ai/unstructured" # noqa E402
9 -from langchain_unstructured import UnstructuredLoader # noqa E402
10 -
11 -from urllib.parse import urlparse
12 -from typing import Callable, Sequence, List, Optional, Tuple
13 -
14 -from langchain_community.document_loaders.pdf import PyMuPDFLoader
15 -from langchain_community.document_transformers import MarkdownifyTransformer
16 -from langchain_community.document_loaders.parsers.images import TesseractBlobParser
17 -
18 -from langchain_core.documents import Document
19 -from langchain.schema import SystemMessage, HumanMessage
20 -
21 -from helpers.print_style import PrintStyle
22 -from helpers.localization import Localization
23 -from helpers import files, errors
24 -from helpers.network import HttpFetchResult, fetch_public_http_resource
25 -from agent import Agent
26 -
27 -from langchain.text_splitter import RecursiveCharacterTextSplitter
28 -
29 -
30 -DEFAULT_SEARCH_THRESHOLD = 0.5
31 -MAX_REMOTE_DOCUMENT_BYTES = 50 * 1024 * 1024
32 -SMALL_DOCUMENT_QA_FALLBACK_CHARS = 12_000
33 -
34 -
35 -class DocumentQueryStore:
36 - """
37 - FAISS Store for document query results.
38 - Manages documents identified by URI for storage, retrieval, and searching.
39 - """
40 -
41 - # Default chunking parameters
42 - DEFAULT_CHUNK_SIZE = 1000
43 - DEFAULT_CHUNK_OVERLAP = 100
44 -
45 - # Cache for initialized stores
46 - _stores: dict[str, "DocumentQueryStore"] = {}
47 -
48 - @staticmethod
49 - def get(agent: Agent):
50 - """Create a DocumentQueryStore instance for the specified agent."""
51 - if not agent or not agent.config:
52 - raise ValueError("Agent and agent config must be provided")
53 -
54 - # Initialize store
55 - store = DocumentQueryStore(agent)
56 - return store
57 -
58 - def __init__(
59 - self,
60 - agent: Agent,
61 - ):
62 - """Initialize a DocumentQueryStore instance."""
63 - self.agent = agent
64 - self.vector_db: VectorDB | None = None
65 -
66 - @staticmethod
67 - def normalize_uri(uri: str) -> str:
68 - """
69 - Normalize a document URI to ensure consistent lookup.
70 -
71 - Args:
72 - uri: The URI to normalize
73 -
74 - Returns:
75 - Normalized URI
76 - """
77 - # Convert to lowercase
78 - normalized = uri.strip() # uri.lower()
79 -
80 - # Parse the URL to get scheme
81 - parsed = urlparse(normalized)
82 - scheme = parsed.scheme or "file"
83 -
84 - # Normalize based on scheme
85 - if scheme == "file":
86 - path = files.fix_dev_path(
87 - normalized.removeprefix("file://").removeprefix("file:")
88 - )
89 - normalized = f"file://{path}"
90 -
91 - elif scheme in ["http", "https"]:
92 - # Always use https for web URLs
93 - normalized = normalized.replace("http://", "https://")
94 -
95 - return normalized
96 -
97 - def init_vector_db(self):
98 - return VectorDB(self.agent, cache=True)
99 -
100 - async def add_document(
101 - self, text: str, document_uri: str, metadata: dict | None = None
102 - ) -> tuple[bool, list[str]]:
103 - """
104 - Add a document to the store with the given URI.
105 -
106 - Args:
107 - text: The document text content
108 - document_uri: The URI that uniquely identifies this document
109 - metadata: Optional metadata for the document
110 -
111 - Returns:
112 - True if successful, False otherwise
113 - """
114 - # Normalize the URI
115 - document_uri = self.normalize_uri(document_uri)
116 -
117 - # Delete existing document if it exists to avoid duplicates
118 - await self.delete_document(document_uri)
119 -
120 - # Initialize metadata
121 - doc_metadata = metadata or {}
122 - doc_metadata["document_uri"] = document_uri
123 - doc_metadata["timestamp"] = Localization.get().now_iso(timespec="seconds")
124 -
125 - # Split text into chunks
126 - text_splitter = RecursiveCharacterTextSplitter(
127 - chunk_size=self.DEFAULT_CHUNK_SIZE, chunk_overlap=self.DEFAULT_CHUNK_OVERLAP
128 - )
129 - chunks = text_splitter.split_text(text)
130 -
131 - # Create documents
132 - docs = []
133 - for i, chunk in enumerate(chunks):
134 - chunk_metadata = doc_metadata.copy()
135 - chunk_metadata["chunk_index"] = i
136 - chunk_metadata["total_chunks"] = len(chunks)
137 - docs.append(Document(page_content=chunk, metadata=chunk_metadata))
138 -
139 - if not docs:
140 - PrintStyle.error(f"No chunks created for document: {document_uri}")
141 - return False, []
142 -
143 - try:
144 - # Initialize vector db if not already initialized
145 - if not self.vector_db:
146 - self.vector_db = self.init_vector_db()
147 -
148 - ids = await self.vector_db.insert_documents(docs)
149 - PrintStyle.standard(
150 - f"Added document '{document_uri}' with {len(docs)} chunks"
151 - )
152 - return True, ids
153 - except Exception as e:
154 - err_text = errors.format_error(e)
155 - PrintStyle.error(f"Error adding document '{document_uri}': {err_text}")
156 - return False, []
157 -
158 - async def get_document(self, document_uri: str) -> Optional[Document]:
159 - """
160 - Retrieve a document by its URI.
161 -
162 - Args:
163 - document_uri: The URI of the document to retrieve
164 -
165 - Returns:
166 - The complete document if found, None otherwise
167 - """
168 -
169 - # DB not initialized, no documents inside
170 - if not self.vector_db:
171 - return None
172 -
173 - # Normalize the URI
174 - document_uri = self.normalize_uri(document_uri)
175 -
176 - # Get all chunks for this document
177 - docs = await self._get_document_chunks(document_uri)
178 - if not docs:
179 - PrintStyle.error(f"Document not found: {document_uri}")
180 - return None
181 -
182 - # Combine chunks into a single document
183 - chunks = sorted(docs, key=lambda x: x.metadata.get("chunk_index", 0))
184 - full_content = "\n".join(chunk.page_content for chunk in chunks)
185 -
186 - # Use metadata from first chunk
187 - metadata = chunks[0].metadata.copy()
188 - metadata.pop("chunk_index", None)
189 - metadata.pop("total_chunks", None)
190 -
191 - return Document(page_content=full_content, metadata=metadata)
192 -
193 - async def _get_document_chunks(self, document_uri: str) -> List[Document]:
194 - """
195 - Get all chunks for a document.
196 -
197 - Args:
198 - document_uri: The URI of the document
199 -
200 - Returns:
201 - List of document chunks
202 - """
203 -
204 - # DB not initialized, no documents inside
205 - if not self.vector_db:
206 - return []
207 -
208 - # Normalize the URI
209 - document_uri = self.normalize_uri(document_uri)
210 -
211 - # get docs from vector db
212 -
213 - chunks = await self.vector_db.search_by_metadata(
214 - filter=f"document_uri == '{document_uri}'",
215 - )
216 -
217 - PrintStyle.standard(f"Found {len(chunks)} chunks for document: {document_uri}")
218 - return chunks
219 -
220 - async def document_exists(self, document_uri: str) -> bool:
221 - """
222 - Check if a document exists in the store.
223 -
224 - Args:
225 - document_uri: The URI of the document to check
226 -
227 - Returns:
228 - True if the document exists, False otherwise
229 - """
230 -
231 - # DB not initialized, no documents inside
232 - if not self.vector_db:
233 - return False
234 -
235 - # Normalize the URI
236 - document_uri = self.normalize_uri(document_uri)
237 -
238 - chunks = await self._get_document_chunks(document_uri)
239 - return len(chunks) > 0
240 -
241 - async def delete_document(self, document_uri: str) -> bool:
242 - """
243 - Delete a document from the store.
244 -
245 - Args:
246 - document_uri: The URI of the document to delete
247 -
248 - Returns:
249 - True if deleted, False if not found
250 - """
251 -
252 - # DB not initialized, no documents inside
253 - if not self.vector_db:
254 - return False
255 -
256 - # Normalize the URI
257 - document_uri = self.normalize_uri(document_uri)
258 -
259 - chunks = await self.vector_db.search_by_metadata(
260 - filter=f"document_uri == '{document_uri}'",
261 - )
262 - if not chunks:
263 - return False
264 -
265 - # Collect IDs to delete
266 - ids_to_delete = [chunk.metadata["id"] for chunk in chunks]
267 -
268 - # Delete from vector store
269 - if ids_to_delete:
270 - dels = await self.vector_db.delete_documents_by_ids(ids_to_delete)
271 - PrintStyle.standard(
272 - f"Deleted document '{document_uri}' with {len(dels)} chunks"
273 - )
274 - return True
275 -
276 - return False
277 -
278 - async def search_documents(
279 - self, query: str, limit: int = 10, threshold: float = 0.5, filter: str = ""
280 - ) -> List[Document]:
281 - """
282 - Search for documents similar to the query across the entire store.
283 -
284 - Args:
285 - query: The search query string
286 - limit: Maximum number of results to return
287 - threshold: Minimum similarity score threshold (0-1)
288 -
289 - Returns:
290 - List of matching documents
291 - """
292 -
293 - # DB not initialized, no documents inside
294 - if not self.vector_db:
295 - return []
296 -
297 - # Handle empty query
298 - if not query:
299 - return []
300 -
301 - # Perform search
302 - try:
303 - results = await self.vector_db.search_by_similarity_threshold(
304 - query=query, limit=limit, threshold=threshold, filter=filter
305 - )
306 -
307 - PrintStyle.standard(f"Search '{query}' returned {len(results)} results")
308 - return results
309 - except Exception as e:
310 - PrintStyle.error(f"Error searching documents: {str(e)}")
311 - return []
312 -
313 - async def search_document(
314 - self, document_uri: str, query: str, limit: int = 10, threshold: float = 0.5
315 - ) -> List[Document]:
316 - """
317 - Search for content within a specific document.
318 -
319 - Args:
320 - document_uri: The URI of the document to search within
321 - query: The search query string
322 - limit: Maximum number of results to return
323 - threshold: Minimum similarity score threshold (0-1)
324 -
325 - Returns:
326 - List of matching document chunks
327 - """
328 - return await self.search_documents(
329 - query, limit, threshold, f"document_uri == '{document_uri}'"
330 - )
331 -
332 - async def list_documents(self) -> List[str]:
333 - """
334 - Get a list of all document URIs in the store.
335 -
336 - Returns:
337 - List of document URIs
338 - """
339 - # DB not initialized, no documents inside
340 - if not self.vector_db:
341 - return []
342 -
343 - # Extract unique URIs
344 - uris = set()
345 - for doc in self.vector_db.db.get_all_docs().values():
346 - if isinstance(doc.metadata, dict):
347 - uri = doc.metadata.get("document_uri")
348 - if uri:
349 - uris.add(uri)
350 -
351 - return sorted(list(uris))
352 -
353 -
354 -class DocumentQueryHelper:
355 -
356 - def __init__(
357 - self, agent: Agent, progress_callback: Callable[[str], None] | None = None
358 - ):
359 - self.agent = agent
360 - self.store = DocumentQueryStore.get(agent)
361 - self.progress_callback = progress_callback or (lambda x: None)
362 - self.store_lock = asyncio.Lock()
363 -
364 - async def document_qa(
365 - self, document_uris: List[str], questions: Sequence[str]
366 - ) -> Tuple[bool, str]:
367 - self.progress_callback(
368 - f"Starting Q&A process for {len(document_uris)} documents"
369 - )
370 - await self.agent.handle_intervention()
371 -
372 - # index documents
373 - document_contents = await asyncio.gather(
374 - *[self.document_get_content(uri, True) for uri in document_uris]
375 - )
376 - await self.agent.handle_intervention()
377 - selected_chunks = {}
378 - for question in questions:
379 - self.progress_callback(f"Optimizing query: {question}")
380 - await self.agent.handle_intervention()
381 - human_content = f'Search Query: "{question}"'
382 - system_content = self.agent.parse_prompt(
383 - "fw.document_query.optmimize_query.md"
384 - )
385 -
386 - optimized_query = (
387 - await self.agent.call_utility_model(
388 - system=system_content, message=human_content
389 - )
390 - ).strip()
391 -
392 - await self.agent.handle_intervention()
393 - self.progress_callback(f"Searching documents with query: {optimized_query}")
394 -
395 - normalized_uris = [self.store.normalize_uri(uri) for uri in document_uris]
396 - doc_filter = " or ".join(
397 - [f"document_uri == '{uri}'" for uri in normalized_uris]
398 - )
399 -
400 - chunks = await self.store.search_documents(
401 - query=optimized_query,
402 - limit=100,
403 - threshold=DEFAULT_SEARCH_THRESHOLD,
404 - filter=doc_filter,
405 - )
406 -
407 - self.progress_callback(f"Found {len(chunks)} chunks")
408 -
409 - for chunk in chunks:
410 - selected_chunks[chunk.metadata["id"]] = chunk
411 -
412 - if not selected_chunks:
413 - content = self._small_document_fallback_content(
414 - document_uris, document_contents
415 - )
416 - if not content:
417 - self.progress_callback("No relevant content found in the documents")
418 - content = f"!!! No content found for documents: {json.dumps(document_uris)} matching queries: {json.dumps(questions)}"
419 - return False, content
420 - self.progress_callback(
421 - "No matching chunks found; using complete small-document content"
422 - )
423 - else:
424 - content = "\n\n----\n\n".join(
425 - [chunk.page_content for chunk in selected_chunks.values()]
426 - )
427 -
428 - self.progress_callback(
429 - f"Processing {len(questions)} questions in document context"
430 - )
431 - await self.agent.handle_intervention()
432 -
433 - questions_str = "\n".join([f" * {question}" for question in questions])
434 -
435 - qa_system_message = self.agent.parse_prompt(
436 - "fw.document_query.system_prompt.md"
437 - )
438 - qa_user_message = f"# Document:\n{content}\n\n# Queries:\n{questions_str}"
439 -
440 - ai_response, _reasoning = await self.agent.call_chat_model(
441 - messages=[
442 - SystemMessage(content=qa_system_message),
443 - HumanMessage(content=qa_user_message),
444 - ],
445 - explicit_caching=False,
446 - )
447 -
448 - self.progress_callback(f"Q&A process completed")
449 -
450 - return True, str(ai_response)
451 -
452 - @staticmethod
453 - def _small_document_fallback_content(
454 - document_uris: Sequence[str], document_contents: Sequence[str]
455 - ) -> str:
456 - total_chars = 0
457 - sections = []
458 -
459 - for uri, content in zip(document_uris, document_contents):
460 - if not isinstance(content, str) or not content.strip():
461 - continue
462 - total_chars += len(content)
463 - if total_chars > SMALL_DOCUMENT_QA_FALLBACK_CHARS:
464 - return ""
465 - sections.append(f"## {uri}\n\n{content.strip()}")
466 -
467 - return "\n\n----\n\n".join(sections)
468 -
469 - async def document_get_content(
470 - self, document_uri: str, add_to_db: bool = False
471 - ) -> str:
472 - self.progress_callback(f"Fetching document content")
473 - await self.agent.handle_intervention()
474 - url = urlparse(document_uri)
475 - scheme = url.scheme or "file"
476 - mimetype, encoding = mimetypes.guess_type(document_uri)
477 - mimetype = mimetype or "application/octet-stream"
478 - remote_resource: HttpFetchResult | None = None
479 -
480 - if scheme in ["http", "https"]:
481 - remote_resource = await asyncio.to_thread(
482 - fetch_public_http_resource,
483 - document_uri,
484 - max_bytes=MAX_REMOTE_DOCUMENT_BYTES,
485 - )
486 - if (
487 - remote_resource.content_type
488 - and remote_resource.content_type != "application/octet-stream"
489 - ):
490 - mimetype = remote_resource.content_type
491 -
492 - if scheme == "file":
493 - try:
494 - document_uri = files.fix_dev_path(url.path)
495 - except Exception as e:
496 - raise ValueError(f"Invalid document path '{url.path}'") from e
497 -
498 - if encoding:
499 - raise ValueError(
500 - f"Compressed documents are unsupported '{encoding}' ({document_uri})"
501 - )
502 -
503 - if mimetype == "application/octet-stream":
504 - raise ValueError(
505 - f"Unsupported document mimetype '{mimetype}' ({document_uri})"
506 - )
507 -
508 - # Use the store's normalization method
509 - document_uri_norm = self.store.normalize_uri(document_uri)
510 -
511 - await self.agent.handle_intervention()
512 - exists = await self.store.document_exists(document_uri_norm)
513 - document_content = ""
514 - if not exists:
515 - await self.agent.handle_intervention()
516 - if mimetype.startswith("image/"):
517 - document_content = self.handle_image_document(
518 - document_uri, scheme, remote_resource=remote_resource
519 - )
520 - elif mimetype == "text/html":
521 - document_content = self.handle_html_document(
522 - document_uri, scheme, remote_resource=remote_resource
523 - )
524 - elif mimetype.startswith("text/") or mimetype == "application/json":
525 - document_content = self.handle_text_document(
526 - document_uri, scheme, remote_resource=remote_resource
527 - )
528 - elif mimetype == "application/pdf":
529 - document_content = self.handle_pdf_document(
530 - document_uri, scheme, remote_resource=remote_resource
531 - )
532 - else:
533 - document_content = self.handle_unstructured_document(
534 - document_uri, scheme, remote_resource=remote_resource
535 - )
536 - if add_to_db:
537 - self.progress_callback(f"Indexing document")
538 - await self.agent.handle_intervention()
539 - async with self.store_lock:
540 - success, ids = await self.store.add_document(
541 - document_content, document_uri_norm
542 - )
543 - if not success:
544 - self.progress_callback(f"Failed to index document")
545 - raise ValueError(
546 - f"DocumentQueryHelper::document_get_content: Failed to index document: {document_uri_norm}"
547 - )
548 - self.progress_callback(f"Indexed {len(ids)} chunks")
549 - else:
550 - await self.agent.handle_intervention()
551 - doc = await self.store.get_document(document_uri_norm)
552 - if doc:
553 - document_content = doc.page_content
554 - else:
555 - raise ValueError(
556 - f"DocumentQueryHelper::document_get_content: Document not found: {document_uri_norm}"
557 - )
558 - return document_content
559 -
560 - @staticmethod
561 - def _decode_remote_text(remote_resource: HttpFetchResult) -> str:
562 - encoding = remote_resource.encoding or "utf-8"
563 - try:
564 - return remote_resource.content.decode(encoding)
565 - except (LookupError, UnicodeDecodeError):
566 - return remote_resource.content.decode("utf-8", errors="replace")
567 -
568 - @staticmethod
569 - def _get_temp_file_suffix(
570 - document: str, remote_resource: HttpFetchResult | None = None
571 - ) -> str:
572 - parsed = urlparse(document)
573 - _stem, ext = os.path.splitext(parsed.path or document)
574 - if ext:
575 - return ext
576 -
577 - if remote_resource and remote_resource.content_type:
578 - guessed_ext = mimetypes.guess_extension(
579 - remote_resource.content_type, strict=False
580 - )
581 - if guessed_ext:
582 - return guessed_ext
583 -
584 - return ".bin"
585 -
586 - def handle_image_document(
587 - self,
588 - document: str,
589 - scheme: str,
590 - remote_resource: HttpFetchResult | None = None,
591 - ) -> str:
592 - return self.handle_unstructured_document(
593 - document, scheme, remote_resource=remote_resource
594 - )
595 -
596 - def handle_html_document(
597 - self,
598 - document: str,
599 - scheme: str,
600 - remote_resource: HttpFetchResult | None = None,
601 - ) -> str:
602 - if scheme in ["http", "https"]:
603 - if remote_resource is None:
604 - raise ValueError("Missing prefetched remote HTML content")
605 - html_content = self._decode_remote_text(remote_resource)
606 - parts = [Document(page_content=html_content, metadata={"source": document})]
607 - elif scheme == "file":
608 - # Use RFC file operations instead of TextLoader
609 - file_content_bytes = files.read_file_bin(document)
610 - file_content = file_content_bytes.decode("utf-8")
611 - # Create Document manually since we're not using TextLoader
612 - parts = [Document(page_content=file_content, metadata={"source": document})]
613 - else:
614 - raise ValueError(f"Unsupported scheme: {scheme}")
615 -
616 - return "\n".join(
617 - [
618 - element.page_content
619 - for element in MarkdownifyTransformer().transform_documents(parts)
620 - ]
621 - )
622 -
623 - def handle_text_document(
624 - self,
625 - document: str,
626 - scheme: str,
627 - remote_resource: HttpFetchResult | None = None,
628 - ) -> str:
629 - if scheme in ["http", "https"]:
630 - if remote_resource is None:
631 - raise ValueError("Missing prefetched remote text content")
632 - file_content = self._decode_remote_text(remote_resource)
633 - elements = [
634 - Document(page_content=file_content, metadata={"source": document})
635 - ]
636 - elif scheme == "file":
637 - # Use RFC file operations instead of TextLoader
638 - file_content_bytes = files.read_file_bin(document)
639 - file_content = file_content_bytes.decode("utf-8")
640 - # Create Document manually since we're not using TextLoader
641 - elements = [
642 - Document(page_content=file_content, metadata={"source": document})
643 - ]
644 - else:
645 - raise ValueError(f"Unsupported scheme: {scheme}")
646 -
647 - return "\n".join([element.page_content for element in elements])
648 -
649 - def handle_pdf_document(
650 - self,
651 - document: str,
652 - scheme: str,
653 - remote_resource: HttpFetchResult | None = None,
654 - ) -> str:
655 - temp_file_path = ""
656 - if scheme == "file":
657 - # Use RFC file operations to read the PDF file as binary
658 - file_content_bytes = files.read_file_bin(document)
659 - # Create a temporary file for PyMuPDFLoader since it needs a file path
660 - import tempfile
661 -
662 - with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file:
663 - temp_file.write(file_content_bytes)
664 - temp_file_path = temp_file.name
665 - elif scheme in ["http", "https"]:
666 - import tempfile
667 -
668 - if remote_resource is None:
669 - raise ValueError("Missing prefetched remote PDF content")
670 - with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file:
671 - temp_file.write(remote_resource.content)
672 - temp_file_path = temp_file.name
673 - else:
674 - raise ValueError(f"Unsupported scheme: {scheme}")
675 -
676 - if not os.path.exists(temp_file_path):
677 - raise ValueError(
678 - f"DocumentQueryHelper::handle_pdf_document: Temporary file not found: {temp_file_path}"
679 - )
680 -
681 - try:
682 - try:
683 - loader = PyMuPDFLoader(
684 - temp_file_path,
685 - mode="single",
686 - extract_tables="markdown",
687 - extract_images=True,
688 - images_inner_format="text",
689 - images_parser=TesseractBlobParser(),
690 - pages_delimiter="\n",
691 - )
692 - elements: list[Document] = loader.load()
693 - contents = "\n".join([element.page_content for element in elements])
694 - except Exception as e:
695 - PrintStyle.error(
696 - f"DocumentQueryHelper::handle_pdf_document: Error loading with PyMuPDF: {e}"
697 - )
698 - contents = ""
699 -
700 - if not contents:
701 - import pdf2image
702 - import pytesseract
703 -
704 - PrintStyle.debug(
705 - f"DocumentQueryHelper::handle_pdf_document: FALLBACK Converting PDF to images: {temp_file_path}"
706 - )
707 -
708 - # Convert PDF to images
709 - pages = pdf2image.convert_from_path(temp_file_path) # type: ignore
710 - for page in pages:
711 - contents += pytesseract.image_to_string(page) + "\n\n"
712 -
713 - return contents
714 - finally:
715 - os.unlink(temp_file_path)
716 -
717 - def handle_unstructured_document(
718 - self,
719 - document: str,
720 - scheme: str,
721 - remote_resource: HttpFetchResult | None = None,
722 - ) -> str:
723 - elements: list[Document] = []
724 - if scheme in ["http", "https"]:
725 - if remote_resource is None:
726 - raise ValueError("Missing prefetched remote document content")
727 - import tempfile
728 -
729 - temp_file_path = ""
730 - suffix = self._get_temp_file_suffix(document, remote_resource)
731 - with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
732 - temp_file.write(remote_resource.content)
733 - temp_file_path = temp_file.name
734 -
735 - try:
736 - loader = UnstructuredLoader(
737 - file_path=temp_file_path,
738 - mode="single",
739 - partition_via_api=False,
740 - # chunking_strategy="by_page",
741 - strategy="hi_res",
742 - )
743 - elements = loader.load()
744 - finally:
745 - os.unlink(temp_file_path)
746 - elif scheme == "file":
747 - # Use RFC file operations to read the file as binary
748 - file_content_bytes = files.read_file_bin(document)
749 - # Create a temporary file for UnstructuredLoader since it needs a file path
750 - import tempfile
751 - import os
752 -
753 - # Get file extension to preserve it for proper processing
754 - _, ext = os.path.splitext(document)
755 - with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as temp_file:
756 - temp_file.write(file_content_bytes)
757 - temp_file_path = temp_file.name
758 -
759 - try:
760 - loader = UnstructuredLoader(
761 - file_path=temp_file_path,
762 - mode="single",
763 - partition_via_api=False,
764 - # chunking_strategy="by_page",
765 - strategy="hi_res",
766 - )
767 - elements = loader.load()
768 - finally:
769 - # Clean up temporary file
770 - os.unlink(temp_file_path)
771 - else:
772 - raise ValueError(f"Unsupported scheme: {scheme}")
773 -
774 - return "\n".join([element.page_content for element in elements])
helpers/document_query.py new
+13
@@ -0,0 +1,13 @@
1 +"""Compatibility shim for the document_query plugin extraction."""
2 +
3 +from plugins._document_query.helpers.document_query import (
4 + DEFAULT_SEARCH_THRESHOLD,
5 + DocumentQueryHelper,
6 + DocumentQueryStore,
7 +)
8 +
9 +__all__ = [
10 + "DEFAULT_SEARCH_THRESHOLD",
11 + "DocumentQueryHelper",
12 + "DocumentQueryStore",
13 +]
plugins/_document_query/README.md
+11 -1
@@ -6,6 +6,8 @@ timeouts and thread-safe parsers.
6 ## Features
7
8 - **Strategy-pattern parsers** - MIME-type routing to dedicated parser classes
9 +- **Centralized fetching** - local and HTTP(S) resources are fetched once, size-checked, then passed to parsers
10 +- **LiteParse first path** - fast local parsing for PDFs and supported document/image formats, with legacy fallbacks
11 - **Thread-safe execution** - all sync parsers offloaded to asyncio.to_thread
12 - **Configurable timeouts** - per-document and gather-level timeouts
13 - **Expanded format support** - PDF, HTML, text, YAML, XML, TOML, JS, TS, images, and catch-all Unstructured
@@ -17,17 +19,25 @@ See default_config.yaml for all options. Key settings:
19 | Setting | Default | Description |
20 |---------|---------|-------------|
21 | fetch_timeout | 30 | HTTP fetch timeout (seconds) |
22 +| fetch_retries | 3 | HTTP retry attempts |
23 +| max_remote_bytes | 52428800 | Max remote document size |
24 | per_document_timeout | 60 | Max time for a single document parse |
25 | gather_timeout | 120 | Max time for all documents combined |
26 | chunk_size | 1000 | Text splitter chunk size |
27 | chunk_overlap | 100 | Text splitter overlap |
28 | search_threshold | 0.5 | Similarity search threshold |
29 +| liteparse_enabled | true | Prefer LiteParse before legacy parser fallbacks |
30 | thread_offload | true | Offload sync parsers to thread pool |
31
32 +LiteParse is installed into the Agent Zero framework runtime from hooks.py during
33 +plugin install/startup. If installation fails, the plugin logs the error and
34 +continues with the legacy parser fallbacks.
35 +
36 ## Parsers
37
38 | Parser | MIME Types | Backend |
39 |--------|-----------|---------|
40 +| LiteParseParser | PDF, Office/OpenDocument, images | LiteParse |
41 | PdfParser | application/pdf | PyMuPDF + Tesseract OCR fallback |
42 | HtmlParser | text/html | Markdownify transformer |
43 | TextParser | text/*, application/json, YAML, XML, TOML, JS, TS, shell | Direct read |
@@ -38,5 +48,5 @@ See default_config.yaml for all options. Key settings:
48
49 1. Create helpers/parsers/<format>.py extending BaseParser
50 2. Set mimetypes class attribute
41 -3. Implement _parse_sync(document_uri, scheme)
51 +3. Implement _parse_sync(document, config)
52 4. Register in helpers/parsers/__init__.py
plugins/_document_query/default_config.yaml
+14 -1
@@ -3,6 +3,8 @@
3
4 # --- Timeouts ---
5 fetch_timeout: 30 # HTTP fetch connect/read timeout
6 +fetch_retries: 3 # HTTP retry attempts
7 +fetch_retry_backoff: 1.0 # delay between HTTP retry attempts
8 per_document_timeout: 60 # max time for a single document parse
9 gather_timeout: 120 # max time for all documents combined in one call
10
@@ -14,5 +16,16 @@ search_limit: 100
16 max_remote_bytes: 52428800 # 50 MB
17
18 # --- Feature flags ---
17 -pdf_ocr_fallback: true # enable Tesseract fallback for PDFs
19 +liteparse_enabled: true # prefer LiteParse before legacy parser fallbacks
20 +liteparse_ocr_enabled: true
21 +liteparse_ocr_language: eng
22 +liteparse_ocr_server_url:
23 +liteparse_tessdata_path:
24 +liteparse_max_pages: 1000
25 +liteparse_target_pages:
26 +liteparse_dpi: 150
27 +liteparse_preserve_very_small_text: false
28 +liteparse_output_format: text
29 +liteparse_num_workers:
30 +pdf_ocr_fallback: true # enable legacy Tesseract fallback after PyMuPDF
31 thread_offload: true # offload sync parsers to thread pool
plugins/_document_query/extensions/python/startup_migration/_20_document_query_runtime.py new
+12
@@ -0,0 +1,12 @@
1 +from helpers.extension import Extension
2 +from helpers.plugins import call_plugin_hook
3 +
4 +
5 +class DocumentQueryRuntime(Extension):
6 + def execute(self, **kwargs):
7 + call_plugin_hook(
8 + "_document_query",
9 + "ensure_dependencies",
10 + raise_on_error=False,
11 + )
12 +
plugins/_document_query/helpers/document_query.py
+55 -60
@@ -7,13 +7,10 @@ a thread pool and bounded by configurable timeouts.
7
8 import asyncio
9 import json
10 -import mimetypes
11 -import os
10 from datetime import datetime
11 from typing import Callable, List, Optional, Sequence, Tuple
12 from urllib.parse import urlparse
13
16 -import aiohttp
14 from langchain.schema import SystemMessage, HumanMessage
15 from langchain.text_splitter import RecursiveCharacterTextSplitter
16 from langchain_core.documents import Document
@@ -23,7 +20,8 @@ from helpers.print_style import PrintStyle
20 from helpers.vector_db import VectorDB
21 from agent import Agent
22
26 -from plugins._document_query.helpers.parsers import get_parser_for_mimetype
23 +from plugins._document_query.helpers.fetch import FetchedDocument, fetch_public_resource
24 +from plugins._document_query.helpers.parsers import BaseParser, get_parsers_for_mimetype
25
26
27 DEFAULT_SEARCH_THRESHOLD = 0.5
@@ -198,8 +196,12 @@ class DocumentQueryHelper:
196 self.config = _load_config(agent)
197
198 async def document_qa(
201 - self, document_uris: List[str], questions: Sequence[str]
199 + self, document_uris: List[str] | str, questions: Sequence[str] | str
200 ) -> Tuple[bool, str]:
201 + if isinstance(document_uris, str):
202 + document_uris = [document_uris]
203 + if isinstance(questions, str):
204 + questions = [questions]
205 self.progress_callback(f"Starting Q&A process for {len(document_uris)} documents")
206 await self.agent.handle_intervention()
207
@@ -273,74 +275,38 @@ class DocumentQueryHelper:
275 ) -> str:
276 self.progress_callback(f"Fetching document content")
277 await self.agent.handle_intervention()
276 - url = urlparse(document_uri)
277 - scheme = url.scheme or "file"
278 - mimetype, encoding = mimetypes.guess_type(document_uri)
279 - mimetype = mimetype or "application/octet-stream"
280 -
281 - if mimetype == "application/octet-stream":
282 - if url.scheme in ["http", "https"]:
283 - response = None
284 - retries = 0
285 - last_error = ""
286 - while not response and retries < 3:
287 - try:
288 - async with aiohttp.ClientSession() as session:
289 - response = await session.head(
290 - document_uri,
291 - timeout=aiohttp.ClientTimeout(total=2.0),
292 - allow_redirects=True,
293 - )
294 - if response.status > 399:
295 - raise Exception(response.status)
296 - break
297 - except Exception as e:
298 - await asyncio.sleep(1)
299 - last_error = str(e)
300 - retries += 1
301 - await self.agent.handle_intervention()
302 - if not response:
303 - raise ValueError(f"Document fetch error: {document_uri} ({last_error})")
304 - mimetype = response.headers["content-type"]
305 - if "content-length" in response.headers:
306 - content_length = float(response.headers["content-length"]) / 1024 / 1024
307 - if content_length > 50.0:
308 - raise ValueError(f"Document exceeds max 50MB: {content_length} MB ({document_uri})")
309 - if mimetype and "; charset=" in mimetype:
310 - mimetype = mimetype.split("; charset=")[0]
311 -
312 - if scheme == "file":
313 - try:
314 - document_uri = files.fix_dev_path(url.path)
315 - except Exception as e:
316 - raise ValueError(f"Invalid document path '{url.path}'") from e
317 -
318 - if encoding:
319 - raise ValueError(f"Compressed documents are unsupported '{encoding}' ({document_uri})")
320 - if mimetype == "application/octet-stream":
321 - raise ValueError(f"Unsupported document mimetype '{mimetype}' ({document_uri})")
322 -
323 - document_uri_norm = self.store.normalize_uri(document_uri)
278 + document = await fetch_public_resource(
279 + document_uri,
280 + self.config,
281 + self.agent.handle_intervention,
282 + )
283 + document_uri_norm = self.store.normalize_uri(document.uri)
284 await self.agent.handle_intervention()
285 exists = await self.store.document_exists(document_uri_norm)
286 document_content = ""
287
288 if not exists:
289 await self.agent.handle_intervention()
330 - parser = get_parser_for_mimetype(mimetype)
331 - if parser is None:
332 - raise ValueError(f"No parser found for mimetype '{mimetype}' ({document_uri})")
290 + parsers = get_parsers_for_mimetype(document.mimetype, self.config)
291 + if not parsers:
292 + raise ValueError(
293 + f"No parser found for mimetype '{document.mimetype}' ({document.uri})"
294 + )
295 per_doc_timeout = self.config.get("per_document_timeout", 60)
296 thread_offload = self.config.get("thread_offload", True)
335 - document_content = await parser.parse(
336 - document_uri=document_uri, scheme=scheme,
337 - timeout=per_doc_timeout, thread_offload=thread_offload,
297 + document_content = await self._parse_document(
298 + document=document,
299 + parsers=parsers,
300 + timeout=per_doc_timeout,
301 + thread_offload=thread_offload,
302 )
303 if add_to_db:
304 self.progress_callback(f"Indexing document")
305 await self.agent.handle_intervention()
306 async with self.store_lock:
343 - success, ids = await self.store.add_document(document_content, document_uri_norm)
307 + success, ids = await self.store.add_document(
308 + document_content, document_uri_norm
309 + )
310 if not success:
311 self.progress_callback(f"Failed to index document")
312 raise ValueError(f"Failed to index document: {document_uri_norm}")
@@ -353,3 +319,32 @@ class DocumentQueryHelper:
319 else:
320 raise ValueError(f"Document not found: {document_uri_norm}")
321 return document_content
322 +
323 + async def _parse_document(
324 + self,
325 + document: FetchedDocument,
326 + parsers: list[BaseParser],
327 + timeout: float,
328 + thread_offload: bool,
329 + ) -> str:
330 + errors_seen = []
331 + for parser in parsers:
332 + try:
333 + self.progress_callback("Parsing document content")
334 + content = await parser.parse(
335 + document=document,
336 + config=self.config,
337 + timeout=timeout,
338 + thread_offload=thread_offload,
339 + )
340 + if content:
341 + return content
342 + errors_seen.append(f"{parser.__class__.__name__}: no content")
343 + except Exception as e:
344 + errors_seen.append(f"{parser.__class__.__name__}: {e}")
345 + PrintStyle.error(f"Document parser failed: {errors_seen[-1]}")
346 +
347 + raise ValueError(
348 + f"No parser succeeded for mimetype '{document.mimetype}' ({document.uri}): "
349 + + "; ".join(errors_seen)
350 + )
plugins/_document_query/helpers/fetch.py new
+221
@@ -0,0 +1,221 @@
1 +"""Centralized document fetching for the document_query plugin."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import mimetypes
7 +import os
8 +import tempfile
9 +from contextlib import contextmanager
10 +from dataclasses import dataclass
11 +from pathlib import Path
12 +from typing import Awaitable, Callable
13 +from urllib.parse import urlparse
14 +
15 +import aiohttp
16 +
17 +from helpers import files
18 +
19 +
20 +InterventionCallback = Callable[[], Awaitable[None]]
21 +
22 +
23 +@dataclass(frozen=True)
24 +class FetchedDocument:
25 + """Fetched document bytes plus metadata needed by parsers."""
26 +
27 + uri: str
28 + scheme: str
29 + mimetype: str
30 + content: bytes
31 + encoding: str | None = None
32 + charset: str | None = None
33 + local_path: str | None = None
34 + source_uri: str | None = None
35 +
36 + def text(self) -> str:
37 + charset = self.charset or "utf-8"
38 + return self.content.decode(charset, errors="replace")
39 +
40 + def suffix(self) -> str:
41 + path = self.local_path or urlparse(self.uri).path or self.uri
42 + suffix = Path(path).suffix
43 + if suffix:
44 + return suffix
45 + guessed = mimetypes.guess_extension(self.mimetype)
46 + return guessed or ".bin"
47 +
48 + @contextmanager
49 + def local_file(self):
50 + """Yield a filesystem path for parsers that cannot consume bytes."""
51 + if self.local_path and os.path.exists(self.local_path):
52 + yield self.local_path
53 + return
54 +
55 + tmp = ""
56 + try:
57 + with tempfile.NamedTemporaryFile(delete=False, suffix=self.suffix()) as f:
58 + f.write(self.content)
59 + tmp = f.name
60 + yield tmp
61 + finally:
62 + if tmp and os.path.exists(tmp):
63 + os.unlink(tmp)
64 +
65 +
66 +ProtocolHandler = Callable[
67 + [str, str, dict, InterventionCallback | None], Awaitable[FetchedDocument]
68 +]
69 +
70 +_PROTOCOL_HANDLERS: dict[str, ProtocolHandler] = {}
71 +
72 +
73 +def register_protocol_handler(scheme: str, handler: ProtocolHandler) -> None:
74 + """Register or replace a fetch handler for a URI scheme."""
75 + _PROTOCOL_HANDLERS[scheme.lower()] = handler
76 +
77 +
78 +async def fetch_public_resource(
79 + uri: str,
80 + config: dict | None = None,
81 + intervention_callback: InterventionCallback | None = None,
82 +) -> FetchedDocument:
83 + """Fetch local or remote content once, then pass bytes to parsers."""
84 + config = config or {}
85 + parsed = urlparse(uri)
86 + scheme = (parsed.scheme or "file").lower()
87 + handler = _PROTOCOL_HANDLERS.get(scheme)
88 + if not handler:
89 + raise ValueError(f"Unsupported document scheme: {scheme}")
90 + return await handler(uri, scheme, config, intervention_callback)
91 +
92 +
93 +async def _fetch_file(
94 + uri: str,
95 + scheme: str,
96 + config: dict,
97 + intervention_callback: InterventionCallback | None,
98 +) -> FetchedDocument:
99 + parsed = urlparse(uri)
100 + raw_path = parsed.path if parsed.scheme == "file" else uri
101 + if not raw_path:
102 + raise ValueError(f"Invalid document path: {uri}")
103 +
104 + path = _fix_file_path(raw_path)
105 + mimetype, encoding = mimetypes.guess_type(path)
106 + if encoding:
107 + raise ValueError(f"Compressed documents are unsupported '{encoding}' ({uri})")
108 + mimetype = mimetype or "application/octet-stream"
109 + if mimetype == "application/octet-stream":
110 + raise ValueError(f"Unsupported document mimetype '{mimetype}' ({uri})")
111 +
112 + if intervention_callback:
113 + await intervention_callback()
114 + return FetchedDocument(
115 + uri=path,
116 + source_uri=uri,
117 + scheme=scheme,
118 + mimetype=mimetype,
119 + encoding=encoding,
120 + content=files.read_file_bin(path),
121 + local_path=path,
122 + )
123 +
124 +
125 +async def _fetch_http(
126 + uri: str,
127 + scheme: str,
128 + config: dict,
129 + intervention_callback: InterventionCallback | None,
130 +) -> FetchedDocument:
131 + timeout = float(config.get("fetch_timeout", 30))
132 + retries = max(1, int(config.get("fetch_retries", 3)))
133 + retry_backoff = float(config.get("fetch_retry_backoff", 1.0))
134 + max_remote_bytes = int(config.get("max_remote_bytes", 50 * 1024 * 1024))
135 + parsed = urlparse(uri)
136 + guessed_mimetype, encoding = mimetypes.guess_type(parsed.path or uri)
137 + if encoding:
138 + raise ValueError(f"Compressed documents are unsupported '{encoding}' ({uri})")
139 +
140 + last_error = ""
141 + for attempt in range(retries):
142 + try:
143 + async with aiohttp.ClientSession(
144 + timeout=aiohttp.ClientTimeout(total=timeout)
145 + ) as session:
146 + async with session.get(uri, allow_redirects=True) as response:
147 + if response.status > 399:
148 + raise ValueError(f"HTTP {response.status}")
149 +
150 + content_length = response.headers.get("content-length")
151 + if content_length and int(content_length) > max_remote_bytes:
152 + size_mb = int(content_length) / 1024 / 1024
153 + raise ValueError(
154 + f"Document exceeds max {max_remote_bytes / 1024 / 1024:.0f}MB: "
155 + f"{size_mb:.2f} MB ({uri})"
156 + )
157 +
158 + chunks: list[bytes] = []
159 + downloaded = 0
160 + async for chunk in response.content.iter_chunked(64 * 1024):
161 + downloaded += len(chunk)
162 + if downloaded > max_remote_bytes:
163 + size_mb = downloaded / 1024 / 1024
164 + raise ValueError(
165 + f"Document exceeds max {max_remote_bytes / 1024 / 1024:.0f}MB: "
166 + f"{size_mb:.2f} MB ({uri})"
167 + )
168 + chunks.append(chunk)
169 + if intervention_callback:
170 + await intervention_callback()
171 +
172 + content_type = response.headers.get("content-type", "")
173 + mimetype, charset = _parse_content_type(content_type)
174 + if not mimetype or mimetype == "application/octet-stream":
175 + mimetype = guessed_mimetype or "application/octet-stream"
176 + if mimetype == "application/octet-stream":
177 + raise ValueError(
178 + f"Unsupported document mimetype '{mimetype}' ({uri})"
179 + )
180 +
181 + return FetchedDocument(
182 + uri=str(response.url),
183 + source_uri=uri,
184 + scheme=response.url.scheme or scheme,
185 + mimetype=mimetype,
186 + encoding=encoding,
187 + charset=charset,
188 + content=b"".join(chunks),
189 + )
190 + except Exception as e:
191 + last_error = str(e)
192 + if attempt < retries - 1:
193 + await asyncio.sleep(retry_backoff)
194 + if intervention_callback:
195 + await intervention_callback()
196 +
197 + raise ValueError(f"Document fetch error: {uri} ({last_error})")
198 +
199 +
200 +def _parse_content_type(value: str) -> tuple[str | None, str | None]:
201 + if not value:
202 + return None, None
203 + parts = [part.strip() for part in value.split(";") if part.strip()]
204 + mimetype = parts[0].lower() if parts else None
205 + charset = None
206 + for part in parts[1:]:
207 + if part.lower().startswith("charset="):
208 + charset = part.split("=", 1)[1].strip("\"'")
209 + break
210 + return mimetype, charset
211 +
212 +
213 +register_protocol_handler("file", _fetch_file)
214 +register_protocol_handler("http", _fetch_http)
215 +register_protocol_handler("https", _fetch_http)
216 +
217 +
218 +def _fix_file_path(path: str) -> str:
219 + if os.path.isabs(path) and os.path.exists(path):
220 + return path
221 + return files.fix_dev_path(path)
plugins/_document_query/helpers/parsers/__init__.py
+31 -5
@@ -1,17 +1,43 @@
1 """Document parser registry."""
2 from .base import BaseParser
3 +from .liteparse import LiteParseParser
4 from .pdf import PdfParser
5 from .html import HtmlParser
6 from .text import TextParser
7 from .image import ImageParser
8 from .unstructured import UnstructuredParser
9
9 -_PARSERS = [PdfParser(), HtmlParser(), TextParser(), ImageParser(), UnstructuredParser()]
10 +_PARSERS = [
11 + LiteParseParser(),
12 + PdfParser(),
13 + HtmlParser(),
14 + TextParser(),
15 + ImageParser(),
16 + UnstructuredParser(),
17 +]
18
11 -def get_parser_for_mimetype(mimetype: str) -> BaseParser | None:
19 +def get_parsers_for_mimetype(mimetype: str, config: dict | None = None) -> list[BaseParser]:
20 + config = config or {}
21 + parsers = []
22 for parser in _PARSERS:
13 - if parser.can_handle(mimetype):
14 - return parser
23 + if parser.enabled(config) and parser.can_handle(mimetype):
24 + parsers.append(parser)
25 + return parsers
26 +
27 +def get_parser_for_mimetype(mimetype: str, config: dict | None = None) -> BaseParser | None:
28 + parsers = get_parsers_for_mimetype(mimetype, config)
29 + if parsers:
30 + return parsers[0]
31 return None
32
17 -__all__ = ["BaseParser", "PdfParser", "HtmlParser", "TextParser", "ImageParser", "UnstructuredParser", "get_parser_for_mimetype"]
33 +__all__ = [
34 + "BaseParser",
35 + "LiteParseParser",
36 + "PdfParser",
37 + "HtmlParser",
38 + "TextParser",
39 + "ImageParser",
40 + "UnstructuredParser",
41 + "get_parser_for_mimetype",
42 + "get_parsers_for_mimetype",
43 +]
plugins/_document_query/helpers/parsers/base.py
+13 -10
@@ -2,9 +2,9 @@
2
3 import asyncio
4 from abc import ABC, abstractmethod
5 -from typing import Optional
5
6 from helpers.print_style import PrintStyle
7 +from plugins._document_query.helpers.fetch import FetchedDocument
8
9
10 class BaseParser(ABC):
@@ -18,6 +18,9 @@ class BaseParser(ABC):
18
19 mimetypes: list[str] = []
20
21 + def enabled(self, config: dict) -> bool:
22 + return True
23 +
24 def can_handle(self, mimetype: str) -> bool:
25 """Return True if this parser supports *mimetype*."""
26 for pattern in self.mimetypes:
@@ -32,31 +35,31 @@ class BaseParser(ABC):
35
36 async def parse(
37 self,
35 - document_uri: str,
36 - scheme: str,
38 + document: FetchedDocument,
39 + config: dict,
40 timeout: float = 60.0,
41 thread_offload: bool = True,
42 ) -> str:
43 try:
44 if thread_offload:
45 return await asyncio.wait_for(
43 - asyncio.to_thread(self._parse_sync, document_uri, scheme),
46 + asyncio.to_thread(self._parse_sync, document, config),
47 timeout=timeout,
48 )
49 else:
50 return await asyncio.wait_for(
48 - self._parse_async(document_uri, scheme),
51 + self._parse_async(document, config),
52 timeout=timeout,
53 )
54 except asyncio.TimeoutError:
55 PrintStyle.error(
53 - f"Parser {self.__class__.__name__} timed out after {timeout}s on {document_uri}"
56 + f"Parser {self.__class__.__name__} timed out after {timeout}s on {document.uri}"
57 )
55 - raise ValueError(f"Document parsing timed out after {timeout}s: {document_uri}")
58 + raise ValueError(f"Document parsing timed out after {timeout}s: {document.uri}")
59
57 - async def _parse_async(self, document_uri: str, scheme: str) -> str:
58 - return self._parse_sync(document_uri, scheme)
60 + async def _parse_async(self, document: FetchedDocument, config: dict) -> str:
61 + return self._parse_sync(document, config)
62
63 @abstractmethod
61 - def _parse_sync(self, document_uri: str, scheme: str) -> str:
64 + def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
65 ...
plugins/_document_query/helpers/parsers/html.py
+11 -13
@@ -1,22 +1,20 @@
1 """HTML parser using Markdownify transformer."""
2
3 -from langchain_community.document_loaders import AsyncHtmlLoader
4 -from langchain_community.document_transformers import MarkdownifyTransformer
5 -from langchain_core.documents import Document
6 -
7 -from helpers import files
3 +from plugins._document_query.helpers.fetch import FetchedDocument
4 from .base import BaseParser
5
6
7 class HtmlParser(BaseParser):
8 mimetypes = ["text/html"]
9
14 - def _parse_sync(self, document_uri: str, scheme: str) -> str:
15 - if scheme in ["http", "https"]:
16 - parts = AsyncHtmlLoader(web_path=document_uri).load()
17 - elif scheme == "file":
18 - content = files.read_file_bin(document_uri).decode("utf-8")
19 - parts = [Document(page_content=content, metadata={"source": document_uri})]
20 - else:
21 - raise ValueError(f"Unsupported scheme: {scheme}")
10 + def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
11 + from langchain_community.document_transformers import MarkdownifyTransformer
12 + from langchain_core.documents import Document
13 +
14 + parts = [
15 + Document(
16 + page_content=document.text(),
17 + metadata={"source": document.source_uri or document.uri},
18 + )
19 + ]
20 return "\n".join(e.page_content for e in MarkdownifyTransformer().transform_documents(parts))
plugins/_document_query/helpers/parsers/image.py
+3 -2
@@ -1,9 +1,10 @@
1 """Image parser — delegates to UnstructuredLoader."""
2 +from plugins._document_query.helpers.fetch import FetchedDocument
3 from .base import BaseParser
4 from .unstructured import UnstructuredParser
5
6
7 class ImageParser(BaseParser):
8 mimetypes = ["image/"]
8 - def _parse_sync(self, document_uri: str, scheme: str) -> str:
9 - return UnstructuredParser()._parse_sync(document_uri, scheme)
9 + def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
10 + return UnstructuredParser()._parse_sync(document, config)
plugins/_document_query/helpers/parsers/liteparse.py new
+93
@@ -0,0 +1,93 @@
1 +"""LiteParse-backed parser for fast local document parsing."""
2 +
3 +from __future__ import annotations
4 +
5 +import os
6 +from pathlib import Path
7 +
8 +from plugins._document_query.helpers.fetch import FetchedDocument
9 +from .base import BaseParser
10 +
11 +
12 +class LiteParseParser(BaseParser):
13 + """Fast parser powered by run-llama/liteparse when available."""
14 +
15 + mimetypes = [
16 + "application/pdf",
17 + "application/msword",
18 + "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
19 + "application/vnd.ms-excel",
20 + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
21 + "application/vnd.ms-powerpoint",
22 + "application/vnd.openxmlformats-officedocument.presentationml.presentation",
23 + "application/vnd.oasis.opendocument.text",
24 + "application/vnd.oasis.opendocument.spreadsheet",
25 + "application/vnd.oasis.opendocument.presentation",
26 + "image/",
27 + ]
28 +
29 + def enabled(self, config: dict) -> bool:
30 + return bool(config.get("liteparse_enabled", True))
31 +
32 + def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
33 + try:
34 + from liteparse import LiteParse
35 + except Exception as e:
36 + raise RuntimeError("LiteParse is not installed") from e
37 +
38 + parser = LiteParse(**self._liteparse_kwargs(config))
39 + with document.local_file() as file_path:
40 + result = parser.parse(file_path)
41 +
42 + text = getattr(result, "text", "") or ""
43 + if not text.strip():
44 + raise ValueError("LiteParse returned no text")
45 + return text
46 +
47 + def _liteparse_kwargs(self, config: dict) -> dict:
48 + kwargs = {
49 + "ocr_enabled": bool(config.get("liteparse_ocr_enabled", True)),
50 + "ocr_language": config.get("liteparse_ocr_language", "eng"),
51 + "max_pages": int(config.get("liteparse_max_pages", 1000)),
52 + "dpi": float(config.get("liteparse_dpi", 150)),
53 + "preserve_very_small_text": bool(
54 + config.get("liteparse_preserve_very_small_text", False)
55 + ),
56 + "quiet": True,
57 + }
58 +
59 + optional_keys = {
60 + "ocr_server_url": "liteparse_ocr_server_url",
61 + "target_pages": "liteparse_target_pages",
62 + "output_format": "liteparse_output_format",
63 + "password": "liteparse_password",
64 + }
65 + for liteparse_key, config_key in optional_keys.items():
66 + value = config.get(config_key)
67 + if value not in (None, ""):
68 + kwargs[liteparse_key] = value
69 +
70 + num_workers = config.get("liteparse_num_workers")
71 + if num_workers not in (None, ""):
72 + kwargs["num_workers"] = int(num_workers)
73 +
74 + tessdata_path = config.get("liteparse_tessdata_path") or _detect_tessdata_path()
75 + if tessdata_path:
76 + kwargs["tessdata_path"] = tessdata_path
77 +
78 + return kwargs
79 +
80 +
81 +def _detect_tessdata_path() -> str:
82 + env_path = os.getenv("TESSDATA_PREFIX", "")
83 + candidates = [
84 + env_path,
85 + "/usr/share/tesseract-ocr/5/tessdata",
86 + "/usr/share/tesseract-ocr/4.00/tessdata",
87 + "/usr/share/tessdata",
88 + "/usr/local/share/tessdata",
89 + ]
90 + for candidate in candidates:
91 + if candidate and (Path(candidate) / "eng.traineddata").is_file():
92 + return candidate
93 + return ""
plugins/_document_query/helpers/parsers/pdf.py
+12 -31
@@ -1,14 +1,9 @@
1 """PDF parser with PyMuPDF primary and Tesseract OCR fallback."""
2
3 import os
4 -import tempfile
4
6 -from langchain_community.document_loaders.pdf import PyMuPDFLoader
7 -from langchain_community.document_loaders.parsers.images import TesseractBlobParser
8 -from langchain_core.documents import Document
9 -
10 -from helpers import files
5 from helpers.print_style import PrintStyle
6 +from plugins._document_query.helpers.fetch import FetchedDocument
7
8 from .base import BaseParser
9
@@ -16,35 +11,21 @@ from .base import BaseParser
11 class PdfParser(BaseParser):
12 mimetypes = ["application/pdf"]
13
19 - def _parse_sync(self, document_uri: str, scheme: str) -> str:
20 - temp_file_path = ""
21 - if scheme == "file":
22 - file_content_bytes = files.read_file_bin(document_uri)
23 - with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as f:
24 - f.write(file_content_bytes)
25 - temp_file_path = f.name
26 - elif scheme in ["http", "https"]:
27 - import requests
28 - with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as f:
29 - resp = requests.get(document_uri, timeout=10.0)
30 - if resp.status_code != 200:
31 - raise ValueError(f"Failed to download PDF: {resp.status_code}")
32 - f.write(resp.content)
33 - temp_file_path = f.name
34 - else:
35 - raise ValueError(f"Unsupported scheme: {scheme}")
36 -
37 - if not os.path.exists(temp_file_path):
38 - raise ValueError(f"Temporary file not found: {temp_file_path}")
39 - try:
40 - contents = self._parse_with_pymupdf(temp_file_path)
14 + def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
15 + with document.local_file() as file_path:
16 + if not os.path.exists(file_path):
17 + raise ValueError(f"Temporary file not found: {file_path}")
18 + contents = self._parse_with_pymupdf(file_path)
19 if not contents:
42 - contents = self._parse_with_ocr(temp_file_path)
20 + if not config.get("pdf_ocr_fallback", True):
21 + raise ValueError("PyMuPDF returned no content and OCR fallback is disabled")
22 + contents = self._parse_with_ocr(file_path)
23 return contents
44 - finally:
45 - os.unlink(temp_file_path)
24
25 def _parse_with_pymupdf(self, file_path: str) -> str:
26 + from langchain_community.document_loaders.pdf import PyMuPDFLoader
27 + from langchain_community.document_loaders.parsers.images import TesseractBlobParser
28 +
29 try:
30 loader = PyMuPDFLoader(
31 file_path, mode="single", extract_tables="markdown",
plugins/_document_query/helpers/parsers/text.py
+3 -13
@@ -1,9 +1,6 @@
1 """Plain text and config document parser."""
2
3 -from langchain_community.document_loaders import AsyncHtmlLoader
4 -from langchain_core.documents import Document
5 -
6 -from helpers import files
3 +from plugins._document_query.helpers.fetch import FetchedDocument
4 from .base import BaseParser
5
6
@@ -15,12 +12,5 @@ class TextParser(BaseParser):
12 "application/x-sh", "application/x-shellscript",
13 ]
14
18 - def _parse_sync(self, document_uri: str, scheme: str) -> str:
19 - if scheme in ["http", "https"]:
20 - elements = AsyncHtmlLoader(web_path=document_uri).load()
21 - elif scheme == "file":
22 - content = files.read_file_bin(document_uri).decode("utf-8")
23 - elements = [Document(page_content=content, metadata={"source": document_uri})]
24 - else:
25 - raise ValueError(f"Unsupported scheme: {scheme}")
26 - return "\n".join(e.page_content for e in elements)
15 + def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
16 + return document.text()
plugins/_document_query/helpers/parsers/unstructured.py
+11 -19
@@ -1,33 +1,25 @@
1 """Catch-all parser using UnstructuredLoader."""
2
3 import os
4 -import tempfile
4
6 -from helpers import files
5 +from plugins._document_query.helpers.fetch import FetchedDocument
6 from .base import BaseParser
7
8 os.environ.setdefault("USER_AGENT", "@mixedbread-ai/unstructured")
10 -from langchain_unstructured import UnstructuredLoader
9
10
11 class UnstructuredParser(BaseParser):
12 mimetypes = ["*"]
13
16 - def _parse_sync(self, document_uri: str, scheme: str) -> str:
17 - if scheme in ["http", "https"]:
18 - loader = UnstructuredLoader(web_url=document_uri, mode="single", partition_via_api=False, strategy="hi_res")
14 + def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
15 + from langchain_unstructured import UnstructuredLoader
16 +
17 + with document.local_file() as file_path:
18 + loader = UnstructuredLoader(
19 + file_path=file_path,
20 + mode="single",
21 + partition_via_api=False,
22 + strategy="hi_res",
23 + )
24 elements = loader.load()
20 - elif scheme == "file":
21 - content = files.read_file_bin(document_uri)
22 - _, ext = os.path.splitext(document_uri)
23 - with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as f:
24 - f.write(content)
25 - tmp = f.name
26 - try:
27 - loader = UnstructuredLoader(file_path=tmp, mode="single", partition_via_api=False, strategy="hi_res")
28 - elements = loader.load()
29 - finally:
30 - if os.path.exists(tmp): os.unlink(tmp)
31 - else:
32 - raise ValueError(f"Unsupported scheme: {scheme}")
25 return "\n".join(e.page_content for e in elements)
plugins/_document_query/hooks.py new
+85
@@ -0,0 +1,85 @@
1 +from __future__ import annotations
2 +
3 +import importlib
4 +import importlib.util
5 +import shutil
6 +import subprocess
7 +import sys
8 +import threading
9 +from pathlib import Path
10 +
11 +from helpers.errors import format_error
12 +from helpers.print_style import PrintStyle
13 +
14 +
15 +_LOCK = threading.Lock()
16 +_CHECKED = False
17 +_PLUGIN_DIR = Path(__file__).resolve().parent
18 +_REQUIREMENTS_FILE = _PLUGIN_DIR / "requirements.txt"
19 +
20 +
21 +def has_liteparse() -> bool:
22 + return importlib.util.find_spec("liteparse") is not None
23 +
24 +
25 +def ensure_dependencies(raise_on_error: bool = True) -> bool:
26 + """Install framework-runtime dependencies needed by the plugin."""
27 + global _CHECKED
28 +
29 + if _CHECKED and has_liteparse():
30 + return True
31 +
32 + with _LOCK:
33 + if _CHECKED and has_liteparse():
34 + return True
35 + if has_liteparse():
36 + _CHECKED = True
37 + return True
38 +
39 + try:
40 + _install_requirements()
41 + importlib.invalidate_caches()
42 + if not has_liteparse():
43 + raise RuntimeError(
44 + "Document Query dependency 'liteparse' is still unavailable after installation"
45 + )
46 + _CHECKED = True
47 + return True
48 + except Exception as e:
49 + message = (
50 + "Document Query: failed to install LiteParse dependency: "
51 + f"{format_error(e)}"
52 + )
53 + if raise_on_error:
54 + raise RuntimeError(message) from e
55 + PrintStyle.error(message)
56 + return False
57 +
58 +
59 +def install() -> bool:
60 + return ensure_dependencies(raise_on_error=True)
61 +
62 +
63 +def _install_requirements() -> None:
64 + uv = shutil.which("uv")
65 + if not uv:
66 + raise RuntimeError(
67 + "Document Query plugin requires 'uv' to install liteparse automatically"
68 + )
69 + if not _REQUIREMENTS_FILE.is_file():
70 + raise RuntimeError(
71 + f"Document Query requirements file not found: {_REQUIREMENTS_FILE}"
72 + )
73 +
74 + cmd = [
75 + uv,
76 + "pip",
77 + "install",
78 + "--python",
79 + sys.executable,
80 + "-r",
81 + str(_REQUIREMENTS_FILE),
82 + ]
83 +
84 + PrintStyle.info("Document Query: liteparse not found, installing plugin dependency")
85 + subprocess.check_call(cmd, cwd=str(_PLUGIN_DIR))
plugins/_document_query/prompts/agent.system.tool.document_query.md
+9 -7
@@ -1,8 +1,10 @@
1 ### document_query
2 -read local or remote documents or answer questions about them
3 -args:
4 -- `document`: url path or list of them
5 -- `queries`: optional list of questions
6 -- `query`: optional single-question alias
7 -without `query` or `queries` it returns document content
8 -for local files use full path; for web documents use full urls
2 +read, extract, summarize, compare, OCR, or answer questions over local/remote documents, code files, and text-heavy document images/scans.
3 +
4 +For document Q&A, document/code analysis, multi-document comparison, or OCR/text extraction from images/scans, first load the `document-query` skill with `skills_tool:load`, then call this tool using the loaded instructions.
5 +
6 +Minimal args after loading the skill:
7 +- `document`: one local path/URL or a list of paths/URLs
8 +- `queries`: optional list of questions; omit it to return extracted document content
9 +
10 +Use normal user-facing language in final answers. Keep parser/runtime details internal.
plugins/_document_query/requirements.txt new
+1
@@ -0,0 +1 @@
1 +liteparse>=2.0.0,<3.0.0
plugins/_document_query/skills/document-query/SKILL.md new
+153
@@ -0,0 +1,153 @@
1 +---
2 +name: document-query
3 +description: Use when reading, extracting, summarizing, comparing, OCRing, or answering questions over local or remote documents, code files, PDFs, Office files, HTML/text files, and text-heavy document images or scans with the document_query tool.
4 +version: 1.0.0
5 +author: Agent Zero Team
6 +tags: ["documents", "ocr", "qa", "pdf", "code", "analysis"]
7 +trigger_patterns:
8 + - document query
9 + - read document
10 + - ask questions about a document
11 + - summarize document
12 + - compare documents
13 + - extract text from image
14 + - OCR document
15 + - analyze code file
16 +---
17 +
18 +# Document Query
19 +
20 +Use the `document_query` tool to read, extract, summarize, compare, or answer questions over documents and document-like files.
21 +
22 +## When To Use
23 +
24 +Use `document_query` for:
25 +
26 +- Local files and URLs containing document text: PDF, HTML, Office files, plain text, Markdown, CSV/TSV, XML/JSON, logs, code files, and similar content.
27 +- Q&A over one or more documents.
28 +- Summaries, comparisons, entity extraction, key-point extraction, and table/text extraction.
29 +- Code-file Q&A when the user points to specific files or URLs and wants answers from their contents.
30 +- Text-heavy images, scans, screenshots, and document images when the task is OCR/text extraction or Q&A over visible text.
31 +- Image OCR when vision tools are unavailable, when the main chat model is not multimodal, or when the user wants document text rather than visual scene understanding.
32 +
33 +Do not use `document_query` for purely visual questions that require spatial/visual reasoning beyond document text; use vision tools when available for those cases.
34 +
35 +## Inputs
36 +
37 +`document_query` accepts:
38 +
39 +- `document`: required. A single local path/URL or a list of local paths/URLs.
40 +- `queries`: optional. A list of questions. When omitted, the tool returns extracted document content.
41 +- `query`: optional compatibility shortcut for a single question.
42 +
43 +Local paths must be full paths. `file://` is optional for local files. URLs should use `http://` or `https://`.
44 +
45 +For directories or codebases, first identify the relevant files with file/search tools, then pass the files themselves to `document_query`. Do not pass a directory path as the document.
46 +
47 +## Workflow
48 +
49 +1. Use `document_query` directly after this skill is loaded.
50 +2. If the user asks for an answer, summary, comparison, or extraction, pass natural-language questions in `queries`.
51 +3. If the user asks to inspect raw contents or you need to see the extracted text first, omit `queries`.
52 +4. For multiple documents, pass a list in `document` and ask comparison or extraction questions in `queries`.
53 +5. Keep parser/runtime details internal. Do not tell the user which parser, OCR runtime, or fallback path was used unless they explicitly ask about internals.
54 +6. Answer from the returned document evidence. If the document does not contain the answer, say that it is not found in the document.
55 +
56 +## Examples
57 +
58 +### Return Extracted Content
59 +
60 +```json
61 +{
62 + "thoughts": [
63 + "The user wants the document text, so I should extract the content."
64 + ],
65 + "headline": "Extracting document content",
66 + "tool_name": "document_query",
67 + "tool_args": {
68 + "document": "/a0/usr/workdir/report.pdf"
69 + }
70 +}
71 +```
72 +
73 +### Answer Questions Over A Document
74 +
75 +```json
76 +{
77 + "thoughts": [
78 + "The user asks questions whose answers should come from the PDF."
79 + ],
80 + "headline": "Answering questions from the document",
81 + "tool_name": "document_query",
82 + "tool_args": {
83 + "document": "/a0/usr/workdir/report.pdf",
84 + "queries": [
85 + "What is the report's main conclusion?",
86 + "What dates or deadlines does it mention?"
87 + ]
88 + }
89 +}
90 +```
91 +
92 +### Compare Multiple Documents
93 +
94 +```json
95 +{
96 + "thoughts": [
97 + "The user wants a comparison across two source documents."
98 + ],
99 + "headline": "Comparing documents",
100 + "tool_name": "document_query",
101 + "tool_args": {
102 + "document": [
103 + "https://example.com/policy-2025.pdf",
104 + "/a0/usr/workdir/policy-2026.pdf"
105 + ],
106 + "queries": [
107 + "Compare the main changes between the two documents.",
108 + "Which requirements appear only in the second document?"
109 + ]
110 + }
111 +}
112 +```
113 +
114 +### OCR Or Q&A Over A Document Image
115 +
116 +```json
117 +{
118 + "thoughts": [
119 + "The user wants text from a scanned document image."
120 + ],
121 + "headline": "Reading text from the scanned document",
122 + "tool_name": "document_query",
123 + "tool_args": {
124 + "document": "/a0/usr/workdir/scan.png",
125 + "queries": [
126 + "What text is visible in the document?",
127 + "What is the document title?"
128 + ]
129 + }
130 +}
131 +```
132 +
133 +### Code File Q&A
134 +
135 +```json
136 +{
137 + "thoughts": [
138 + "The user asks about specific code files, so I can query those files as documents."
139 + ],
140 + "headline": "Answering from code files",
141 + "tool_name": "document_query",
142 + "tool_args": {
143 + "document": [
144 + "/a0/usr/workdir/src/app.py",
145 + "/a0/usr/workdir/src/config.py"
146 + ],
147 + "queries": [
148 + "Where is the database connection configured?",
149 + "Which environment variables are required?"
150 + ]
151 + }
152 +}
153 +```
prompts/agent.system.main.tips.md
+8
@@ -21,3 +21,11 @@ python nodejs linux libraries for solutions
21 use tools to simplify tasks achieve goals
22 never rely on aging memories like time date etc
23 always use specialized subordinate agents for specialized tasks matching their prompt profile
24 +
25 +## Documents and OCR
26 +
27 +use document_query to read, extract, summarize, compare, or answer questions about documents from local paths or URLs
28 +use document_query for Q&A, summaries, comparisons, or extraction over specific code files when the user asks about file contents rather than asking to edit or search the codebase
29 +use document_query for document images, screenshots, scans, and other image files when the task is text extraction/OCR or Q&A over document content
30 +when vision tools are unavailable or the main chat model is not multimodal, use document_query for image OCR instead of asking the user to switch models
31 +keep parser/runtime details internal; users only need the document answer
prompts/agent.system.tool.document_query._md.bak deleted
-41
@@ -1,41 +0,0 @@
1 -### document_query
2 -read local or remote documents or answer questions about them
3 -prefer this over text_editor when the user says "check/read this document" or asks a question about a document path/url, even for Markdown files
4 -args:
5 -- `document`: url path or list of them
6 -- `queries`: optional list of questions
7 -- `query`: optional single-question alias
8 -- without `query` or `queries` it returns document content
9 -- `document` accepts one path/url or a list for cross-document comparison
10 -- for local files use full paths; for web documents use full urls
11 -examples:
12 -1 read a document
13 -~~~json
14 -{
15 - "thoughts": ["I need the full contents of the report before answering."],
16 - "headline": "Loading report contents",
17 - "tool_name": "document_query",
18 - "tool_args": {
19 - "document": "https://example.com/report.pdf"
20 - }
21 -}
22 -~~~
23 -
24 -2 compare documents with questions
25 -~~~json
26 -{
27 - "thoughts": ["I need targeted answers across two documents."],
28 - "headline": "Comparing two documents",
29 - "tool_name": "document_query",
30 - "tool_args": {
31 - "document": [
32 - "https://example.com/report-one.pdf",
33 - "/path/to/report-two.pdf"
34 - ],
35 - "queries": [
36 - "Compare the main conclusions.",
37 - "What changed between the two versions?"
38 - ]
39 - }
40 -}
41 -~~~
prompts/fw.document_query.optmimize_query._md.bak deleted
-28
@@ -1,28 +0,0 @@
1 -# AI role
2 -- You are an AI assistant being part of a larger RAG system based on vector similarity search
3 -- Your job is to take a human written question and convert it into a concise vector store search query
4 -- The goal is to yield as many correct results and as few false positives as possible
5 -
6 -# Input
7 -- you are provided with original search query as user message
8 -
9 -# Response rules !!!
10 -- respond only with optimized result query text
11 -- no text before or after
12 -- no conversation, you are a tool agent, not a conversational agent
13 -
14 -# Optimized query
15 -- optimized query is consise, short and to the point
16 -- contains only keywords and phrases, no full sentences
17 -- include alternatives and variations for better coverage
18 -
19 -
20 -# Examples
21 -User: What is the capital of France?
22 -Agent: france capital city
23 -
24 -User: What does it say about transmission?
25 -Agent: transmission gearbox automatic manual
26 -
27 -User: What did John ask Monica on Tuesday?
28 -Agent: john monica conversation dialogue question ask tuesday
prompts/fw.document_query.system_prompt._md.bak deleted
-5
@@ -1,5 +0,0 @@
1 -You are an AI assistant who can answer questions about a given document text.
2 -The assistant is part of a larger application that is used to answer questions about a document.
3 -The assistant is given a document and a list of queries and the assistant must answer the quries based on the document.
4 -!! The response should be in markdown format.
5 -!! The response should only include the queries as headings and the answers to the queries. The markdown should contain paragraphs with "#### <Query>" as headings (<Query> being the original query) followed by the query answer as the paragraph text content.
requirements.txt
+1
@@ -17,6 +17,7 @@ simpleeval==1.0.3
17 langchain-core==0.3.49
18 langchain-community==0.3.19
19 langchain-unstructured==0.1.6
20 +liteparse>=2.0.0,<3.0.0
21 openai-whisper==20250625
22 lxml_html_clean>=0.4.0 # CVE-2024-52595 fix: XSS CWE-79 CVSS 8.4
23 markdown==3.7
tests/test_document_query_plugin.py new
+145
@@ -0,0 +1,145 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +from pathlib import Path
5 +
6 +import pytest
7 +
8 +from plugins._document_query.helpers.fetch import FetchedDocument, fetch_public_resource
9 +from plugins._document_query.helpers.document_query import DocumentQueryHelper
10 +from plugins._document_query.helpers.parsers.base import BaseParser
11 +from plugins._document_query.helpers.parsers import get_parsers_for_mimetype
12 +from plugins._document_query.helpers.parsers.text import TextParser
13 +
14 +
15 +ROOT = Path(__file__).resolve().parents[1]
16 +
17 +
18 +def run_async(coro):
19 + with asyncio.Runner() as runner:
20 + return runner.run(coro)
21 +
22 +
23 +class ParserNameShouldNotLeak(BaseParser):
24 + mimetypes = ["text/plain"]
25 +
26 + def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
27 + return "parsed"
28 +
29 +
30 +def test_fetch_file_detects_mimetype_and_reads_once(tmp_path):
31 + document = tmp_path / "notes.txt"
32 + document.write_text("hello\nworld\n", encoding="utf-8")
33 +
34 + fetched = run_async(fetch_public_resource(str(document), {}))
35 +
36 + assert fetched.scheme == "file"
37 + assert fetched.mimetype == "text/plain"
38 + assert fetched.local_path == str(document)
39 + assert fetched.text() == "hello\nworld\n"
40 +
41 +
42 +def test_parser_registry_prefers_liteparse_for_pdf():
43 + parsers = get_parsers_for_mimetype("application/pdf", {"liteparse_enabled": True})
44 +
45 + assert [parser.__class__.__name__ for parser in parsers[:2]] == [
46 + "LiteParseParser",
47 + "PdfParser",
48 + ]
49 +
50 +
51 +def test_parser_registry_can_disable_liteparse():
52 + parsers = get_parsers_for_mimetype("application/pdf", {"liteparse_enabled": False})
53 +
54 + assert parsers
55 + assert parsers[0].__class__.__name__ == "PdfParser"
56 +
57 +
58 +def test_text_parser_uses_prefetched_content():
59 + fetched = FetchedDocument(
60 + uri="/tmp/example.json",
61 + source_uri="/tmp/example.json",
62 + scheme="file",
63 + mimetype="application/json",
64 + content=b'{"ok": true}',
65 + local_path=None,
66 + )
67 +
68 + text = run_async(TextParser().parse(fetched, {}, timeout=1))
69 +
70 + assert text == '{"ok": true}'
71 +
72 +
73 +def test_compatibility_imports_point_to_plugin_classes():
74 + pytest.importorskip("langchain_core")
75 +
76 + from helpers.document_query import DocumentQueryHelper as CompatHelper
77 + from plugins._document_query.helpers.document_query import DocumentQueryHelper
78 + from plugins._document_query.tools.document_query import DocumentQueryTool
79 + from tools.document_query import DocumentQueryTool as CompatTool
80 +
81 + assert CompatHelper is DocumentQueryHelper
82 + assert CompatTool is DocumentQueryTool
83 +
84 +
85 +def test_liteparse_is_installed_by_docker_and_plugin_hook_requirements():
86 + root_requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8")
87 + plugin_requirements = (
88 + ROOT / "plugins" / "_document_query" / "requirements.txt"
89 + ).read_text(encoding="utf-8")
90 +
91 + assert "liteparse>=2.0.0,<3.0.0" in root_requirements
92 + assert plugin_requirements.strip().splitlines() == ["liteparse>=2.0.0,<3.0.0"]
93 +
94 +
95 +def test_parser_progress_is_user_facing_and_generic():
96 + fetched = FetchedDocument(
97 + uri="/tmp/example.txt",
98 + source_uri="/tmp/example.txt",
99 + scheme="file",
100 + mimetype="text/plain",
101 + content=b"content",
102 + local_path=None,
103 + )
104 + progress = []
105 + helper = object.__new__(DocumentQueryHelper)
106 + helper.config = {}
107 + helper.progress_callback = progress.append
108 +
109 + content = run_async(
110 + helper._parse_document(
111 + document=fetched,
112 + parsers=[ParserNameShouldNotLeak()],
113 + timeout=1,
114 + thread_offload=False,
115 + )
116 + )
117 +
118 + assert content == "parsed"
119 + assert progress == ["Parsing document content"]
120 +
121 +
122 +def test_document_query_prompt_uses_progressive_skill_disclosure():
123 + from helpers.skills import find_skill
124 +
125 + prompt = (
126 + ROOT
127 + / "plugins"
128 + / "_document_query"
129 + / "prompts"
130 + / "agent.system.tool.document_query.md"
131 + ).read_text(encoding="utf-8")
132 + main_prompt = (ROOT / "prompts" / "agent.system.main.tips.md").read_text(
133 + encoding="utf-8"
134 + )
135 + skill = find_skill("document-query", include_content=True)
136 +
137 + assert skill is not None
138 + assert "document_query for Q&A" in main_prompt
139 + assert "specific code files" in main_prompt
140 + assert "skills_tool:load" in prompt
141 + assert "document-query" in prompt
142 + assert "document_query" in prompt
143 + assert "answering questions over local or remote documents" in skill.description
144 + assert "### Answer Questions Over A Document" in skill.content
145 + assert "### OCR Or Q&A Over A Document Image" in skill.content
tools/document_query._py.bak deleted
-47
@@ -1,47 +0,0 @@
1 -import asyncio
2 -
3 -from helpers.tool import Tool, Response
4 -from helpers.document_query import DocumentQueryHelper
5 -
6 -
7 -class DocumentQueryTool(Tool):
8 -
9 - async def execute(self, **kwargs):
10 - document_uri = kwargs.get("document")
11 - document_uris = []
12 -
13 - if isinstance(document_uri, list):
14 - document_uris = document_uri
15 - elif isinstance(document_uri, str):
16 - document_uris = [document_uri]
17 -
18 - if not document_uris:
19 - return Response(message="Error: no document provided", break_loop=False)
20 -
21 - queries = (
22 - kwargs["queries"]
23 - if "queries" in kwargs
24 - else [kwargs["query"]]
25 - if ("query" in kwargs and kwargs["query"])
26 - else []
27 - )
28 - try:
29 -
30 - progress = []
31 -
32 - # logging callback
33 - def progress_callback(msg):
34 - progress.append(msg)
35 - self.log.update(progress="\n".join(progress))
36 -
37 - helper = DocumentQueryHelper(self.agent, progress_callback)
38 - if not queries:
39 - contents = await asyncio.gather(
40 - *[helper.document_get_content(uri) for uri in document_uris]
41 - )
42 - content = "\n\n---\n\n".join(contents)
43 - else:
44 - _, content = await helper.document_qa(document_uris, queries)
45 - return Response(message=content, break_loop=False)
46 - except Exception as e: # pylint: disable=broad-exception-caught
47 - return Response(message=f"Error processing document: {e}", break_loop=False)
tools/document_query.py new
+5
@@ -0,0 +1,5 @@
1 +"""Compatibility shim for the document_query plugin tool."""
2 +
3 +from plugins._document_query.tools.document_query import DocumentQueryTool
4 +
5 +__all__ = ["DocumentQueryTool"]