mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 18:44:05 +02:00
242 lines
7.8 KiB
Python
242 lines
7.8 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable
|
|
from enum import StrEnum
|
|
from typing import Literal, assert_never
|
|
|
|
from pydantic import Field, model_validator
|
|
|
|
from stirling.contracts.ledger import Verdict
|
|
from stirling.models import OPERATIONS, ApiModel, FileId, ToolEndpoint
|
|
from stirling.models.agent_tool_models import AGENT_OPERATIONS, AgentToolId, AnyParamModel, AnyToolId
|
|
|
|
|
|
class PdfContentType(StrEnum):
|
|
"""Types of content that can be extracted from a PDF and sent to the AI.
|
|
|
|
Java counterpart: AiPdfContentType.java - values must stay in sync.
|
|
"""
|
|
|
|
# Document-level structured data
|
|
PAGE_LAYOUT = "page_layout"
|
|
DOCUMENT_METADATA = "document_metadata"
|
|
ENCRYPTION_INFO = "encryption_info"
|
|
BOOKMARKS = "bookmarks"
|
|
LAYERS = "layers"
|
|
EMBEDDED_FILES = "embedded_files"
|
|
JAVASCRIPT = "javascript"
|
|
LINKS = "links"
|
|
IMAGE_INFO = "image_info"
|
|
FONTS = "fonts"
|
|
|
|
# Text and content
|
|
PAGE_TEXT = "page_text"
|
|
FULL_TEXT = "full_text"
|
|
FORM_FIELDS = "form_fields"
|
|
ANNOTATIONS = "annotations"
|
|
SIGNATURES = "signatures"
|
|
STRUCTURE_TREE = "structure_tree"
|
|
XMP_METADATA = "xmp_metadata"
|
|
|
|
# Heavy content
|
|
COMPLIANCE = "compliance"
|
|
IMAGES = "images"
|
|
|
|
|
|
class WorkflowOutcome(StrEnum):
|
|
"""Discriminator values for all workflow response unions (outcome field).
|
|
|
|
Java counterpart: AiWorkflowOutcome.java - values must stay in sync.
|
|
"""
|
|
|
|
ANSWER = "answer"
|
|
NEED_CONTENT = "need_content"
|
|
NEED_INGEST = "need_ingest"
|
|
NOT_FOUND = "not_found"
|
|
PLAN = "plan"
|
|
NEED_CLARIFICATION = "need_clarification"
|
|
CANNOT_DO = "cannot_do"
|
|
DRAFT = "draft"
|
|
TOOL_CALL = "tool_call"
|
|
COMPLETED = "completed"
|
|
CANNOT_CONTINUE = "cannot_continue"
|
|
UNSUPPORTED_CAPABILITY = "unsupported_capability"
|
|
GENERATE_FILE = "generate_file"
|
|
CONVERT_MARKDOWN = "convert_markdown"
|
|
|
|
|
|
class ArtifactKind(StrEnum):
|
|
"""Discriminator values for WorkflowArtifact unions (kind field).
|
|
|
|
Java counterpart: PdfContentExtractor.ArtifactKind - values must stay in sync.
|
|
"""
|
|
|
|
EXTRACTED_TEXT = "extracted_text"
|
|
PAGE_LAYOUT = "page_layout"
|
|
TOOL_REPORT = "tool_report"
|
|
|
|
|
|
class StepKind(StrEnum):
|
|
"""Discriminator values for AgentSpecStep unions (kind field)."""
|
|
|
|
TOOL = "tool"
|
|
AI_TOOL = "ai_tool"
|
|
|
|
|
|
class SupportedCapability(StrEnum):
|
|
ORCHESTRATE = "orchestrate"
|
|
PDF_EDIT = "pdf_edit"
|
|
PDF_QUESTION = "pdf_question"
|
|
PDF_REVIEW = "pdf_review"
|
|
AGENT_DRAFT = "agent_draft"
|
|
AGENT_REVISE = "agent_revise"
|
|
AGENT_NEXT_ACTION = "agent_next_action"
|
|
MATH_AUDITOR_AGENT = "math_auditor_agent"
|
|
PDF_TO_MARKDOWN = "pdf_to_markdown"
|
|
|
|
|
|
class ConversationMessage(ApiModel):
|
|
role: str
|
|
content: str
|
|
|
|
|
|
class AiFile(ApiModel):
|
|
"""A file the user has supplied, identified by both a stable id and a display name.
|
|
|
|
The id is opaque to the engine: Java generates it (content hash, file path, UUID, etc.)
|
|
and the engine uses it as the RAG collection key for any agent that indexes content.
|
|
The name is used in user-facing prompts and responses.
|
|
"""
|
|
|
|
id: FileId = Field(min_length=1)
|
|
name: str = Field(min_length=1)
|
|
|
|
|
|
def format_conversation_history(conversation_history: list[ConversationMessage]) -> str:
|
|
if not conversation_history:
|
|
return "None"
|
|
return "\n".join(f"- {message.role}: {message.content}" for message in conversation_history)
|
|
|
|
|
|
def format_file_names(files: list[AiFile]) -> str:
|
|
if not files:
|
|
return "No file names were provided."
|
|
return ", ".join(file.name for file in files)
|
|
|
|
|
|
class PdfTextSelection(ApiModel):
|
|
page_number: int | None = None
|
|
text: str
|
|
|
|
|
|
class ExtractedFileText(ApiModel):
|
|
file_name: str
|
|
pages: list[PdfTextSelection] = Field(default_factory=list)
|
|
|
|
|
|
class NeedContentFileRequest(ApiModel):
|
|
file: AiFile
|
|
page_numbers: list[int] = Field(default_factory=list)
|
|
content_types: list[PdfContentType]
|
|
|
|
|
|
class NeedContentResponse(ApiModel):
|
|
outcome: Literal[WorkflowOutcome.NEED_CONTENT] = WorkflowOutcome.NEED_CONTENT
|
|
resume_with: SupportedCapability
|
|
reason: str
|
|
files: list[NeedContentFileRequest] = Field(default_factory=list)
|
|
max_pages: int
|
|
max_characters: int
|
|
|
|
|
|
class MathAuditorToolReportArtifact(ApiModel):
|
|
"""Structured Verdict produced by the math-auditor on a previous orchestrator turn.
|
|
|
|
New specialists that the orchestrator needs to digest on a resume turn
|
|
should add a sibling artifact type here and lift this into a discriminated
|
|
union keyed on ``source_tool``.
|
|
|
|
Java counterpart: {@code PdfContentExtractor.ToolReportArtifact}.
|
|
"""
|
|
|
|
kind: Literal[ArtifactKind.TOOL_REPORT] = ArtifactKind.TOOL_REPORT
|
|
source_tool: Literal[AgentToolId.MATH_AUDITOR_AGENT] = AgentToolId.MATH_AUDITOR_AGENT
|
|
report: Verdict
|
|
|
|
|
|
# Type alias kept around so callers don't have to know there's only one variant
|
|
# today; lifts into a discriminated union when a second consumer-side report
|
|
# appears.
|
|
ToolReportArtifact = MathAuditorToolReportArtifact
|
|
|
|
|
|
class NeedIngestResponse(ApiModel):
|
|
"""Signal that the listed files must be ingested before the agent can continue.
|
|
|
|
Java's handling: for each file, extract the requested content types, POST to
|
|
``/api/v1/documents`` keyed by ``file.id``, then retry the original request.
|
|
"""
|
|
|
|
outcome: Literal[WorkflowOutcome.NEED_INGEST] = WorkflowOutcome.NEED_INGEST
|
|
resume_with: SupportedCapability
|
|
reason: str
|
|
files_to_ingest: list[AiFile]
|
|
content_types: list[PdfContentType] = Field(default_factory=list)
|
|
|
|
|
|
class ConvertMarkdownResponse(ApiModel):
|
|
"""Terminal signal: convert the listed files to Markdown deterministically.
|
|
|
|
This is a deterministic, non-AI conversion. Java runs the PDF→Markdown converter
|
|
(``PdfMarkdownConverter``) on each file and returns the resulting ``.md`` file(s) as a
|
|
completed result. There is no resume turn — the conversion output is the final answer.
|
|
"""
|
|
|
|
outcome: Literal[WorkflowOutcome.CONVERT_MARKDOWN] = WorkflowOutcome.CONVERT_MARKDOWN
|
|
reason: str
|
|
files_to_ingest: list[AiFile]
|
|
|
|
|
|
class ToolOperationStep(ApiModel):
|
|
kind: Literal[StepKind.TOOL] = StepKind.TOOL
|
|
tool: AnyToolId
|
|
parameters: AnyParamModel
|
|
|
|
@model_validator(mode="after")
|
|
def validate_tool_parameter_pairing(self) -> ToolOperationStep:
|
|
if isinstance(self.tool, AgentToolId):
|
|
expected_type = AGENT_OPERATIONS[self.tool]
|
|
elif isinstance(self.tool, ToolEndpoint):
|
|
expected_type = OPERATIONS[self.tool]
|
|
else:
|
|
assert_never(self.tool)
|
|
|
|
if not isinstance(self.parameters, expected_type):
|
|
actual_type = type(self.parameters).__name__
|
|
raise ValueError(f"Parameters for tool {self.tool} must be {expected_type.__name__}, got {actual_type}.")
|
|
return self
|
|
|
|
|
|
class GenerateFileResponse(ApiModel):
|
|
"""Return generated text content directly to Java for file packaging.
|
|
|
|
Java converts the content string to bytes and stores it as a result file,
|
|
avoiding a round-trip through a write-file tool endpoint.
|
|
"""
|
|
|
|
outcome: Literal[WorkflowOutcome.GENERATE_FILE] = WorkflowOutcome.GENERATE_FILE
|
|
content: str
|
|
filename: str = Field(pattern=r"^[^/\\]+$", description="Output filename; no path separators.")
|
|
summary: str | None = None
|
|
|
|
|
|
def drop_unknown_tool_endpoints(value: Iterable[str | ToolEndpoint]) -> list[ToolEndpoint]:
|
|
"""Coerce inbound endpoint identifiers into `ToolEndpoint` members, dropping unknowns.
|
|
|
|
Java sends the full set of endpoints it considers enabled. The engine and the Java
|
|
backend may have version drift in either direction, so we silently drop anything we
|
|
don't recognise rather than failing the request. Anything dropped simply doesn't
|
|
appear as a supported tool to the planner.
|
|
"""
|
|
return [ToolEndpoint(item) for item in value if item in ToolEndpoint]
|