Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package stirling.software.proprietary.controller.api;

import java.io.IOException;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import stirling.software.proprietary.model.api.ai.contradiction.ContradictionVerdict;
import stirling.software.proprietary.service.AiToolInputValidator;
import stirling.software.proprietary.service.ContradictionAgentOrchestrator;

/**
* Public entry point for the Contradiction Agent (contradictionAgent).
*
* <p>Accepts a PDF from the client, hands it to the {@link ContradictionAgentOrchestrator} which
* runs the multi-round Java-Python negotiation, and returns the agent's {@link
* ContradictionVerdict} as JSON.
*
* <p>This endpoint is a pure specialist — it produces the structured finding and nothing more.
* Presentation (rendering as a chat answer, projecting to PDF comments, etc.) is the responsibility
* of the caller (e.g. the orchestrator's {@code delegate_pdf_question} or {@code
* delegate_pdf_review} meta-agents).
*
* <p>Lives under {@code /api/v1/ai/tools/} so it is dispatchable by the AI orchestrator via the
* standard {@code InternalApiClient} allowlist — no special-case plumbing needed.
*
* <p>Scope is purely <strong>textual</strong>: arguments, claimed facts, recommendations, and
* stated positions that conflict with one another across the document. Numeric arithmetic is
* handled by the separate Math Auditor Agent.
*
* <p>The raw PDF never leaves Java. Python receives only structured text data.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/ai/tools")
@RequiredArgsConstructor
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class ContradictionAgentController {

private final ContradictionAgentOrchestrator orchestrator;

@PostMapping(value = "/contradiction-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Detect textual contradictions in a PDF",
description =
"""
Analyses a PDF document for textual contradictions using the Contradiction
Agent.

The agent looks for:
- Conflicting factual claims about the same entity or topic
- Opposing recommendations or stances (approve vs reject, etc.)
- Inconsistent attribute claims across pages
- Cross-page tension between arguments and points of view

Numeric / arithmetic errors are out of scope — those are handled by the
separate Math Auditor Agent.

Returns a JSON ContradictionVerdict describing every conflict found, each
anchored to two verbatim quotes (one per conflicting page). How the verdict is
presented to the end user (chat answer, paired sticky-note comments, etc.) is
up to the caller.

Input: PDF Output: JSON Type: SISO
""")
public ResponseEntity<ContradictionVerdict> contradictionAgent(
@Parameter(description = "The PDF document to audit", required = true)
@RequestParam("fileInput")
MultipartFile fileInput) {

AiToolInputValidator.validatePdfUpload(fileInput);

String safeName =
fileInput.getOriginalFilename() != null
? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_")
: "<unnamed>";
log.info("[contradiction-agent] request file={}", safeName);

try {
ContradictionVerdict verdict = orchestrator.audit(fileInput);
return ResponseEntity.ok(verdict);
} catch (IOException e) {
log.error("[contradiction-agent] IO error during audit", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package stirling.software.proprietary.model.api.ai.contradiction;

/**
* A single atomic factual claim, recommendation, or position extracted from one PDF page by the
* Python Contradiction Agent.
*
* <p>Java counterpart of the Python {@code Claim} model in {@code contracts/contradiction.py};
* field names mirror the Python {@code ApiModel} camelCase serialisation.
*
* @param page 0-indexed page number where the claim appears.
* @param text Paraphrased atomic claim.
* @param subject The entity / topic the claim is about (used for canonicalised bucketing).
* @param polarity One of the values defined in {@link ClaimPolarity}; rejects unknown values on
* deserialisation so a Python-side literal expansion surfaces early instead of silently
* drifting through the wire.
* @param quote Verbatim quote from the page (≤200 chars), used as the comment anchor.
*/
public record Claim(int page, String text, String subject, ClaimPolarity polarity, String quote) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package stirling.software.proprietary.model.api.ai.contradiction;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

/**
* Polarity of a {@link Claim} extracted by the Contradiction Agent.
*
* <p>Java counterpart: the {@code polarity} {@code Literal} in {@code Claim} in {@code
* contracts/contradiction.py} — values must stay in sync. Adding a new polarity is a coordinated
* cross-language change: Python's {@code Literal} rejects unknown values on parse, and so does this
* enum (via {@link #fromJson}). Old clients carrying a verdict with a new polarity through a resume
* artifact will fail validation early — that is the intended behaviour, so failures surface at the
* boundary instead of silently drifting.
*/
public enum ClaimPolarity {
ASSERT,
DENY,
RECOMMEND,
REJECT,
NEUTRAL;

@JsonValue
public String toJson() {
return name().toLowerCase();
}

@JsonCreator
public static ClaimPolarity fromJson(String value) {
if (value == null) {
throw new IllegalArgumentException("ClaimPolarity value cannot be null");
}
return ClaimPolarity.valueOf(value.toUpperCase());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package stirling.software.proprietary.model.api.ai.contradiction;

/**
* A single textual contradiction found by the Python Contradiction Agent.
*
* <p>Two {@link Claim}s about the same canonical {@code subject} that cannot both be true. The
* derived {@link #page1()} and {@link #page2()} accessors expose each claim's page for convenience.
*
* <p>Java counterpart of the Python {@code Contradiction} model in {@code
* contracts/contradiction.py}; field names mirror the Python {@code ApiModel} camelCase
* serialisation.
*
* @param subject Canonicalised subject the two claims share.
* @param claim1 First claim (typically lower page number).
* @param claim2 Second claim.
* @param explanation One-sentence explanation of why the two claims conflict.
* @param severity {@code ERROR} for definite conflict, {@code WARNING} for plausible tension.
*/
public record Contradiction(
String subject,
Claim claim1,
Claim claim2,
String explanation,
ContradictionSeverity severity) {

// page1/page2 mirror Python's sorted invariant (min/max of the two claim
// pages) so callers can rely on page1 <= page2 regardless of which Claim
// sits in the claim1/claim2 slot. See contracts/contradiction.py.

/** Lower of the two claim pages (0-indexed). */
public int page1() {
return Math.min(claim1.page(), claim2.page());
}

/** Higher of the two claim pages (0-indexed). */
public int page2() {
return Math.max(claim1.page(), claim2.page());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package stirling.software.proprietary.model.api.ai.contradiction;

import com.fasterxml.jackson.annotation.JsonValue;

/**
* Severity of a textual contradiction found by the Contradiction Agent.
*
* <p>Java counterpart: {@code ContradictionSeverity} in {@code contracts/contradiction.py} — values
* must stay in sync.
*/
public enum ContradictionSeverity {
ERROR,
WARNING;

@JsonValue
public String toJson() {
return name().toLowerCase();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package stirling.software.proprietary.model.api.ai.contradiction;

import java.util.List;

/**
* The Contradiction Agent's final opinion on the document's textual self-consistency.
*
* <p>This is the terminal message in the contradiction-audit negotiation; Java returns it to the
* client once received from Python.
*
* <p>Java counterpart of the Python {@code ContradictionVerdict} model in {@code
* contracts/contradiction.py}; field names mirror the Python {@code ApiModel} camelCase
* serialisation.
*
* @param type Discriminator — always {@code "contradiction_verdict"}.
* @param sessionId Matches the session opened by the original client request.
* @param contradictions Every textual contradiction found, sorted by {@code (page1, page2)}.
* @param pagesExamined 0-indexed page numbers the agent actually inspected.
* @param roundsTaken How many negotiation rounds were needed (1–3).
* @param summary One or two sentences suitable for the end user.
* @param clean {@code true} iff no errors were found (warnings are tolerated).
* @param unauditablePages Pages that could not be examined — typically image-only pages for which
* OCR was requested but is not yet wired. The client should indicate that these pages were not
* checked.
*/
public record ContradictionVerdict(
String type,
String sessionId,
List<Contradiction> contradictions,
List<Integer> pagesExamined,
int roundsTaken,
String summary,
boolean clean,
List<Integer> unauditablePages) {

public long errorCount() {
return contradictions == null
? 0
: contradictions.stream()
.filter(c -> c.severity() == ContradictionSeverity.ERROR)
.count();
}

public long warningCount() {
return contradictions == null
? 0
: contradictions.stream()
.filter(c -> c.severity() == ContradictionSeverity.WARNING)
.count();
}
}
Loading
Loading