mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +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):
|
||||
|
||||
Reference in New Issue
Block a user