mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Pdf comment agent (#6196)
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
2dc5276e8b
commit
86774d556e
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
)
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user