fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
323 lines
16 KiB
Python
323 lines
16 KiB
Python
"""Read Document tool: parse an input artifact to text/markdown/structured/chunks via the backend parser.
|
|
|
|
The ``read_document`` action resolves a parent-scoped input artifact, enqueues a
|
|
``parse_document`` task on the dedicated ``parsing`` Celery queue, and awaits the
|
|
result with a timeout. The run-scoped authz gate is enforced TWICE — here before
|
|
enqueue (reject cross-tenant) and again in the worker (re-resolve, never trusting a
|
|
raw path). When a ``json_schema`` is supplied the structured payload is validated
|
|
through the existing jsonschema path; the full result may also be persisted as a
|
|
``data`` artifact by reference (handled in the worker).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from celery import current_task
|
|
|
|
from application.agents.tools.artifact_ref import resolve_artifact_id
|
|
from application.agents.tools.attachment_bridge import (
|
|
AttachmentBridgeError,
|
|
bridge_attachment,
|
|
match_attachment,
|
|
)
|
|
from application.agents.tools.base import Tool
|
|
from application.core.json_schema_utils import (
|
|
JsonSchemaValidationError,
|
|
normalize_json_schema_payload,
|
|
)
|
|
from application.core.settings import settings
|
|
from application.storage.db.repositories.artifacts import ArtifactsRepository
|
|
from application.storage.db.session import db_readonly
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import jsonschema
|
|
except Exception: # pragma: no cover - jsonschema is a declared dependency
|
|
jsonschema = None # type: ignore[assignment]
|
|
|
|
|
|
class ReadDocumentTool(Tool):
|
|
"""Read Document
|
|
Parse a document (PDF, Word, PowerPoint, ...) to text, markdown, or structured data.
|
|
"""
|
|
|
|
# Hidden from the Add-Tool catalog; surfaced (workflow-only) via the
|
|
# BUILTIN_AGENT_TOOLS synthetic-id path. Does not gate tool_manager loading
|
|
# nor synthetic-id execution.
|
|
internal: bool = True
|
|
|
|
def __init__(self, tool_config: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None) -> None:
|
|
"""Bind the tool to the invoker and its conversation/run scope."""
|
|
self.config: Dict[str, Any] = tool_config or {}
|
|
self.user_id: Optional[str] = user_id
|
|
self.tool_id: Optional[str] = self.config.get("tool_id")
|
|
self.conversation_id: Optional[str] = self.config.get("conversation_id")
|
|
self.workflow_run_id: Optional[str] = self.config.get("workflow_run_id")
|
|
self.message_id: Optional[str] = self.config.get("message_id")
|
|
self._last_artifact_id: Optional[str] = None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Tool ABC
|
|
# ------------------------------------------------------------------
|
|
def get_actions_metadata(self) -> List[Dict[str, Any]]:
|
|
"""Return JSON metadata describing the ``read_document`` action for tool schemas."""
|
|
return [
|
|
{
|
|
"name": "read_document",
|
|
"description": (
|
|
"Read a document artifact (pdf/docx/pptx/...) and return its parsed content as "
|
|
"markdown, plain text, structured JSON (with tables), or chunks. Optionally "
|
|
"validate the structured result against a json_schema and persist it as a "
|
|
"downloadable data artifact."
|
|
),
|
|
"active": True,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"input": {
|
|
"type": "string",
|
|
"description": "Document to read; accepts the short ref like `A1` returned by a "
|
|
"previous artifact action, a full artifact id, or the name/id of a file the user "
|
|
"attached to this conversation.",
|
|
},
|
|
"output": {
|
|
"type": "string",
|
|
"enum": ["markdown", "text", "structured", "chunks"],
|
|
"description": "Shape of the parsed result (default: markdown). Note: "
|
|
"`structured` always uses the Docling engine regardless of `engine` "
|
|
"(the `fast` engine is markdown/text only).",
|
|
},
|
|
"ocr": {
|
|
"type": "string",
|
|
"enum": ["auto", "on", "off"],
|
|
"description": "OCR mode for scanned pages/images (default: auto, follows server config).",
|
|
},
|
|
"pages": {
|
|
"type": "string",
|
|
"description": "Optional page range to read, e.g. `1-3` or `2` (best-effort).",
|
|
},
|
|
"engine": {
|
|
"type": "string",
|
|
"enum": ["auto", "docling", "fast"],
|
|
"description": "Parser engine (default: auto). `fast` is a lighter "
|
|
"markdown/text-only engine; it is ignored when `output='structured'`, "
|
|
"which always uses Docling.",
|
|
},
|
|
"max_chars": {
|
|
"type": "integer",
|
|
"description": "Optional cap on returned characters.",
|
|
},
|
|
"include_tables": {
|
|
"type": "boolean",
|
|
"description": "Include extracted tables in the result (default: true).",
|
|
},
|
|
"json_schema": {
|
|
"type": "object",
|
|
"description": "Optional JSON schema the structured payload must satisfy.",
|
|
},
|
|
"persist": {
|
|
"type": "boolean",
|
|
"description": "Persist the parsed result as a downloadable data artifact (default true).",
|
|
},
|
|
},
|
|
"required": ["input"],
|
|
},
|
|
}
|
|
]
|
|
|
|
def get_config_requirements(self) -> Dict[str, Any]:
|
|
"""Return configuration requirements (none beyond a running parsing worker)."""
|
|
return {}
|
|
|
|
def get_artifact_id(self, action_name: str, **kwargs: Any) -> Optional[str]:
|
|
"""Return the persisted parse artifact id so the UI artifact rail lights up."""
|
|
return self._last_artifact_id
|
|
|
|
# ------------------------------------------------------------------
|
|
# Dispatch
|
|
# ------------------------------------------------------------------
|
|
def execute_action(self, action_name: str, **kwargs: Any) -> Dict[str, Any]:
|
|
"""Dispatch a tool action; only ``read_document`` is supported."""
|
|
self._last_artifact_id = None
|
|
if action_name != "read_document":
|
|
return {"status": "error", "error": f"unknown action: {action_name}"}
|
|
if not self.user_id:
|
|
return {"status": "error", "error": "read_document requires a valid user_id."}
|
|
if self.conversation_id is None and self.workflow_run_id is None:
|
|
return {"status": "error", "error": "read_document requires a conversation_id or workflow_run_id."}
|
|
return self._read(**kwargs)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Read
|
|
# ------------------------------------------------------------------
|
|
def _read(self, **kwargs: Any) -> Dict[str, Any]:
|
|
"""Resolve the input run-scoped (reject cross-tenant before enqueue), enqueue+await, validate."""
|
|
input_id = kwargs.get("input")
|
|
json_schema = kwargs.get("json_schema")
|
|
if not isinstance(input_id, str) or not input_id.strip():
|
|
return {"status": "error", "error": "input artifact id is required."}
|
|
if json_schema is not None:
|
|
schema_err = self._check_schema(json_schema)
|
|
if schema_err is not None:
|
|
return schema_err
|
|
|
|
artifact_id = self._resolve_input(input_id.strip())
|
|
if isinstance(artifact_id, dict):
|
|
return artifact_id # error payload
|
|
|
|
options = {
|
|
"output": kwargs.get("output", "markdown"),
|
|
"ocr": kwargs.get("ocr", "auto"),
|
|
"pages": kwargs.get("pages"),
|
|
"engine": kwargs.get("engine", "auto"),
|
|
"max_chars": kwargs.get("max_chars"),
|
|
"include_tables": kwargs.get("include_tables", True),
|
|
"persist": kwargs.get("persist", True),
|
|
"tool_id": self.tool_id,
|
|
}
|
|
result = self._dispatch(artifact_id, options)
|
|
if result.get("status") == "error":
|
|
return result
|
|
if json_schema is not None:
|
|
valid = self._validate(json_schema, result.get("structured"))
|
|
if valid is not None:
|
|
return valid
|
|
artifact = result.get("artifact")
|
|
if isinstance(artifact, dict) and artifact.get("artifact_id"):
|
|
self._last_artifact_id = artifact["artifact_id"]
|
|
return result
|
|
|
|
def _dispatch(self, artifact_id: str, options: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Parse INLINE inside a Celery worker, else dispatch to the parsing queue and await.
|
|
|
|
This tool runs in the WEB process (/stream) OR inside a Celery worker
|
|
(headless/scheduled/workflow agents). When it already runs inside a worker that also
|
|
serves the ``parsing`` queue (the shipped default ``-Q docsgpt,parsing``), dispatching
|
|
and blocking on ``get()`` would self-deadlock: concurrent agent tasks each hold a pool
|
|
slot blocked in ``get()`` so ``parse_document`` never gets a free slot. So parse INLINE
|
|
in-process inside a worker; only dispatch+await (degrading on timeout/failure) from web.
|
|
"""
|
|
parent = self._parent()
|
|
|
|
# ``current_task`` is a Celery proxy: truthy only while this runs inside a worker task,
|
|
# falsy in the web process (the bare proxy is NOT identity-None, so test truthiness).
|
|
if current_task:
|
|
from application.worker import run_parse_document
|
|
|
|
try:
|
|
result = run_parse_document(artifact_id, parent, self.user_id, options)
|
|
except Exception as exc:
|
|
logger.exception("read_document: inline parse failed")
|
|
return {"status": "error", "error": f"document parsing failed: {type(exc).__name__}: {exc}"}
|
|
if not isinstance(result, dict):
|
|
return {"status": "error", "error": "document parsing produced an unexpected result."}
|
|
return result
|
|
|
|
from celery.exceptions import TimeoutError as CeleryTimeoutError
|
|
|
|
from application.api.user.tasks import parse_document
|
|
|
|
timeout = float(getattr(settings, "DOCUMENT_PARSE_TIMEOUT", 120))
|
|
queue = getattr(settings, "DOCUMENT_PARSE_QUEUE", "parsing")
|
|
try:
|
|
async_result = parse_document.apply_async(args=[artifact_id, parent, self.user_id, options], queue=queue)
|
|
# The web process (not a worker) awaits here; ``disable_sync_subtasks=False`` keeps
|
|
# the call correct if invoked from a non-prefork (eventlet/gevent) worker where the
|
|
# inline branch above still ran but the blanket guard would otherwise raise.
|
|
result = async_result.get(timeout=timeout, disable_sync_subtasks=False)
|
|
except (CeleryTimeoutError, TimeoutError):
|
|
return {"status": "error", "error": f"document parsing timed out after {int(timeout)}s."}
|
|
except Exception as exc:
|
|
logger.exception("read_document: parse task failed")
|
|
return {"status": "error", "error": f"document parsing failed: {type(exc).__name__}: {exc}"}
|
|
if not isinstance(result, dict):
|
|
return {"status": "error", "error": "document parsing produced an unexpected result."}
|
|
return result
|
|
|
|
# ------------------------------------------------------------------
|
|
# Input resolution (run-scoped gate, before enqueue)
|
|
# ------------------------------------------------------------------
|
|
def _resolve_input(self, raw_id: str) -> Any:
|
|
"""Resolve a short ref/uuid to a parent-scoped artifact id; an error dict on miss/cross-tenant."""
|
|
try:
|
|
with db_readonly() as conn:
|
|
repo = ArtifactsRepository(conn)
|
|
artifact_id = resolve_artifact_id(
|
|
repo,
|
|
raw_id,
|
|
conversation_id=self.conversation_id,
|
|
workflow_run_id=self.workflow_run_id,
|
|
)
|
|
artifact = (
|
|
repo.get_artifact_in_parent(
|
|
artifact_id,
|
|
conversation_id=self.conversation_id,
|
|
workflow_run_id=self.workflow_run_id,
|
|
)
|
|
if artifact_id is not None
|
|
else None
|
|
)
|
|
except Exception:
|
|
logger.exception("read_document: failed to resolve input artifact")
|
|
return {"status": "error", "error": f"failed to resolve input artifact {raw_id}."}
|
|
if artifact is None:
|
|
# Conversation scope only: a raw ref that is not an artifact may name a
|
|
# chat attachment; bridge it on demand. Workflows bridge up front.
|
|
bridged_id = self._bridge_chat_attachment(raw_id)
|
|
if isinstance(bridged_id, dict):
|
|
return bridged_id
|
|
if bridged_id is not None:
|
|
return bridged_id
|
|
return {"status": "error", "error": f"input artifact {raw_id} not found in this conversation/run."}
|
|
return str(artifact_id)
|
|
|
|
def _bridge_chat_attachment(self, raw_id: str) -> Any:
|
|
"""Bridge a referenced chat attachment to a conversation artifact id; None on miss, error dict on failure."""
|
|
if not self.conversation_id or not self.user_id:
|
|
return None
|
|
attachment = match_attachment(self.config.get("attachments"), raw_id, self.user_id)
|
|
if attachment is None:
|
|
return None
|
|
try:
|
|
return bridge_attachment(attachment, user_id=self.user_id, conversation_id=self.conversation_id)
|
|
except AttachmentBridgeError as exc:
|
|
return {"status": "error", "error": f"failed to attach {raw_id}: {exc}"}
|
|
|
|
def _parent(self) -> Dict[str, Any]:
|
|
"""Build the run-scoped parent dict passed to the worker for its independent re-resolve."""
|
|
if self.conversation_id is not None:
|
|
parent: Dict[str, Any] = {"conversation_id": self.conversation_id}
|
|
if self.message_id:
|
|
parent["message_id"] = self.message_id
|
|
return parent
|
|
return {"workflow_run_id": self.workflow_run_id}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Schema validation
|
|
# ------------------------------------------------------------------
|
|
@staticmethod
|
|
def _check_schema(json_schema: Any) -> Optional[Dict[str, Any]]:
|
|
"""Return an error payload when ``json_schema`` itself is malformed, else None."""
|
|
try:
|
|
normalize_json_schema_payload(json_schema)
|
|
except JsonSchemaValidationError as exc:
|
|
return {"status": "error", "error": f"invalid json_schema: {exc}"}
|
|
return None
|
|
|
|
@staticmethod
|
|
def _validate(json_schema: Any, instance: Any) -> Optional[Dict[str, Any]]:
|
|
"""Validate ``instance`` against the (already-normalized) json_schema; error payload on mismatch."""
|
|
if jsonschema is None:
|
|
return {"status": "error", "error": "jsonschema is required for json_schema validation."}
|
|
if instance is None:
|
|
return {"status": "error", "error": "json_schema validation requires output='structured'."}
|
|
schema = normalize_json_schema_payload(json_schema)
|
|
try:
|
|
jsonschema.validate(instance=instance, schema=schema)
|
|
except jsonschema.exceptions.ValidationError as exc:
|
|
return {"status": "error", "error": f"parsed structure did not match json_schema: {exc.message}"}
|
|
return None
|