Pdf comment agent (#6196)

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-05-01 10:19:38 +01:00
committed by GitHub
co-authored by James Brunton
parent 2dc5276e8b
commit 86774d556e
78 changed files with 5091 additions and 112 deletions
+2
View File
@@ -4,6 +4,7 @@ from .execution import ExecutionPlanningAgent
from .orchestrator import OrchestratorAgent
from .pdf_edit import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
from .pdf_questions import PdfQuestionAgent
from .pdf_review import PdfReviewAgent
from .user_spec import UserSpecAgent
__all__ = [
@@ -13,5 +14,6 @@ __all__ = [
"PdfEditParameterSelector",
"PdfEditPlanSelection",
"PdfQuestionAgent",
"PdfReviewAgent",
"UserSpecAgent",
]
+8 -1
View File
@@ -1,6 +1,13 @@
from __future__ import annotations
from stirling.contracts import ExtractedFileText
from stirling.contracts import ExtractedFileText, ExtractedTextArtifact, OrchestratorRequest
def get_extracted_text_artifact(request: OrchestratorRequest) -> ExtractedTextArtifact | None:
for artifact in request.artifacts:
if isinstance(artifact, ExtractedTextArtifact):
return artifact
return None
def has_page_text(page_text: list[ExtractedFileText]) -> bool:
@@ -0,0 +1,79 @@
"""
Math-auditor presentation helpers.
Used by ``PdfQuestionAgent`` and ``PdfReviewAgent`` to (a) decide whether
a request needs the math auditor at all, and (b) pull a Verdict back out
of the resume-turn artifacts.
Intent classification is language-agnostic — a small LLM call rather than
an English regex — so a request like "vérifiez les totaux" routes to the
math path the same as "check the totals".
"""
from __future__ import annotations
from pydantic import Field
from pydantic_ai import Agent
from stirling.contracts import (
MathAuditorToolReportArtifact,
OrchestratorRequest,
Verdict,
)
from stirling.models import ApiModel
from stirling.services import AppRuntime
def extract_math_verdict(request: OrchestratorRequest) -> Verdict | None:
"""Find a math-auditor Verdict in the request's artifacts, if any.
Meta-agents call this on resume to detect whether the specialist has
already run. The Verdict is already type-validated by the time it lands
in :class:`MathAuditorToolReportArtifact` — pydantic rejected the whole
request earlier if the payload was malformed.
"""
for artifact in request.artifacts:
if isinstance(artifact, MathAuditorToolReportArtifact):
return artifact.report
return None
_MATH_INTENT_SYSTEM_PROMPT = (
"Decide whether the user's prompt is asking for verification of "
"numerical content — math correctness, audit, recalculation, totals, "
"sums, percentages, balances, arithmetic, or financial figures. "
"Set is_math=true if so, otherwise false. Decide from the meaning of "
"the prompt, not specific keywords; the prompt may be in any language."
)
class _MathIntentDecision(ApiModel):
is_math: bool = Field(
description=(
"True if the prompt is about verifying numerical content "
"(math, audit, calculations, totals, percentages, etc.)."
),
)
class MathIntentClassifier:
"""Tiny LLM classifier that returns whether a prompt needs the math auditor.
Shared between ``PdfQuestionAgent`` and ``PdfReviewAgent`` so both delegates
use the same decision shape and prompt. One agent instance per consumer
(cheap; matches the existing pattern of per-request agent construction).
"""
def __init__(self, runtime: AppRuntime) -> None:
self._agent: Agent[None, _MathIntentDecision] = Agent(
model=runtime.fast_model,
output_type=_MathIntentDecision,
system_prompt=_MATH_INTENT_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
async def classify(self, user_message: str) -> bool:
if not user_message:
return False
result = await self._agent.run(user_message)
return result.output.is_math
+27 -52
View File
@@ -10,24 +10,20 @@ from pydantic_ai.tools import RunContext
from stirling.agents.pdf_edit import PdfEditAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
from stirling.agents.pdf_review import PdfReviewAgent
from stirling.agents.user_spec import UserSpecAgent
from stirling.contracts import (
AgentDraftRequest,
AgentDraftWorkflowResponse,
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
PdfEditRequest,
PdfEditResponse,
PdfQuestionRequest,
PdfQuestionResponse,
SupportedCapability,
ToolOperationStep,
UnsupportedCapabilityResponse,
format_conversation_history,
)
from stirling.contracts.pdf_edit import EditPlanResponse
from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
@@ -61,11 +57,14 @@ class OrchestratorAgent:
description="Delegate requests to create or revise a user agent spec and return the draft result.",
),
ToolOutput(
self.math_auditor_agent,
name="math_auditor_agent",
self.delegate_pdf_review,
name="delegate_pdf_review",
description=(
"Delegate requests to check arithmetic, validate table totals, "
"audit financial calculations, or verify mathematical accuracy in PDFs."
"Delegate requests to review a PDF and leave review comments, notes, or"
" sticky-note annotations on the document itself. Use this when the user"
" wants the PDF returned with comments attached (e.g. 'review this',"
" 'add review comments', 'flag unclear sentences', 'annotate with"
" feedback')."
),
),
ToolOutput(
@@ -81,8 +80,9 @@ class OrchestratorAgent:
"Use delegate_pdf_edit for requested modifications of single or multiple PDFs. "
"Use delegate_pdf_question for questions about PDF contents. "
"Use delegate_user_spec for requests to create or define an agent spec. "
"Use math_auditor_agent for requests to check arithmetic, validate "
"table totals, audit financial calculations, or verify math in PDFs. "
"Use delegate_pdf_review when the user wants the PDF returned with review"
" comments attached — anything like 'review this', 'annotate with comments',"
" 'leave feedback on the PDF'. "
"Use unsupported_capability only when none of the other outputs fit."
),
model_settings=runtime.fast_model_settings,
@@ -106,10 +106,17 @@ class OrchestratorAgent:
return result.output
async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse:
"""Fast-path to get back to the correct endpoint without having to call AI."""
"""Fast-path to get back to the correct endpoint without having to call AI.
Also the entry point for the *multi-turn* flow where a delegate emits a plan with
``resume_with`` set — Java runs the plan, captures any tool reports as artifacts, and
re-enters via this method so the delegate can digest the reports.
"""
match capability:
case SupportedCapability.PDF_QUESTION:
return await self._run_pdf_question(request)
case SupportedCapability.PDF_REVIEW:
return await self._run_pdf_review(request)
case SupportedCapability.PDF_EDIT:
return await self._run_pdf_edit(request)
case SupportedCapability.AGENT_DRAFT:
@@ -128,51 +135,25 @@ class OrchestratorAgent:
return await self._run_pdf_edit(ctx.deps.request)
async def _run_pdf_edit(self, request: OrchestratorRequest) -> PdfEditResponse:
extracted_text = self._get_extracted_text_artifact(request)
return await PdfEditAgent(self.runtime).handle(
PdfEditRequest(
user_message=request.user_message,
file_names=request.file_names,
conversation_history=request.conversation_history,
page_text=extracted_text.files if extracted_text is not None else [],
)
)
return await PdfEditAgent(self.runtime).orchestrate(request)
async def delegate_pdf_question(self, ctx: RunContext[OrchestratorDeps]) -> PdfQuestionResponse:
return await self._run_pdf_question(ctx.deps.request)
async def _run_pdf_question(self, request: OrchestratorRequest) -> PdfQuestionResponse:
extracted_text = self._get_extracted_text_artifact(request)
return await PdfQuestionAgent(self.runtime).handle(
PdfQuestionRequest(
question=request.user_message,
file_names=request.file_names,
page_text=extracted_text.files if extracted_text is not None else [],
conversation_history=request.conversation_history,
)
)
return await PdfQuestionAgent(self.runtime).orchestrate(request)
async def delegate_user_spec(self, ctx: RunContext[OrchestratorDeps]) -> AgentDraftWorkflowResponse:
return await self._run_agent_draft(ctx.deps.request)
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
return await UserSpecAgent(self.runtime).draft(
AgentDraftRequest(
user_message=request.user_message,
conversation_history=request.conversation_history,
)
)
return await UserSpecAgent(self.runtime).orchestrate(request)
async def math_auditor_agent(self, ctx: RunContext[OrchestratorDeps]) -> EditPlanResponse:
return EditPlanResponse(
summary="Validate mathematical calculations in the document.",
steps=[
ToolOperationStep(
tool=AgentToolId.MATH_AUDITOR_AGENT,
parameters=MathAuditorAgentParams(),
)
],
)
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> EditPlanResponse:
return await self._run_pdf_review(ctx.deps.request)
async def _run_pdf_review(self, request: OrchestratorRequest) -> EditPlanResponse:
return await PdfReviewAgent(self.runtime).orchestrate(request)
async def unsupported_capability(
self,
@@ -182,12 +163,6 @@ class OrchestratorAgent:
) -> UnsupportedCapabilityResponse:
return UnsupportedCapabilityResponse(capability=capability, message=message)
def _get_extracted_text_artifact(self, request: OrchestratorRequest) -> ExtractedTextArtifact | None:
for artifact in request.artifacts:
if isinstance(artifact, ExtractedTextArtifact):
return artifact
return None
def _build_prompt(self, request: OrchestratorRequest) -> str:
artifact_summary = self._describe_artifacts(request)
file_names = ", ".join(request.file_names) if request.file_names else "Unknown files"
@@ -0,0 +1,5 @@
"""PDF Comment Agent (pdfCommentAgent) — AI-powered review comments for PDFs."""
from .agent import PdfCommentAgent
__all__ = ["PdfCommentAgent"]
@@ -0,0 +1,196 @@
"""
PDF Comment Agent (pdfCommentAgent) — pydantic-ai agent for review comments.
Given a list of positioned text chunks extracted by Java and a user prompt,
the agent selects chunks worth commenting on and returns concise review
comments. Java then applies the actual PDF sticky-note annotations using
the chunk bounding boxes it already holds; the agent never sees the PDF.
The model only fills in fields it's well-suited to fill: a chunk ordinal
(a small bounded int) and the comment text. All non-LLM fields (the real
``chunk_id`` echoed back to Java) are filled in by Python after the call,
so the LLM has no opportunity to hallucinate opaque string identifiers.
"""
from __future__ import annotations
import json
import logging
from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.pdf_comment.prompts import COMMENT_AGENT_SYSTEM_PROMPT
from stirling.contracts.pdf_comments import (
MAX_COMMENT_TEXT_LENGTH,
PdfCommentInstruction,
PdfCommentRequest,
PdfCommentResponse,
TextChunk,
)
from stirling.logging import Pretty
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
class LlmCommentInstruction(ApiModel):
"""LLM-facing comment shape — only fields the model is well-suited to fill.
``chunk_index`` is the ordinal of the chunk in the input list (0-based).
Bounds are sanity-checked in agent code after the call; an ordinal is
structurally much harder to hallucinate than the opaque ``chunk_id``
string used on the Java-facing contract.
"""
chunk_index: int = Field(
ge=0,
description="0-based index of the chunk in the input list this comment anchors to.",
)
comment_text: str = Field(
min_length=1,
max_length=MAX_COMMENT_TEXT_LENGTH,
description="The comment body shown in the sticky-note popup. One or two sentences.",
)
author: str | None = Field(default=None, max_length=128)
subject: str | None = Field(default=None, max_length=256)
class LlmCommentOutput(ApiModel):
"""Structured output the LLM returns. Translated to ``PdfCommentResponse``
by the agent before reaching Java.
"""
comments: list[LlmCommentInstruction] = Field(default_factory=list)
rationale: str = Field(max_length=1_000)
class PdfCommentAgent:
"""Encapsulates the single-shot PDF comment generation pipeline.
Instantiated once at app startup with an :class:`AppRuntime`, which
provides the pre-built fast model and model settings.
"""
def __init__(self, runtime: AppRuntime) -> None:
self._runtime = runtime
self._agent = Agent(
model=runtime.fast_model,
output_type=LlmCommentOutput,
system_prompt=COMMENT_AGENT_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
async def generate(self, request: PdfCommentRequest) -> PdfCommentResponse:
"""Run the agent against a ``PdfCommentRequest`` and return comments.
Short-circuits with an empty response when the input has no chunks.
Any out-of-range ``chunk_index`` returned by the model is dropped
(this should be vanishingly rare given the bounded int surface).
Agent failures propagate to the caller (FastAPI translates to HTTP
5xx) rather than being silently swallowed; callers need to know
when the agent failed.
"""
session_id = request.session_id
logger.info(
"[pdf-comment-agent] session=%s generating comments for %d chunks",
session_id,
len(request.chunks),
)
logger.debug(
"REQUEST (pdf-comment-agent generate)\n%s",
Pretty(
{
"session_id": session_id,
"user_message": request.user_message,
"chunk_count": len(request.chunks),
}
),
)
if not request.chunks:
logger.debug(
"[pdf-comment-agent] session=%s no chunks; skipping agent call",
session_id,
)
return PdfCommentResponse(
session_id=session_id,
comments=[],
rationale="No text chunks were provided; no comments generated.",
)
prompt = self._build_prompt(request)
result = await self._agent.run(prompt)
output = result.output
comments = self._map_to_instructions(request.chunks, output.comments, session_id)
response = PdfCommentResponse(
session_id=session_id,
comments=comments,
rationale=output.rationale,
)
logger.debug(
"RESPONSE (pdf-comment-agent generate)\n%s",
Pretty(response),
)
return response
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _build_prompt(request: PdfCommentRequest) -> str:
"""Build a structured prompt with chunks listed by ordinal index.
Both the user's free-text prompt and each chunk's text are JSON-
encoded so any quotes, newlines, or stray delimiters in attacker-
influenced content (the user message or PDF-derived chunks) are
escaped and cannot break out of the prompt structure.
"""
lines: list[str] = [
"User prompt (JSON-encoded, untrusted input):",
json.dumps(request.user_message),
"",
f"Chunks ({len(request.chunks)} total). Each line shows the chunk index",
"you must return on `chunk_index`, the 1-indexed page number, and the",
"JSON-encoded text content.",
"",
]
for index, chunk in enumerate(request.chunks):
lines.append(f"[{index}] page={chunk.page + 1} text={json.dumps(chunk.text)}")
return "\n".join(lines)
@staticmethod
def _map_to_instructions(
chunks: list[TextChunk],
llm_comments: list[LlmCommentInstruction],
session_id: str,
) -> list[PdfCommentInstruction]:
"""Translate LLM ordinal-based output into the Java-facing contract,
dropping any out-of-range ordinals as a defence-in-depth guard.
"""
kept: list[PdfCommentInstruction] = []
dropped: list[int] = []
for comment in llm_comments:
if 0 <= comment.chunk_index < len(chunks):
kept.append(
PdfCommentInstruction(
chunk_id=chunks[comment.chunk_index].id,
comment_text=comment.comment_text,
author=comment.author,
subject=comment.subject,
)
)
else:
dropped.append(comment.chunk_index)
if dropped:
logger.warning(
"[pdf-comment-agent] session=%s dropped %d comment(s) with out-of-range chunk_index: %s",
session_id,
len(dropped),
dropped,
)
return kept
@@ -0,0 +1,30 @@
"""
PDF Comment Agent — system prompts.
Kept in a separate module so the prompt text can be reviewed and tuned
without touching agent wiring, mirroring the Ledger Auditor layout.
"""
COMMENT_AGENT_SYSTEM_PROMPT = """\
You are a document review assistant.
You receive (a) a user prompt describing what review comments are wanted and \
(b) a list of text chunks extracted from a PDF. Each chunk is shown with a \
0-based index in square brackets, a 1-indexed page number, and the JSON- \
encoded text content. Your job is to select the chunks that warrant a \
comment and produce one concise remark per chunk.
Rules:
- Every `chunk_index` you return MUST be the 0-based index of a chunk shown \
in the input (the number in square brackets). Indices outside the visible \
range are dropped.
- Each comment must directly address the user's prompt. If no chunk is \
relevant, return an empty `comments` list.
- Prefer one comment per distinct idea — do not duplicate or chain comments \
about the same content, and do not split a single thought across chunks.
- Keep `comment_text` short (one or two sentences, plain text).
- Return at most 20 comments unless the user's prompt explicitly asks for an \
exhaustive review.
- Populate `rationale` with one sentence describing your overall approach \
for traceability in server logs.
"""
+18 -1
View File
@@ -7,13 +7,14 @@ from pydantic import Field
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents._page_text import format_page_text, has_page_text
from stirling.agents._page_text import format_page_text, get_extracted_text_artifact, has_page_text
from stirling.contracts import (
EditCannotDoResponse,
EditClarificationRequest,
EditPlanResponse,
NeedContentFileRequest,
NeedContentResponse,
OrchestratorRequest,
PdfContentType,
PdfEditRequest,
PdfEditResponse,
@@ -142,6 +143,22 @@ class PdfEditAgent:
self.supported_operations = list(OPERATIONS)
self.parameter_selector = PdfEditParameterSelector(runtime)
async def orchestrate(self, request: OrchestratorRequest) -> PdfEditResponse:
"""Entry point for the orchestrator delegate — adapts the orchestrator's
request shape into a :class:`PdfEditRequest` and runs the standard
:meth:`handle` pipeline. Direct API callers continue to use ``handle``
with a typed :class:`PdfEditRequest`.
"""
extracted_text = get_extracted_text_artifact(request)
return await self.handle(
PdfEditRequest(
user_message=request.user_message,
file_names=request.file_names,
conversation_history=request.conversation_history,
page_text=extracted_text.files if extracted_text is not None else [],
)
)
@overload
async def handle(self, request: PdfEditRequest, allow_need_content: Literal[False]) -> PdfEditTerminalResponse: ...
@overload
+85 -2
View File
@@ -3,20 +3,40 @@ from __future__ import annotations
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents._page_text import format_page_text, has_page_text
from stirling.agents._page_text import (
format_page_text,
get_extracted_text_artifact,
has_page_text,
)
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
from stirling.contracts import (
EditPlanResponse,
NeedContentFileRequest,
NeedContentResponse,
OrchestratorRequest,
PdfContentType,
PdfQuestionAnswerResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
PdfQuestionResponse,
SupportedCapability,
ToolOperationStep,
Verdict,
format_conversation_history,
)
from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams
from stirling.services import AppRuntime
_MATH_SYNTH_SYSTEM_PROMPT = (
"You are given a math-audit Verdict (structured JSON) and the user's "
"original question. Answer the question in plain prose using only "
"facts from the Verdict; do not invent figures or pages. "
"Reply in the SAME LANGUAGE as the user's question. Keep the answer "
"concise — a sentence or short paragraph. "
"Quote any stated/expected numeric values from the Verdict verbatim — "
"do not paraphrase, abbreviate, or convert units."
)
class PdfQuestionAgent:
def __init__(self, runtime: AppRuntime) -> None:
@@ -34,12 +54,20 @@ class PdfQuestionAgent:
"Answer questions about PDFs using only the extracted page text provided in the prompt. "
"Do not guess or use outside knowledge. "
"If the answer is not supported by the provided text, return not_found. "
"When answering, include a short list of evidence snippets with their page numbers."
"When answering, include a short list of evidence snippets with their page numbers. "
"Reply in the SAME LANGUAGE as the question."
),
instructions=rag.instructions,
toolsets=[rag.toolset],
model_settings=runtime.smart_model_settings,
)
self._math_synth_agent: Agent[None, str] = Agent(
model=runtime.fast_model,
output_type=str,
system_prompt=_MATH_SYNTH_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
self._math_intent_classifier = MathIntentClassifier(runtime)
async def handle(self, request: PdfQuestionRequest) -> PdfQuestionResponse:
if not has_page_text(request.page_text):
@@ -58,10 +86,65 @@ class PdfQuestionAgent:
)
return await self._run_answer_agent(request)
async def orchestrate(self, request: OrchestratorRequest) -> PdfQuestionResponse:
"""Entry point for the orchestrator delegate.
Decides math intent locally via a small classifier LLM (language-agnostic).
On a math first turn, embeds an :class:`EditPlanResponse` in the answer
response; on the resume turn, digests the captured :class:`Verdict` into
a localised prose answer. Non-math first turns fall through to the
text-grounded :meth:`handle` pipeline.
"""
verdict = extract_math_verdict(request)
if verdict is not None:
# Resume turn — Verdict in hand. Synthesise a localised answer from
# the structured verdict via a small LLM that mirrors the user's
# language; no English glue in the response.
answer = await self._synthesise_math_answer(request.user_message, verdict)
return PdfQuestionAnswerResponse(answer=answer, evidence=[])
if await self._math_intent_classifier.classify(request.user_message):
# First turn — ask the caller to run the math specialist and come back.
# The plan rides on the answer response as a nullable member; ``answer``
# is empty on this turn and the caller resumes once the plan is run.
return PdfQuestionAnswerResponse(
answer="",
evidence=[],
edit_plan=EditPlanResponse(
summary="",
steps=[
ToolOperationStep(
tool=AgentToolId.MATH_AUDITOR_AGENT,
parameters=MathAuditorAgentParams(),
)
],
resume_with=SupportedCapability.PDF_QUESTION,
),
)
extracted_text = get_extracted_text_artifact(request)
return await self.handle(
PdfQuestionRequest(
question=request.user_message,
file_names=request.file_names,
page_text=extracted_text.files if extracted_text is not None else [],
conversation_history=request.conversation_history,
)
)
async def _run_answer_agent(self, request: PdfQuestionRequest) -> PdfQuestionResponse:
result = await self.agent.run(self._build_prompt(request))
return result.output
async def _synthesise_math_answer(self, user_message: str, verdict: Verdict) -> str:
"""Use a small LLM to render the structured Verdict as a natural-language
answer in the same language as the user's question. The system prompt
forbids invented figures; the LLM only restates Verdict facts.
"""
prompt = f"User question:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
result = await self._math_synth_agent.run(prompt)
return result.output
def _build_prompt(self, request: PdfQuestionRequest) -> str:
file_names = ", ".join(request.file_names) if request.file_names else "Unknown files"
pages = format_page_text(request.page_text, empty="")
+173
View File
@@ -0,0 +1,173 @@
"""PDF review delegate.
Produces an annotated PDF with review comments. Math-flavoured prompts
consult the math-auditor specialist first (via a plan + resume) and then
project the :class:`Verdict` into sticky-note specs for ``add-comments``.
Other review prompts route to the composed ``pdf-comment-agent`` tool,
which does its own chunk extraction + AI round-trip.
Sticky-note text is produced by a small LLM that reads the structured
Verdict and the user's original prompt and writes comments in the SAME
LANGUAGE as the prompt. Bounding-box placement is deterministic Python.
"""
from __future__ import annotations
import json
from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
from stirling.contracts import (
CommentSpec,
EditPlanResponse,
OrchestratorRequest,
SupportedCapability,
ToolOperationStep,
Verdict,
)
from stirling.contracts.ledger import Discrepancy
from stirling.models import ApiModel, ToolEndpoint
from stirling.models.agent_tool_models import (
AgentToolId,
MathAuditorAgentParams,
PdfCommentAgentParams,
)
from stirling.models.tool_models import AddCommentsParams
from stirling.services import AppRuntime
# Fallback right-margin placement used when a discrepancy has no usable
# anchor text. A4/Letter portrait assumed.
_ICON_X = 520.0
_ICON_Y_TOP = 770.0
_ICON_Y_STRIDE = 28.0
_ICON_SIZE = 20.0
_DEFAULT_AUTHOR = "Stirling Math Auditor"
_LOCALISER_SYSTEM_PROMPT = (
"You are given a math-audit Verdict (structured JSON) and the user's "
"original review request. Produce one sticky-note entry per Discrepancy "
"the user would care about. Each entry carries the discrepancy's index "
"in the input list, a short subject (a few words), and a body of one or "
"two sentences. Reply in the SAME LANGUAGE as the user's request. Do "
"not invent figures; only restate what the Verdict already says. "
"When a Discrepancy carries `stated` or `expected` values, quote them "
"verbatim in the comment body — do not paraphrase, abbreviate, or "
"convert units."
)
class _LocalisedComment(ApiModel):
discrepancy_index: int = Field(ge=0, description="0-based index of the Discrepancy in verdict.discrepancies.")
subject: str = Field(min_length=1, max_length=256)
text: str = Field(min_length=1, max_length=2_000)
class _LocalisedVerdict(ApiModel):
comments: list[_LocalisedComment] = Field(default_factory=list)
class PdfReviewAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self._localiser_agent: Agent[None, _LocalisedVerdict] = Agent(
model=runtime.fast_model,
output_type=_LocalisedVerdict,
system_prompt=_LOCALISER_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
self._math_intent_classifier = MathIntentClassifier(runtime)
async def orchestrate(self, request: OrchestratorRequest) -> EditPlanResponse:
"""Entry point for the orchestrator delegate.
Decides math intent locally via a small classifier LLM (language-agnostic).
On a math first turn, emits a plan to consult the math auditor; on the
resume turn, projects the captured :class:`Verdict` into localised
sticky-note specs. Non-math review prompts route to the composed
``pdf-comment-agent`` tool for prose review.
"""
verdict = extract_math_verdict(request)
if verdict is not None:
comments_json = await self._build_localised_comments_payload(request.user_message, verdict)
return EditPlanResponse(
summary="",
steps=[
ToolOperationStep(
tool=ToolEndpoint.ADD_COMMENTS,
parameters=AddCommentsParams(comments=comments_json),
)
],
)
if await self._math_intent_classifier.classify(request.user_message):
return EditPlanResponse(
summary="",
steps=[
ToolOperationStep(
tool=AgentToolId.MATH_AUDITOR_AGENT,
parameters=MathAuditorAgentParams(),
)
],
resume_with=SupportedCapability.PDF_REVIEW,
)
return EditPlanResponse(
summary="",
steps=[
ToolOperationStep(
tool=AgentToolId.PDF_COMMENT_AGENT,
parameters=PdfCommentAgentParams(prompt=request.user_message),
)
],
)
async def _build_localised_comments_payload(self, user_message: str, verdict: Verdict) -> str:
"""Run the localiser LLM, then combine its output with deterministic
placement geometry to produce the JSON the ``add-comments`` tool wants.
"""
prompt = f"User review request:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
result = await self._localiser_agent.run(prompt)
specs = self._build_comment_specs(verdict, result.output.comments)
serialised = [spec.model_dump(by_alias=True, exclude_none=True) for spec in specs]
return json.dumps(serialised)
@staticmethod
def _build_comment_specs(verdict: Verdict, localised: list[_LocalisedComment]) -> list[CommentSpec]:
"""Fuse LLM-localised text with deterministic position geometry.
Out-of-range ordinals are dropped (defence-in-depth: the LLM's index
is bounds-checked at validation but we re-check here too).
"""
specs: list[CommentSpec] = []
per_page_index: dict[int, int] = {}
for comment in localised:
if comment.discrepancy_index >= len(verdict.discrepancies):
continue
d = verdict.discrepancies[comment.discrepancy_index]
stack_index = per_page_index.get(d.page, 0)
per_page_index[d.page] = stack_index + 1
y = _ICON_Y_TOP - stack_index * _ICON_Y_STRIDE
specs.append(
CommentSpec(
page_index=d.page,
x=_ICON_X,
y=y,
width=_ICON_SIZE,
height=_ICON_SIZE,
text=comment.text,
author=_DEFAULT_AUTHOR,
subject=comment.subject,
anchor_text=_anchor_text_for(d),
)
)
return specs
def _anchor_text_for(d: Discrepancy) -> str | None:
stated = d.stated.strip()
if stated:
return stated
return d.context.strip() or None
+13
View File
@@ -15,6 +15,7 @@ from stirling.contracts import (
AiToolAgentStep,
ConversationMessage,
EditPlanResponse,
OrchestratorRequest,
PdfEditRequest,
PdfEditTerminalResponse,
format_conversation_history,
@@ -44,6 +45,18 @@ class UserSpecAgent:
model_settings=runtime.smart_model_settings,
)
async def orchestrate(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
"""Entry point for the orchestrator delegate — adapts the orchestrator's
request shape into an :class:`AgentDraftRequest` and runs the standard
:meth:`draft` pipeline.
"""
return await self.draft(
AgentDraftRequest(
user_message=request.user_message,
conversation_history=request.conversation_history,
)
)
async def draft(self, request: AgentDraftRequest) -> AgentDraftWorkflowResponse:
edit_plan = await self._build_edit_plan(request.user_message, request.conversation_history)
if not isinstance(edit_plan, EditPlanResponse):