Pdf comment agent (#6196)

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-05-01 10:19:38 +01:00
committed by GitHub
co-authored by James Brunton
parent 2dc5276e8b
commit 86774d556e
78 changed files with 5091 additions and 112 deletions
+2
View File
@@ -4,6 +4,7 @@ from .execution import ExecutionPlanningAgent
from .orchestrator import OrchestratorAgent
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
from .pdf_questions import PdfQuestionAgent
from .pdf_review import PdfReviewAgent
from .user_spec import UserSpecAgent
__all__ = [
@@ -13,5 +14,6 @@ __all__ = [
"PdfEditParameterSelector",
"PdfEditPlanSelection",
"PdfQuestionAgent",
"PdfReviewAgent",
"UserSpecAgent",
]
+8 -1
View File
@@ -1,6 +1,13 @@
from __future__ import annotations
from stirling.contracts import ExtractedFileText
from stirling.contracts import ExtractedFileText, ExtractedTextArtifact, OrchestratorRequest
def get_extracted_text_artifact(request: OrchestratorRequest) -> ExtractedTextArtifact | None:
for artifact in request.artifacts:
if isinstance(artifact, ExtractedTextArtifact):
return artifact
return None
def has_page_text(page_text: list[ExtractedFileText]) -> bool:
@@ -0,0 +1,79 @@
"""
Math-auditor presentation helpers.
Used by ``PdfQuestionAgent`` and ``PdfReviewAgent`` to (a) decide whether
a request needs the math auditor at all, and (b) pull a Verdict back out
of the resume-turn artifacts.
Intent classification is language-agnostic — a small LLM call rather than
an English regex — so a request like "vérifiez les totaux" routes to the
math path the same as "check the totals".
"""
from __future__ import annotations
from pydantic import Field
from pydantic_ai import Agent
from stirling.contracts import (
MathAuditorToolReportArtifact,
OrchestratorRequest,
Verdict,
)
from stirling.models import ApiModel
from stirling.services import AppRuntime
def extract_math_verdict(request: OrchestratorRequest) -> Verdict | None:
"""Find a math-auditor Verdict in the request's artifacts, if any.
Meta-agents call this on resume to detect whether the specialist has
already run. The Verdict is already type-validated by the time it lands
in :class:`MathAuditorToolReportArtifact` — pydantic rejected the whole
request earlier if the payload was malformed.
"""
for artifact in request.artifacts:
if isinstance(artifact, MathAuditorToolReportArtifact):
return artifact.report
return None
_MATH_INTENT_SYSTEM_PROMPT = (
"Decide whether the user's prompt is asking for verification of "
"numerical content — math correctness, audit, recalculation, totals, "
"sums, percentages, balances, arithmetic, or financial figures. "
"Set is_math=true if so, otherwise false. Decide from the meaning of "
"the prompt, not specific keywords; the prompt may be in any language."
)
class _MathIntentDecision(ApiModel):
is_math: bool = Field(
description=(
"True if the prompt is about verifying numerical content "
"(math, audit, calculations, totals, percentages, etc.)."
),
)
class MathIntentClassifier:
"""Tiny LLM classifier that returns whether a prompt needs the math auditor.
Shared between ``PdfQuestionAgent`` and ``PdfReviewAgent`` so both delegates
use the same decision shape and prompt. One agent instance per consumer
(cheap; matches the existing pattern of per-request agent construction).
"""
def __init__(self, runtime: AppRuntime) -> None:
self._agent: Agent[None, _MathIntentDecision] = Agent(
model=runtime.fast_model,
output_type=_MathIntentDecision,
system_prompt=_MATH_INTENT_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
async def classify(self, user_message: str) -> bool:
if not user_message:
return False
result = await self._agent.run(user_message)
return result.output.is_math
+27 -52
View File
@@ -10,24 +10,20 @@ from pydantic_ai.tools import RunContext
from stirling.agents.pdf_edit import PdfEditAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
from stirling.agents.pdf_review import PdfReviewAgent
from stirling.agents.user_spec import UserSpecAgent
from stirling.contracts import (
AgentDraftRequest,
AgentDraftWorkflowResponse,
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
PdfEditRequest,
PdfEditResponse,
PdfQuestionRequest,
PdfQuestionResponse,
SupportedCapability,
ToolOperationStep,
UnsupportedCapabilityResponse,
format_conversation_history,
)
from stirling.contracts.pdf_edit import EditPlanResponse
from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
@@ -61,11 +57,14 @@ class OrchestratorAgent:
description="Delegate requests to create or revise a user agent spec and return the draft result.",
),
ToolOutput(
self.math_auditor_agent,
name="math_auditor_agent",
self.delegate_pdf_review,
name="delegate_pdf_review",
description=(
"Delegate requests to check arithmetic, validate table totals, "
"audit financial calculations, or verify mathematical accuracy in PDFs."
"Delegate requests to review a PDF and leave review comments, notes, or"
" sticky-note annotations on the document itself. Use this when the user"
" wants the PDF returned with comments attached (e.g. 'review this',"
" 'add review comments', 'flag unclear sentences', 'annotate with"
" feedback')."
),
),
ToolOutput(
@@ -81,8 +80,9 @@ class OrchestratorAgent:
"Use delegate_pdf_edit for requested modifications of single or multiple PDFs. "
"Use delegate_pdf_question for questions about PDF contents. "
"Use delegate_user_spec for requests to create or define an agent spec. "
"Use math_auditor_agent for requests to check arithmetic, validate "
"table totals, audit financial calculations, or verify math in PDFs. "
"Use delegate_pdf_review when the user wants the PDF returned with review"
" comments attached — anything like 'review this', 'annotate with comments',"
" 'leave feedback on the PDF'. "
"Use unsupported_capability only when none of the other outputs fit."
),
model_settings=runtime.fast_model_settings,
@@ -106,10 +106,17 @@ class OrchestratorAgent:
return result.output
async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse:
"""Fast-path to get back to the correct endpoint without having to call AI."""
"""Fast-path to get back to the correct endpoint without having to call AI.
Also the entry point for the *multi-turn* flow where a delegate emits a plan with
``resume_with`` set — Java runs the plan, captures any tool reports as artifacts, and
re-enters via this method so the delegate can digest the reports.
"""
match capability:
case SupportedCapability.PDF_QUESTION:
return await self._run_pdf_question(request)
case SupportedCapability.PDF_REVIEW:
return await self._run_pdf_review(request)
case SupportedCapability.PDF_EDIT:
return await self._run_pdf_edit(request)
case SupportedCapability.AGENT_DRAFT:
@@ -128,51 +135,25 @@ class OrchestratorAgent:
return await self._run_pdf_edit(ctx.deps.request)
async def _run_pdf_edit(self, request: OrchestratorRequest) -> PdfEditResponse:
extracted_text = self._get_extracted_text_artifact(request)
return await PdfEditAgent(self.runtime).handle(
PdfEditRequest(
user_message=request.user_message,
file_names=request.file_names,
conversation_history=request.conversation_history,
page_text=extracted_text.files if extracted_text is not None else [],
)
)
return await PdfEditAgent(self.runtime).orchestrate(request)
async def delegate_pdf_question(self, ctx: RunContext[OrchestratorDeps]) -> PdfQuestionResponse:
return await self._run_pdf_question(ctx.deps.request)
async def _run_pdf_question(self, request: OrchestratorRequest) -> PdfQuestionResponse:
extracted_text = self._get_extracted_text_artifact(request)
return await PdfQuestionAgent(self.runtime).handle(
PdfQuestionRequest(
question=request.user_message,
file_names=request.file_names,
page_text=extracted_text.files if extracted_text is not None else [],
conversation_history=request.conversation_history,
)
)
return await PdfQuestionAgent(self.runtime).orchestrate(request)
async def delegate_user_spec(self, ctx: RunContext[OrchestratorDeps]) -> AgentDraftWorkflowResponse:
return await self._run_agent_draft(ctx.deps.request)
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
return await UserSpecAgent(self.runtime).draft(
AgentDraftRequest(
user_message=request.user_message,
conversation_history=request.conversation_history,
)
)
return await UserSpecAgent(self.runtime).orchestrate(request)
async def math_auditor_agent(self, ctx: RunContext[OrchestratorDeps]) -> EditPlanResponse:
return EditPlanResponse(
summary="Validate mathematical calculations in the document.",
steps=[
ToolOperationStep(
tool=AgentToolId.MATH_AUDITOR_AGENT,
parameters=MathAuditorAgentParams(),
)
],
)
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> EditPlanResponse:
return await self._run_pdf_review(ctx.deps.request)
async def _run_pdf_review(self, request: OrchestratorRequest) -> EditPlanResponse:
return await PdfReviewAgent(self.runtime).orchestrate(request)
async def unsupported_capability(
self,
@@ -182,12 +163,6 @@ class OrchestratorAgent:
) -> UnsupportedCapabilityResponse:
return UnsupportedCapabilityResponse(capability=capability, message=message)
def _get_extracted_text_artifact(self, request: OrchestratorRequest) -> ExtractedTextArtifact | None:
for artifact in request.artifacts:
if isinstance(artifact, ExtractedTextArtifact):
return artifact
return None
def _build_prompt(self, request: OrchestratorRequest) -> str:
artifact_summary = self._describe_artifacts(request)
file_names = ", ".join(request.file_names) if request.file_names else "Unknown files"
@@ -0,0 +1,5 @@
"""PDF Comment Agent (pdfCommentAgent) — AI-powered review comments for PDFs."""
from .agent import PdfCommentAgent
__all__ = ["PdfCommentAgent"]
@@ -0,0 +1,196 @@
"""
PDF Comment Agent (pdfCommentAgent) — pydantic-ai agent for review comments.
Given a list of positioned text chunks extracted by Java and a user prompt,
the agent selects chunks worth commenting on and returns concise review
comments. Java then applies the actual PDF sticky-note annotations using
the chunk bounding boxes it already holds; the agent never sees the PDF.
The model only fills in fields it's well-suited to fill: a chunk ordinal
(a small bounded int) and the comment text. All non-LLM fields (the real
``chunk_id`` echoed back to Java) are filled in by Python after the call,
so the LLM has no opportunity to hallucinate opaque string identifiers.
"""
from __future__ import annotations
import json
import logging
from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.pdf_comment.prompts import COMMENT_AGENT_SYSTEM_PROMPT
from stirling.contracts.pdf_comments import (
MAX_COMMENT_TEXT_LENGTH,
PdfCommentInstruction,
PdfCommentRequest,
PdfCommentResponse,
TextChunk,
)
from stirling.logging import Pretty
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
class LlmCommentInstruction(ApiModel):
"""LLM-facing comment shape — only fields the model is well-suited to fill.
``chunk_index`` is the ordinal of the chunk in the input list (0-based).
Bounds are sanity-checked in agent code after the call; an ordinal is
structurally much harder to hallucinate than the opaque ``chunk_id``
string used on the Java-facing contract.
"""
chunk_index: int = Field(
ge=0,
description="0-based index of the chunk in the input list this comment anchors to.",
)
comment_text: str = Field(
min_length=1,
max_length=MAX_COMMENT_TEXT_LENGTH,
description="The comment body shown in the sticky-note popup. One or two sentences.",
)
author: str | None = Field(default=None, max_length=128)
subject: str | None = Field(default=None, max_length=256)
class LlmCommentOutput(ApiModel):
"""Structured output the LLM returns. Translated to ``PdfCommentResponse``
by the agent before reaching Java.
"""
comments: list[LlmCommentInstruction] = Field(default_factory=list)
rationale: str = Field(max_length=1_000)
class PdfCommentAgent:
"""Encapsulates the single-shot PDF comment generation pipeline.
Instantiated once at app startup with an :class:`AppRuntime`, which
provides the pre-built fast model and model settings.
"""
def __init__(self, runtime: AppRuntime) -> None:
self._runtime = runtime
self._agent = Agent(
model=runtime.fast_model,
output_type=LlmCommentOutput,
system_prompt=COMMENT_AGENT_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
async def generate(self, request: PdfCommentRequest) -> PdfCommentResponse:
"""Run the agent against a ``PdfCommentRequest`` and return comments.
Short-circuits with an empty response when the input has no chunks.
Any out-of-range ``chunk_index`` returned by the model is dropped
(this should be vanishingly rare given the bounded int surface).
Agent failures propagate to the caller (FastAPI translates to HTTP
5xx) rather than being silently swallowed; callers need to know
when the agent failed.
"""
session_id = request.session_id
logger.info(
"[pdf-comment-agent] session=%s generating comments for %d chunks",
session_id,
len(request.chunks),
)
logger.debug(
"REQUEST (pdf-comment-agent generate)\n%s",
Pretty(
{
"session_id": session_id,
"user_message": request.user_message,
"chunk_count": len(request.chunks),
}
),
)
if not request.chunks:
logger.debug(
"[pdf-comment-agent] session=%s no chunks; skipping agent call",
session_id,
)
return PdfCommentResponse(
session_id=session_id,
comments=[],
rationale="No text chunks were provided; no comments generated.",
)
prompt = self._build_prompt(request)
result = await self._agent.run(prompt)
output = result.output
comments = self._map_to_instructions(request.chunks, output.comments, session_id)
response = PdfCommentResponse(
session_id=session_id,
comments=comments,
rationale=output.rationale,
)
logger.debug(
"RESPONSE (pdf-comment-agent generate)\n%s",
Pretty(response),
)
return response
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _build_prompt(request: PdfCommentRequest) -> str:
"""Build a structured prompt with chunks listed by ordinal index.
Both the user's free-text prompt and each chunk's text are JSON-
encoded so any quotes, newlines, or stray delimiters in attacker-
influenced content (the user message or PDF-derived chunks) are
escaped and cannot break out of the prompt structure.
"""
lines: list[str] = [
"User prompt (JSON-encoded, untrusted input):",
json.dumps(request.user_message),
"",
f"Chunks ({len(request.chunks)} total). Each line shows the chunk index",
"you must return on `chunk_index`, the 1-indexed page number, and the",
"JSON-encoded text content.",
"",
]
for index, chunk in enumerate(request.chunks):
lines.append(f"[{index}] page={chunk.page + 1} text={json.dumps(chunk.text)}")
return "\n".join(lines)
@staticmethod
def _map_to_instructions(
chunks: list[TextChunk],
llm_comments: list[LlmCommentInstruction],
session_id: str,
) -> list[PdfCommentInstruction]:
"""Translate LLM ordinal-based output into the Java-facing contract,
dropping any out-of-range ordinals as a defence-in-depth guard.
"""
kept: list[PdfCommentInstruction] = []
dropped: list[int] = []
for comment in llm_comments:
if 0 <= comment.chunk_index < len(chunks):
kept.append(
PdfCommentInstruction(
chunk_id=chunks[comment.chunk_index].id,
comment_text=comment.comment_text,
author=comment.author,
subject=comment.subject,
)
)
else:
dropped.append(comment.chunk_index)
if dropped:
logger.warning(
"[pdf-comment-agent] session=%s dropped %d comment(s) with out-of-range chunk_index: %s",
session_id,
len(dropped),
dropped,
)
return kept
@@ -0,0 +1,30 @@
"""
PDF Comment Agent — system prompts.
Kept in a separate module so the prompt text can be reviewed and tuned
without touching agent wiring, mirroring the Ledger Auditor layout.
"""
COMMENT_AGENT_SYSTEM_PROMPT = """\
You are a document review assistant.
You receive (a) a user prompt describing what review comments are wanted and \
(b) a list of text chunks extracted from a PDF. Each chunk is shown with a \
0-based index in square brackets, a 1-indexed page number, and the JSON- \
encoded text content. Your job is to select the chunks that warrant a \
comment and produce one concise remark per chunk.
Rules:
- Every `chunk_index` you return MUST be the 0-based index of a chunk shown \
in the input (the number in square brackets). Indices outside the visible \
range are dropped.
- Each comment must directly address the user's prompt. If no chunk is \
relevant, return an empty `comments` list.
- Prefer one comment per distinct idea — do not duplicate or chain comments \
about the same content, and do not split a single thought across chunks.
- Keep `comment_text` short (one or two sentences, plain text).
- Return at most 20 comments unless the user's prompt explicitly asks for an \
exhaustive review.
- Populate `rationale` with one sentence describing your overall approach \
for traceability in server logs.
"""
+18 -1
View File
@@ -7,13 +7,14 @@ from pydantic import Field
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents._page_text import format_page_text, has_page_text
from stirling.agents._page_text import format_page_text, get_extracted_text_artifact, has_page_text
from stirling.contracts import (
EditCannotDoResponse,
EditClarificationRequest,
EditPlanResponse,
NeedContentFileRequest,
NeedContentResponse,
OrchestratorRequest,
PdfContentType,
PdfEditRequest,
PdfEditResponse,
@@ -142,6 +143,22 @@ class PdfEditAgent:
self.supported_operations = list(OPERATIONS)
self.parameter_selector = PdfEditParameterSelector(runtime)
async def orchestrate(self, request: OrchestratorRequest) -> PdfEditResponse:
"""Entry point for the orchestrator delegate — adapts the orchestrator's
request shape into a :class:`PdfEditRequest` and runs the standard
:meth:`handle` pipeline. Direct API callers continue to use ``handle``
with a typed :class:`PdfEditRequest`.
"""
extracted_text = get_extracted_text_artifact(request)
return await self.handle(
PdfEditRequest(
user_message=request.user_message,
file_names=request.file_names,
conversation_history=request.conversation_history,
page_text=extracted_text.files if extracted_text is not None else [],
)
)
@overload
async def handle(self, request: PdfEditRequest, allow_need_content: Literal[False]) -> PdfEditTerminalResponse: ...
@overload
+85 -2
View File
@@ -3,20 +3,40 @@ from __future__ import annotations
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents._page_text import format_page_text, has_page_text
from stirling.agents._page_text import (
format_page_text,
get_extracted_text_artifact,
has_page_text,
)
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
from stirling.contracts import (
EditPlanResponse,
NeedContentFileRequest,
NeedContentResponse,
OrchestratorRequest,
PdfContentType,
PdfQuestionAnswerResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
PdfQuestionResponse,
SupportedCapability,
ToolOperationStep,
Verdict,
format_conversation_history,
)
from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams
from stirling.services import AppRuntime
_MATH_SYNTH_SYSTEM_PROMPT = (
"You are given a math-audit Verdict (structured JSON) and the user's "
"original question. Answer the question in plain prose using only "
"facts from the Verdict; do not invent figures or pages. "
"Reply in the SAME LANGUAGE as the user's question. Keep the answer "
"concise — a sentence or short paragraph. "
"Quote any stated/expected numeric values from the Verdict verbatim — "
"do not paraphrase, abbreviate, or convert units."
)
class PdfQuestionAgent:
def __init__(self, runtime: AppRuntime) -> None:
@@ -34,12 +54,20 @@ class PdfQuestionAgent:
"Answer questions about PDFs using only the extracted page text provided in the prompt. "
"Do not guess or use outside knowledge. "
"If the answer is not supported by the provided text, return not_found. "
"When answering, include a short list of evidence snippets with their page numbers."
"When answering, include a short list of evidence snippets with their page numbers. "
"Reply in the SAME LANGUAGE as the question."
),
instructions=rag.instructions,
toolsets=[rag.toolset],
model_settings=runtime.smart_model_settings,
)
self._math_synth_agent: Agent[None, str] = Agent(
model=runtime.fast_model,
output_type=str,
system_prompt=_MATH_SYNTH_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
self._math_intent_classifier = MathIntentClassifier(runtime)
async def handle(self, request: PdfQuestionRequest) -> PdfQuestionResponse:
if not has_page_text(request.page_text):
@@ -58,10 +86,65 @@ class PdfQuestionAgent:
)
return await self._run_answer_agent(request)
async def orchestrate(self, request: OrchestratorRequest) -> PdfQuestionResponse:
"""Entry point for the orchestrator delegate.
Decides math intent locally via a small classifier LLM (language-agnostic).
On a math first turn, embeds an :class:`EditPlanResponse` in the answer
response; on the resume turn, digests the captured :class:`Verdict` into
a localised prose answer. Non-math first turns fall through to the
text-grounded :meth:`handle` pipeline.
"""
verdict = extract_math_verdict(request)
if verdict is not None:
# Resume turn — Verdict in hand. Synthesise a localised answer from
# the structured verdict via a small LLM that mirrors the user's
# language; no English glue in the response.
answer = await self._synthesise_math_answer(request.user_message, verdict)
return PdfQuestionAnswerResponse(answer=answer, evidence=[])
if await self._math_intent_classifier.classify(request.user_message):
# First turn — ask the caller to run the math specialist and come back.
# The plan rides on the answer response as a nullable member; ``answer``
# is empty on this turn and the caller resumes once the plan is run.
return PdfQuestionAnswerResponse(
answer="",
evidence=[],
edit_plan=EditPlanResponse(
summary="",
steps=[
ToolOperationStep(
tool=AgentToolId.MATH_AUDITOR_AGENT,
parameters=MathAuditorAgentParams(),
)
],
resume_with=SupportedCapability.PDF_QUESTION,
),
)
extracted_text = get_extracted_text_artifact(request)
return await self.handle(
PdfQuestionRequest(
question=request.user_message,
file_names=request.file_names,
page_text=extracted_text.files if extracted_text is not None else [],
conversation_history=request.conversation_history,
)
)
async def _run_answer_agent(self, request: PdfQuestionRequest) -> PdfQuestionResponse:
result = await self.agent.run(self._build_prompt(request))
return result.output
async def _synthesise_math_answer(self, user_message: str, verdict: Verdict) -> str:
"""Use a small LLM to render the structured Verdict as a natural-language
answer in the same language as the user's question. The system prompt
forbids invented figures; the LLM only restates Verdict facts.
"""
prompt = f"User question:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
result = await self._math_synth_agent.run(prompt)
return result.output
def _build_prompt(self, request: PdfQuestionRequest) -> str:
file_names = ", ".join(request.file_names) if request.file_names else "Unknown files"
pages = format_page_text(request.page_text, empty="")
+173
View File
@@ -0,0 +1,173 @@
"""PDF review delegate.
Produces an annotated PDF with review comments. Math-flavoured prompts
consult the math-auditor specialist first (via a plan + resume) and then
project the :class:`Verdict` into sticky-note specs for ``add-comments``.
Other review prompts route to the composed ``pdf-comment-agent`` tool,
which does its own chunk extraction + AI round-trip.
Sticky-note text is produced by a small LLM that reads the structured
Verdict and the user's original prompt and writes comments in the SAME
LANGUAGE as the prompt. Bounding-box placement is deterministic Python.
"""
from __future__ import annotations
import json
from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
from stirling.contracts import (
CommentSpec,
EditPlanResponse,
OrchestratorRequest,
SupportedCapability,
ToolOperationStep,
Verdict,
)
from stirling.contracts.ledger import Discrepancy
from stirling.models import ApiModel, ToolEndpoint
from stirling.models.agent_tool_models import (
AgentToolId,
MathAuditorAgentParams,
PdfCommentAgentParams,
)
from stirling.models.tool_models import AddCommentsParams
from stirling.services import AppRuntime
# Fallback right-margin placement used when a discrepancy has no usable
# anchor text. A4/Letter portrait assumed.
_ICON_X = 520.0
_ICON_Y_TOP = 770.0
_ICON_Y_STRIDE = 28.0
_ICON_SIZE = 20.0
_DEFAULT_AUTHOR = "Stirling Math Auditor"
_LOCALISER_SYSTEM_PROMPT = (
"You are given a math-audit Verdict (structured JSON) and the user's "
"original review request. Produce one sticky-note entry per Discrepancy "
"the user would care about. Each entry carries the discrepancy's index "
"in the input list, a short subject (a few words), and a body of one or "
"two sentences. Reply in the SAME LANGUAGE as the user's request. Do "
"not invent figures; only restate what the Verdict already says. "
"When a Discrepancy carries `stated` or `expected` values, quote them "
"verbatim in the comment body — do not paraphrase, abbreviate, or "
"convert units."
)
class _LocalisedComment(ApiModel):
discrepancy_index: int = Field(ge=0, description="0-based index of the Discrepancy in verdict.discrepancies.")
subject: str = Field(min_length=1, max_length=256)
text: str = Field(min_length=1, max_length=2_000)
class _LocalisedVerdict(ApiModel):
comments: list[_LocalisedComment] = Field(default_factory=list)
class PdfReviewAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self._localiser_agent: Agent[None, _LocalisedVerdict] = Agent(
model=runtime.fast_model,
output_type=_LocalisedVerdict,
system_prompt=_LOCALISER_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
self._math_intent_classifier = MathIntentClassifier(runtime)
async def orchestrate(self, request: OrchestratorRequest) -> EditPlanResponse:
"""Entry point for the orchestrator delegate.
Decides math intent locally via a small classifier LLM (language-agnostic).
On a math first turn, emits a plan to consult the math auditor; on the
resume turn, projects the captured :class:`Verdict` into localised
sticky-note specs. Non-math review prompts route to the composed
``pdf-comment-agent`` tool for prose review.
"""
verdict = extract_math_verdict(request)
if verdict is not None:
comments_json = await self._build_localised_comments_payload(request.user_message, verdict)
return EditPlanResponse(
summary="",
steps=[
ToolOperationStep(
tool=ToolEndpoint.ADD_COMMENTS,
parameters=AddCommentsParams(comments=comments_json),
)
],
)
if await self._math_intent_classifier.classify(request.user_message):
return EditPlanResponse(
summary="",
steps=[
ToolOperationStep(
tool=AgentToolId.MATH_AUDITOR_AGENT,
parameters=MathAuditorAgentParams(),
)
],
resume_with=SupportedCapability.PDF_REVIEW,
)
return EditPlanResponse(
summary="",
steps=[
ToolOperationStep(
tool=AgentToolId.PDF_COMMENT_AGENT,
parameters=PdfCommentAgentParams(prompt=request.user_message),
)
],
)
async def _build_localised_comments_payload(self, user_message: str, verdict: Verdict) -> str:
"""Run the localiser LLM, then combine its output with deterministic
placement geometry to produce the JSON the ``add-comments`` tool wants.
"""
prompt = f"User review request:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
result = await self._localiser_agent.run(prompt)
specs = self._build_comment_specs(verdict, result.output.comments)
serialised = [spec.model_dump(by_alias=True, exclude_none=True) for spec in specs]
return json.dumps(serialised)
@staticmethod
def _build_comment_specs(verdict: Verdict, localised: list[_LocalisedComment]) -> list[CommentSpec]:
"""Fuse LLM-localised text with deterministic position geometry.
Out-of-range ordinals are dropped (defence-in-depth: the LLM's index
is bounds-checked at validation but we re-check here too).
"""
specs: list[CommentSpec] = []
per_page_index: dict[int, int] = {}
for comment in localised:
if comment.discrepancy_index >= len(verdict.discrepancies):
continue
d = verdict.discrepancies[comment.discrepancy_index]
stack_index = per_page_index.get(d.page, 0)
per_page_index[d.page] = stack_index + 1
y = _ICON_Y_TOP - stack_index * _ICON_Y_STRIDE
specs.append(
CommentSpec(
page_index=d.page,
x=_ICON_X,
y=y,
width=_ICON_SIZE,
height=_ICON_SIZE,
text=comment.text,
author=_DEFAULT_AUTHOR,
subject=comment.subject,
anchor_text=_anchor_text_for(d),
)
)
return specs
def _anchor_text_for(d: Discrepancy) -> str | None:
stated = d.stated.strip()
if stated:
return stated
return d.context.strip() or None
+13
View File
@@ -15,6 +15,7 @@ from stirling.contracts import (
AiToolAgentStep,
ConversationMessage,
EditPlanResponse,
OrchestratorRequest,
PdfEditRequest,
PdfEditTerminalResponse,
format_conversation_history,
@@ -44,6 +45,18 @@ class UserSpecAgent:
model_settings=runtime.smart_model_settings,
)
async def orchestrate(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
"""Entry point for the orchestrator delegate — adapts the orchestrator's
request shape into an :class:`AgentDraftRequest` and runs the standard
:meth:`draft` pipeline.
"""
return await self.draft(
AgentDraftRequest(
user_message=request.user_message,
conversation_history=request.conversation_history,
)
)
async def draft(self, request: AgentDraftRequest) -> AgentDraftWorkflowResponse:
edit_plan = await self._build_edit_plan(request.user_message, request.conversation_history)
if not isinstance(edit_plan, EditPlanResponse):
+4
View File
@@ -9,12 +9,14 @@ from pydantic_ai.models.instrumented import InstrumentationSettings
from stirling.agents import ExecutionPlanningAgent, OrchestratorAgent, PdfEditAgent, PdfQuestionAgent, UserSpecAgent
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.api.middleware import UserIdMiddleware
from stirling.api.routes import (
agent_draft_router,
execution_router,
ledger_router,
orchestrator_router,
pdf_comments_router,
pdf_edit_router,
pdf_question_router,
rag_router,
@@ -44,6 +46,7 @@ async def lifespan(fast_api: FastAPI):
fast_api.state.user_spec_agent = UserSpecAgent(runtime)
fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime)
fast_api.state.math_auditor_agent = MathAuditorAgent(runtime)
fast_api.state.pdf_comment_agent = PdfCommentAgent(runtime)
tracer_provider = setup_posthog_tracking(settings)
if tracer_provider:
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
@@ -61,6 +64,7 @@ app.include_router(agent_draft_router)
app.include_router(execution_router)
app.include_router(rag_router)
app.include_router(ledger_router)
app.include_router(pdf_comments_router)
@app.get("/health", response_model=HealthResponse)
+5
View File
@@ -4,6 +4,7 @@ from fastapi import Request
from stirling.agents import ExecutionPlanningAgent, OrchestratorAgent, PdfEditAgent, PdfQuestionAgent, UserSpecAgent
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.rag import RagService
from stirling.services import AppRuntime
@@ -42,3 +43,7 @@ def get_rag_embedding_model(request: Request) -> str:
def get_math_auditor_agent(request: Request) -> MathAuditorAgent:
return request.app.state.math_auditor_agent
def get_pdf_comment_agent(request: Request) -> PdfCommentAgent:
return request.app.state.pdf_comment_agent
@@ -2,6 +2,7 @@ from .agent_drafts import router as agent_draft_router
from .execution import router as execution_router
from .ledger import router as ledger_router
from .orchestrator import router as orchestrator_router
from .pdf_comments import router as pdf_comments_router
from .pdf_edit import router as pdf_edit_router
from .pdf_questions import router as pdf_question_router
from .rag import router as rag_router
@@ -11,6 +12,7 @@ __all__ = [
"execution_router",
"ledger_router",
"orchestrator_router",
"pdf_comments_router",
"pdf_edit_router",
"pdf_question_router",
"rag_router",
@@ -0,0 +1,33 @@
"""
PDF Comment Agent (pdfCommentAgent) — FastAPI routes.
One internal endpoint, called only by the Java PdfCommentAgentOrchestrator:
POST /api/v1/ai/pdf-comment-agent/generate
Java sends a PdfCommentRequest (prompt + positioned text chunks).
Python returns a PdfCommentResponse listing which chunks to comment on.
"""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.api.dependencies import get_pdf_comment_agent
from stirling.contracts.pdf_comments import PdfCommentRequest, PdfCommentResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/ai/pdf-comment-agent", tags=["pdf-comment-agent"])
@router.post("/generate", response_model=PdfCommentResponse)
async def generate_endpoint(
request: PdfCommentRequest,
agent: Annotated[PdfCommentAgent, Depends(get_pdf_comment_agent)],
) -> PdfCommentResponse:
"""Generate review comments for the supplied text chunks."""
return await agent.generate(request)
+18
View File
@@ -8,10 +8,12 @@ from .agent_drafts import (
AgentRevisionWorkflowResponse,
)
from .agent_specs import AgentSpec, AgentSpecStep, AiToolAgentStep
from .comments import CommentSpec
from .common import (
ArtifactKind,
ConversationMessage,
ExtractedFileText,
MathAuditorToolReportArtifact,
NeedContentFileRequest,
NeedContentResponse,
PdfContentType,
@@ -19,6 +21,7 @@ from .common import (
StepKind,
SupportedCapability,
ToolOperationStep,
ToolReportArtifact,
WorkflowOutcome,
format_conversation_history,
)
@@ -50,6 +53,13 @@ from .orchestrator import (
UnsupportedCapabilityResponse,
WorkflowArtifact,
)
from .pdf_comments import (
PdfCommentInstruction,
PdfCommentReport,
PdfCommentRequest,
PdfCommentResponse,
TextChunk,
)
from .pdf_edit import (
EditCannotDoResponse,
EditClarificationRequest,
@@ -92,6 +102,7 @@ __all__ = [
"AiToolAgentStep",
"ArtifactKind",
"CannotContinueExecutionAction",
"CommentSpec",
"CompletedExecutionAction",
"ConversationMessage",
"Discrepancy",
@@ -109,11 +120,16 @@ __all__ = [
"FolioType",
"format_conversation_history",
"HealthResponse",
"MathAuditorToolReportArtifact",
"NeedContentFileRequest",
"NeedContentResponse",
"NextExecutionAction",
"OrchestratorRequest",
"OrchestratorResponse",
"PdfCommentInstruction",
"PdfCommentReport",
"PdfCommentRequest",
"PdfCommentResponse",
"PdfContentType",
"PdfEditRequest",
"PdfEditResponse",
@@ -136,8 +152,10 @@ __all__ = [
"Severity",
"StepKind",
"SupportedCapability",
"TextChunk",
"ToolCallExecutionAction",
"ToolOperationStep",
"ToolReportArtifact",
"UnsupportedCapabilityResponse",
"Verdict",
"WorkflowArtifact",
+36
View File
@@ -0,0 +1,36 @@
"""Structured sticky-note comment specs for the ``add-comments`` tool.
The ``/api/v1/misc/add-comments`` tool takes a JSON string of comment specs
(see :class:`stirling.models.tool_models.AddCommentsParams`). This module
defines the typed Python shape we serialise into that string so callers
don't have to hand-roll dictionaries.
"""
from __future__ import annotations
from pydantic import Field
from stirling.models import ApiModel
class CommentSpec(ApiModel):
"""Sticky-note spec serialised into the ``comments`` JSON string sent to
``/api/v1/misc/add-comments``. The backend's tool contract takes the JSON
string form, not this type; this is the engine-side structured representation.
"""
page_index: int = Field(description="0-indexed page number.")
x: float = Field(description="Bottom-left x coord of the icon (PDF user-space).")
y: float = Field(description="Bottom-left y coord of the icon (PDF user-space).")
width: float = Field(description="Width of the icon in user-space units.")
height: float = Field(description="Height of the icon in user-space units.")
text: str = Field(description="Comment body shown in the popup.")
author: str | None = Field(default=None)
subject: str | None = Field(default=None)
anchor_text: str | None = Field(
default=None,
description=(
"Optional text snippet to locate on the page; when set, the server anchors"
" the icon at the first matching line and ignores the x/y coords."
),
)
+24
View File
@@ -5,6 +5,7 @@ from typing import Literal, assert_never
from pydantic import Field, model_validator
from stirling.contracts.ledger import Verdict
from stirling.models import OPERATIONS, ApiModel, ToolEndpoint
from stirling.models.agent_tool_models import AGENT_OPERATIONS, AgentToolId, AnyParamModel, AnyToolId
@@ -67,6 +68,7 @@ class ArtifactKind(StrEnum):
"""
EXTRACTED_TEXT = "extracted_text"
TOOL_REPORT = "tool_report"
class StepKind(StrEnum):
@@ -80,6 +82,7 @@ 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"
@@ -122,6 +125,27 @@ class NeedContentResponse(ApiModel):
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 ToolOperationStep(ApiModel):
kind: Literal[StepKind.TOOL] = StepKind.TOOL
tool: AnyToolId
@@ -13,6 +13,7 @@ from .common import (
ExtractedFileText,
NeedContentResponse,
SupportedCapability,
ToolReportArtifact,
WorkflowOutcome,
)
from .execution import NextExecutionAction
@@ -25,7 +26,7 @@ class ExtractedTextArtifact(ApiModel):
files: list[ExtractedFileText] = Field(default_factory=list)
WorkflowArtifact = Annotated[ExtractedTextArtifact, Field(discriminator="kind")]
WorkflowArtifact = Annotated[ExtractedTextArtifact | ToolReportArtifact, Field(discriminator="kind")]
class OrchestratorRequest(ApiModel):
@@ -0,0 +1,150 @@
"""
PDF Comment Agent — shared models for the Java-Python protocol.
The Java backend extracts positioned text chunks from a PDF and sends them
along with a user prompt to the Python engine. Python selects the chunks
that warrant a comment and returns an instruction list; Java then applies
the actual PDF sticky-note annotations.
Python never touches the PDF bytes. It only sees pre-extracted text with
stable ids and must echo those ids back so Java can resolve each comment
to its anchor.
"""
from __future__ import annotations
from pydantic import Field
from stirling.models import ApiModel
# Bounds shared between the on-wire contract (enforced by pydantic) and any
# Python-side defence-in-depth validation. Java enforces its own caps before
# sending, but a malicious or buggy direct caller could otherwise ship an
# unbounded payload.
MAX_USER_MESSAGE_LENGTH = 4_000
MAX_CHUNK_TEXT_LENGTH = 1_000
MAX_COMMENT_TEXT_LENGTH = 2_000
MAX_CHUNKS_PER_REQUEST = 2_500 # a hair above Java's 2000 cap — soft ceiling
class TextChunk(ApiModel):
"""One positioned text chunk extracted from a PDF page by Java.
The ``id`` is the stable handle used to anchor a comment to this chunk;
Python must echo it back verbatim on any comment that targets this chunk.
The bounding box is in PDF user-space (origin = bottom-left of the page).
"""
id: str = Field(
min_length=1,
max_length=64,
description="Stable id, typically 'p{page}-c{chunk}'. Must be echoed unchanged on returned comments.",
)
page: int = Field(ge=0, description="0-indexed page number this chunk lives on.")
x: float = Field(description="PDF user-space x of the chunk's bounding box (bottom-left origin).")
y: float = Field(description="PDF user-space y of the chunk's bounding box (bottom-left origin).")
width: float = Field(ge=0, description="Width of the chunk's bounding box, in PDF user-space units.")
height: float = Field(ge=0, description="Height of the chunk's bounding box, in PDF user-space units.")
text: str = Field(
min_length=1,
max_length=MAX_CHUNK_TEXT_LENGTH,
description="The extracted text for this chunk. Typically one line.",
)
class PdfCommentRequest(ApiModel):
"""Request body Java sends to POST /api/v1/ai/pdf-comment-agent/generate.
Carries the user's natural-language instruction plus the list of text
chunks Java was able to extract from the PDF.
"""
session_id: str = Field(
min_length=1,
max_length=128,
description="Opaque handle Java uses to correlate the request with its in-flight PDF job.",
)
user_message: str = Field(
min_length=1,
max_length=MAX_USER_MESSAGE_LENGTH,
description="The end-user prompt describing what the AI should comment on.",
)
chunks: list[TextChunk] = Field(
default_factory=list,
max_length=MAX_CHUNKS_PER_REQUEST,
description="All positioned text chunks Java extracted from the PDF; may be empty if the PDF has no text.",
)
class PdfCommentInstruction(ApiModel):
"""One review comment the agent wants Java to apply to the PDF.
``chunk_id`` MUST match the id of a chunk that appeared in the request;
Java uses it to resolve the bounding box and anchor the sticky-note
annotation. Comments referencing an unknown id are dropped.
"""
chunk_id: str = Field(
min_length=1,
max_length=64,
description="Id of the input chunk this comment anchors to. Must match an input chunk.id.",
)
comment_text: str = Field(
min_length=1,
max_length=MAX_COMMENT_TEXT_LENGTH,
description="The comment body shown in the sticky-note popup. One or two sentences.",
)
author: str | None = Field(
default=None,
max_length=128,
description="Optional author label; Java falls back to a default when absent.",
)
subject: str | None = Field(
default=None,
max_length=256,
description="Optional short subject/title for the comment popup; Java falls back to a default when absent.",
)
class PdfCommentResponse(ApiModel):
"""Response body the agent returns for POST /api/v1/ai/pdf-comment-agent/generate.
``session_id`` is echoed from the request so Java can match the reply to
its pending job. ``comments`` is the (possibly filtered) list of review
instructions Java should apply as PDF Text annotations.
"""
session_id: str = Field(
min_length=1,
max_length=128,
description="Echoed from the request so Java can match the reply to its pending job.",
)
comments: list[PdfCommentInstruction] = Field(
default_factory=list,
description="Review comments to apply. Each chunk_id is guaranteed to match an input chunk.",
)
rationale: str = Field(
max_length=1_000,
description="One-sentence summary describing the agent's overall approach for traceability/logging.",
)
class PdfCommentReport(ApiModel):
"""Structured report surfaced by the pdf-comment-agent tool alongside the
annotated PDF body. Mirrors the JSON shape the controller builds in
``PdfCommentAgentController.buildReportHeader``.
Lands as the top-level ``AiWorkflowResponse.report`` on the COMPLETED
outcome (the pdf-comment-agent flow terminates without ``resume_with``,
so this never re-enters the orchestrator as a resume artifact).
"""
annotations_applied: int = Field(
ge=0, description="Number of sticky-note annotations actually written into the PDF."
)
instructions_received: int = Field(
ge=0, description="Number of comment instructions the engine produced before filtering."
)
rationale: str | None = Field(
default=None, description="One-sentence summary the engine emitted alongside the comments."
)
+17 -1
View File
@@ -6,7 +6,14 @@ from pydantic import Field
from stirling.models import ApiModel
from .common import ConversationMessage, ExtractedFileText, NeedContentResponse, ToolOperationStep, WorkflowOutcome
from .common import (
ConversationMessage,
ExtractedFileText,
NeedContentResponse,
SupportedCapability,
ToolOperationStep,
WorkflowOutcome,
)
class PdfEditRequest(ApiModel):
@@ -21,6 +28,15 @@ class EditPlanResponse(ApiModel):
summary: str
rationale: str | None = None
steps: list[ToolOperationStep]
resume_with: SupportedCapability | None = Field(
default=None,
description=(
"Optional: if set, Java runs the plan steps then re-invokes the orchestrator with"
" the captured tool reports attached as ToolReportArtifacts and"
" resume_with set to this capability. Used by meta-agents that need to digest a"
" specialist's output (e.g. pdf_review consulting math-auditor)."
),
)
class EditClarificationRequest(ApiModel):
@@ -12,6 +12,7 @@ from .common import (
NeedContentResponse,
WorkflowOutcome,
)
from .pdf_edit import EditPlanResponse
class PdfQuestionRequest(ApiModel):
@@ -25,6 +26,15 @@ class PdfQuestionAnswerResponse(ApiModel):
outcome: Literal[WorkflowOutcome.ANSWER] = WorkflowOutcome.ANSWER
answer: str
evidence: list[ExtractedFileText] = Field(default_factory=list)
edit_plan: EditPlanResponse | None = Field(
default=None,
description=(
"Optional plan the caller must run before the answer is final. When"
" populated, ``answer`` is empty on this turn — the caller executes"
" the plan and re-invokes the orchestrator with ``resume_with`` set"
" to PDF_QUESTION; the real answer arrives on the resume turn."
),
)
class PdfQuestionNotFoundResponse(ApiModel):
@@ -13,18 +13,24 @@ from stirling.models.tool_models import ParamToolModel, ToolEndpoint
class AgentToolId(StrEnum):
MATH_AUDITOR_AGENT = "mathAuditorAgent"
MATH_AUDITOR_AGENT = "/api/v1/ai/tools/math-auditor-agent"
PDF_COMMENT_AGENT = "/api/v1/ai/tools/pdf-comment-agent"
class MathAuditorAgentParams(ApiModel):
tolerance: str = "0.01"
type AgentParamModel = MathAuditorAgentParams
class PdfCommentAgentParams(ApiModel):
prompt: str | None = None
type AgentParamModel = MathAuditorAgentParams | PdfCommentAgentParams
type AnyToolId = ToolEndpoint | AgentToolId
type AnyParamModel = ParamToolModel | AgentParamModel
AGENT_OPERATIONS: dict[AgentToolId, type[AgentParamModel]] = {
AgentToolId.MATH_AUDITOR_AGENT: MathAuditorAgentParams,
AgentToolId.PDF_COMMENT_AGENT: PdfCommentAgentParams,
}
+35
View File
@@ -18,6 +18,16 @@ class AddAttachmentsParams(ApiModel):
)
class AddCommentsParams(ApiModel):
comments: str | None = Field(
None,
description="JSON array of comment specs. Each element has: {pageIndex, x, y, width, height, text, author?, subject?}. Coordinates are PDF user-space with origin at the page's bottom-left.",
examples=[
'[{"pageIndex":0,"x":72,"y":720,"width":20,"height":20,"text":"Check this paragraph","author":"Reviewer","subject":"Unclear wording"}]'
],
)
class AddImageParams(ApiModel):
every_page: bool | None = Field(False, description="Whether to overlay the image onto every page of the PDF.")
x: float | None = Field(0, description="The x-coordinate at which to place the top-left corner of the image.")
@@ -447,6 +457,7 @@ class MergePdfsParams(ApiModel):
client_file_ids: str | None = Field(
None, description="JSON array of client-provided IDs for each uploaded file (same order as fileInput)"
)
file_order: str | None = None
generate_toc: bool | None = Field(
False,
description="Flag indicating whether to generate a table of contents for the merged PDF. If true, a table of contents will be created using the input filenames as chapter names.",
@@ -688,6 +699,10 @@ class PdfToPresentationParams(ApiModel):
output_format: OutputFormat2 | None = Field(None, description="The output Presentation format")
class PdfToTextEditorParams(ApiModel):
lightweight: bool | None = False
class OutputFormat3(StrEnum):
rtf = "rtf"
txt = "txt"
@@ -1037,6 +1052,11 @@ class UrlToPdfParams(ApiModel):
url_input: str | None = Field(None, description="The input URL to be converted to a PDF file")
class ValidateCertificateParams(ApiModel):
cert_type: str | None = None
password: str | None = None
class OutputFormat6(StrEnum):
eps = "eps"
ps = "ps"
@@ -1075,6 +1095,7 @@ class Model(
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
| PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
@@ -1097,6 +1118,7 @@ class Model(
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
| AddAttachmentsParams
| AddCommentsParams
| AddImageParams
| AddPageNumbersParams
| AddStampParams
@@ -1118,6 +1140,7 @@ class Model(
| AutoRedactParams
| CertSignParams
| SessionsParams
| ValidateCertificateParams
| RedactParams
| RemovePasswordParams
| SanitizePdfParams
@@ -1139,6 +1162,7 @@ class Model(
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
| PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
@@ -1161,6 +1185,7 @@ class Model(
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
| AddAttachmentsParams
| AddCommentsParams
| AddImageParams
| AddPageNumbersParams
| AddStampParams
@@ -1182,6 +1207,7 @@ class Model(
| AutoRedactParams
| CertSignParams
| SessionsParams
| ValidateCertificateParams
| RedactParams
| RemovePasswordParams
| SanitizePdfParams
@@ -1204,6 +1230,7 @@ type ParamToolModel = (
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
| PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
@@ -1226,6 +1253,7 @@ type ParamToolModel = (
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
| AddAttachmentsParams
| AddCommentsParams
| AddImageParams
| AddPageNumbersParams
| AddStampParams
@@ -1247,6 +1275,7 @@ type ParamToolModel = (
| AutoRedactParams
| CertSignParams
| SessionsParams
| ValidateCertificateParams
| RedactParams
| RemovePasswordParams
| SanitizePdfParams
@@ -1270,6 +1299,7 @@ class ToolEndpoint(StrEnum):
PDF_TO_PDFA = "/api/v1/convert/pdf/pdfa"
PDF_TO_PRESENTATION = "/api/v1/convert/pdf/presentation"
PDF_TO_TEXT = "/api/v1/convert/pdf/text"
PDF_TO_TEXT_EDITOR = "/api/v1/convert/pdf/text-editor"
PDF_TO_VECTOR = "/api/v1/convert/pdf/vector"
PDF_TO_WORD = "/api/v1/convert/pdf/word"
PDF_TO_XLSX = "/api/v1/convert/pdf/xlsx"
@@ -1292,6 +1322,7 @@ class ToolEndpoint(StrEnum):
SPLIT_PDF_BY_CHAPTERS = "/api/v1/general/split-pdf-by-chapters"
SPLIT_PDF_BY_SECTIONS = "/api/v1/general/split-pdf-by-sections"
ADD_ATTACHMENTS = "/api/v1/misc/add-attachments"
ADD_COMMENTS = "/api/v1/misc/add-comments"
ADD_IMAGE = "/api/v1/misc/add-image"
ADD_PAGE_NUMBERS = "/api/v1/misc/add-page-numbers"
ADD_STAMP = "/api/v1/misc/add-stamp"
@@ -1313,6 +1344,7 @@ class ToolEndpoint(StrEnum):
AUTO_REDACT = "/api/v1/security/auto-redact"
CERT_SIGN = "/api/v1/security/cert-sign"
SESSIONS = "/api/v1/security/cert-sign/sessions"
VALIDATE_CERTIFICATE = "/api/v1/security/cert-sign/validate-certificate"
REDACT = "/api/v1/security/redact"
REMOVE_PASSWORD = "/api/v1/security/remove-password"
SANITIZE_PDF = "/api/v1/security/sanitize-pdf"
@@ -1334,6 +1366,7 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.PDF_TO_PDFA: PdfToPdfaParams,
ToolEndpoint.PDF_TO_PRESENTATION: PdfToPresentationParams,
ToolEndpoint.PDF_TO_TEXT: PdfToTextParams,
ToolEndpoint.PDF_TO_TEXT_EDITOR: PdfToTextEditorParams,
ToolEndpoint.PDF_TO_VECTOR: PdfToVectorParams,
ToolEndpoint.PDF_TO_WORD: PdfToWordParams,
ToolEndpoint.PDF_TO_XLSX: PdfToXlsxParams,
@@ -1356,6 +1389,7 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.SPLIT_PDF_BY_CHAPTERS: SplitPdfByChaptersParams,
ToolEndpoint.SPLIT_PDF_BY_SECTIONS: SplitPdfBySectionsParams,
ToolEndpoint.ADD_ATTACHMENTS: AddAttachmentsParams,
ToolEndpoint.ADD_COMMENTS: AddCommentsParams,
ToolEndpoint.ADD_IMAGE: AddImageParams,
ToolEndpoint.ADD_PAGE_NUMBERS: AddPageNumbersParams,
ToolEndpoint.ADD_STAMP: AddStampParams,
@@ -1377,6 +1411,7 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.AUTO_REDACT: AutoRedactParams,
ToolEndpoint.CERT_SIGN: CertSignParams,
ToolEndpoint.SESSIONS: SessionsParams,
ToolEndpoint.VALIDATE_CERTIFICATE: ValidateCertificateParams,
ToolEndpoint.REDACT: RedactParams,
ToolEndpoint.REMOVE_PASSWORD: RemovePasswordParams,
ToolEndpoint.SANITIZE_PDF: SanitizePdfParams,