mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
30
Commits
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.10.0
|
||||
pkgver=2.10.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.10.0
|
||||
pkgver=2.10.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -48,6 +48,81 @@ updates:
|
||||
cooldown:
|
||||
default-days: 7
|
||||
rebase-strategy: "auto"
|
||||
groups:
|
||||
embedpdf:
|
||||
patterns:
|
||||
- "@embedpdf/*"
|
||||
mantine:
|
||||
patterns:
|
||||
- "@mantine/*"
|
||||
- "postcss-preset-mantine"
|
||||
mui:
|
||||
patterns:
|
||||
- "@mui/*"
|
||||
tauri-js:
|
||||
patterns:
|
||||
- "@tauri-apps/*"
|
||||
emotion:
|
||||
patterns:
|
||||
- "@emotion/*"
|
||||
react:
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
typescript-eslint:
|
||||
patterns:
|
||||
- "@typescript-eslint/*"
|
||||
- "typescript-eslint"
|
||||
eslint:
|
||||
patterns:
|
||||
- "eslint"
|
||||
- "@eslint/*"
|
||||
vite:
|
||||
patterns:
|
||||
- "vite"
|
||||
- "vite-*"
|
||||
- "@vitejs/*"
|
||||
vitest:
|
||||
patterns:
|
||||
- "vitest"
|
||||
- "@vitest/*"
|
||||
testing-library:
|
||||
patterns:
|
||||
- "@testing-library/*"
|
||||
i18next:
|
||||
patterns:
|
||||
- "i18next"
|
||||
- "i18next-*"
|
||||
- "react-i18next"
|
||||
iconify:
|
||||
patterns:
|
||||
- "@iconify/*"
|
||||
- "@iconify-json/*"
|
||||
stripe:
|
||||
patterns:
|
||||
- "@stripe/*"
|
||||
posthog:
|
||||
patterns:
|
||||
- "@posthog/*"
|
||||
- "posthog-js"
|
||||
supabase:
|
||||
patterns:
|
||||
- "@supabase/*"
|
||||
dnd-kit:
|
||||
patterns:
|
||||
- "@dnd-kit/*"
|
||||
tailwind:
|
||||
patterns:
|
||||
- "tailwindcss"
|
||||
- "@tailwindcss/*"
|
||||
postcss:
|
||||
patterns:
|
||||
- "postcss"
|
||||
- "postcss-*"
|
||||
exclude-patterns:
|
||||
- "postcss-preset-mantine"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directories:
|
||||
@@ -59,6 +134,24 @@ updates:
|
||||
cooldown:
|
||||
default-days: 7
|
||||
rebase-strategy: "auto"
|
||||
groups:
|
||||
tauri:
|
||||
patterns:
|
||||
- "tauri"
|
||||
- "tauri-build"
|
||||
- "tauri-plugin-*"
|
||||
serde:
|
||||
patterns:
|
||||
- "serde"
|
||||
- "serde_*"
|
||||
tracing:
|
||||
patterns:
|
||||
- "tracing"
|
||||
- "tracing-*"
|
||||
tokio:
|
||||
patterns:
|
||||
- "tokio"
|
||||
- "tokio-*"
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /testing/cucumber
|
||||
|
||||
@@ -35,13 +35,13 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Resolve PR info
|
||||
id: resolve
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
|
||||
- name: Add deployment started comment
|
||||
id: deployment-started
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
|
||||
- name: Post V2 deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -376,7 +376,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clean up V2 deployment comments
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
- name: Get PR data
|
||||
id: get-pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.eventName === 'workflow_dispatch'
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Add 'in_progress' reaction to comment
|
||||
if: github.event_name == 'issue_comment'
|
||||
id: add-eyes-reaction
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -383,7 +383,7 @@ jobs:
|
||||
|
||||
- name: Add success reaction to comment
|
||||
if: success() && github.event_name == 'issue_comment'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -418,7 +418,7 @@ jobs:
|
||||
|
||||
- name: Add failure reaction to comment
|
||||
if: failure() && github.event_name == 'issue_comment'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -438,7 +438,7 @@ jobs:
|
||||
|
||||
- name: Post deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -474,7 +474,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -489,7 +489,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Apply label commands
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Remove 'pr-deployed' label if present
|
||||
id: remove-label-comment
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: Comment on fixer failures
|
||||
if: steps.fixer_changes.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- engine-check -->';
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Post comment on PR if needed
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.spotless-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- java-formatting-check -->';
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
stop_backend
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-enterprise-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
proprietary: ${{ steps.changes.outputs.proprietary }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
- name: FAILED - check the licenses for compatibility
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
- name: Get PR data
|
||||
id: get-pr-data
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
|
||||
- name: Determine reference file
|
||||
id: determine-file
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
|
||||
- name: Post comment on PR
|
||||
if: env.SCRIPT_OUTPUT != ''
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
|
||||
- name: Upload Cucumber Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: testing/cucumber/report.html
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: docker-compose-test-reports
|
||||
path: testing/reports/
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -82,14 +82,14 @@ jobs:
|
||||
fi
|
||||
- name: Upload backend log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: backend-log-live-${{ github.run_id }}
|
||||
path: /tmp/backend.log
|
||||
retention-days: 7
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-live-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
|
||||
@@ -14,13 +14,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
run: task frontend:test:e2e -- --project=stubbed --workers=3
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-stubbed-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -316,7 +316,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -369,7 +369,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact on license issues
|
||||
if: env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: backend-dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
@@ -382,7 +382,7 @@ jobs:
|
||||
|
||||
- name: Delete previous backend license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -412,7 +412,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR - Backend License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.frontend-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- frontend-check -->';
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
echo "============================================"
|
||||
exit 1
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -93,18 +93,18 @@ jobs:
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push/release events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-jars:
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -140,13 +140,13 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -218,7 +218,23 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
# x86_64 JDK is set up first so the aarch64 step below can leave its
|
||||
# JAVA_HOME as the active one. The macOS universal JRE build needs
|
||||
# jmods from both arches; the x64 path is captured into the env
|
||||
# before the second setup-java overwrites JAVA_HOME.
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
architecture: "x64"
|
||||
|
||||
- name: Capture x86_64 JAVA_HOME
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -227,13 +243,21 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
# Build the universal JRE before desktop:prepare so the jlink:runtime
|
||||
# task short-circuits on its `test -d runtime/jre` status check.
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
env:
|
||||
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
|
||||
run: task desktop:jlink:universal-mac
|
||||
|
||||
- name: Prepare desktop build
|
||||
run: task desktop:prepare
|
||||
env:
|
||||
@@ -321,7 +345,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -342,7 +366,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -557,7 +581,7 @@ jobs:
|
||||
# Only ship the MSI installer on Windows. The loose exe and WiX toolset exes
|
||||
# are not the user-facing installer - the MSI contains the signed inner exe.
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
@@ -568,7 +592,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: always() && steps.digicert-setup.conclusion != 'failure'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -582,7 +606,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-nightly-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
|
||||
@@ -22,8 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.info.outputs.version }}
|
||||
dmg_arm64_sha256: ${{ steps.hashes.outputs.dmg_arm64_sha256 }}
|
||||
dmg_x86_64_sha256: ${{ steps.hashes.outputs.dmg_x86_64_sha256 }}
|
||||
dmg_sha256: ${{ steps.hashes.outputs.dmg_sha256 }}
|
||||
msi_sha256: ${{ steps.hashes.outputs.msi_sha256 }}
|
||||
deb_sha256: ${{ steps.hashes.outputs.deb_sha256 }}
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
@@ -63,17 +62,15 @@ jobs:
|
||||
sha256sum "$file" | awk '{print $1}'
|
||||
}
|
||||
|
||||
DMG_ARM64_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-aarch64.dmg")
|
||||
DMG_X64_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-x86_64.dmg")
|
||||
DMG_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-universal.dmg")
|
||||
MSI_SHA=$(download_sha256 "${BASE}/Stirling-PDF-windows-x86_64.msi")
|
||||
DEB_SHA=$(download_sha256 "${BASE}/Stirling-PDF-linux-x86_64.deb")
|
||||
JAR_SHA=$(download_sha256 "${BASE}/Stirling-PDF-with-login.jar")
|
||||
|
||||
echo "dmg_arm64_sha256=$DMG_ARM64_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "dmg_x86_64_sha256=$DMG_X64_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "msi_sha256=$MSI_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_sha256=$DEB_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "dmg_sha256=$DMG_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "msi_sha256=$MSI_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_sha256=$DEB_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
update-homebrew-and-scoop:
|
||||
needs: get-release-info
|
||||
@@ -93,22 +90,21 @@ jobs:
|
||||
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
path: tap
|
||||
|
||||
# Stirling-PDF now ships a single universal DMG, so the Cask should
|
||||
# have one sha256 line rather than separate on_arm/on_intel blocks.
|
||||
# We rewrite every `sha256 "..."` in the Cask to the same value so
|
||||
# the workflow keeps working through the Cask migration: pre-migration
|
||||
# both arch blocks get the universal SHA (still correct, since both
|
||||
# would resolve to the same DMG), post-migration the lone sha256 line
|
||||
# is updated.
|
||||
- name: Update Homebrew cask (Casks/stirling-pdf.rb)
|
||||
env:
|
||||
VERSION: ${{ needs.get-release-info.outputs.version }}
|
||||
ARM64_SHA: ${{ needs.get-release-info.outputs.dmg_arm64_sha256 }}
|
||||
X64_SHA: ${{ needs.get-release-info.outputs.dmg_x86_64_sha256 }}
|
||||
DMG_SHA: ${{ needs.get-release-info.outputs.dmg_sha256 }}
|
||||
run: |
|
||||
CASK="tap/Casks/stirling-pdf.rb"
|
||||
sed -i "s/version \".*\"/version \"${VERSION}\"/" "$CASK"
|
||||
awk -v arm="$ARM64_SHA" -v x64="$X64_SHA" '
|
||||
/on_arm/ { in_arm=1 }
|
||||
/on_intel/ { in_arm=0; in_intel=1 }
|
||||
/end/ { in_arm=0; in_intel=0 }
|
||||
in_arm && /sha256/ { sub(/sha256 ".*"/, "sha256 \"" arm "\"") }
|
||||
in_intel && /sha256/ { sub(/sha256 ".*"/, "sha256 \"" x64 "\"") }
|
||||
{ print }
|
||||
' "$CASK" > tmp && mv tmp "$CASK"
|
||||
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$CASK"
|
||||
sed -i "s/sha256 \"[^\"]*\"/sha256 \"${DMG_SHA}\"/g" "$CASK"
|
||||
|
||||
- name: Update Homebrew formula (Formula/stirling-pdf-server.rb)
|
||||
env:
|
||||
@@ -116,8 +112,8 @@ jobs:
|
||||
JAR_SHA: ${{ needs.get-release-info.outputs.jar_sha256 }}
|
||||
run: |
|
||||
FORMULA="tap/Formula/stirling-pdf-server.rb"
|
||||
sed -i "s/version \".*\"/version \"${VERSION}\"/" "$FORMULA"
|
||||
sed -i "s/sha256 \".*\"/sha256 \"${JAR_SHA}\"/" "$FORMULA"
|
||||
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$FORMULA"
|
||||
sed -i "s/sha256 \"[^\"]*\"/sha256 \"${JAR_SHA}\"/" "$FORMULA"
|
||||
|
||||
- name: Update Scoop stirling-pdf.json
|
||||
env:
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Build Tauri Applications
|
||||
|
||||
# Multi-OS Tauri desktop bundle build matrix (Windows / macOS arm+intel /
|
||||
# Multi-OS Tauri desktop bundle build matrix (Windows / macOS universal /
|
||||
# Linux). Called from build.yml on PRs that touch desktop sources (gated
|
||||
# via the `tauri` filter in .github/config/.files.yaml). Also runnable
|
||||
# on demand via workflow_dispatch with a per-platform selector.
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -48,17 +48,16 @@ jobs:
|
||||
PLATFORM: ${{ inputs.platform }}
|
||||
run: |
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
|
||||
MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}'
|
||||
MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}'
|
||||
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
|
||||
|
||||
# Resolve requested platform — populated by either workflow_dispatch
|
||||
# or workflow_call inputs; both paths default to "all".
|
||||
case "$PLATFORM" in
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;;
|
||||
macos) ENTRIES=("$MACOS") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
|
||||
esac
|
||||
|
||||
# Drop macOS entries when Apple certificate secret is unavailable
|
||||
@@ -87,7 +86,7 @@ jobs:
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -101,7 +100,7 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
@@ -111,7 +110,23 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
# x86_64 JDK is set up first so the aarch64 step below can leave its
|
||||
# JAVA_HOME as the active one. The macOS universal JRE build needs
|
||||
# jmods from both arches; the x64 path is captured into the env
|
||||
# before the second setup-java overwrites JAVA_HOME.
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
architecture: "x64"
|
||||
|
||||
- name: Capture x86_64 JAVA_HOME
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
@@ -120,12 +135,21 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Setup Task
|
||||
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
|
||||
|
||||
# Build the universal JRE before desktop:prepare so the jlink:runtime
|
||||
# task short-circuits on its `test -d runtime/jre` status check.
|
||||
- name: Build universal macOS JRE
|
||||
if: matrix.platform == 'macos-15'
|
||||
env:
|
||||
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
|
||||
run: task desktop:jlink:universal-mac
|
||||
|
||||
- name: Prepare desktop build
|
||||
run: task desktop:prepare
|
||||
env:
|
||||
@@ -213,7 +237,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -234,7 +258,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
|
||||
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -246,7 +270,7 @@ jobs:
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Check DMG creation dependencies (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
|
||||
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
|
||||
@@ -373,7 +397,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
if: matrix.platform == 'macos-15'
|
||||
run: |
|
||||
echo "🔍 Verifying notarization status..."
|
||||
cd ./frontend/src-tauri/target
|
||||
@@ -398,7 +422,7 @@ jobs:
|
||||
# Only ship the MSI installer. The loose exe and WiX toolset exes
|
||||
# are not the user-facing installer - the MSI contains the signed inner exe.
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
@@ -472,7 +496,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -491,7 +515,7 @@ jobs:
|
||||
echo "❌ No Windows executable found"
|
||||
exit 1
|
||||
fi
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
|
||||
echo "Checking for macOS artifacts..."
|
||||
find . -name "*.dmg" | head -5
|
||||
if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then
|
||||
@@ -533,12 +557,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Post/Update PR Comment with Download Links
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
@@ -556,8 +580,7 @@ jobs:
|
||||
// Map of expected artifact names to display info
|
||||
const artifactMap = {
|
||||
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
|
||||
'Stirling-PDF-macos-aarch64': { icon: '🍎', platform: 'macOS ARM64', files: '.dmg' },
|
||||
'Stirling-PDF-macos-x86_64': { icon: '🍎', platform: 'macOS Intel', files: '.dmg' },
|
||||
'Stirling-PDF-macos-universal': { icon: '🍎', platform: 'macOS Universal', files: '.dmg' },
|
||||
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .rpm, .AppImage' }
|
||||
};
|
||||
|
||||
@@ -625,7 +648,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
|
||||
- name: Upload Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: reports-docker-${{ matrix.artifact-suffix }}
|
||||
path: |
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -150,14 +150,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -266,3 +266,7 @@ docs/type3/signatures/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
# Playwright MCP screenshots / traces
|
||||
.playwright-mcp/
|
||||
*.playwright-mcp.png
|
||||
|
||||
+22
-2
@@ -1,7 +1,12 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
# Single source of truth for the jlink module list (also read by
|
||||
# frontend/scripts/build-universal-mac-jre.sh during CI). The desktop
|
||||
# taskfile runs with dir: frontend (see Taskfile.yml), so this path
|
||||
# is relative to that directory.
|
||||
JLINK_MODULES:
|
||||
sh: tr -d '\n' < src-tauri/jlink-modules.txt
|
||||
|
||||
tasks:
|
||||
prepare:
|
||||
@@ -96,7 +101,7 @@ tasks:
|
||||
jlink
|
||||
--add-modules {{.JLINK_MODULES}}
|
||||
--strip-debug
|
||||
--compress=2
|
||||
--compress=zip-6
|
||||
--no-header-files
|
||||
--no-man-pages
|
||||
--output src-tauri/runtime/jre
|
||||
@@ -107,3 +112,18 @@ tasks:
|
||||
desc: "Remove JLink runtime and bundled JARs"
|
||||
cmds:
|
||||
- rm -rf src-tauri/libs src-tauri/runtime
|
||||
|
||||
# macOS-only. Replaces jlink:runtime's single-arch JRE with a universal
|
||||
# (arm64 + x86_64) one for the universal Tauri shell. Runs the x86_64
|
||||
# jlink under Rosetta on Apple Silicon, so it is opt-in and not part of
|
||||
# the default desktop:build flow. Requires AARCH64_JAVA_HOME and
|
||||
# X64_JAVA_HOME to point at matching JDK installations with jmods/.
|
||||
jlink:universal-mac:
|
||||
desc: "Create universal (arm64+x86_64) JRE for the macOS Tauri build"
|
||||
deps: [jlink:jar]
|
||||
platforms: [darwin]
|
||||
env:
|
||||
JLINK_MODULES: "{{.JLINK_MODULES}}"
|
||||
OUTPUT_DIR: src-tauri/runtime/jre
|
||||
cmds:
|
||||
- scripts/build-universal-mac-jre.sh
|
||||
|
||||
@@ -239,6 +239,7 @@ tasks:
|
||||
- task: typecheck:saas
|
||||
- task: typecheck:desktop
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
|
||||
@@ -45,7 +45,7 @@ dependencies {
|
||||
api 'com.github.junrar:junrar:7.5.10' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2"
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:8.12.6'
|
||||
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
|
||||
|
||||
@@ -68,6 +68,36 @@ public class EndpointConfiguration {
|
||||
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a full request URI like {@code /api/v1/general/remove-pages} into the endpoint key
|
||||
* used by this configuration ({@code remove-pages}). Convert endpoints are a special case -
|
||||
* {@code /api/v1/convert/pdf/img} is registered as {@code pdf-to-img}. Returns {@code null} if
|
||||
* the URI is not an {@code /api/v1/<group>/<endpoint>} path.
|
||||
*/
|
||||
public static String endpointKeyForUri(String uri) {
|
||||
if (uri == null || !uri.contains("/api/v1")) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = uri.split("/");
|
||||
if (parts.length <= 4) {
|
||||
return null;
|
||||
}
|
||||
if ("convert".equals(parts[3]) && parts.length > 5) {
|
||||
return parts[4] + "-to-" + parts[5];
|
||||
}
|
||||
return parts[4];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around {@link #isEndpointEnabled(String)} that accepts a full request URI
|
||||
* and translates it to the endpoint key. Falls back to treating the URI itself as a key for
|
||||
* non-{@code /api/v1/...} paths so callers can pass arbitrary URIs.
|
||||
*/
|
||||
public boolean isEndpointEnabledForUri(String uri) {
|
||||
String key = endpointKeyForUri(uri);
|
||||
return isEndpointEnabled(key != null ? key : uri);
|
||||
}
|
||||
|
||||
public void enableEndpoint(String endpoint) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
endpointStatuses.put(normalized, true);
|
||||
|
||||
@@ -237,6 +237,13 @@ public class ApplicationProperties {
|
||||
private boolean enabled = false;
|
||||
private String url = "http://localhost:5001";
|
||||
private int timeoutSeconds = 120;
|
||||
|
||||
/**
|
||||
* Longer timeout for heavy operations like RAG ingestion, which embeds the whole document
|
||||
* and can take multiple minutes for large books. Applied per-call when the caller
|
||||
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
|
||||
*/
|
||||
private int longRunningTimeoutSeconds = 600;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.common.model.api.comments;
|
||||
|
||||
/**
|
||||
* Absolute position of a PDF annotation in the document.
|
||||
*
|
||||
* <p>Coordinates are in PDF user-space with the origin at the page's bottom-left, consistent with
|
||||
* PDFBox's {@code PDRectangle} convention.
|
||||
*
|
||||
* @param pageIndex 0-indexed page number the annotation lives on.
|
||||
* @param x bottom-left x coordinate of the annotation rectangle.
|
||||
* @param y bottom-left y coordinate of the annotation rectangle.
|
||||
* @param width width of the annotation rectangle, in user-space units.
|
||||
* @param height height of the annotation rectangle, in user-space units.
|
||||
*/
|
||||
public record AnnotationLocation(int pageIndex, float x, float y, float width, float height) {}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.common.model.api.comments;
|
||||
|
||||
/**
|
||||
* Description of a single sticky-note (PDF Text) annotation to place on a document.
|
||||
*
|
||||
* <p>{@code author} and {@code subject} are optional — callers that pass {@code null} get a default
|
||||
* author/subject from {@code PdfAnnotationService}.
|
||||
*
|
||||
* @param location where to anchor the annotation icon, in PDF user-space.
|
||||
* @param text the comment body shown in the popup (required, non-blank).
|
||||
* @param author optional author label shown in the popup; {@code null} → service default.
|
||||
* @param subject optional subject line shown in the popup; {@code null} → service default.
|
||||
*/
|
||||
public record StickyNoteSpec(
|
||||
AnnotationLocation location, String text, String author, String subject) {}
|
||||
@@ -34,11 +34,17 @@ import stirling.software.common.util.TempFileManager;
|
||||
@Slf4j
|
||||
public class InternalApiClient {
|
||||
|
||||
// Allowlist for internal dispatch. Matches a fixed namespace prefix,
|
||||
// Allowlist for internal dispatch. Matches fixed namespace prefixes,
|
||||
// but rejects traversal (..), URL-encoding (%), query/fragment, backslashes, and any other
|
||||
// character that could alter the resolved endpoint on the local Spring server.
|
||||
//
|
||||
// The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are
|
||||
// dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is
|
||||
// intentionally NOT permitted to avoid plan steps re-entering the orchestrator.
|
||||
private static final Pattern ALLOWED_ENDPOINT_PATH =
|
||||
Pattern.compile("^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$");
|
||||
Pattern.compile(
|
||||
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
|
||||
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
|
||||
|
||||
private final ServletContext servletContext;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
|
||||
/**
|
||||
* Shared primitive for adding sticky-note (PDF Text) annotations to a document.
|
||||
*
|
||||
* <p>Used by:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code /api/v1/misc/add-comments} — a deterministic, reusable tool.
|
||||
* <li>AI-agent flows that generate comment specs (e.g. PDF review agent, math auditor review
|
||||
* mode) and hand them off to this service for deterministic placement.
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PdfAnnotationService {
|
||||
|
||||
/** Yellow sticky-note fill colour (R, G, B in 0..1 range). */
|
||||
private static final float[] STICKY_NOTE_COLOR_RGB = {1f, 0.95f, 0.4f};
|
||||
|
||||
/** Opacity for the sticky-note icon. */
|
||||
private static final float ANNOTATION_OPACITY = 0.9f;
|
||||
|
||||
/** PDF Text-annotation icon name — {@code "Comment"} is one of the standard icons. */
|
||||
private static final String ANNOTATION_ICON_NAME = "Comment";
|
||||
|
||||
/** Default subject shown in the annotation popup when a spec does not supply one. */
|
||||
private static final String DEFAULT_SUBJECT = "Stirling AI Comment";
|
||||
|
||||
/** Default author label shown in the annotation popup when a spec does not supply one. */
|
||||
private static final String DEFAULT_AUTHOR = "Stirling AI";
|
||||
|
||||
/**
|
||||
* Cap on sticky-note text length. PDF annotation bodies can technically be much longer, but
|
||||
* anything beyond this is almost certainly pathological (accidental document-dump or malicious
|
||||
* payload) and would bloat the output file.
|
||||
*/
|
||||
private static final int MAX_COMMENT_TEXT_LENGTH = 100_000;
|
||||
|
||||
/**
|
||||
* Add a list of sticky notes to {@code doc}. Specs that reference an out-of-range page or
|
||||
* contain blank text are logged and skipped; this method never throws for a single bad spec.
|
||||
*
|
||||
* @return the number of annotations actually applied
|
||||
*/
|
||||
public int addStickyNotes(PDDocument doc, List<StickyNoteSpec> specs) {
|
||||
if (specs == null || specs.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int totalPages = doc.getNumberOfPages();
|
||||
Calendar now = Calendar.getInstance();
|
||||
int applied = 0;
|
||||
for (int i = 0; i < specs.size(); i++) {
|
||||
StickyNoteSpec spec = specs.get(i);
|
||||
if (!isValid(spec, totalPages, i)) {
|
||||
continue;
|
||||
}
|
||||
apply(doc, spec, now);
|
||||
applied++;
|
||||
}
|
||||
if (applied < specs.size()) {
|
||||
log.warn(
|
||||
"Applied {}/{} sticky notes; {} skipped due to invalid specs.",
|
||||
applied,
|
||||
specs.size(),
|
||||
specs.size() - applied);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single sticky note. Convenience wrapper; prefer {@link #addStickyNotes(PDDocument,
|
||||
* List)} when placing multiple annotations so log output is batched.
|
||||
*/
|
||||
public void addStickyNote(PDDocument doc, StickyNoteSpec spec) {
|
||||
addStickyNotes(doc, List.of(spec));
|
||||
}
|
||||
|
||||
private boolean isValid(StickyNoteSpec spec, int totalPages, int index) {
|
||||
if (spec == null || spec.location() == null) {
|
||||
log.warn("Skipping sticky-note[{}]: spec or location is null.", index);
|
||||
return false;
|
||||
}
|
||||
if (spec.text() == null || spec.text().isBlank()) {
|
||||
log.warn("Skipping sticky-note[{}]: text is blank.", index);
|
||||
return false;
|
||||
}
|
||||
if (spec.text().length() > MAX_COMMENT_TEXT_LENGTH) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: text length {} exceeds limit {}.",
|
||||
index,
|
||||
spec.text().length(),
|
||||
MAX_COMMENT_TEXT_LENGTH);
|
||||
return false;
|
||||
}
|
||||
AnnotationLocation loc = spec.location();
|
||||
if (loc.width() <= 0f || loc.height() <= 0f) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: non-positive dimensions width={} height={}.",
|
||||
index,
|
||||
loc.width(),
|
||||
loc.height());
|
||||
return false;
|
||||
}
|
||||
int page = loc.pageIndex();
|
||||
if (page < 0 || page >= totalPages) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: pageIndex={} out of range [0, {}).",
|
||||
index,
|
||||
page,
|
||||
totalPages);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void apply(PDDocument doc, StickyNoteSpec spec, Calendar now) {
|
||||
AnnotationLocation loc = spec.location();
|
||||
|
||||
PDAnnotationText annot = new PDAnnotationText();
|
||||
annot.setContents(spec.text());
|
||||
annot.setRectangle(new PDRectangle(loc.x(), loc.y(), loc.width(), loc.height()));
|
||||
annot.setSubject(nonBlankOr(spec.subject(), DEFAULT_SUBJECT));
|
||||
annot.setTitlePopup(nonBlankOr(spec.author(), DEFAULT_AUTHOR));
|
||||
annot.setColor(new PDColor(STICKY_NOTE_COLOR_RGB, PDDeviceRGB.INSTANCE));
|
||||
annot.setCreationDate(now);
|
||||
annot.setConstantOpacity(ANNOTATION_OPACITY);
|
||||
annot.getCOSObject().setName(COSName.NAME, ANNOTATION_ICON_NAME);
|
||||
|
||||
try {
|
||||
doc.getPage(loc.pageIndex()).getAnnotations().add(annot);
|
||||
} catch (java.io.IOException e) {
|
||||
log.warn(
|
||||
"Failed to attach sticky note to page {}: {}", loc.pageIndex(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String nonBlankOr(String value, String fallback) {
|
||||
return value != null && !value.isBlank() ? value : fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Locate text on a specific PDF page and return its bounding box in PDF user-space (bottom-left
|
||||
* origin). Used by tools that receive "anchor by text" hints — e.g. {@code
|
||||
* /api/v1/misc/add-comments} when callers supply an {@code anchorText} instead of explicit
|
||||
* coordinates.
|
||||
*
|
||||
* <p>Matching is tolerant: case-insensitive with punctuation/whitespace stripped on both sides, so
|
||||
* a caller-supplied needle of {@code "215000"} matches page text {@code "$215,000"}, and {@code
|
||||
* "Total Revenue"} matches {@code "Total Revenue."}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PdfTextLocator {
|
||||
|
||||
/** One found line of text with its user-space bounding box. */
|
||||
public record MatchedBox(float x, float y, float width, float height) {}
|
||||
|
||||
/**
|
||||
* Find the first line on {@code pageIndex} (0-indexed) whose text contains {@code needle} under
|
||||
* the tolerant match. Returns empty when no match, when the page index is out of range, or when
|
||||
* the needle is blank.
|
||||
*/
|
||||
public Optional<MatchedBox> findOnPage(PDDocument doc, int pageIndex, String needle) {
|
||||
if (doc == null
|
||||
|| needle == null
|
||||
|| needle.isBlank()
|
||||
|| pageIndex < 0
|
||||
|| pageIndex >= doc.getNumberOfPages()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String normalizedNeedle = normalize(needle);
|
||||
if (normalizedNeedle.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
List<CapturedLine> lines = new ArrayList<>();
|
||||
LineCapturingStripper stripper;
|
||||
try {
|
||||
stripper = new LineCapturingStripper(lines);
|
||||
stripper.setStartPage(pageIndex + 1);
|
||||
stripper.setEndPage(pageIndex + 1);
|
||||
stripper.setSortByPosition(true);
|
||||
// Side effect: populates `lines`. We don't need the concatenated text.
|
||||
stripper.getText(doc);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"PdfTextLocator failed to extract text on page {}: {}",
|
||||
pageIndex,
|
||||
e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
PDRectangle mediaBox = doc.getPage(pageIndex).getMediaBox();
|
||||
float pageHeight = mediaBox.getHeight();
|
||||
|
||||
for (CapturedLine line : lines) {
|
||||
if (normalize(line.text).contains(normalizedNeedle)) {
|
||||
// PDFBox's *DirAdj coords descend from the top of the page; convert to PDF
|
||||
// user-space (origin = bottom-left) so the bbox can feed a PDRectangle directly.
|
||||
float userSpaceY = pageHeight - line.yTopDown - line.height;
|
||||
return Optional.of(new MatchedBox(line.x, userSpaceY, line.width, line.height));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Strip everything non-alphanumeric and lowercase for tolerant matching. */
|
||||
private static String normalize(String s) {
|
||||
return s.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static final class CapturedLine {
|
||||
String text;
|
||||
float x;
|
||||
float yTopDown;
|
||||
float width;
|
||||
float height;
|
||||
}
|
||||
|
||||
private static final class LineCapturingStripper extends PDFTextStripper {
|
||||
private final List<CapturedLine> lines;
|
||||
|
||||
LineCapturingStripper(List<CapturedLine> sink) throws IOException {
|
||||
super();
|
||||
this.lines = sink;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeString(String text, List<TextPosition> textPositions)
|
||||
throws IOException {
|
||||
if (textPositions != null && !textPositions.isEmpty()) {
|
||||
CapturedLine line = new CapturedLine();
|
||||
line.text = text;
|
||||
|
||||
float minX = Float.MAX_VALUE;
|
||||
float maxRight = 0f;
|
||||
float minY = Float.MAX_VALUE;
|
||||
float maxHeight = 0f;
|
||||
for (TextPosition p : textPositions) {
|
||||
float x = p.getXDirAdj();
|
||||
float y = p.getYDirAdj();
|
||||
float w = p.getWidthDirAdj();
|
||||
float h = p.getHeightDir();
|
||||
if (h == 0f) {
|
||||
// Workaround: some fonts report 0 height via TextPosition; fall back to
|
||||
// the nominal font size so downstream bboxes are never zero-height.
|
||||
h = p.getFontSizeInPt();
|
||||
}
|
||||
if (x < minX) minX = x;
|
||||
if (x + w > maxRight) maxRight = x + w;
|
||||
if (y < minY) minY = y;
|
||||
if (h > maxHeight) maxHeight = h;
|
||||
}
|
||||
line.x = minX;
|
||||
line.width = maxRight - minX;
|
||||
line.yTopDown = minY;
|
||||
line.height = maxHeight;
|
||||
lines.add(line);
|
||||
}
|
||||
super.writeString(text, textPositions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EndpointConfigurationTest {
|
||||
|
||||
@Test
|
||||
void endpointKeyForUriExtractsSimpleKebab() {
|
||||
assertEquals(
|
||||
"remove-pages",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/general/remove-pages"));
|
||||
assertEquals(
|
||||
"compress-pdf",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/misc/compress-pdf"));
|
||||
assertEquals(
|
||||
"add-watermark",
|
||||
EndpointConfiguration.endpointKeyForUri("/api/v1/security/add-watermark"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointKeyForUriComposesConvertEndpoints() {
|
||||
assertEquals(
|
||||
"pdf-to-img", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/img"));
|
||||
assertEquals(
|
||||
"pdf-to-word", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/word"));
|
||||
assertEquals(
|
||||
"html-to-pdf", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/html/pdf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointKeyForUriReturnsNullForNonApiPaths() {
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri(null));
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("/some-page"));
|
||||
assertNull(EndpointConfiguration.endpointKeyForUri("/api/v1/general"));
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,49 @@ class InternalApiClientTest {
|
||||
assertThrows(SecurityException.class, () -> client.post("/api/v1/admin/settings", body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRejectsAiEndpointsOutsideToolsSubnamespace() {
|
||||
// /api/v1/ai/orchestrate and other non-tool AI endpoints are not internally
|
||||
// dispatchable. Only /api/v1/ai/tools/* and the general/misc/security/convert/filter
|
||||
// namespaces are on the allowlist — letting a plan step re-enter /orchestrate would
|
||||
// introduce recursion risk.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
assertThrows(SecurityException.class, () -> client.post("/api/v1/ai/orchestrate", body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postAcceptsAiToolsSubnamespace() throws Exception {
|
||||
// Agent tool paths like /api/v1/ai/tools/pdf-comment-agent are on the allowlist and
|
||||
// should be dispatchable by the orchestrator's plan executor.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("fileInput", namedResource("input.pdf", "data"));
|
||||
|
||||
Path tempPath = Files.createTempFile("internal-api-ai-tools-test", ".tmp");
|
||||
TempFile tempFile = mock(TempFile.class);
|
||||
when(tempFile.getPath()).thenReturn(tempPath);
|
||||
when(tempFile.getFile()).thenReturn(tempPath.toFile());
|
||||
when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
|
||||
|
||||
try (var ignored =
|
||||
mockConstruction(
|
||||
RestTemplate.class,
|
||||
(rt, ctx) -> {
|
||||
when(rt.httpEntityCallback(any(), eq(Resource.class)))
|
||||
.thenReturn((RequestCallback) req -> {});
|
||||
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
|
||||
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
|
||||
})) {
|
||||
|
||||
ResponseEntity<Resource> response =
|
||||
client.post("/api/v1/ai/tools/pdf-comment-agent", body);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
} finally {
|
||||
Files.deleteIfExists(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRejectsPathTraversal() {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
|
||||
class PdfAnnotationServiceTest {
|
||||
|
||||
private PdfAnnotationService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PdfAnnotationService();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addStickyNotesPlacesOneAnnotationPerValidSpec() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "First comment", "alice", null),
|
||||
spec(1, 100f, 650f, "Second comment", null, "Second"));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(2, applied);
|
||||
byte[] saved = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
assertEquals(1, textAnnotations(reloaded.getPage(0).getAnnotations()).size());
|
||||
assertEquals(1, textAnnotations(reloaded.getPage(1).getAnnotations()).size());
|
||||
|
||||
PDAnnotationText first =
|
||||
textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
|
||||
assertEquals("First comment", first.getContents());
|
||||
assertEquals("alice", first.getTitlePopup(), "author override propagates");
|
||||
assertNotNull(first.getSubject(), "subject falls back to default when null");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithBlankText() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "Valid", null, null),
|
||||
spec(0, 72f, 680f, " ", null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Blank-text spec must be skipped");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithOutOfRangePageIndex() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "OK", null, null),
|
||||
spec(99, 72f, 700f, "Too far", null, null),
|
||||
spec(-1, 72f, 700f, "Negative", null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Only the in-range spec should be applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesNullAndEmptySpecList() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
assertEquals(0, service.addStickyNotes(doc, null));
|
||||
assertEquals(0, service.addStickyNotes(doc, List.of()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithNonPositiveDimensions() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
StickyNoteSpec zeroWidth =
|
||||
new StickyNoteSpec(
|
||||
new AnnotationLocation(0, 72f, 700f, 0f, 20f),
|
||||
"Zero width",
|
||||
null,
|
||||
null);
|
||||
StickyNoteSpec negativeHeight =
|
||||
new StickyNoteSpec(
|
||||
new AnnotationLocation(0, 72f, 680f, 20f, -5f),
|
||||
"Negative height",
|
||||
null,
|
||||
null);
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(spec(0, 72f, 660f, "OK", null, null), zeroWidth, negativeHeight);
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Only the positively-sized spec should be applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithOverlongText() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
String overlong = "x".repeat(100_001);
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "Short", null, null),
|
||||
spec(0, 72f, 680f, overlong, null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Overlong-text spec must be skipped");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesDefaultAuthorAndSubjectWhenAbsent() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
service.addStickyNote(doc, spec(0, 72f, 700f, "No author given", null, null));
|
||||
|
||||
byte[] saved = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAnnotationText annot =
|
||||
textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
|
||||
assertTrue(
|
||||
annot.getTitlePopup() != null && !annot.getTitlePopup().isBlank(),
|
||||
"Default author should be applied");
|
||||
assertTrue(
|
||||
annot.getSubject() != null && !annot.getSubject().isBlank(),
|
||||
"Default subject should be applied");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static StickyNoteSpec spec(
|
||||
int page, float x, float y, String text, String author, String subject) {
|
||||
return new StickyNoteSpec(
|
||||
new AnnotationLocation(page, x, y, 20f, 20f), text, author, subject);
|
||||
}
|
||||
|
||||
private static byte[] twoPagePdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument doc) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
|
||||
List<PDAnnotationText> out = new ArrayList<>();
|
||||
for (PDAnnotation a : annotations) {
|
||||
if (a instanceof PDAnnotationText t) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.PdfTextLocator.MatchedBox;
|
||||
|
||||
class PdfTextLocatorTest {
|
||||
|
||||
private final PdfTextLocator locator = new PdfTextLocator();
|
||||
|
||||
@Test
|
||||
void findsLineContainingNeedleAndReturnsUserSpaceBox() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Revenue: $215,000", "Expenses: $120,000"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "215000");
|
||||
assertThat(match).isPresent();
|
||||
MatchedBox box = match.get();
|
||||
// Line was drawn at y=720 in user-space (bottom-left origin); locator
|
||||
// should return a bbox close to that height band with non-zero width.
|
||||
assertThat(box.width()).isGreaterThan(0f);
|
||||
assertThat(box.height()).isGreaterThan(0f);
|
||||
assertThat(box.y()).isBetween(700f, 740f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchIsCaseAndPunctuationInsensitive() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Total Revenue.", "Q4 summary"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "total revenue");
|
||||
assertThat(match).isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyWhenNeedleNotFound() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Nothing to see here"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "not-on-this-page");
|
||||
assertThat(match).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyForBlankNeedle() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Any text"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertThat(locator.findOnPage(doc, 0, "")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 0, " ")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 0, null)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyForOutOfRangePage() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Single page"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertThat(locator.findOnPage(doc, -1, "single")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 99, "single")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] pdfWithLines(String[] lines) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.setNonStrokingColor(Color.BLACK);
|
||||
float y = 720f;
|
||||
for (String line : lines) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72f, y);
|
||||
cs.showText(line);
|
||||
cs.endText();
|
||||
y -= 20f;
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,28 +21,7 @@ public class EndpointInterceptor implements HandlerInterceptor {
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
String requestURI = request.getRequestURI();
|
||||
boolean isEnabled;
|
||||
|
||||
// Extract the specific endpoint name (e.g: /api/v1/general/remove-pages -> remove-pages)
|
||||
if (requestURI.contains("/api/v1") && requestURI.split("/").length > 4) {
|
||||
|
||||
String[] requestURIParts = requestURI.split("/");
|
||||
String requestEndpoint;
|
||||
|
||||
// Endpoint: /api/v1/convert/pdf/img becomes pdf-to-img
|
||||
if ("convert".equals(requestURIParts[3]) && requestURIParts.length > 5) {
|
||||
requestEndpoint = requestURIParts[4] + "-to-" + requestURIParts[5];
|
||||
} else {
|
||||
requestEndpoint = requestURIParts[4];
|
||||
}
|
||||
|
||||
log.debug("Request endpoint: {}", requestEndpoint);
|
||||
isEnabled = endpointConfiguration.isEndpointEnabled(requestEndpoint);
|
||||
log.debug("Is endpoint enabled: {}", isEnabled);
|
||||
} else {
|
||||
isEnabled = endpointConfiguration.isEndpointEnabled(requestURI);
|
||||
}
|
||||
|
||||
boolean isEnabled = endpointConfiguration.isEndpointEnabledForUri(requestURI);
|
||||
if (!isEnabled) {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
|
||||
return false;
|
||||
|
||||
@@ -185,40 +185,39 @@ public class MergeController {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
// Adds a table of contents to the merged document using filenames as chapter titles
|
||||
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
|
||||
// Create the document outline
|
||||
// Reads page counts from on-disk source files in read-only mode. A failed read falls back to
|
||||
// 1 so TOC generation still produces a usable (if slightly misaligned) outline.
|
||||
private int[] collectPageCounts(File[] sourceFiles) {
|
||||
int[] counts = new int[sourceFiles.length];
|
||||
for (int i = 0; i < sourceFiles.length; i++) {
|
||||
try (PDDocument doc = pdfDocumentFactory.load(sourceFiles[i], true)) {
|
||||
counts[i] = doc.getNumberOfPages();
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("page count for TOC", e);
|
||||
counts[i] = 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Adds a table of contents to the merged document using filenames as chapter titles.
|
||||
// Page counts are passed in so we don't re-open every source PDF just to count pages.
|
||||
private void addTableOfContents(
|
||||
PDDocument mergedDocument, MultipartFile[] files, int[] pageCounts) {
|
||||
PDDocumentOutline outline = new PDDocumentOutline();
|
||||
mergedDocument.getDocumentCatalog().setDocumentOutline(outline);
|
||||
|
||||
int pageIndex = 0; // Current page index in the merged document
|
||||
|
||||
// Iterate through the original files
|
||||
for (MultipartFile file : files) {
|
||||
// Get the filename without extension to use as bookmark title
|
||||
String filename = file.getOriginalFilename();
|
||||
String title = GeneralUtils.removeExtension(filename);
|
||||
|
||||
// Create an outline item for this file
|
||||
int pageIndex = 0;
|
||||
for (int i = 0; i < files.length; i++) {
|
||||
String title = GeneralUtils.removeExtension(files[i].getOriginalFilename());
|
||||
PDOutlineItem item = new PDOutlineItem();
|
||||
item.setTitle(title);
|
||||
|
||||
// Set the destination to the first page of this file in the merged document
|
||||
if (pageIndex < mergedDocument.getNumberOfPages()) {
|
||||
PDPage page = mergedDocument.getPage(pageIndex);
|
||||
item.setDestination(page);
|
||||
item.setDestination(mergedDocument.getPage(pageIndex));
|
||||
}
|
||||
|
||||
// Add the item to the outline
|
||||
outline.addLast(item);
|
||||
|
||||
// Increment page index for the next file
|
||||
try (PDDocument doc = pdfDocumentFactory.load(file)) {
|
||||
pageIndex += doc.getNumberOfPages();
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("document loading for TOC generation", e);
|
||||
pageIndex++; // Increment by at least one if we can't determine page count
|
||||
}
|
||||
int count = pageCounts[i];
|
||||
pageIndex += count > 0 ? count : 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +287,7 @@ public class MergeController {
|
||||
|
||||
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
|
||||
boolean generateToc = request.isGenerateToc();
|
||||
boolean preserveAccessibility = request.isPreserveAccessibility();
|
||||
|
||||
MultipartFile[] files = request.getFileInput();
|
||||
if (files == null) {
|
||||
@@ -306,31 +306,34 @@ public class MergeController {
|
||||
request.getSortType())); // Sort files based on requested sort type
|
||||
}
|
||||
|
||||
try (TempFile mt = new TempFile(tempFileManager, ".pdf")) {
|
||||
|
||||
// Hold the merge output until response streaming completes. We only close it on failure;
|
||||
// on success ownership transfers to the response (deleted when Spring closes the stream).
|
||||
TempFile mergeOutput = new TempFile(tempFileManager, ".pdf");
|
||||
boolean keepMergeOutput = false;
|
||||
try {
|
||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||
// OPTIMIZE_RESOURCES_MODE closes source documents progressively and skips
|
||||
// structure-tree copying — drops PDF/UA tags but uses much less heap.
|
||||
// PDFBOX_LEGACY_MODE preserves tags at the cost of higher peak heap.
|
||||
mergerUtility.setDocumentMergeMode(
|
||||
preserveAccessibility
|
||||
? PDFMergerUtility.DocumentMergeMode.PDFBOX_LEGACY_MODE
|
||||
: PDFMergerUtility.DocumentMergeMode.OPTIMIZE_RESOURCES_MODE);
|
||||
long totalSize = 0;
|
||||
List<Integer> invalidIndexes = new ArrayList<>();
|
||||
File[] sourceFiles = new File[files.length];
|
||||
for (int index = 0; index < files.length; index++) {
|
||||
MultipartFile multipartFile = files[index];
|
||||
totalSize += multipartFile.getSize();
|
||||
File tempFile =
|
||||
tempFileManager.convertMultipartFileToFile(
|
||||
multipartFile); // Convert MultipartFile to File
|
||||
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
|
||||
|
||||
// Pre-validate each PDF so we can report which one(s) are broken
|
||||
// Use the original MultipartFile to avoid deleting the tempFile during validation
|
||||
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
|
||||
// OK
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("PDF pre-validate", e);
|
||||
invalidIndexes.add(index);
|
||||
}
|
||||
mergerUtility.addSource(tempFile); // Add source file to the merger utility
|
||||
File tempFile = tempFileManager.convertMultipartFileToFile(multipartFile);
|
||||
filesToDelete.add(tempFile);
|
||||
sourceFiles[index] = tempFile;
|
||||
mergerUtility.addSource(tempFile);
|
||||
}
|
||||
// Pre-validation is intentionally omitted: PDFMergerUtility surfaces corrupted inputs
|
||||
// via PdfErrorUtils.isCorruptedPdfError below, and a separate validation pass would
|
||||
// double-allocate PDDocument graphs and re-spool every source >10 MB to disk.
|
||||
|
||||
mergerUtility.setDestinationFileName(mt.getFile().getAbsolutePath());
|
||||
mergerUtility.setDestinationFileName(mergeOutput.getFile().getAbsolutePath());
|
||||
|
||||
try {
|
||||
mergerUtility.mergeDocuments(
|
||||
@@ -339,39 +342,59 @@ public class MergeController {
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("PDF merge", e);
|
||||
if (PdfErrorUtils.isCorruptedPdfError(e)) {
|
||||
// Identify which source file(s) are corrupt for operator diagnostics.
|
||||
// Only runs on the failure path so the happy path stays fast.
|
||||
List<String> badFiles = new ArrayList<>();
|
||||
for (int i = 0; i < sourceFiles.length; i++) {
|
||||
try (PDDocument ignored =
|
||||
pdfDocumentFactory.load(sourceFiles[i], true)) {
|
||||
// OK
|
||||
} catch (IOException corruptInput) {
|
||||
String name = files[i].getOriginalFilename();
|
||||
badFiles.add(name != null ? name : ("index " + i));
|
||||
}
|
||||
}
|
||||
if (!badFiles.isEmpty()) {
|
||||
log.warn("Corrupted PDFs in merge input: {}", badFiles);
|
||||
}
|
||||
throw ExceptionUtils.createMultiplePdfCorruptedException(e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Load the merged PDF document and operate on it inside try-with-resources
|
||||
try (PDDocument mergedDocument = pdfDocumentFactory.load(mt.getFile())) {
|
||||
// Remove signatures if removeCertSign is true
|
||||
if (removeCertSign) {
|
||||
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
|
||||
PDAcroForm acroForm = catalog.getAcroForm();
|
||||
if (acroForm != null) {
|
||||
List<PDField> fieldsToRemove =
|
||||
acroForm.getFields().stream()
|
||||
.filter(PDSignatureField.class::isInstance)
|
||||
.toList();
|
||||
// Common case: caller wants neither cert-sign removal nor a TOC. Skip the
|
||||
// load-and-resave round-trip entirely — the merged file on disk is the response.
|
||||
// For 4000+ page jobs this avoids materialising the merged PDDocument in heap.
|
||||
if (!removeCertSign && !generateToc) {
|
||||
outputTempFile = mergeOutput;
|
||||
keepMergeOutput = true;
|
||||
} else {
|
||||
// Page counts are needed only when generating a TOC. Read them from the already-
|
||||
// on-disk source files in read-only mode (no metadata mutation, no extra spool).
|
||||
int[] pageCounts = generateToc ? collectPageCounts(sourceFiles) : null;
|
||||
|
||||
if (!fieldsToRemove.isEmpty()) {
|
||||
acroForm.flatten(
|
||||
fieldsToRemove,
|
||||
false); // Flatten the fields, effectively removing them
|
||||
outputTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try (PDDocument mergedDocument = pdfDocumentFactory.load(mergeOutput.getFile())) {
|
||||
// Resource cache off for the modify pass — we never call getImage() here,
|
||||
// and disabling it prevents PDFBox from caching XObjects when the page tree
|
||||
// is iterated during outline insertion or AcroForm flattening.
|
||||
mergedDocument.setResourceCache(null);
|
||||
if (removeCertSign) {
|
||||
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
|
||||
PDAcroForm acroForm = catalog.getAcroForm();
|
||||
if (acroForm != null) {
|
||||
List<PDField> fieldsToRemove =
|
||||
acroForm.getFields().stream()
|
||||
.filter(PDSignatureField.class::isInstance)
|
||||
.toList();
|
||||
if (!fieldsToRemove.isEmpty()) {
|
||||
acroForm.flatten(fieldsToRemove, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add table of contents if generateToc is true
|
||||
if (generateToc && files.length > 0) {
|
||||
addTableOfContents(mergedDocument, files);
|
||||
}
|
||||
|
||||
// Save the modified document to a temporary file
|
||||
outputTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
if (generateToc && files.length > 0) {
|
||||
addTableOfContents(mergedDocument, files, pageCounts);
|
||||
}
|
||||
mergedDocument.save(outputTempFile.getFile());
|
||||
} catch (Exception e) {
|
||||
outputTempFile.close();
|
||||
@@ -380,7 +403,7 @@ public class MergeController {
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
if (outputTempFile != null) {
|
||||
if (outputTempFile != null && outputTempFile != mergeOutput) {
|
||||
outputTempFile.close();
|
||||
}
|
||||
if (ex instanceof IOException && PdfErrorUtils.isCorruptedPdfError((IOException) ex)) {
|
||||
@@ -390,6 +413,9 @@ public class MergeController {
|
||||
}
|
||||
throw ex;
|
||||
} finally {
|
||||
if (!keepMergeOutput && outputTempFile != mergeOutput) {
|
||||
mergeOutput.close();
|
||||
}
|
||||
for (File file : filesToDelete) {
|
||||
tempFileManager.deleteTempFile(file); // Delete temporary files
|
||||
}
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.misc.AddCommentsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfAnnotationService;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PdfTextLocator;
|
||||
import stirling.software.common.util.PdfTextLocator.MatchedBox;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Deterministic Java tool: add sticky-note comments to a PDF at caller-supplied positions.
|
||||
* Composable primitive used by AI agents (that generate comment specs) and by any other caller —
|
||||
* Automate workflows, scripts, unit tests — that has comment positions and text in hand.
|
||||
*
|
||||
* <p>Each {@code CommentSpec} element accepts either absolute coordinates ({@code x, y, width,
|
||||
* height}) or an {@code anchorText} hint. When {@code anchorText} is present, the tool scans the
|
||||
* target page, finds the first line whose text contains the needle (tolerant match — case and
|
||||
* punctuation insensitive), and anchors the sticky-note icon at that line's bounding box. Falls
|
||||
* back to the supplied coordinates when no match is found.
|
||||
*
|
||||
* <p>Pairs with {@link PdfAnnotationService} (annotation creation) and {@link PdfTextLocator}
|
||||
* (anchor resolution).
|
||||
*/
|
||||
@Slf4j
|
||||
@MiscApi
|
||||
@RequiredArgsConstructor
|
||||
public class AddCommentsController {
|
||||
|
||||
/** Sticky-note icon size in PDF user-space units. Matches the agents' default. */
|
||||
private static final float ANCHOR_ICON_SIZE = 20f;
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final PdfAnnotationService pdfAnnotationService;
|
||||
private final PdfTextLocator pdfTextLocator;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@AutoJobPostMapping(value = "/add-comments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Add sticky-note comments to a PDF at specified positions or anchored text",
|
||||
description =
|
||||
"Attaches PDF Text (sticky-note) annotations to the document."
|
||||
+ " Each CommentSpec can either supply absolute coordinates or an"
|
||||
+ " `anchorText` hint; when provided, the tool locates the first matching"
|
||||
+ " line on the target page and anchors the icon there (falling back to"
|
||||
+ " the coordinates if no match). Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<Resource> addComments(@ModelAttribute AddCommentsRequest request)
|
||||
throws IOException {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "fileInput is required");
|
||||
}
|
||||
String commentsJson = request.getComments();
|
||||
if (commentsJson == null || commentsJson.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "comments JSON is required");
|
||||
}
|
||||
|
||||
List<CommentSpecDto> dtos;
|
||||
try {
|
||||
dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {});
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects");
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
List<StickyNoteSpec> specs = resolveSpecs(document, dtos);
|
||||
pdfAnnotationService.addStickyNotes(document, specs);
|
||||
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try {
|
||||
document.save(tempOut.getFile());
|
||||
} catch (IOException e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(
|
||||
tempOut,
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_commented.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the wire DTOs into {@link StickyNoteSpec}s, resolving any {@code anchorText} hints
|
||||
* against the PDF. Each spec is resolved independently so a miss falls back locally without
|
||||
* affecting other specs.
|
||||
*/
|
||||
private List<StickyNoteSpec> resolveSpecs(PDDocument document, List<CommentSpecDto> dtos) {
|
||||
List<StickyNoteSpec> specs = new ArrayList<>(dtos.size());
|
||||
for (CommentSpecDto dto : dtos) {
|
||||
specs.add(toSpec(document, dto));
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
private StickyNoteSpec toSpec(PDDocument document, CommentSpecDto d) {
|
||||
AnnotationLocation location = resolveLocation(document, d);
|
||||
return new StickyNoteSpec(location, d.text, d.author, d.subject);
|
||||
}
|
||||
|
||||
private AnnotationLocation resolveLocation(PDDocument document, CommentSpecDto d) {
|
||||
if (d.anchorText == null || d.anchorText.isBlank()) {
|
||||
return new AnnotationLocation(d.pageIndex, d.x, d.y, d.width, d.height);
|
||||
}
|
||||
Optional<MatchedBox> match = pdfTextLocator.findOnPage(document, d.pageIndex, d.anchorText);
|
||||
if (match.isEmpty()) {
|
||||
log.debug(
|
||||
"add-comments: no match for anchorText {!r} on page {}; using fallback coords",
|
||||
d.anchorText,
|
||||
d.pageIndex);
|
||||
return new AnnotationLocation(d.pageIndex, d.x, d.y, d.width, d.height);
|
||||
}
|
||||
MatchedBox box = match.get();
|
||||
// Anchor the icon at the top-left of the matched line, matching the convention used by
|
||||
// PdfCommentAgentOrchestrator for its chunk-based placement.
|
||||
float iconX = box.x();
|
||||
float iconY = box.y() + box.height() - ANCHOR_ICON_SIZE;
|
||||
return new AnnotationLocation(
|
||||
d.pageIndex, iconX, iconY, ANCHOR_ICON_SIZE, ANCHOR_ICON_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-format DTO for a single element in the {@code comments} JSON array. Flat record-like
|
||||
* shape keeps the JSON simple for humans, AI-engine plan parameters, and Automate steps alike.
|
||||
*
|
||||
* <p>{@code anchorText} is optional. When present, the server locates the first line on {@code
|
||||
* pageIndex} containing that text (tolerant match) and places the icon there; the {@code
|
||||
* x/y/width/height} act as fallback when no match is found.
|
||||
*/
|
||||
private static final class CommentSpecDto {
|
||||
public int pageIndex;
|
||||
public float x;
|
||||
public float y;
|
||||
public float width;
|
||||
public float height;
|
||||
public String text;
|
||||
public String author;
|
||||
public String subject;
|
||||
public String anchorText;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,16 @@ public class MergePdfsRequest extends MultiplePDFFiles {
|
||||
defaultValue = "false")
|
||||
private boolean generateToc = false;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Flag indicating whether to preserve PDF/UA accessibility tags (structure"
|
||||
+ " tree) in the merged output. When false (default) the merger runs in"
|
||||
+ " resource-optimised mode which drops tags but uses significantly less"
|
||||
+ " heap. Set true when merging tagged PDFs intended for screen readers.",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "false")
|
||||
private boolean preserveAccessibility = false;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"JSON array of client-provided IDs for each uploaded file (same order as fileInput)",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /api/v1/misc/add-comments}.
|
||||
*
|
||||
* <p>The {@code comments} field is a JSON-encoded array of {@code CommentSpec} objects rather than
|
||||
* a nested multipart part so the endpoint stays compatible with {@code InternalApiClient}'s flat
|
||||
* multipart form body (orchestrator plan dispatch). Jackson parses it controller-side.
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AddCommentsRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
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.",
|
||||
example =
|
||||
"[{\"pageIndex\":0,\"x\":72,\"y\":720,\"width\":20,\"height\":20,"
|
||||
+ "\"text\":\"Check this paragraph\",\"author\":\"Reviewer\","
|
||||
+ "\"subject\":\"Unclear wording\"}]",
|
||||
requiredMode = RequiredMode.REQUIRED)
|
||||
private String comments;
|
||||
}
|
||||
@@ -27,60 +27,19 @@ class EndpointInterceptorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleAllowsEnabledApiEndpoint() throws Exception {
|
||||
void preHandleAllowsEnabledEndpoint() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/general/remove-pages");
|
||||
when(endpointConfiguration.isEndpointEnabled("remove-pages")).thenReturn(true);
|
||||
when(endpointConfiguration.isEndpointEnabledForUri("/api/v1/general/remove-pages"))
|
||||
.thenReturn(true);
|
||||
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleBlocksDisabledApiEndpoint() throws Exception {
|
||||
void preHandleBlocksDisabledEndpoint() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/general/remove-pages");
|
||||
when(endpointConfiguration.isEndpointEnabled("remove-pages")).thenReturn(false);
|
||||
when(endpointConfiguration.isEndpointEnabledForUri("/api/v1/general/remove-pages"))
|
||||
.thenReturn(false);
|
||||
assertFalse(interceptor.preHandle(request, response, new Object()));
|
||||
verify(response).sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleExtractsConvertEndpointCorrectly() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/convert/pdf/img");
|
||||
when(endpointConfiguration.isEndpointEnabled("pdf-to-img")).thenReturn(true);
|
||||
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleBlocksDisabledConvertEndpoint() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/convert/pdf/img");
|
||||
when(endpointConfiguration.isEndpointEnabled("pdf-to-img")).thenReturn(false);
|
||||
assertFalse(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleUsesFullUriForNonApiPaths() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/some-page");
|
||||
when(endpointConfiguration.isEndpointEnabled("/some-page")).thenReturn(true);
|
||||
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleBlocksDisabledNonApiPath() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/some-page");
|
||||
when(endpointConfiguration.isEndpointEnabled("/some-page")).thenReturn(false);
|
||||
assertFalse(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleUsesFullUriForShortApiPath() throws Exception {
|
||||
// URI with /api/v1 but not enough segments (split length <= 4)
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/general");
|
||||
when(endpointConfiguration.isEndpointEnabled("/api/v1/general")).thenReturn(true);
|
||||
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleExtractsNonConvertApiEndpoint() throws Exception {
|
||||
when(request.getRequestURI()).thenReturn("/api/v1/security/add-watermark");
|
||||
when(endpointConfiguration.isEndpointEnabled("add-watermark")).thenReturn(true);
|
||||
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||
}
|
||||
}
|
||||
|
||||
+25
-118
@@ -75,137 +75,57 @@ class MergeControllerTest {
|
||||
void testAddTableOfContents_WithMultipleFiles_Success() throws Exception {
|
||||
// Given
|
||||
MultipartFile[] files = {mockFile1, mockFile2, mockFile3};
|
||||
int[] pageCounts = {2, 2, 2};
|
||||
|
||||
// Mock the merged document setup
|
||||
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
|
||||
when(mockMergedDocument.getNumberOfPages()).thenReturn(6);
|
||||
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
|
||||
when(mockMergedDocument.getPage(2)).thenReturn(mockPage2);
|
||||
when(mockMergedDocument.getPage(4)).thenReturn(mockPage1);
|
||||
|
||||
// Mock individual document loading for page count
|
||||
PDDocument doc1 = mock(PDDocument.class);
|
||||
PDDocument doc2 = mock(PDDocument.class);
|
||||
PDDocument doc3 = mock(PDDocument.class);
|
||||
|
||||
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
|
||||
when(pdfDocumentFactory.load(mockFile2)).thenReturn(doc2);
|
||||
when(pdfDocumentFactory.load(mockFile3)).thenReturn(doc3);
|
||||
|
||||
when(doc1.getNumberOfPages()).thenReturn(2);
|
||||
when(doc2.getNumberOfPages()).thenReturn(2);
|
||||
when(doc3.getNumberOfPages()).thenReturn(2);
|
||||
|
||||
// When
|
||||
Method addTableOfContentsMethod =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"addTableOfContents", PDDocument.class, MultipartFile[].class);
|
||||
addTableOfContentsMethod.setAccessible(true);
|
||||
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
|
||||
invokeAddToc(mockMergedDocument, files, pageCounts);
|
||||
|
||||
// Then
|
||||
ArgumentCaptor<PDDocumentOutline> outlineCaptor =
|
||||
ArgumentCaptor.forClass(PDDocumentOutline.class);
|
||||
verify(mockCatalog).setDocumentOutline(outlineCaptor.capture());
|
||||
assertNotNull(outlineCaptor.getValue());
|
||||
|
||||
PDDocumentOutline capturedOutline = outlineCaptor.getValue();
|
||||
assertNotNull(capturedOutline);
|
||||
|
||||
// Verify that documents were loaded for page count
|
||||
verify(pdfDocumentFactory).load(mockFile1);
|
||||
verify(pdfDocumentFactory).load(mockFile2);
|
||||
verify(pdfDocumentFactory).load(mockFile3);
|
||||
|
||||
// Verify document closing
|
||||
verify(doc1).close();
|
||||
verify(doc2).close();
|
||||
verify(doc3).close();
|
||||
// TOC must NOT re-open source PDFs to count pages — that was the OOM hot spot.
|
||||
verifyNoInteractions(pdfDocumentFactory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddTableOfContents_WithSingleFile_Success() throws Exception {
|
||||
// Given
|
||||
MultipartFile[] files = {mockFile1};
|
||||
int[] pageCounts = {3};
|
||||
|
||||
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
|
||||
when(mockMergedDocument.getNumberOfPages()).thenReturn(3);
|
||||
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
|
||||
|
||||
PDDocument doc1 = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
|
||||
when(doc1.getNumberOfPages()).thenReturn(3);
|
||||
invokeAddToc(mockMergedDocument, files, pageCounts);
|
||||
|
||||
// When
|
||||
Method addTableOfContentsMethod =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"addTableOfContents", PDDocument.class, MultipartFile[].class);
|
||||
addTableOfContentsMethod.setAccessible(true);
|
||||
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
|
||||
|
||||
// Then
|
||||
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
|
||||
verify(pdfDocumentFactory).load(mockFile1);
|
||||
verify(doc1).close();
|
||||
verifyNoInteractions(pdfDocumentFactory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddTableOfContents_WithEmptyArray_Success() throws Exception {
|
||||
// Given
|
||||
MultipartFile[] files = {};
|
||||
int[] pageCounts = {};
|
||||
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
|
||||
|
||||
// When
|
||||
Method addTableOfContentsMethod =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"addTableOfContents", PDDocument.class, MultipartFile[].class);
|
||||
addTableOfContentsMethod.setAccessible(true);
|
||||
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
|
||||
invokeAddToc(mockMergedDocument, files, pageCounts);
|
||||
|
||||
// Then
|
||||
verify(mockMergedDocument).getDocumentCatalog();
|
||||
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
|
||||
verifyNoInteractions(pdfDocumentFactory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddTableOfContents_WithIOException_HandlesGracefully() throws Exception {
|
||||
// Given
|
||||
MultipartFile[] files = {mockFile1, mockFile2};
|
||||
|
||||
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
|
||||
when(mockMergedDocument.getNumberOfPages()).thenReturn(4);
|
||||
when(mockMergedDocument.getPage(anyInt()))
|
||||
.thenReturn(mockPage1); // Use anyInt() to avoid stubbing conflicts
|
||||
|
||||
// First document loads successfully
|
||||
PDDocument doc1 = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
|
||||
when(doc1.getNumberOfPages()).thenReturn(2);
|
||||
|
||||
// Second document throws IOException
|
||||
when(pdfDocumentFactory.load(mockFile2))
|
||||
.thenThrow(new IOException("Failed to load document"));
|
||||
|
||||
// When
|
||||
Method addTableOfContentsMethod =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"addTableOfContents", PDDocument.class, MultipartFile[].class);
|
||||
addTableOfContentsMethod.setAccessible(true);
|
||||
|
||||
// Should not throw exception
|
||||
assertDoesNotThrow(
|
||||
() -> addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files));
|
||||
|
||||
// Then
|
||||
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
|
||||
verify(pdfDocumentFactory).load(mockFile1);
|
||||
verify(pdfDocumentFactory).load(mockFile2);
|
||||
verify(doc1).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddTableOfContents_FilenameWithoutExtension_UsesFullName() throws Exception {
|
||||
// Given
|
||||
MockMultipartFile fileWithoutExtension =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
@@ -213,53 +133,40 @@ class MergeControllerTest {
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"PDF content".getBytes());
|
||||
MultipartFile[] files = {fileWithoutExtension};
|
||||
int[] pageCounts = {1};
|
||||
|
||||
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
|
||||
when(mockMergedDocument.getNumberOfPages()).thenReturn(1);
|
||||
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
|
||||
|
||||
PDDocument doc = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(fileWithoutExtension)).thenReturn(doc);
|
||||
when(doc.getNumberOfPages()).thenReturn(1);
|
||||
invokeAddToc(mockMergedDocument, files, pageCounts);
|
||||
|
||||
// When
|
||||
Method addTableOfContentsMethod =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"addTableOfContents", PDDocument.class, MultipartFile[].class);
|
||||
addTableOfContentsMethod.setAccessible(true);
|
||||
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
|
||||
|
||||
// Then
|
||||
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
|
||||
verify(doc).close();
|
||||
verifyNoInteractions(pdfDocumentFactory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddTableOfContents_PageIndexExceedsDocumentPages_HandlesGracefully() throws Exception {
|
||||
// Given
|
||||
MultipartFile[] files = {mockFile1};
|
||||
int[] pageCounts = {3};
|
||||
|
||||
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
|
||||
when(mockMergedDocument.getNumberOfPages()).thenReturn(0); // No pages in merged document
|
||||
|
||||
PDDocument doc1 = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
|
||||
when(doc1.getNumberOfPages()).thenReturn(3);
|
||||
assertDoesNotThrow(() -> invokeAddToc(mockMergedDocument, files, pageCounts));
|
||||
|
||||
// When
|
||||
Method addTableOfContentsMethod =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"addTableOfContents", PDDocument.class, MultipartFile[].class);
|
||||
addTableOfContentsMethod.setAccessible(true);
|
||||
|
||||
// Should not throw exception
|
||||
assertDoesNotThrow(
|
||||
() -> addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files));
|
||||
|
||||
// Then
|
||||
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
|
||||
verify(mockMergedDocument, never()).getPage(anyInt());
|
||||
verify(doc1).close();
|
||||
verifyNoInteractions(pdfDocumentFactory);
|
||||
}
|
||||
|
||||
private void invokeAddToc(PDDocument merged, MultipartFile[] files, int[] pageCounts)
|
||||
throws Exception {
|
||||
Method m =
|
||||
MergeController.class.getDeclaredMethod(
|
||||
"addTableOfContents", PDDocument.class, MultipartFile[].class, int[].class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(mergeController, merged, files, pageCounts);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AddCommentsRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfAnnotationService;
|
||||
import stirling.software.common.util.PdfTextLocator;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AddCommentsControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private PdfAnnotationService pdfAnnotationService;
|
||||
private PdfTextLocator pdfTextLocator;
|
||||
private ObjectMapper objectMapper;
|
||||
private AddCommentsController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
pdfAnnotationService = new PdfAnnotationService();
|
||||
pdfTextLocator = new PdfTextLocator();
|
||||
objectMapper = JsonMapper.builder().build();
|
||||
controller =
|
||||
new AddCommentsController(
|
||||
pdfDocumentFactory,
|
||||
tempFileManager,
|
||||
pdfAnnotationService,
|
||||
pdfTextLocator,
|
||||
objectMapper);
|
||||
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File file =
|
||||
Files.createTempFile(tempDir, "addcomments", ".pdf").toFile();
|
||||
TempFile tf = org.mockito.Mockito.mock(TempFile.class);
|
||||
lenient().when(tf.getPath()).thenReturn(file.toPath());
|
||||
lenient().when(tf.getFile()).thenReturn(file);
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesEachCommentSpecAsStickyNote() throws Exception {
|
||||
MockMultipartFile file = pdf("doc.pdf", twoPagePdfBytes());
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
request.setComments(
|
||||
"""
|
||||
[{"pageIndex":0,"x":72,"y":700,"width":20,"height":20,"text":"First","author":"me","subject":"S1"},
|
||||
{"pageIndex":1,"x":100,"y":650,"width":20,"height":20,"text":"Second"}]
|
||||
""");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
byte[] result = drainBody(response);
|
||||
try (PDDocument reloaded = Loader.loadPDF(result)) {
|
||||
List<PDAnnotationText> p0 = textAnnotations(reloaded.getPage(0).getAnnotations());
|
||||
List<PDAnnotationText> p1 = textAnnotations(reloaded.getPage(1).getAnnotations());
|
||||
assertThat(p0).hasSize(1);
|
||||
assertThat(p1).hasSize(1);
|
||||
assertThat(p0.get(0).getContents()).isEqualTo("First");
|
||||
assertThat(p1.get(0).getContents()).isEqualTo("Second");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anchorsStickyNoteAtLocatedTextWhenAnchorTextMatches() throws Exception {
|
||||
byte[] pdfBytes = singlePagePdfWithLine("Revenue: $215,000");
|
||||
MockMultipartFile file = pdf("doc.pdf", pdfBytes);
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
// Fallback coords deliberately far from the line so we can tell which path ran.
|
||||
request.setComments(
|
||||
"""
|
||||
[{"pageIndex":0,"x":10,"y":10,"width":5,"height":5,
|
||||
"text":"Check this total","author":"tester","subject":"S",
|
||||
"anchorText":"215000"}]
|
||||
""");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
|
||||
List<PDAnnotationText> notes = textAnnotations(reloaded.getPage(0).getAnnotations());
|
||||
assertThat(notes).hasSize(1);
|
||||
PDRectangle rect = notes.get(0).getRectangle();
|
||||
// Line was drawn at user-space y=720 with font size 12; icon should land in that band,
|
||||
// not at the fallback y=10. Width/height fixed to 20 by the anchor path.
|
||||
assertThat(rect.getWidth()).isEqualTo(20f);
|
||||
assertThat(rect.getHeight()).isEqualTo(20f);
|
||||
assertThat(rect.getLowerLeftY()).isBetween(700f, 740f);
|
||||
assertThat(rect.getLowerLeftX()).isGreaterThan(50f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToAbsoluteCoordsWhenAnchorTextMisses() throws Exception {
|
||||
byte[] pdfBytes = singlePagePdfWithLine("Revenue: $215,000");
|
||||
MockMultipartFile file = pdf("doc.pdf", pdfBytes);
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
request.setComments(
|
||||
"""
|
||||
[{"pageIndex":0,"x":55,"y":33,"width":7,"height":9,
|
||||
"text":"No match","anchorText":"not-on-this-page"}]
|
||||
""");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
|
||||
List<PDAnnotationText> notes = textAnnotations(reloaded.getPage(0).getAnnotations());
|
||||
assertThat(notes).hasSize(1);
|
||||
PDRectangle rect = notes.get(0).getRectangle();
|
||||
assertThat(rect.getLowerLeftX()).isEqualTo(55f);
|
||||
assertThat(rect.getLowerLeftY()).isEqualTo(33f);
|
||||
assertThat(rect.getWidth()).isEqualTo(7f);
|
||||
assertThat(rect.getHeight()).isEqualTo(9f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankCommentsJson() {
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(pdf("doc.pdf", new byte[] {1, 2, 3}));
|
||||
request.setComments("");
|
||||
|
||||
assertThatThrownBy(() -> controller.addComments(request))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidJson() {
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(pdf("doc.pdf", new byte[] {1, 2, 3}));
|
||||
request.setComments("not-json");
|
||||
|
||||
assertThatThrownBy(() -> controller.addComments(request))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingFileInput() {
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setComments("[]");
|
||||
|
||||
assertThatThrownBy(() -> controller.addComments(request))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsSuccessForEmptyCommentsArray() throws Exception {
|
||||
// An empty JSON array is a valid payload — nothing to annotate, but the caller
|
||||
// should still get back the input PDF without any error so pipelines that
|
||||
// produce zero comments don't have to special-case the empty result.
|
||||
MockMultipartFile file = pdf("doc.pdf", twoPagePdfBytes());
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
request.setComments("[]");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(textAnnotations(reloaded.getPage(0).getAnnotations())).isEmpty();
|
||||
assertThat(textAnnotations(reloaded.getPage(1).getAnnotations())).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static MockMultipartFile pdf(String name, byte[] bytes) {
|
||||
return new MockMultipartFile("fileInput", name, MediaType.APPLICATION_PDF_VALUE, bytes);
|
||||
}
|
||||
|
||||
private static byte[] twoPagePdfBytes() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] singlePagePdfWithLine(String line) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.setNonStrokingColor(Color.BLACK);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72f, 720f);
|
||||
cs.showText(line);
|
||||
cs.endText();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (java.io.InputStream is = response.getBody().getInputStream()) {
|
||||
is.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
|
||||
List<PDAnnotationText> out = new ArrayList<>();
|
||||
for (PDAnnotation a : annotations) {
|
||||
if (a instanceof PDAnnotationText t) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ spotless {
|
||||
}
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
api 'com.google.guava:guava:33.5.0-jre'
|
||||
api 'com.google.guava:guava:33.6.0-jre'
|
||||
|
||||
api 'org.springframework:spring-jdbc'
|
||||
api 'org.springframework:spring-webmvc'
|
||||
|
||||
+30
-5
@@ -34,11 +34,14 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.AiEngineEndpointResolver;
|
||||
import stirling.software.proprietary.service.AiWorkflowService;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@@ -53,6 +56,7 @@ public class AiEngineController {
|
||||
private final Executor aiStreamExecutor;
|
||||
private final TaskManager taskManager;
|
||||
private final JobOwnershipService jobOwnershipService;
|
||||
private final AiEngineEndpointResolver endpointResolver;
|
||||
|
||||
/**
|
||||
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
|
||||
@@ -68,13 +72,15 @@ public class AiEngineController {
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
|
||||
TaskManager taskManager,
|
||||
JobOwnershipService jobOwnershipService) {
|
||||
JobOwnershipService jobOwnershipService,
|
||||
AiEngineEndpointResolver endpointResolver) {
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.aiWorkflowService = aiWorkflowService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.aiStreamExecutor = aiStreamExecutor;
|
||||
this.taskManager = taskManager;
|
||||
this.jobOwnershipService = jobOwnershipService;
|
||||
this.endpointResolver = endpointResolver;
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
@@ -197,17 +203,36 @@ public class AiEngineController {
|
||||
"Sends a user message to the PDF edit agent which returns a structured plan"
|
||||
+ " of tool operations to perform")
|
||||
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
|
||||
validateJson(requestBody);
|
||||
String response = aiEngineClient.post("/api/v1/pdf/edit", requestBody);
|
||||
JsonNode parsed = parseJson(requestBody);
|
||||
if (!parsed.isObject()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
|
||||
}
|
||||
String forwardedBody = withEnabledEndpoints((ObjectNode) parsed);
|
||||
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody);
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
|
||||
}
|
||||
|
||||
private void validateJson(String body) {
|
||||
private JsonNode parseJson(String body) {
|
||||
try {
|
||||
objectMapper.readValue(body, JsonNode.class);
|
||||
return objectMapper.readValue(body, JsonNode.class);
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Request body is not valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Always overwrite {@code enabled_endpoints} with the server's view of which endpoints are
|
||||
* usable. The engine must not trust a client-supplied list - the gate is owned by the Java
|
||||
* EndpointConfiguration. Values are full URL paths (e.g. {@code /api/v1/misc/compress-pdf})
|
||||
* that the engine matches against its {@code ToolEndpoint} enum, silently dropping any it
|
||||
* doesn't recognise (which lets the two sides drift in either direction without breaking).
|
||||
*/
|
||||
private String withEnabledEndpoints(ObjectNode body) {
|
||||
ArrayNode enabled = objectMapper.createArrayNode();
|
||||
endpointResolver.getEnabledEndpointUrls().forEach(enabled::add);
|
||||
body.set("enabled_endpoints", enabled);
|
||||
return body.toString();
|
||||
}
|
||||
}
|
||||
|
||||
+17
-14
@@ -20,21 +20,30 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.Verdict;
|
||||
import stirling.software.proprietary.service.AiToolInputValidator;
|
||||
import stirling.software.proprietary.service.MathAuditorOrchestrator;
|
||||
|
||||
/**
|
||||
* Public entry point for the Math Auditor Agent (mathAuditorAgent).
|
||||
*
|
||||
* <p>Accepts a PDF from the client, hands it to the {@link MathAuditorOrchestrator} which runs the
|
||||
* multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict}.
|
||||
* multi-round Java-Python negotiation, and returns the Auditor's {@link Verdict} 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>The raw PDF never leaves Java. Python receives only structured text and CSV data.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai")
|
||||
@RequestMapping("/api/v1/ai/tools")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "AI Engine", description = "AI-powered document analysis endpoints.")
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
public class MathAuditorAgentController {
|
||||
|
||||
private final MathAuditorOrchestrator orchestrator;
|
||||
@@ -49,11 +58,12 @@ public class MathAuditorAgentController {
|
||||
The auditor checks:
|
||||
- Table row and column totals (tally errors)
|
||||
- Inline arithmetic expressions (e.g. "100 + 200 = 300")
|
||||
- Cross-page figure consistency (same figure cited differently on different pages)
|
||||
- Cross-page figure consistency
|
||||
- Prose claims about percentages, growth rates, and comparisons
|
||||
|
||||
The PDF is processed entirely on the Java side; only extracted text and table data
|
||||
are sent to the AI engine.
|
||||
Returns a JSON Verdict describing every discrepancy found. How the Verdict is
|
||||
presented to the end user (chat answer, PDF annotations, etc.) is up to the
|
||||
caller.
|
||||
|
||||
Input: PDF Output: JSON Type: SISO
|
||||
""")
|
||||
@@ -68,11 +78,7 @@ public class MathAuditorAgentController {
|
||||
@RequestParam(value = "tolerance", defaultValue = "0.01")
|
||||
BigDecimal tolerance) {
|
||||
|
||||
String contentType = fileInput.getContentType();
|
||||
if (contentType == null || !contentType.equals("application/pdf")) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
AiToolInputValidator.validatePdfUpload(fileInput);
|
||||
if (tolerance.compareTo(BigDecimal.ZERO) < 0) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
@@ -89,9 +95,6 @@ public class MathAuditorAgentController {
|
||||
} catch (IOException e) {
|
||||
log.error("[math-auditor-agent] IO error during audit", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
} catch (Exception e) {
|
||||
log.error("[math-auditor-agent] unexpected error during audit", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
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.service.AiToolResponseHeaders;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Public entry point for the PDF Comment Agent (pdfCommentAgent).
|
||||
*
|
||||
* <p>Accepts a PDF and a natural-language prompt, delegates to {@link PdfCommentAgentOrchestrator}
|
||||
* which consults the Python engine and applies {@code PDAnnotationText} sticky-note annotations,
|
||||
* then streams the annotated PDF back in the response body. This shape matches the rest of the
|
||||
* Stirling tool endpoints ({@code /api/v1/misc/*}, {@code /api/v1/general/*}) and is what the AI
|
||||
* workflow orchestrator expects when dispatching this tool as a plan step.
|
||||
*
|
||||
* <p>The raw PDF never leaves Java. Python only receives positioned text chunks.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/tools")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
public class PdfCommentAgentController {
|
||||
|
||||
private final PdfCommentAgentOrchestrator orchestrator;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostMapping(
|
||||
value = "/pdf-comment-agent",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
produces = MediaType.APPLICATION_PDF_VALUE)
|
||||
@Operation(
|
||||
summary = "Annotate a PDF with AI-generated sticky-note comments",
|
||||
description =
|
||||
"""
|
||||
Runs the PDF Comment Agent against the supplied PDF. Java extracts positioned
|
||||
text chunks from the document, ships them (with the user's prompt) to the
|
||||
AI engine, then applies the returned comments as standard PDF Text
|
||||
annotations (sticky notes) anchored to the relevant chunks.
|
||||
|
||||
The annotated PDF is streamed back in the response body with
|
||||
Content-Type: application/pdf.
|
||||
|
||||
Input: PDF + prompt Output: PDF Type: SISO
|
||||
""")
|
||||
public ResponseEntity<Resource> pdfCommentAgent(
|
||||
@Parameter(description = "The PDF document to annotate", required = true)
|
||||
@RequestParam("fileInput")
|
||||
MultipartFile fileInput,
|
||||
@Parameter(
|
||||
description =
|
||||
"Natural-language instructions for the AI — what to comment on",
|
||||
required = true)
|
||||
@RequestParam("prompt")
|
||||
String prompt)
|
||||
throws IOException {
|
||||
|
||||
String safeName =
|
||||
fileInput.getOriginalFilename() != null
|
||||
? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_")
|
||||
: "<unnamed>";
|
||||
log.info(
|
||||
"[pdf-comment-agent] request file={} promptLen={}",
|
||||
safeName,
|
||||
prompt == null ? 0 : prompt.length());
|
||||
|
||||
// ResponseStatusException (validation errors) propagates to Spring's default handler;
|
||||
// IOException is re-thrown to produce a 500. Other RuntimeExceptions likewise propagate.
|
||||
AnnotatedPdf annotated = orchestrator.applyComments(fileInput, prompt);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
headers.setContentDispositionFormData("attachment", annotated.fileName());
|
||||
headers.setContentLength(annotated.bytes().length);
|
||||
headers.set(AiToolResponseHeaders.TOOL_REPORT, buildReportHeader(annotated));
|
||||
return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(annotated.bytes()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the metadata JSON surfaced in {@link AiToolResponseHeaders#TOOL_REPORT} alongside the
|
||||
* annotated PDF. Kept small (fits comfortably in a header): counts and the agent's rationale so
|
||||
* a chat UI can show "Added 3 comments: <rationale>" alongside the downloaded file.
|
||||
*/
|
||||
private String buildReportHeader(AnnotatedPdf annotated) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
node.put("annotationsApplied", annotated.annotationsApplied());
|
||||
node.put("instructionsReceived", annotated.instructionsReceived());
|
||||
if (annotated.rationale() != null) {
|
||||
node.put("rationale", annotated.rationale());
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(node);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to serialise pdf-comment-agent report header: {}", e.getMessage());
|
||||
return "{\"annotationsApplied\":" + annotated.annotationsApplied() + "}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* A file supplied to the AI engine, identified by a stable opaque id plus a display name.
|
||||
*
|
||||
* <p>Values MUST match {@code AiFile} in {@code engine/src/stirling/contracts/common.py}.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "File reference sent to the AI engine")
|
||||
public class AiFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Opaque, stable identifier. Owned by Java; used as the RAG collection key.")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "Original filename, used by agents in user-facing prompts and responses.")
|
||||
private String name;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Body for {@code POST /api/v1/rag/documents} on the AI engine. Sent by Java when the engine
|
||||
* reports {@code need_ingest} and the requested document's extracted content must be stored before
|
||||
* the workflow can continue.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AiRagIngestRequest {
|
||||
|
||||
private String documentId;
|
||||
|
||||
private String source;
|
||||
|
||||
private List<AiRagPageText> pageText;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** A single page of extracted text for RAG ingest requests. */
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AiRagPageText {
|
||||
|
||||
private int pageNumber;
|
||||
|
||||
private String text;
|
||||
}
|
||||
+2
-2
@@ -11,8 +11,8 @@ import lombok.Data;
|
||||
@Schema(description = "Per-file content extraction request from the AI engine")
|
||||
public class AiWorkflowFileRequest {
|
||||
|
||||
@Schema(description = "Original filename of the requested file", example = "contract.pdf")
|
||||
private String fileName;
|
||||
@Schema(description = "The file the engine wants content extracted for")
|
||||
private AiFile file;
|
||||
|
||||
@Schema(description = "Specific 1-based page numbers to extract from this file")
|
||||
private List<Integer> pageNumbers = new ArrayList<>();
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ public enum AiWorkflowOutcome {
|
||||
ANSWER("answer"),
|
||||
NOT_FOUND("not_found"),
|
||||
NEED_CONTENT("need_content"),
|
||||
NEED_INGEST("need_ingest"),
|
||||
PLAN("plan"),
|
||||
NEED_CLARIFICATION("need_clarification"),
|
||||
CANNOT_DO("cannot_do"),
|
||||
|
||||
+16
@@ -8,6 +8,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Structured AI workflow result")
|
||||
public class AiWorkflowResponse {
|
||||
@@ -71,6 +73,12 @@ public class AiWorkflowResponse {
|
||||
@Schema(description = "Per-file text extraction requests from the AI engine")
|
||||
private List<AiWorkflowFileRequest> files = new ArrayList<>();
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Files the AI engine requires to be ingested into RAG before it can continue"
|
||||
+ " the workflow. Populated on need_ingest outcomes.")
|
||||
private List<AiFile> filesToIngest = new ArrayList<>();
|
||||
|
||||
@Schema(description = "Maximum number of pages the AI engine wants text extracted from")
|
||||
private Integer maxPages;
|
||||
|
||||
@@ -79,4 +87,12 @@ public class AiWorkflowResponse {
|
||||
|
||||
@Schema(description = "AI engine capability to resume with on the next turn")
|
||||
private String resumeWith;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Optional structured report from the tool (e.g. math-auditor Verdict, PDF"
|
||||
+ " comment-agent summary). Tools surface this either via a JSON response"
|
||||
+ " body or via the X-Stirling-Tool-Report header. May be null for tools"
|
||||
+ " that produce only a file.")
|
||||
private JsonNode report;
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.model.api.ai.comments;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Request body sent from Java to the Python PDF Comment Agent at {@code POST
|
||||
* /api/v1/ai/pdf-comment-agent/generate}.
|
||||
*
|
||||
* @param sessionId Random UUID that uniquely identifies this generate call.
|
||||
* @param userMessage The user's natural-language prompt (e.g. "flag any ambiguous dates").
|
||||
* @param chunks Positioned text chunks extracted from the PDF that the model may comment on.
|
||||
*/
|
||||
public record PdfCommentEngineRequest(
|
||||
String sessionId, String userMessage, List<TextChunk> chunks) {}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.model.api.ai.comments;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Response body returned by the Python PDF Comment Agent at {@code POST
|
||||
* /api/v1/ai/pdf-comment-agent/generate}.
|
||||
*
|
||||
* @param sessionId Echoes the session id from the request.
|
||||
* @param comments The comments the agent wants to place on the document.
|
||||
* @param rationale Short free-text explanation of the agent's choices.
|
||||
*/
|
||||
public record PdfCommentEngineResponse(
|
||||
String sessionId, List<PdfCommentInstruction> comments, String rationale) {}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package stirling.software.proprietary.model.api.ai.comments;
|
||||
|
||||
/**
|
||||
* A single comment instruction returned by the Python PDF Comment Agent.
|
||||
*
|
||||
* @param chunkId The {@link TextChunk#id()} the comment should anchor to.
|
||||
* @param commentText The comment body (required, non-null).
|
||||
* @param author Optional author/title for the popup. May be {@code null}.
|
||||
* @param subject Optional subject line for the popup. May be {@code null}.
|
||||
*/
|
||||
public record PdfCommentInstruction(
|
||||
String chunkId, String commentText, String author, String subject) {}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.model.api.ai.comments;
|
||||
|
||||
/**
|
||||
* One positioned text chunk extracted from a PDF, sent to the Python PDF Comment Agent so it can
|
||||
* pick which chunks to annotate.
|
||||
*
|
||||
* <p>The bounding box is in PDF user-space coordinates (origin at the page's bottom-left).
|
||||
*
|
||||
* @param id Stable chunk id in the form {@code "p{pageIdx}-c{chunkIdx}"} (both 0-indexed).
|
||||
* @param page 0-indexed page number.
|
||||
* @param x Bottom-left x coordinate of the chunk bbox (PDF user-space).
|
||||
* @param y Bottom-left y coordinate of the chunk bbox (PDF user-space).
|
||||
* @param width Width of the chunk bbox.
|
||||
* @param height Height of the chunk bbox.
|
||||
* @param text The plain-text content of the chunk (truncated to a sane length).
|
||||
*/
|
||||
public record TextChunk(
|
||||
String id, int page, float x, float y, float width, float height, String text) {}
|
||||
+38
-5
@@ -5,8 +5,10 @@ import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -22,32 +24,55 @@ public class AiEngineClient {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
@Autowired
|
||||
public AiEngineClient(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.httpClient =
|
||||
this(
|
||||
applicationProperties,
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(
|
||||
Duration.ofSeconds(
|
||||
applicationProperties.getAiEngine().getTimeoutSeconds()))
|
||||
.build();
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Package-private constructor that accepts an HttpClient directly; intended for tests. */
|
||||
AiEngineClient(ApplicationProperties applicationProperties, HttpClient httpClient) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
public String post(String path, String jsonBody) throws IOException {
|
||||
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
|
||||
return postWithTimeout(path, jsonBody, Duration.ofSeconds(config.getTimeoutSeconds()));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST with an explicit per-call timeout, for heavy operations (e.g. RAG ingestion of a large
|
||||
* document) that legitimately take longer than the default timeout.
|
||||
*/
|
||||
public String postLongRunning(String path, String jsonBody) throws IOException {
|
||||
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
|
||||
return postWithTimeout(
|
||||
path, jsonBody, Duration.ofSeconds(config.getLongRunningTimeoutSeconds()));
|
||||
}
|
||||
|
||||
private String postWithTimeout(String path, String jsonBody, Duration timeout)
|
||||
throws IOException {
|
||||
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
|
||||
if (!config.isEnabled()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled");
|
||||
}
|
||||
|
||||
String url = config.getUrl().stripTrailing() + path;
|
||||
log.debug("Proxying AI engine request to {}", url);
|
||||
log.debug("Proxying AI engine request to {} (timeout {}s)", url, timeout.toSeconds());
|
||||
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||
.timeout(timeout)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||
.build();
|
||||
|
||||
@@ -86,6 +111,14 @@ public class AiEngineClient {
|
||||
private HttpResponse<String> sendRequest(HttpRequest request) throws IOException {
|
||||
try {
|
||||
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
} catch (HttpTimeoutException e) {
|
||||
throw new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "AI engine timed out", e);
|
||||
} catch (IOException e) {
|
||||
// Connection refused, DNS failure, socket reset, etc. — surface as
|
||||
// SERVICE_UNAVAILABLE so every caller of this client sees a structured
|
||||
// status rather than a raw 500 from an unhandled IOException.
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.SERVICE_UNAVAILABLE, "AI engine unreachable: " + e.getMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ResponseStatusException(
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
|
||||
/**
|
||||
* Discovers every {@code /api/v1/...} request mapping in the application and exposes the subset
|
||||
* that {@link EndpointConfiguration} reports as currently enabled. The AI engine receives this list
|
||||
* as-is and silently drops anything it doesn't recognise, so we don't try to predict what the
|
||||
* engine considers a tool - we just emit what's enabled here.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AiEngineEndpointResolver {
|
||||
|
||||
private static final String API_PREFIX = "/api/v1/";
|
||||
|
||||
private final ApplicationContext applicationContext;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
// Written once on the Spring startup thread during ContextRefreshedEvent, read on HTTP
|
||||
// request threads. Spring's lifecycle establishes happens-before (the servlet container
|
||||
// and its worker threads are started after refresh completes), so no volatile is needed.
|
||||
private Set<String> apiUrls = Set.of();
|
||||
|
||||
public AiEngineEndpointResolver(
|
||||
ApplicationContext applicationContext, EndpointConfiguration endpointConfiguration) {
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
}
|
||||
|
||||
@EventListener(ContextRefreshedEvent.class)
|
||||
public void discoverApiUrls() {
|
||||
Set<String> discovered = new TreeSet<>();
|
||||
for (RequestMappingHandlerMapping mapping :
|
||||
applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) {
|
||||
for (RequestMappingInfo info : mapping.getHandlerMethods().keySet()) {
|
||||
for (String pattern : extractPatterns(info)) {
|
||||
if (pattern.startsWith(API_PREFIX)) {
|
||||
discovered.add(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
apiUrls = Set.copyOf(discovered);
|
||||
log.debug("Discovered {} /api/v1/ endpoint URLs for AI engine filtering", apiUrls.size());
|
||||
}
|
||||
|
||||
public List<String> getEnabledEndpointUrls() {
|
||||
return apiUrls.stream()
|
||||
.filter(endpointConfiguration::isEndpointEnabledForUri)
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static Set<String> extractPatterns(RequestMappingInfo info) {
|
||||
try {
|
||||
Method getDirectPaths = info.getClass().getMethod("getDirectPaths");
|
||||
Object result = getDirectPaths.invoke(info);
|
||||
if (result instanceof Set<?> set) {
|
||||
Set<String> patterns = new HashSet<>();
|
||||
for (Object value : set) {
|
||||
if (value instanceof String s) {
|
||||
patterns.add(s);
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace("getDirectPaths unavailable on RequestMappingInfo", e);
|
||||
}
|
||||
return Set.of();
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Shared input-validation for AI-backed tool endpoints.
|
||||
*
|
||||
* <p>Spring's {@code spring.servlet.multipart.max-file-size} is tuned for the regular PDF tools (2
|
||||
* GB) — far too permissive for AI tools where upload size translates directly into token budget,
|
||||
* memory, and engine cost. Every AI tool should call {@link #validatePdfUpload} on its input before
|
||||
* doing any work.
|
||||
*/
|
||||
public final class AiToolInputValidator {
|
||||
|
||||
/**
|
||||
* Upper bound on PDF size accepted by any AI tool. Chosen so that a realistic document fits
|
||||
* (contracts, research papers, books) while capping pathological uploads that would blow the
|
||||
* engine's token budget or memory.
|
||||
*/
|
||||
public static final long MAX_INPUT_FILE_BYTES = 50L * 1024 * 1024;
|
||||
|
||||
private AiToolInputValidator() {}
|
||||
|
||||
/**
|
||||
* Validate a PDF uploaded to an AI tool endpoint. Throws {@link ResponseStatusException} with
|
||||
* an appropriate HTTP status on any failure.
|
||||
*/
|
||||
public static void validatePdfUpload(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "fileInput is required");
|
||||
}
|
||||
String contentType = file.getContentType();
|
||||
if (contentType == null || !contentType.equals(MediaType.APPLICATION_PDF_VALUE)) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Only application/pdf uploads are supported");
|
||||
}
|
||||
if (file.getSize() > MAX_INPUT_FILE_BYTES) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
"PDF exceeds maximum size of "
|
||||
+ (MAX_INPUT_FILE_BYTES / (1024 * 1024))
|
||||
+ " MB for AI tools");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
/**
|
||||
* Custom response headers the AI tools use when returning a file body with structured metadata.
|
||||
*
|
||||
* <p>Kept in one place because the value is referenced both server-side (tools that produce the
|
||||
* header, orchestrator code that consumes it) and client-side (HTTP response handling in the
|
||||
* frontend). Changing the string requires updating every reader, so centralising avoids the "must
|
||||
* stay in sync" coupling.
|
||||
*/
|
||||
public final class AiToolResponseHeaders {
|
||||
|
||||
/**
|
||||
* Header tools set to surface a structured metadata report alongside a file body. Value is a
|
||||
* JSON object whose shape depends on the tool (e.g. {@code annotationsApplied}, {@code
|
||||
* rationale} for pdf-comment-agent). Absent when the tool has no metadata to report.
|
||||
*/
|
||||
public static final String TOOL_REPORT = "X-Stirling-Tool-Report";
|
||||
|
||||
private AiToolResponseHeaders() {}
|
||||
}
|
||||
+261
-57
@@ -11,6 +11,7 @@ import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
@@ -35,6 +36,9 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.ZipExtractionUtils;
|
||||
import stirling.software.proprietary.model.api.ai.AiConversationMessage;
|
||||
import stirling.software.proprietary.model.api.ai.AiFile;
|
||||
import stirling.software.proprietary.model.api.ai.AiRagIngestRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiRagPageText;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowFileInput;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome;
|
||||
@@ -47,6 +51,8 @@ import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.WorkflowArtifact;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Slf4j
|
||||
@@ -54,6 +60,8 @@ import tools.jackson.databind.ObjectMapper;
|
||||
@RequiredArgsConstructor
|
||||
public class AiWorkflowService {
|
||||
|
||||
private static final String RAG_DOCUMENTS_ENDPOINT = "/api/v1/rag/documents";
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final AiEngineClient aiEngineClient;
|
||||
private final PdfContentExtractor pdfContentExtractor;
|
||||
@@ -62,6 +70,8 @@ public class AiWorkflowService {
|
||||
private final FileStorage fileStorage;
|
||||
private final ToolMetadataService toolMetadataService;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final FileIdStrategy fileIdStrategy;
|
||||
private final AiEngineEndpointResolver endpointResolver;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ProgressListener {
|
||||
@@ -76,6 +86,16 @@ public class AiWorkflowService {
|
||||
record Terminal(AiWorkflowResponse response) implements WorkflowState {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal value-class for tool responses. {@code files} holds any result files (typically one;
|
||||
* multiple for ZIP-response tools). {@code report} holds an optional structured metadata
|
||||
* payload the tool chose to surface alongside (or instead of) a file.
|
||||
*
|
||||
* <p>Tools populate the report either by returning a JSON body (whole body → report) or by
|
||||
* adding the {@link AiToolResponseHeaders#TOOL_REPORT} header alongside a file body.
|
||||
*/
|
||||
private record ToolResult(List<Resource> files, JsonNode report) {}
|
||||
|
||||
public AiWorkflowResponse orchestrate(AiWorkflowRequest request) throws IOException {
|
||||
return orchestrate(request, NOOP_LISTENER);
|
||||
}
|
||||
@@ -84,42 +104,53 @@ public class AiWorkflowService {
|
||||
throws IOException {
|
||||
validateRequest(request);
|
||||
|
||||
Map<String, MultipartFile> filesByName = new LinkedHashMap<>();
|
||||
// Key by opaque file id, not filename. Filenames aren't guaranteed unique across an
|
||||
// upload (users can rotate the same 'scan.pdf' twice), and the engine identifies files
|
||||
// by id in every response shape that asks Java to look a file up again.
|
||||
Map<String, MultipartFile> filesById = new LinkedHashMap<>();
|
||||
List<AiFile> files = new ArrayList<>();
|
||||
for (AiWorkflowFileInput fileInput : request.getFileInputs()) {
|
||||
filesByName.put(
|
||||
fileInput.getFileInput().getOriginalFilename(), fileInput.getFileInput());
|
||||
MultipartFile multipartFile = fileInput.getFileInput();
|
||||
AiFile aiFile =
|
||||
new AiFile(
|
||||
fileIdStrategy.idFor(multipartFile),
|
||||
multipartFile.getOriginalFilename());
|
||||
filesById.put(aiFile.getId(), multipartFile);
|
||||
files.add(aiFile);
|
||||
}
|
||||
|
||||
WorkflowTurnRequest initialRequest = new WorkflowTurnRequest();
|
||||
initialRequest.setUserMessage(request.getUserMessage().trim());
|
||||
initialRequest.setFileNames(new ArrayList<>(filesByName.keySet()));
|
||||
initialRequest.setFiles(files);
|
||||
initialRequest.setConversationHistory(
|
||||
request.getConversationHistory() == null
|
||||
? new ArrayList<>()
|
||||
: new ArrayList<>(request.getConversationHistory()));
|
||||
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
|
||||
|
||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
|
||||
|
||||
WorkflowState state = new WorkflowState.Pending(initialRequest);
|
||||
while (state instanceof WorkflowState.Pending pending) {
|
||||
state = advance(pending.request(), filesByName, listener);
|
||||
state = advance(pending.request(), filesById, listener);
|
||||
}
|
||||
return ((WorkflowState.Terminal) state).response();
|
||||
}
|
||||
|
||||
private WorkflowState advance(
|
||||
WorkflowTurnRequest request,
|
||||
Map<String, MultipartFile> filesByName,
|
||||
Map<String, MultipartFile> filesById,
|
||||
ProgressListener listener)
|
||||
throws IOException {
|
||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.CALLING_ENGINE));
|
||||
AiWorkflowResponse response = invokeOrchestrator(request);
|
||||
return switch (response.getOutcome()) {
|
||||
case NEED_CONTENT -> onNeedContent(response, filesByName, request, listener);
|
||||
case TOOL_CALL -> onToolCall(response, filesByName, listener);
|
||||
case PLAN -> onPlan(response, filesByName, listener);
|
||||
case ANSWER,
|
||||
NOT_FOUND,
|
||||
case NEED_CONTENT -> onNeedContent(response, filesById, request, listener);
|
||||
case NEED_INGEST -> onNeedIngest(response, filesById, request, listener);
|
||||
case TOOL_CALL -> onToolCall(response, filesById, listener);
|
||||
case PLAN -> onPlan(response, filesById, request, listener);
|
||||
case ANSWER -> onAnswer(response, filesById, request, listener);
|
||||
case NOT_FOUND,
|
||||
NEED_CLARIFICATION,
|
||||
CANNOT_DO,
|
||||
DRAFT,
|
||||
@@ -132,7 +163,7 @@ public class AiWorkflowService {
|
||||
|
||||
private WorkflowState onNeedContent(
|
||||
AiWorkflowResponse response,
|
||||
Map<String, MultipartFile> filesByName,
|
||||
Map<String, MultipartFile> filesById,
|
||||
WorkflowTurnRequest request,
|
||||
ProgressListener listener)
|
||||
throws IOException {
|
||||
@@ -143,43 +174,42 @@ public class AiWorkflowService {
|
||||
|
||||
List<AiWorkflowFileRequest> requestedFiles = response.getFiles();
|
||||
|
||||
// Validate requested file names before loading anything
|
||||
// Validate requested file ids before loading anything
|
||||
if (requestedFiles != null && !requestedFiles.isEmpty()) {
|
||||
for (AiWorkflowFileRequest fileReq : requestedFiles) {
|
||||
if (!filesByName.containsKey(fileReq.getFileName())) {
|
||||
AiFile file = fileReq.getFile();
|
||||
if (file == null || !filesById.containsKey(file.getId())) {
|
||||
String display = file == null ? "<missing file>" : file.getName();
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(
|
||||
"AI engine requested unknown file: " + fileReq.getFileName()));
|
||||
cannotContinue("AI engine requested unknown file: " + display));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String> fileNamesToLoad =
|
||||
List<AiFile> filesToLoad =
|
||||
(requestedFiles == null || requestedFiles.isEmpty())
|
||||
? new ArrayList<>(filesByName.keySet())
|
||||
: requestedFiles.stream().map(AiWorkflowFileRequest::getFileName).toList();
|
||||
? new ArrayList<>(request.getFiles())
|
||||
: requestedFiles.stream().map(AiWorkflowFileRequest::getFile).toList();
|
||||
|
||||
Map<String, AiWorkflowFileRequest> requestedByName =
|
||||
Map<String, AiWorkflowFileRequest> requestedById =
|
||||
requestedFiles == null || requestedFiles.isEmpty()
|
||||
? Map.of()
|
||||
: requestedFiles.stream()
|
||||
.collect(
|
||||
Collectors.toMap(
|
||||
AiWorkflowFileRequest::getFileName, r -> r));
|
||||
.collect(Collectors.toMap(r -> r.getFile().getId(), r -> r));
|
||||
|
||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.EXTRACTING_CONTENT));
|
||||
|
||||
List<LoadedFile> loadedFiles = new ArrayList<>();
|
||||
try {
|
||||
for (String fileName : fileNamesToLoad) {
|
||||
PDDocument doc = pdfDocumentFactory.load(filesByName.get(fileName), true);
|
||||
loadedFiles.add(new LoadedFile(fileName, doc));
|
||||
for (AiFile file : filesToLoad) {
|
||||
PDDocument doc = pdfDocumentFactory.load(filesById.get(file.getId()), true);
|
||||
loadedFiles.add(new LoadedFile(file.getId(), file.getName(), doc));
|
||||
}
|
||||
|
||||
List<PdfContentResult> contentResults =
|
||||
pdfContentExtractor.extractContent(
|
||||
loadedFiles,
|
||||
requestedByName,
|
||||
requestedById,
|
||||
response.getMaxPages(),
|
||||
response.getMaxCharacters());
|
||||
|
||||
@@ -187,10 +217,11 @@ public class AiWorkflowService {
|
||||
|
||||
WorkflowTurnRequest nextRequest = new WorkflowTurnRequest();
|
||||
nextRequest.setUserMessage(request.getUserMessage());
|
||||
nextRequest.setFileNames(request.getFileNames());
|
||||
nextRequest.setFiles(request.getFiles());
|
||||
nextRequest.setConversationHistory(request.getConversationHistory());
|
||||
nextRequest.setArtifacts(pdfContentExtractor.buildArtifacts(contentResults));
|
||||
nextRequest.setResumeWith(response.getResumeWith());
|
||||
nextRequest.setEnabledEndpoints(request.getEnabledEndpoints());
|
||||
return new WorkflowState.Pending(nextRequest);
|
||||
} finally {
|
||||
for (LoadedFile lf : loadedFiles) {
|
||||
@@ -203,10 +234,74 @@ public class AiWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
private WorkflowState onNeedIngest(
|
||||
AiWorkflowResponse response,
|
||||
Map<String, MultipartFile> filesById,
|
||||
WorkflowTurnRequest request,
|
||||
ProgressListener listener)
|
||||
throws IOException {
|
||||
List<AiFile> filesToIngest = response.getFilesToIngest();
|
||||
if (filesToIngest == null || filesToIngest.isEmpty()) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(
|
||||
"AI engine returned need_ingest without listing any files to ingest."));
|
||||
}
|
||||
// Guard against a retry loop: if we've already ingested this turn and the engine still
|
||||
// asks for more, something is wrong on its side.
|
||||
if (!request.getArtifacts().isEmpty() || request.getResumeWith() != null) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(
|
||||
"AI engine requested ingest after the workflow had already been resumed."));
|
||||
}
|
||||
|
||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.EXTRACTING_CONTENT));
|
||||
|
||||
for (AiFile file : filesToIngest) {
|
||||
MultipartFile multipartFile = filesById.get(file.getId());
|
||||
if (multipartFile == null) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(
|
||||
"AI engine requested ingest for unknown file: " + file.getName()));
|
||||
}
|
||||
ingestFile(file, multipartFile);
|
||||
}
|
||||
|
||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.PROCESSING));
|
||||
|
||||
WorkflowTurnRequest nextRequest = new WorkflowTurnRequest();
|
||||
nextRequest.setUserMessage(request.getUserMessage());
|
||||
nextRequest.setFiles(request.getFiles());
|
||||
nextRequest.setConversationHistory(request.getConversationHistory());
|
||||
nextRequest.setResumeWith(response.getResumeWith());
|
||||
return new WorkflowState.Pending(nextRequest);
|
||||
}
|
||||
|
||||
private void ingestFile(AiFile file, MultipartFile multipartFile) throws IOException {
|
||||
List<AiRagPageText> pages = new ArrayList<>();
|
||||
try (PDDocument document = pdfDocumentFactory.load(multipartFile, true)) {
|
||||
int pageCount = document.getNumberOfPages();
|
||||
for (int pageNumber = 1; pageNumber <= pageCount; pageNumber++) {
|
||||
String pageText = pdfContentExtractor.extractPageTextRaw(document, pageNumber);
|
||||
if (pageText != null && !pageText.isBlank()) {
|
||||
pages.add(new AiRagPageText(pageNumber, pageText));
|
||||
}
|
||||
}
|
||||
}
|
||||
AiRagIngestRequest ingestRequest =
|
||||
new AiRagIngestRequest(file.getId(), file.getName(), pages);
|
||||
String body = objectMapper.writeValueAsString(ingestRequest);
|
||||
aiEngineClient.postLongRunning(RAG_DOCUMENTS_ENDPOINT, body);
|
||||
log.debug(
|
||||
"Ingested file into RAG: id={}, name={}, pages={}",
|
||||
file.getId(),
|
||||
file.getName(),
|
||||
pages.size());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private WorkflowState onToolCall(
|
||||
AiWorkflowResponse response,
|
||||
Map<String, MultipartFile> filesByName,
|
||||
Map<String, MultipartFile> filesById,
|
||||
ProgressListener listener) {
|
||||
String endpointPath = response.getTool();
|
||||
Map<String, Object> parameters = response.getParameters();
|
||||
@@ -219,14 +314,15 @@ public class AiWorkflowService {
|
||||
}
|
||||
|
||||
try {
|
||||
List<Resource> inputFiles = toResources(filesByName);
|
||||
List<Resource> inputFiles = toResources(filesById);
|
||||
listener.onProgress(AiWorkflowProgressEvent.executingTool(endpointPath, 1, 1));
|
||||
List<Resource> results = executeStep(endpointPath, parameters, inputFiles);
|
||||
ToolResult result = executeStep(endpointPath, parameters, inputFiles);
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
response.getRationale(),
|
||||
results,
|
||||
new ArrayList<>(filesByName.keySet())));
|
||||
result.files(),
|
||||
inputFileNames(filesById),
|
||||
result.report()));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(
|
||||
@@ -234,19 +330,46 @@ public class AiWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private WorkflowState onPlan(
|
||||
AiWorkflowResponse response,
|
||||
Map<String, MultipartFile> filesByName,
|
||||
Map<String, MultipartFile> filesById,
|
||||
WorkflowTurnRequest previousRequest,
|
||||
ProgressListener listener) {
|
||||
return runPlan(
|
||||
response.getSteps(),
|
||||
response.getResumeWith(),
|
||||
response.getSummary(),
|
||||
filesById,
|
||||
previousRequest,
|
||||
listener);
|
||||
}
|
||||
|
||||
private WorkflowState onAnswer(
|
||||
AiWorkflowResponse response,
|
||||
Map<String, MultipartFile> filesById,
|
||||
WorkflowTurnRequest previousRequest,
|
||||
ProgressListener listener) {
|
||||
return new WorkflowState.Terminal(response);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private WorkflowState runPlan(
|
||||
List<Map<String, Object>> steps,
|
||||
String resumeWith,
|
||||
String summary,
|
||||
Map<String, MultipartFile> filesById,
|
||||
WorkflowTurnRequest previousRequest,
|
||||
ProgressListener listener) {
|
||||
List<Map<String, Object>> steps = response.getSteps();
|
||||
if (steps == null || steps.isEmpty()) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue("AI engine returned a plan with no steps."));
|
||||
}
|
||||
|
||||
try {
|
||||
List<Resource> currentFiles = toResources(filesByName);
|
||||
List<Resource> currentFiles = toResources(filesById);
|
||||
// Propagate the *last* non-null report — the terminal step defines the output.
|
||||
JsonNode lastReport = null;
|
||||
String lastReportTool = null;
|
||||
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
Map<String, Object> step = steps.get(i);
|
||||
@@ -263,14 +386,34 @@ public class AiWorkflowService {
|
||||
|
||||
listener.onProgress(
|
||||
AiWorkflowProgressEvent.executingTool(endpointPath, i + 1, steps.size()));
|
||||
currentFiles = executeStep(endpointPath, parameters, currentFiles);
|
||||
ToolResult stepResult = executeStep(endpointPath, parameters, currentFiles);
|
||||
currentFiles = stepResult.files();
|
||||
if (stepResult.report() != null) {
|
||||
lastReport = stepResult.report();
|
||||
lastReportTool = endpointPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-turn: if the plan was emitted with resume_with set, the delegate wants
|
||||
// Java to re-invoke the orchestrator with any captured report as an artifact.
|
||||
if (resumeWith != null && !resumeWith.isBlank() && lastReport != null) {
|
||||
WorkflowTurnRequest resumeRequest = new WorkflowTurnRequest();
|
||||
resumeRequest.setUserMessage(previousRequest.getUserMessage());
|
||||
resumeRequest.setFiles(previousRequest.getFiles());
|
||||
resumeRequest.setConversationHistory(previousRequest.getConversationHistory());
|
||||
resumeRequest.setArtifacts(new ArrayList<>(previousRequest.getArtifacts()));
|
||||
resumeRequest
|
||||
.getArtifacts()
|
||||
.add(
|
||||
new PdfContentExtractor.ToolReportArtifact(
|
||||
lastReportTool, lastReport));
|
||||
resumeRequest.setResumeWith(resumeWith);
|
||||
return new WorkflowState.Pending(resumeRequest);
|
||||
}
|
||||
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
response.getSummary(),
|
||||
currentFiles,
|
||||
new ArrayList<>(filesByName.keySet())));
|
||||
summary, currentFiles, inputFileNames(filesById), lastReport));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to execute plan: {}", e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(
|
||||
@@ -278,31 +421,54 @@ public class AiWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> inputFileNames(Map<String, MultipartFile> filesById) {
|
||||
return filesById.values().stream().map(MultipartFile::getOriginalFilename).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one
|
||||
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each
|
||||
* inner file is treated as its own result (e.g. split outputs a ZIP of pages).
|
||||
*
|
||||
* <p>A structured {@code report} may be returned alongside (or instead of) files — see {@link
|
||||
* ToolResult}. For per-file dispatch (single-input endpoints called once per input), the first
|
||||
* non-null report wins.
|
||||
*/
|
||||
private List<Resource> executeStep(
|
||||
private ToolResult executeStep(
|
||||
String endpointPath, Map<String, Object> parameters, List<Resource> inputFiles)
|
||||
throws IOException {
|
||||
List<Resource> results = new ArrayList<>();
|
||||
List<Resource> files = new ArrayList<>();
|
||||
JsonNode report = null;
|
||||
if (toolMetadataService.isMultiInput(endpointPath)) {
|
||||
results.addAll(callEndpoint(endpointPath, parameters, inputFiles));
|
||||
ToolResult r = callEndpoint(endpointPath, parameters, inputFiles);
|
||||
files.addAll(r.files());
|
||||
report = r.report();
|
||||
} else {
|
||||
for (Resource file : inputFiles) {
|
||||
results.addAll(callEndpoint(endpointPath, parameters, List.of(file)));
|
||||
ToolResult r = callEndpoint(endpointPath, parameters, List.of(file));
|
||||
files.addAll(r.files());
|
||||
if (report == null) {
|
||||
report = r.report();
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
return new ToolResult(files, report);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an endpoint and return the response body. Endpoints that are declared as ZIP-returning
|
||||
* in the API spec (multi-output, or {@code Output:ZIP-*}) are unpacked into their individual
|
||||
* entries so callers always see a flat list of result files.
|
||||
* Call an endpoint and return its result files and optional report.
|
||||
*
|
||||
* <ul>
|
||||
* <li>JSON body (Content-Type: application/json) → the entire body is the report, no files
|
||||
* are returned.
|
||||
* <li>File body (PDF etc.) → the file is returned; if an {@link
|
||||
* AiToolResponseHeaders#TOOL_REPORT} header is present, its (minified JSON) value is
|
||||
* parsed as the report.
|
||||
* <li>ZIP responses declared by the tool metadata service are unpacked so callers always see
|
||||
* a flat list of result files.
|
||||
* </ul>
|
||||
*/
|
||||
private List<Resource> callEndpoint(
|
||||
private ToolResult callEndpoint(
|
||||
String endpointPath, Map<String, Object> parameters, List<Resource> files)
|
||||
throws IOException {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
@@ -324,15 +490,48 @@ public class AiWorkflowService {
|
||||
"Tool returned HTTP " + response.getStatusCode() + " for " + endpointPath);
|
||||
}
|
||||
Resource resource = response.getBody();
|
||||
if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) {
|
||||
return ZipExtractionUtils.extractZip(resource, tempFileManager);
|
||||
HttpHeaders headers = response.getHeaders();
|
||||
MediaType contentType = headers.getContentType();
|
||||
|
||||
// JSON-only response — the whole body is the structured report, no result file.
|
||||
if (contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
|
||||
try (java.io.InputStream is = resource.getInputStream()) {
|
||||
JsonNode report = objectMapper.readTree(is);
|
||||
return new ToolResult(List.of(), report);
|
||||
}
|
||||
}
|
||||
return List.of(resource);
|
||||
|
||||
JsonNode report = parseReportHeader(headers, endpointPath);
|
||||
if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) {
|
||||
return new ToolResult(ZipExtractionUtils.extractZip(resource, tempFileManager), report);
|
||||
}
|
||||
return new ToolResult(List.of(resource), report);
|
||||
}
|
||||
|
||||
private List<Resource> toResources(Map<String, MultipartFile> filesByName) throws IOException {
|
||||
/**
|
||||
* Parse the optional {@link AiToolResponseHeaders#TOOL_REPORT} header into a {@link JsonNode},
|
||||
* or return null.
|
||||
*/
|
||||
private JsonNode parseReportHeader(HttpHeaders headers, String endpointPath) {
|
||||
String raw = headers.getFirst(AiToolResponseHeaders.TOOL_REPORT);
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(raw);
|
||||
} catch (JacksonException e) {
|
||||
log.warn(
|
||||
"Ignoring malformed {} header from {}: {}",
|
||||
AiToolResponseHeaders.TOOL_REPORT,
|
||||
endpointPath,
|
||||
e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Resource> toResources(Map<String, MultipartFile> filesById) throws IOException {
|
||||
List<Resource> resources = new ArrayList<>();
|
||||
for (MultipartFile file : filesByName.values()) {
|
||||
for (MultipartFile file : filesById.values()) {
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
|
||||
file.transferTo(tempFile.getPath());
|
||||
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
|
||||
@@ -348,7 +547,10 @@ public class AiWorkflowService {
|
||||
}
|
||||
|
||||
private AiWorkflowResponse buildCompletedResponse(
|
||||
String summary, List<Resource> resultFiles, List<String> inputFileNames)
|
||||
String summary,
|
||||
List<Resource> resultFiles,
|
||||
List<String> inputFileNames,
|
||||
JsonNode report)
|
||||
throws IOException {
|
||||
// Store every output file individually so each gets its own Stirling file ID and the
|
||||
// frontend can add them as independent variants without going through a zip.
|
||||
@@ -386,6 +588,7 @@ public class AiWorkflowService {
|
||||
completed.setOutcome(AiWorkflowOutcome.COMPLETED);
|
||||
completed.setSummary(summary);
|
||||
completed.setResultFiles(descriptors);
|
||||
completed.setReport(report);
|
||||
// Mirror the first file into the legacy single-file fields so existing clients still work.
|
||||
if (!descriptors.isEmpty()) {
|
||||
AiWorkflowResultFile first = descriptors.getFirst();
|
||||
@@ -420,9 +623,10 @@ public class AiWorkflowService {
|
||||
@Data
|
||||
private static class WorkflowTurnRequest {
|
||||
private String userMessage;
|
||||
private List<String> fileNames = new ArrayList<>();
|
||||
private List<AiFile> files = new ArrayList<>();
|
||||
private List<AiConversationMessage> conversationHistory = new ArrayList<>();
|
||||
private List<WorkflowArtifact> artifacts = new ArrayList<>();
|
||||
private String resumeWith;
|
||||
private List<String> enabledEndpoints = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* Content-addressable id derived from the SHA-256 hash of the uploaded bytes. Same content always
|
||||
* hashes to the same id, so re-uploads dedupe naturally in RAG. Suitable for session and SaaS
|
||||
* deployments; a folder-watch deployment would use a different strategy keyed by path.
|
||||
*/
|
||||
@Component
|
||||
public class ByteHashFileIdStrategy implements FileIdStrategy {
|
||||
|
||||
/**
|
||||
* Hex-char length of the returned id. 16 chars = 64 bits of collision space, which is plenty
|
||||
* for per-user document sets and keeps RAG collection names short.
|
||||
*/
|
||||
private static final int ID_HEX_LENGTH = 16;
|
||||
|
||||
private static final int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
@Override
|
||||
public String idFor(MultipartFile file) throws IOException {
|
||||
MessageDigest digest = sha256();
|
||||
try (InputStream in = file.getInputStream()) {
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
byte[] hash = digest.digest();
|
||||
StringBuilder hex = new StringBuilder(ID_HEX_LENGTH);
|
||||
for (int i = 0; hex.length() < ID_HEX_LENGTH; i++) {
|
||||
hex.append(String.format("%02x", hash[i]));
|
||||
}
|
||||
return hex.toString();
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
// SHA-256 is mandated by the JDK; absent only if the platform is broken.
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* Produces stable identifiers for uploaded files. The identifier is opaque to the AI engine and
|
||||
* serves as the RAG collection key when content is ingested. Swapping implementations (content
|
||||
* hash, filesystem path, tenant-scoped id, etc.) is how the system adapts to different deployment
|
||||
* models without any engine-side change.
|
||||
*/
|
||||
public interface FileIdStrategy {
|
||||
|
||||
String idFor(MultipartFile file) throws IOException;
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfAnnotationService;
|
||||
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineRequest;
|
||||
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineResponse;
|
||||
import stirling.software.proprietary.model.api.ai.comments.PdfCommentInstruction;
|
||||
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Composed AI tool for PDF comment generation.
|
||||
*
|
||||
* <p>Runs the full flow:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Validate inputs (PDF, non-empty prompt within length limit).
|
||||
* <li>Extract positioned text chunks from the PDF.
|
||||
* <li>POST the chunks + prompt to the Python agent at {@code
|
||||
* /api/v1/ai/pdf-comment-agent/generate}.
|
||||
* <li>Resolve each returned chunk-id reference to an absolute {@link StickyNoteSpec}.
|
||||
* <li>Hand the specs to {@link PdfAnnotationService} for deterministic placement.
|
||||
* <li>Save the annotated PDF, return the bytes + filename.
|
||||
* </ol>
|
||||
*
|
||||
* <p>Annotation primitives live in {@link PdfAnnotationService} (shared with {@code
|
||||
* /api/v1/misc/add-comments}). This class owns only the AI-specific bits: chunk extraction and
|
||||
* engine round-trip.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PdfCommentAgentOrchestrator {
|
||||
|
||||
private static final String GENERATE_PATH = "/api/v1/ai/pdf-comment-agent/generate";
|
||||
private static final int MAX_PROMPT_LEN = 4000;
|
||||
|
||||
/** Width/height of the sticky-note icon placed on the page, in PDF user-space units. */
|
||||
private static final float ANNOTATION_SIZE = 20f;
|
||||
|
||||
/** Filename used when the uploaded PDF has no usable original filename. */
|
||||
private static final String FALLBACK_OUTPUT_NAME = "document-commented.pdf";
|
||||
|
||||
/**
|
||||
* Small value record returned to the controller: the annotated PDF bytes, the suggested
|
||||
* download filename (used in the {@code Content-Disposition} header), and metadata the
|
||||
* controller emits in the {@code X-Stirling-Tool-Report} header so callers (frontend,
|
||||
* orchestrator) can surface a chat-style summary alongside the file.
|
||||
*/
|
||||
public record AnnotatedPdf(
|
||||
byte[] bytes,
|
||||
String fileName,
|
||||
int annotationsApplied,
|
||||
int instructionsReceived,
|
||||
String rationale) {}
|
||||
|
||||
private final AiEngineClient aiEngineClient;
|
||||
private final PdfTextChunkExtractor pdfTextChunkExtractor;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PdfAnnotationService pdfAnnotationService;
|
||||
|
||||
/**
|
||||
* Run the full PDF comment generation flow.
|
||||
*
|
||||
* @param pdfFile the uploaded PDF
|
||||
* @param prompt the user's natural-language instructions
|
||||
* @return the annotated PDF bytes and suggested filename
|
||||
*/
|
||||
public AnnotatedPdf applyComments(MultipartFile pdfFile, String prompt) throws IOException {
|
||||
AiToolInputValidator.validatePdfUpload(pdfFile);
|
||||
String trimmedPrompt = prompt == null ? "" : prompt.trim();
|
||||
if (trimmedPrompt.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Prompt is required");
|
||||
}
|
||||
if (trimmedPrompt.length() > MAX_PROMPT_LEN) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Prompt exceeds maximum length of " + MAX_PROMPT_LEN + " characters");
|
||||
}
|
||||
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
log.info(
|
||||
"[pdf-comment-agent] session={} file={} promptLen={}",
|
||||
sessionId,
|
||||
safeName(pdfFile.getOriginalFilename()),
|
||||
trimmedPrompt.length());
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
List<TextChunk> chunks = pdfTextChunkExtractor.extract(document);
|
||||
if (chunks.isEmpty()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "PDF has no extractable text");
|
||||
}
|
||||
log.info(
|
||||
"[pdf-comment-agent] session={} extracted {} chunks across {} pages",
|
||||
sessionId,
|
||||
chunks.size(),
|
||||
document.getNumberOfPages());
|
||||
|
||||
PdfCommentEngineResponse engineResponse =
|
||||
requestComments(sessionId, trimmedPrompt, chunks);
|
||||
List<PdfCommentInstruction> instructions =
|
||||
engineResponse.comments() == null ? List.of() : engineResponse.comments();
|
||||
|
||||
// Resolve chunk-id-referenced comments to absolute sticky-note specs, then delegate
|
||||
// placement to the shared service (same primitive /api/v1/misc/add-comments uses).
|
||||
List<StickyNoteSpec> specs = resolveSpecs(instructions, chunks, sessionId);
|
||||
int applied = pdfAnnotationService.addStickyNotes(document, specs);
|
||||
log.info(
|
||||
"[pdf-comment-agent] session={} placed {}/{} sticky notes",
|
||||
sessionId,
|
||||
applied,
|
||||
instructions.size());
|
||||
|
||||
byte[] annotatedBytes;
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
document.save(baos);
|
||||
annotatedBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
String outputName = buildOutputFileName(pdfFile.getOriginalFilename());
|
||||
log.info(
|
||||
"[pdf-comment-agent] session={} done fileName={} bytes={}",
|
||||
sessionId,
|
||||
outputName,
|
||||
annotatedBytes.length);
|
||||
return new AnnotatedPdf(
|
||||
annotatedBytes,
|
||||
outputName,
|
||||
applied,
|
||||
instructions.size(),
|
||||
engineResponse.rationale());
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Engine round-trip
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private PdfCommentEngineResponse requestComments(
|
||||
String sessionId, String prompt, List<TextChunk> chunks) throws IOException {
|
||||
PdfCommentEngineRequest engineRequest =
|
||||
new PdfCommentEngineRequest(sessionId, prompt, chunks);
|
||||
String requestBody = objectMapper.writeValueAsString(engineRequest);
|
||||
String responseBody = aiEngineClient.post(GENERATE_PATH, requestBody);
|
||||
PdfCommentEngineResponse engineResponse =
|
||||
objectMapper.readValue(responseBody, PdfCommentEngineResponse.class);
|
||||
|
||||
List<PdfCommentInstruction> instructions =
|
||||
engineResponse.comments() == null ? List.of() : engineResponse.comments();
|
||||
log.info(
|
||||
"[pdf-comment-agent] session={} engine returned {} comments: {}",
|
||||
sessionId,
|
||||
instructions.size(),
|
||||
engineResponse.rationale());
|
||||
return engineResponse;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Chunk-id → StickyNoteSpec resolution
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Translate each engine-returned {@link PdfCommentInstruction} (chunk-id-referenced) into an
|
||||
* absolute-positioned {@link StickyNoteSpec}. Unknown or malformed ids are logged and dropped.
|
||||
*/
|
||||
private List<StickyNoteSpec> resolveSpecs(
|
||||
List<PdfCommentInstruction> instructions, List<TextChunk> chunks, String sessionId) {
|
||||
if (instructions.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<String, TextChunk> chunksById = new HashMap<>();
|
||||
for (TextChunk chunk : chunks) {
|
||||
chunksById.put(chunk.id(), chunk);
|
||||
}
|
||||
|
||||
List<StickyNoteSpec> specs = new ArrayList<>(instructions.size());
|
||||
for (PdfCommentInstruction inst : instructions) {
|
||||
if (inst == null || inst.chunkId() == null || inst.commentText() == null) {
|
||||
log.warn(
|
||||
"[pdf-comment-agent] session={} skipping malformed instruction: {}",
|
||||
sessionId,
|
||||
inst);
|
||||
continue;
|
||||
}
|
||||
TextChunk chunk = chunksById.get(inst.chunkId());
|
||||
if (chunk == null) {
|
||||
log.warn(
|
||||
"[pdf-comment-agent] session={} unknown chunkId={} - skipping",
|
||||
sessionId,
|
||||
inst.chunkId());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anchor the sticky-note icon at the top-left of the chunk's bbox.
|
||||
float iconX = chunk.x();
|
||||
float iconY = chunk.y() + chunk.height() - ANNOTATION_SIZE;
|
||||
AnnotationLocation loc =
|
||||
new AnnotationLocation(
|
||||
chunk.page(), iconX, iconY, ANNOTATION_SIZE, ANNOTATION_SIZE);
|
||||
specs.add(new StickyNoteSpec(loc, inst.commentText(), inst.author(), inst.subject()));
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static String buildOutputFileName(String originalFilename) {
|
||||
String safe = safeName(originalFilename);
|
||||
if (safe == null || safe.isBlank() || "<unnamed>".equals(safe)) {
|
||||
return FALLBACK_OUTPUT_NAME;
|
||||
}
|
||||
String base = FilenameUtils.getBaseName(safe);
|
||||
if (base == null || base.isBlank()) {
|
||||
base = "document";
|
||||
}
|
||||
return base + "-commented.pdf";
|
||||
}
|
||||
|
||||
private static String safeName(String originalFilename) {
|
||||
return originalFilename != null
|
||||
? originalFilename.replaceAll("[\\r\\n]", "_")
|
||||
: "<unnamed>";
|
||||
}
|
||||
}
|
||||
+34
-4
@@ -44,7 +44,11 @@ public class PdfContentExtractor {
|
||||
|
||||
private static final int TEXT_PRESENCE_THRESHOLD = 20;
|
||||
|
||||
record LoadedFile(String fileName, PDDocument document) {}
|
||||
/**
|
||||
* A loaded PDF alongside the opaque file id used by the AI engine as its RAG collection key.
|
||||
* Keyed by id (not name) because filenames aren't unique across an upload.
|
||||
*/
|
||||
record LoadedFile(String id, String fileName, PDDocument document) {}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Low-level extraction methods (usable by any agent)
|
||||
@@ -126,7 +130,7 @@ public class PdfContentExtractor {
|
||||
*/
|
||||
List<PdfContentResult> extractContent(
|
||||
List<LoadedFile> loadedFiles,
|
||||
Map<String, AiWorkflowFileRequest> requestedByName,
|
||||
Map<String, AiWorkflowFileRequest> requestedById,
|
||||
int maxPages,
|
||||
int maxCharacters)
|
||||
throws IOException {
|
||||
@@ -136,7 +140,7 @@ public class PdfContentExtractor {
|
||||
|
||||
for (LoadedFile lf : loadedFiles) {
|
||||
if (remainingPages <= 0 || remainingCharacters <= 0) break;
|
||||
AiWorkflowFileRequest fileReq = requestedByName.get(lf.fileName());
|
||||
AiWorkflowFileRequest fileReq = requestedById.get(lf.id());
|
||||
List<AiPdfContentType> contentTypes =
|
||||
fileReq != null && !fileReq.getContentTypes().isEmpty()
|
||||
? fileReq.getContentTypes()
|
||||
@@ -210,6 +214,12 @@ public class PdfContentExtractor {
|
||||
artifact.setFiles(results.stream().map(ExtractedFileText.class::cast).toList());
|
||||
yield artifact;
|
||||
}
|
||||
case TOOL_REPORT ->
|
||||
// TOOL_REPORT artifacts don't come from PDF content extraction — they're
|
||||
// built by AiWorkflowService from tool-response metadata. Never reached
|
||||
// from this code path; presence in the enum is to satisfy the switch.
|
||||
throw new IllegalArgumentException(
|
||||
"TOOL_REPORT artifacts are not produced by PdfContentExtractor");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -320,7 +330,8 @@ public class PdfContentExtractor {
|
||||
* Values MUST match {@code ArtifactKind} in {@code engine/src/stirling/contracts/common.py}.
|
||||
*/
|
||||
enum ArtifactKind {
|
||||
EXTRACTED_TEXT("extracted_text");
|
||||
EXTRACTED_TEXT("extracted_text"),
|
||||
TOOL_REPORT("tool_report");
|
||||
|
||||
private final String value;
|
||||
|
||||
@@ -364,4 +375,23 @@ public class PdfContentExtractor {
|
||||
private final ArtifactKind kind = ArtifactKind.EXTRACTED_TEXT;
|
||||
private List<ExtractedFileText> files = new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries a structured report produced by a specialist tool back to the orchestrator on a
|
||||
* resume turn. Shape matches {@code engine/src/stirling/contracts/common.py ToolReportArtifact}
|
||||
* — {@code sourceTool} must be a valid endpoint path string.
|
||||
*/
|
||||
@Data
|
||||
static final class ToolReportArtifact implements WorkflowArtifact {
|
||||
private final ArtifactKind kind = ArtifactKind.TOOL_REPORT;
|
||||
private String sourceTool;
|
||||
private tools.jackson.databind.JsonNode report;
|
||||
|
||||
ToolReportArtifact() {}
|
||||
|
||||
ToolReportArtifact(String sourceTool, tools.jackson.databind.JsonNode report) {
|
||||
this.sourceTool = sourceTool;
|
||||
this.report = report;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Writer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
|
||||
|
||||
/**
|
||||
* Extracts positioned, line-level text chunks from a PDF so the PDF Comment Agent can decide where
|
||||
* to anchor annotations. One chunk per text line, with the bounding box converted to PDF user-space
|
||||
* coordinates (origin = bottom-left).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PdfTextChunkExtractor {
|
||||
|
||||
/** Hard cap on total chunks emitted per document. */
|
||||
private static final int MAX_CHUNKS_PER_DOC = 2000;
|
||||
|
||||
/** Truncate each chunk's text to at most this many characters. */
|
||||
private static final int MAX_CHUNK_TEXT_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* Extract line-level text chunks with bounding boxes from the given document.
|
||||
*
|
||||
* <p>Each chunk's coordinates are in PDF user-space (origin = bottom-left of the page). Chunks
|
||||
* that are whitespace-only after trimming are skipped.
|
||||
*
|
||||
* @param document the open PDF
|
||||
* @return positioned chunks (never {@code null})
|
||||
* @throws IOException on PDF parse errors
|
||||
*/
|
||||
public List<TextChunk> extract(PDDocument document) throws IOException {
|
||||
List<TextChunk> chunks = new ArrayList<>();
|
||||
ChunkStripper stripper = new ChunkStripper(document, chunks);
|
||||
stripper.setSortByPosition(true);
|
||||
stripper.getText(document);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* PDFTextStripper subclass that emits one {@link TextChunk} per call to {@code writeString}.
|
||||
* PDFBox invokes {@code writeString} once per visual line when {@link
|
||||
* PDFTextStripper#setSortByPosition(boolean)} is true, which gives us exactly the granularity
|
||||
* we want.
|
||||
*/
|
||||
private static final class ChunkStripper extends PDFTextStripper {
|
||||
|
||||
private final PDDocument document;
|
||||
private final List<TextChunk> chunks;
|
||||
private int currentPageIdx = 0; // 0-indexed, tracked via startPage
|
||||
private int chunkIdxOnPage = 0;
|
||||
private boolean capWarningLogged = false;
|
||||
|
||||
ChunkStripper(PDDocument document, List<TextChunk> chunks) throws IOException {
|
||||
super();
|
||||
this.document = document;
|
||||
this.chunks = chunks;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startPage(org.apache.pdfbox.pdmodel.PDPage page) throws IOException {
|
||||
super.startPage(page);
|
||||
// getCurrentPageNo() is 1-based; convert to 0-based.
|
||||
currentPageIdx = getCurrentPageNo() - 1;
|
||||
chunkIdxOnPage = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeString(String text, List<TextPosition> textPositions)
|
||||
throws IOException {
|
||||
if (chunks.size() >= MAX_CHUNKS_PER_DOC) {
|
||||
if (!capWarningLogged) {
|
||||
log.warn(
|
||||
"[pdf-comment-agent] chunk cap of {} reached; remaining text will not"
|
||||
+ " be extracted",
|
||||
MAX_CHUNKS_PER_DOC);
|
||||
capWarningLogged = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (textPositions == null || textPositions.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String trimmed = text == null ? "" : text.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the bounding box from the min/max of TextPosition adjusted coordinates.
|
||||
// getXDirAdj / getYDirAdj / getHeightDir / getWidthDirAdj already account for the
|
||||
// page's rotation so we can treat them as axis-aligned in the page's display frame.
|
||||
float minX = Float.POSITIVE_INFINITY;
|
||||
float maxRight = Float.NEGATIVE_INFINITY;
|
||||
float minYTopDown = Float.POSITIVE_INFINITY; // smallest y in top-down coords
|
||||
float maxHeight = 0f;
|
||||
|
||||
for (TextPosition pos : textPositions) {
|
||||
float x = pos.getXDirAdj();
|
||||
float right = x + pos.getWidthDirAdj();
|
||||
float yTop = pos.getYDirAdj();
|
||||
float h = pos.getHeightDir();
|
||||
if (h <= 0f) {
|
||||
h = pos.getFontSizeInPt();
|
||||
}
|
||||
if (x < minX) minX = x;
|
||||
if (right > maxRight) maxRight = right;
|
||||
if (yTop < minYTopDown) minYTopDown = yTop;
|
||||
if (h > maxHeight) maxHeight = h;
|
||||
}
|
||||
if (maxHeight <= 0f) {
|
||||
// Fallback if everything was zero — small but non-zero so the rect is valid.
|
||||
maxHeight = 10f;
|
||||
}
|
||||
|
||||
float width = maxRight - minX;
|
||||
if (width <= 0f) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert y to PDF user-space (origin at bottom-left of the page).
|
||||
// getYDirAdj reports the top of each glyph, measured from the top of the page.
|
||||
PDRectangle mediaBox = document.getPage(currentPageIdx).getMediaBox();
|
||||
float pageHeight = mediaBox.getHeight();
|
||||
float bottomY = pageHeight - minYTopDown - maxHeight;
|
||||
|
||||
String id = "p" + currentPageIdx + "-c" + chunkIdxOnPage;
|
||||
chunkIdxOnPage++;
|
||||
|
||||
String storedText = trimmed;
|
||||
if (storedText.length() > MAX_CHUNK_TEXT_LENGTH) {
|
||||
storedText = storedText.substring(0, MAX_CHUNK_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
chunks.add(
|
||||
new TextChunk(id, currentPageIdx, minX, bottomY, width, maxHeight, storedText));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeCharacters(TextPosition text) {
|
||||
// no-op: we only emit chunks at writeString granularity
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getText(PDDocument doc) throws IOException {
|
||||
// We don't actually need the concatenated text — just the side-effects. Return early
|
||||
// to avoid building a (potentially massive) StringBuilder of the whole document.
|
||||
try (Writer discard =
|
||||
new Writer() {
|
||||
@Override
|
||||
public void write(char[] cbuf, int off, int len) {
|
||||
// discard
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() {}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
}) {
|
||||
writeText(doc, discard);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver;
|
||||
import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver;
|
||||
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
||||
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Controller tests for {@link PdfCommentAgentController}. The orchestrator is mocked so the test
|
||||
* never hits the engine or real filesystem.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PdfCommentAgentControllerTest {
|
||||
|
||||
@Mock private PdfCommentAgentOrchestrator orchestrator;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
PdfCommentAgentController controller =
|
||||
new PdfCommentAgentController(orchestrator, JsonMapper.builder().build());
|
||||
mockMvc =
|
||||
MockMvcBuilders.standaloneSetup(controller)
|
||||
// standaloneSetup's defaults don't handle ResponseStatusException; wire up
|
||||
// both the ResponseStatusException resolver (for orchestrator 400s) and
|
||||
// DefaultHandlerExceptionResolver (so missing @RequestParam still 400s).
|
||||
.setHandlerExceptionResolvers(
|
||||
new ResponseStatusExceptionResolver(),
|
||||
new DefaultHandlerExceptionResolver())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsValidPdfAndReturnsAnnotatedBytes() throws Exception {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"input.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"%PDF-1.4\n%%EOF".getBytes());
|
||||
|
||||
byte[] annotatedBytes = "%PDF-1.4\n<annotated>\n%%EOF".getBytes();
|
||||
AnnotatedPdf stub = new AnnotatedPdf(annotatedBytes, "input-commented.pdf", 2, 2, "ok");
|
||||
when(orchestrator.applyComments(any(MultipartFile.class), eq("flag dates")))
|
||||
.thenReturn(stub);
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v1/ai/tools/pdf-comment-agent")
|
||||
.file(pdfFile)
|
||||
.param("prompt", "flag dates"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
|
||||
.andExpect(
|
||||
header().string(
|
||||
"Content-Disposition",
|
||||
org.hamcrest.Matchers.containsString(
|
||||
"input-commented.pdf")))
|
||||
.andExpect(content().bytes(annotatedBytes));
|
||||
|
||||
verify(orchestrator).applyComments(any(MultipartFile.class), eq("flag dates"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesOrchestratorBadRequestForNonPdfUpload() throws Exception {
|
||||
// The controller delegates validation to the orchestrator; a ResponseStatusException
|
||||
// thrown by the orchestrator should propagate to Spring as a 400.
|
||||
MockMultipartFile notPdf =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "input.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes());
|
||||
when(orchestrator.applyComments(any(MultipartFile.class), eq("whatever")))
|
||||
.thenThrow(
|
||||
new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Only application/pdf uploads are supported"));
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v1/ai/tools/pdf-comment-agent")
|
||||
.file(notPdf)
|
||||
.param("prompt", "whatever"))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verify(orchestrator).applyComments(any(MultipartFile.class), eq("whatever"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingFileInput() throws Exception {
|
||||
mockMvc.perform(multipart("/api/v1/ai/tools/pdf-comment-agent").param("prompt", "test"))
|
||||
.andExpect(status().is4xxClientError());
|
||||
|
||||
verify(orchestrator, never()).applyComments(any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingPromptParameter() throws Exception {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"input.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"%PDF-1.4\n%%EOF".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/ai/tools/pdf-comment-agent").file(pdfFile))
|
||||
.andExpect(status().is4xxClientError());
|
||||
|
||||
verify(orchestrator, never()).applyComments(any(), anyString());
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ConnectException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Verifies that AiEngineClient surfaces network-layer failures as structured HTTP statuses so every
|
||||
* AI tool caller sees a consistent, meaningful error rather than a raw 500.
|
||||
*/
|
||||
class AiEngineClientTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private HttpClient httpClient;
|
||||
private AiEngineClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getAiEngine().setEnabled(true);
|
||||
applicationProperties.getAiEngine().setUrl("http://localhost:5001");
|
||||
applicationProperties.getAiEngine().setTimeoutSeconds(5);
|
||||
httpClient = mock(HttpClient.class);
|
||||
client = new AiEngineClient(applicationProperties, httpClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
void postWrapsConnectIOExceptionAsServiceUnavailable() throws Exception {
|
||||
ConnectException cause = new ConnectException("Connection refused");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
|
||||
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
assertSame(cause, ex.getCause(), "Original cause should be preserved for diagnostics");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postWrapsTimeoutAsGatewayTimeout() throws Exception {
|
||||
HttpTimeoutException cause = new HttpTimeoutException("request timed out");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
|
||||
|
||||
assertEquals(HttpStatus.GATEWAY_TIMEOUT, ex.getStatusCode());
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWrapsGenericIOExceptionAsServiceUnavailable() throws Exception {
|
||||
IOException cause = new IOException("socket reset");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.get("/x"));
|
||||
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void postShortCircuitsWhenEngineDisabled() {
|
||||
applicationProperties.getAiEngine().setEnabled(false);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
|
||||
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
class AiToolInputValidatorTest {
|
||||
|
||||
@Test
|
||||
void acceptsValidPdfUpload() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "a.pdf", "application/pdf", new byte[] {1, 2});
|
||||
assertDoesNotThrow(() -> AiToolInputValidator.validatePdfUpload(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullFile() {
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> AiToolInputValidator.validatePdfUpload(null));
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmptyFile() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "a.pdf", "application/pdf", new byte[0]);
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> AiToolInputValidator.validatePdfUpload(file));
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonPdfContentType() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "a.txt", "text/plain", new byte[] {1, 2});
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> AiToolInputValidator.validatePdfUpload(file));
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingContentType() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "a.pdf", null, new byte[] {1, 2});
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> AiToolInputValidator.validatePdfUpload(file));
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedFile() {
|
||||
// Mock getSize() to avoid allocating a 50 MB test payload.
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getContentType()).thenReturn("application/pdf");
|
||||
when(file.getSize()).thenReturn(AiToolInputValidator.MAX_INPUT_FILE_BYTES + 1);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> AiToolInputValidator.validatePdfUpload(file));
|
||||
assertEquals(HttpStatus.PAYLOAD_TOO_LARGE, ex.getStatusCode());
|
||||
}
|
||||
}
|
||||
+59
-2
@@ -3,8 +3,11 @@ package stirling.software.proprietary.service;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -21,6 +24,8 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -32,6 +37,7 @@ import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -72,6 +78,8 @@ class AiWorkflowServiceTest {
|
||||
@Mock private InternalApiClient internalApiClient;
|
||||
@Mock private FileStorage fileStorage;
|
||||
@Mock private ToolMetadataService toolMetadataService;
|
||||
@Mock private FileIdStrategy fileIdStrategy;
|
||||
@Mock private AiEngineEndpointResolver endpointResolver;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@@ -80,13 +88,19 @@ class AiWorkflowServiceTest {
|
||||
private AiWorkflowService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
void setUp() throws IOException {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
props.getSystem().getTempFileManagement().setPrefix("ai-test-");
|
||||
tempFileManager = new TempFileManager(new TempFileRegistry(), props);
|
||||
objectMapper = JsonMapper.builder().build();
|
||||
|
||||
// Mock strategy yields the filename as id so each MockMultipartFile in a test gets a
|
||||
// distinct collection key. Real strategy (ByteHashFileIdStrategy) hashes bytes.
|
||||
lenient()
|
||||
.when(fileIdStrategy.idFor(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> ((MultipartFile) inv.getArgument(0)).getOriginalFilename());
|
||||
|
||||
service =
|
||||
new AiWorkflowService(
|
||||
pdfDocumentFactory,
|
||||
@@ -96,7 +110,10 @@ class AiWorkflowServiceTest {
|
||||
internalApiClient,
|
||||
fileStorage,
|
||||
toolMetadataService,
|
||||
tempFileManager);
|
||||
tempFileManager,
|
||||
fileIdStrategy,
|
||||
endpointResolver);
|
||||
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -246,6 +263,46 @@ class AiWorkflowServiceTest {
|
||||
verify(internalApiClient, never()).post(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void needIngestExtractsPageTextAndPostsToRagThenRetries() throws IOException {
|
||||
MockMultipartFile input = pdf("report.pdf", "bytes");
|
||||
when(fileIdStrategy.idFor(any())).thenReturn("report-id");
|
||||
|
||||
PDDocument document = new PDDocument();
|
||||
document.addPage(new PDPage());
|
||||
document.addPage(new PDPage());
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class), anyBoolean())).thenReturn(document);
|
||||
when(pdfContentExtractor.extractPageTextRaw(eq(document), anyInt()))
|
||||
.thenReturn("page content");
|
||||
|
||||
int[] orchestratorCalls = {0};
|
||||
when(aiEngineClient.post(eq("/api/v1/orchestrator"), anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
orchestratorCalls[0]++;
|
||||
if (orchestratorCalls[0] == 1) {
|
||||
return """
|
||||
{
|
||||
"outcome":"need_ingest",
|
||||
"resumeWith":"pdf_question",
|
||||
"reason":"ingest first",
|
||||
"filesToIngest":[{"id":"report-id","name":"report.pdf"}],
|
||||
"contentTypes":["page_text"]
|
||||
}
|
||||
""";
|
||||
}
|
||||
return """
|
||||
{"outcome":"answer","answer":"done","evidence":[]}
|
||||
""";
|
||||
});
|
||||
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(input, "summarise this"));
|
||||
|
||||
assertEquals(AiWorkflowOutcome.ANSWER, result.getOutcome());
|
||||
verify(aiEngineClient, times(1)).postLongRunning(eq("/api/v1/rag/documents"), anyString());
|
||||
verify(aiEngineClient, times(2)).post(eq("/api/v1/orchestrator"), anyString());
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private void stubOrchestrator(String responseJson) throws IOException {
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfAnnotationService;
|
||||
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineResponse;
|
||||
import stirling.software.proprietary.model.api.ai.comments.PdfCommentInstruction;
|
||||
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Smoke tests for {@link PdfCommentAgentOrchestrator}. Collaborators (engine client, PDF factory,
|
||||
* chunk extractor) are mocked; the orchestrator is exercised end-to-end on an in-memory PDF so the
|
||||
* returned bytes can be re-loaded and inspected.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PdfCommentAgentOrchestratorTest {
|
||||
|
||||
@Mock private AiEngineClient aiEngineClient;
|
||||
@Mock private PdfTextChunkExtractor pdfTextChunkExtractor;
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
private PdfAnnotationService pdfAnnotationService;
|
||||
private PdfCommentAgentOrchestrator orchestrator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = JsonMapper.builder().build();
|
||||
// Real (not mocked) — it's a pure primitive; exercising it in the test gives us stronger
|
||||
// assertions (the annotated PDF actually has the expected sticky notes).
|
||||
pdfAnnotationService = new PdfAnnotationService();
|
||||
orchestrator =
|
||||
new PdfCommentAgentOrchestrator(
|
||||
aiEngineClient,
|
||||
pdfTextChunkExtractor,
|
||||
pdfDocumentFactory,
|
||||
objectMapper,
|
||||
pdfAnnotationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void happyPathAppliesValidInstructionsOnCorrectPagesAndReturnsBytes() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
byte[] pdfBytes = twoPagePdfBytes();
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
|
||||
|
||||
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk zero");
|
||||
TextChunk c1 = new TextChunk("p0-c1", 0, 72f, 680f, 100f, 12f, "Chunk one");
|
||||
TextChunk c2 = new TextChunk("p1-c0", 1, 72f, 700f, 100f, 12f, "Chunk two");
|
||||
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0, c1, c2));
|
||||
|
||||
PdfCommentEngineResponse engineResponse =
|
||||
new PdfCommentEngineResponse(
|
||||
"session-1",
|
||||
List.of(
|
||||
new PdfCommentInstruction(
|
||||
"p0-c0", "Comment on page 0", "alice", "Heads up"),
|
||||
new PdfCommentInstruction(
|
||||
"p1-c0", "Comment on page 1", null, null)),
|
||||
"reviewed");
|
||||
when(aiEngineClient.post(anyString(), anyString()))
|
||||
.thenReturn(objectMapper.writeValueAsString(engineResponse));
|
||||
|
||||
AnnotatedPdf result = orchestrator.applyComments(input, "please comment");
|
||||
|
||||
assertEquals("doc-commented.pdf", result.fileName());
|
||||
assertNotNull(result.bytes(), "Returned bytes must not be null");
|
||||
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
|
||||
List<PDAnnotationText> page0Texts = textAnnotations(saved.getPage(0).getAnnotations());
|
||||
List<PDAnnotationText> page1Texts = textAnnotations(saved.getPage(1).getAnnotations());
|
||||
assertEquals(1, page0Texts.size(), "Exactly one annotation on page 0");
|
||||
assertEquals(1, page1Texts.size(), "Exactly one annotation on page 1");
|
||||
assertEquals("Comment on page 0", page0Texts.get(0).getContents());
|
||||
assertEquals("Comment on page 1", page1Texts.get(0).getContents());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownChunkIdsAreSkippedButValidOnesApplied() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
byte[] pdfBytes = twoPagePdfBytes();
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
|
||||
|
||||
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk zero");
|
||||
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0));
|
||||
|
||||
PdfCommentEngineResponse engineResponse =
|
||||
new PdfCommentEngineResponse(
|
||||
"session-2",
|
||||
List.of(
|
||||
new PdfCommentInstruction("p0-c0", "Valid", null, null),
|
||||
new PdfCommentInstruction("p999-c999", "Bogus", null, null)),
|
||||
"mixed");
|
||||
when(aiEngineClient.post(anyString(), anyString()))
|
||||
.thenReturn(objectMapper.writeValueAsString(engineResponse));
|
||||
|
||||
AnnotatedPdf result = orchestrator.applyComments(input, "test");
|
||||
|
||||
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
|
||||
int totalAnnotations = 0;
|
||||
for (int i = 0; i < saved.getNumberOfPages(); i++) {
|
||||
totalAnnotations += textAnnotations(saved.getPage(i).getAnnotations()).size();
|
||||
}
|
||||
assertEquals(1, totalAnnotations, "Only the valid chunk annotation should be applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyCommentsListReturnsDocumentWithoutAnnotations() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
byte[] pdfBytes = twoPagePdfBytes();
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
|
||||
|
||||
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk");
|
||||
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0));
|
||||
|
||||
PdfCommentEngineResponse engineResponse =
|
||||
new PdfCommentEngineResponse("s", List.of(), "no comments worth making");
|
||||
when(aiEngineClient.post(anyString(), anyString()))
|
||||
.thenReturn(objectMapper.writeValueAsString(engineResponse));
|
||||
|
||||
AnnotatedPdf result = orchestrator.applyComments(input, "test");
|
||||
|
||||
assertEquals("doc-commented.pdf", result.fileName());
|
||||
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
|
||||
for (int i = 0; i < saved.getNumberOfPages(); i++) {
|
||||
assertTrue(
|
||||
textAnnotations(saved.getPage(i).getAnnotations()).isEmpty(),
|
||||
"Page " + i + " should have no text annotations");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyChunksListThrowsBadRequestAndDoesNotCallEngine() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
byte[] pdfBytes = twoPagePdfBytes();
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
|
||||
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of());
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> orchestrator.applyComments(input, "whatever"));
|
||||
assertEquals(400, ex.getStatusCode().value());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void promptTooLongThrowsBadRequestAndDoesNotCallEngine() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
String tooLong = "x".repeat(4001);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> orchestrator.applyComments(input, tooLong));
|
||||
assertEquals(400, ex.getStatusCode().value());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankPromptThrowsBadRequestAndDoesNotCallEngine() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> orchestrator.applyComments(input, " "));
|
||||
assertEquals(400, ex.getStatusCode().value());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static MockMultipartFile pdf(String filename) {
|
||||
return new MockMultipartFile(
|
||||
"fileInput",
|
||||
filename,
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"%PDF-1.4\n%%EOF".getBytes());
|
||||
}
|
||||
|
||||
private static byte[] twoPagePdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(72, 700);
|
||||
cs.showText("Page " + i + " content");
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
|
||||
List<PDAnnotationText> out = new ArrayList<>();
|
||||
for (PDAnnotation a : annotations) {
|
||||
if (a instanceof PDAnnotationText t) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PdfTextChunkExtractor}. Exercises chunk id format, bounding-box validity,
|
||||
* multi-page extraction, the empty-PDF path, and the 2000-chunk cap.
|
||||
*/
|
||||
class PdfTextChunkExtractorTest {
|
||||
|
||||
private static final Pattern CHUNK_ID_PATTERN = Pattern.compile("^p\\d+-c\\d+$");
|
||||
|
||||
private final PdfTextChunkExtractor extractor = new PdfTextChunkExtractor();
|
||||
|
||||
@Test
|
||||
void extractsOneChunkPerVisualLineWithValidBoundingBoxes() throws IOException {
|
||||
byte[] pdf = buildTwoPagePdf("Line A on page one", "Line B on page two");
|
||||
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TextChunk> chunks = extractor.extract(doc);
|
||||
|
||||
assertFalse(chunks.isEmpty(), "Extractor should produce at least one chunk");
|
||||
|
||||
for (TextChunk chunk : chunks) {
|
||||
assertTrue(
|
||||
CHUNK_ID_PATTERN.matcher(chunk.id()).matches(),
|
||||
"Chunk id should match p{page}-c{idx}, got: " + chunk.id());
|
||||
assertTrue(chunk.width() > 0f, "width > 0, chunk=" + chunk);
|
||||
assertTrue(chunk.height() > 0f, "height > 0, chunk=" + chunk);
|
||||
assertFalse(chunk.text() == null || chunk.text().isBlank(), "text non-blank");
|
||||
|
||||
PDRectangle box = doc.getPage(chunk.page()).getMediaBox();
|
||||
assertTrue(chunk.x() >= 0f, "x >= 0, chunk=" + chunk);
|
||||
assertTrue(chunk.y() >= 0f, "y >= 0, chunk=" + chunk);
|
||||
assertTrue(
|
||||
chunk.x() + chunk.width() <= box.getWidth() + 0.01f,
|
||||
"x + width fits within page width, chunk=" + chunk);
|
||||
assertTrue(
|
||||
chunk.y() + chunk.height() <= box.getHeight() + 0.01f,
|
||||
"y + height fits within page height, chunk=" + chunk);
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
chunks.stream().anyMatch(c -> c.page() == 0 && c.text().contains("Line A")),
|
||||
"Expected a page-0 chunk containing 'Line A'; chunks=" + chunks);
|
||||
assertTrue(
|
||||
chunks.stream().anyMatch(c -> c.page() == 1 && c.text().contains("Line B")),
|
||||
"Expected a page-1 chunk containing 'Line B'; chunks=" + chunks);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyListForPdfWithNoExtractableText() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
byte[] pdf = baos.toByteArray();
|
||||
|
||||
try (PDDocument loaded = Loader.loadPDF(pdf)) {
|
||||
List<TextChunk> chunks = extractor.extract(loaded);
|
||||
assertTrue(chunks.isEmpty(), "Expected no chunks, got=" + chunks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void enforcesHardCapOf2000Chunks() throws IOException {
|
||||
byte[] pdf = buildPdfWithManyLines(2500);
|
||||
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TextChunk> chunks = extractor.extract(doc);
|
||||
assertEquals(
|
||||
2000,
|
||||
chunks.size(),
|
||||
"Extractor should cap at MAX_CHUNKS_PER_DOC (2000); got=" + chunks.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static byte[] buildTwoPagePdf(String page1Text, String page2Text) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
addPageWithLine(doc, page1Text);
|
||||
addPageWithLine(doc, page2Text);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static void addPageWithLine(PDDocument doc, String text) throws IOException {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(72, 700);
|
||||
cs.showText(text);
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PDF with {@code totalLines} short lines of text spread across pages so the extractor
|
||||
* has to produce one chunk per line.
|
||||
*/
|
||||
private static byte[] buildPdfWithManyLines(int totalLines) throws IOException {
|
||||
int linesPerPage = 50;
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
int remaining = totalLines;
|
||||
int lineCounter = 0;
|
||||
while (remaining > 0) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72, 780);
|
||||
int toWrite = Math.min(linesPerPage, remaining);
|
||||
for (int i = 0; i < toWrite; i++) {
|
||||
cs.showText("line-" + lineCounter++);
|
||||
if (i < toWrite - 1) {
|
||||
cs.newLineAtOffset(0, -14);
|
||||
}
|
||||
}
|
||||
cs.endText();
|
||||
}
|
||||
remaining -= linesPerPage;
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -25,7 +25,7 @@ ext {
|
||||
imageioVersion = "3.13.1"
|
||||
lombokVersion = "1.18.44"
|
||||
bouncycastleVersion = "1.83"
|
||||
springSecuritySamlVersion = "7.0.4"
|
||||
springSecuritySamlVersion = "7.0.5"
|
||||
openSamlVersion = "5.2.1"
|
||||
commonmarkVersion = "0.28.0"
|
||||
googleJavaFormatVersion = "1.28.0"
|
||||
@@ -78,7 +78,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.10.0'
|
||||
version = '2.10.1'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
|
||||
|
||||
@@ -364,7 +364,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
|
||||
|
||||
# Final runtime image - the actual base image
|
||||
FROM eclipse-temurin:25-jre-noble@sha256:a051234f864d7ab78bf0188c3c540ac06c711a3b566f00f246be37073cc99dce AS runtime
|
||||
FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS runtime
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
--no-daemon
|
||||
|
||||
# Stage 2: Extract Spring Boot Layers
|
||||
FROM eclipse-temurin:25-jre-noble@sha256:a051234f864d7ab78bf0188c3c540ac06c711a3b566f00f246be37073cc99dce AS jar-extract
|
||||
FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS jar-extract
|
||||
WORKDIR /tmp
|
||||
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
|
||||
|
||||
+6
-1
@@ -30,7 +30,12 @@ STIRLING_RAG_PGVECTOR_DSN=
|
||||
|
||||
STIRLING_RAG_CHUNK_SIZE=512
|
||||
STIRLING_RAG_CHUNK_OVERLAP=64
|
||||
STIRLING_RAG_TOP_K=5
|
||||
STIRLING_RAG_TOP_K=20
|
||||
|
||||
# Per-run cap on ``search_knowledge`` calls. After this many calls the tool is
|
||||
# removed from the agent's toolset so it must answer from what it already retrieved
|
||||
# rather than chain more searches.
|
||||
STIRLING_RAG_MAX_SEARCHES=5
|
||||
|
||||
# Upper bounds on PDF page text the engine will request per extraction round.
|
||||
STIRLING_MAX_PAGES=200
|
||||
|
||||
@@ -68,4 +68,9 @@ reportDeprecated = "warning"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
# ``tests`` is on the path so test modules can import shared helpers (e.g.
|
||||
# ``from conftest import build_app_settings``) without packaging the tests dir.
|
||||
pythonpath = ["src", "tests"]
|
||||
# Use importlib import mode so test directories don't need __init__.py files
|
||||
# and duplicate basenames (e.g. multiple test_routes.py) collect cleanly.
|
||||
addopts = "--import-mode=importlib"
|
||||
|
||||
@@ -73,7 +73,11 @@ class ToolDiscovery:
|
||||
for path, path_item in sorted(self.spec.get("paths", {}).items()):
|
||||
if "{" in path or not any(path.startswith(p) for p in self.ALLOWED_PATH_PREFIXES):
|
||||
continue
|
||||
properties = self._get_request_properties(path_item)
|
||||
body_props = self._get_request_properties(path_item) or {}
|
||||
query_props = self._get_query_parameters(path_item)
|
||||
# Body properties win on name collision — body is the canonical param source
|
||||
# for the existing tools; query params are additive.
|
||||
properties = {**query_props, **body_props}
|
||||
if not properties:
|
||||
continue
|
||||
clean_props = self._filter_properties(properties)
|
||||
@@ -127,6 +131,26 @@ class ToolDiscovery:
|
||||
return self._resolve_ref(schema).get("properties")
|
||||
return None
|
||||
|
||||
def _get_query_parameters(self, path_item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract query parameters as a property map — AI tools expose their main
|
||||
inputs (e.g. ``prompt``, ``tolerance``) here rather than in the request body,
|
||||
and a handful of converters use query strings alongside multipart files.
|
||||
"""
|
||||
post = path_item.get("post") or {}
|
||||
props: dict[str, Any] = {}
|
||||
for param in post.get("parameters") or []:
|
||||
if param.get("in") != "query":
|
||||
continue
|
||||
name = param.get("name")
|
||||
schema = param.get("schema")
|
||||
if not name or not schema:
|
||||
continue
|
||||
resolved = dict(self._resolve_ref(schema))
|
||||
if "description" not in resolved and param.get("description"):
|
||||
resolved["description"] = param["description"]
|
||||
props[name] = resolved
|
||||
return props
|
||||
|
||||
def _filter_properties(self, properties: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Remove base-class fields and binary upload fields, resolving any $refs."""
|
||||
clean: dict[str, Any] = {}
|
||||
@@ -246,7 +270,7 @@ def main() -> None:
|
||||
raise SystemExit(f"OpenAPI spec not found at {spec_path}\nRun 'task engine:tool-models' to generate it.")
|
||||
output_path = Path(args.output)
|
||||
|
||||
with open(spec_path) as f:
|
||||
with open(spec_path, encoding="utf-8") as f:
|
||||
spec = json.load(f)
|
||||
|
||||
result = ToolDiscovery(spec).discover()
|
||||
@@ -255,7 +279,7 @@ def main() -> None:
|
||||
|
||||
print(f"Generated {len(result.tools)} tool models from {spec_path.name}")
|
||||
for tool in result.tools:
|
||||
print(f" {tool.enum_name}: {tool.path} → {tool.class_name}")
|
||||
print(f" {tool.enum_name}: {tool.path} -> {tool.class_name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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,21 @@ 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,
|
||||
PdfQuestionOrchestrateResponse,
|
||||
SupportedCapability,
|
||||
ToolOperationStep,
|
||||
UnsupportedCapabilityResponse,
|
||||
format_conversation_history,
|
||||
format_file_names,
|
||||
)
|
||||
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 +58,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(
|
||||
@@ -79,11 +79,13 @@ class OrchestratorAgent:
|
||||
"You are the top-level orchestrator. "
|
||||
"Choose exactly one output function that best handles the request. "
|
||||
"Use delegate_pdf_edit for requested modifications of single or multiple PDFs. "
|
||||
"Use delegate_pdf_question for questions about PDF contents. "
|
||||
"Use delegate_pdf_question for questions about the contents of the attached PDFs. "
|
||||
"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 unsupported_capability only when none of the other outputs fit."
|
||||
"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 when the user asks about the assistant itself "
|
||||
"or when none of the other outputs fit; supply a helpful message."
|
||||
),
|
||||
model_settings=runtime.fast_model_settings,
|
||||
)
|
||||
@@ -91,7 +93,7 @@ class OrchestratorAgent:
|
||||
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
|
||||
logger.info(
|
||||
"[orchestrator] handle: files=%s resume_with=%s artifacts=%s msg=%r",
|
||||
request.file_names,
|
||||
[file.name for file in request.files],
|
||||
request.resume_with,
|
||||
[type(a).__name__ for a in request.artifacts],
|
||||
request.user_message,
|
||||
@@ -106,10 +108,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 +137,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:
|
||||
async def delegate_pdf_question(self, ctx: RunContext[OrchestratorDeps]) -> PdfQuestionOrchestrateResponse:
|
||||
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,
|
||||
)
|
||||
)
|
||||
async def _run_pdf_question(self, request: OrchestratorRequest) -> PdfQuestionOrchestrateResponse:
|
||||
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,20 +165,13 @@ 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"
|
||||
history = format_conversation_history(request.conversation_history)
|
||||
return (
|
||||
f"Conversation history:\n{history}\n"
|
||||
f"User message: {request.user_message}\n"
|
||||
f"Files: {file_names}\n"
|
||||
f"Files: {format_file_names(request.files)}\n"
|
||||
f"Available artifacts:\n{artifact_summary}"
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user