mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
Set up document management for Stirling Engine (#6476)
# Description of Changes Change Stirling Engine to support deleting documents automatically. This happens both on user logout and after an amount of time specified by the Java when ingesting a document (allowing for personal documents to have short lifetimes but org documents to be left in the db with no expiry date). Also sets up an [ACL policy](https://en.wikipedia.org/wiki/Access-control_list) for the documents so the database knows which users have access to which documents. This is not fully implemented in the Java, so currently all docs are treated as having a single owner, the uploader, but theoretically when we need to support org storage, we shouldn't need to change the db schema.
This commit is contained in:
+12
-3
@@ -5,6 +5,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -30,6 +31,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
||||
@@ -58,6 +60,7 @@ public class AiEngineController {
|
||||
private final TaskManager taskManager;
|
||||
private final JobOwnershipService jobOwnershipService;
|
||||
private final AiEngineEndpointResolver endpointResolver;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
|
||||
@@ -74,7 +77,8 @@ public class AiEngineController {
|
||||
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
|
||||
TaskManager taskManager,
|
||||
JobOwnershipService jobOwnershipService,
|
||||
AiEngineEndpointResolver endpointResolver) {
|
||||
AiEngineEndpointResolver endpointResolver,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.aiWorkflowService = aiWorkflowService;
|
||||
this.objectMapper = objectMapper;
|
||||
@@ -82,6 +86,11 @@ public class AiEngineController {
|
||||
this.taskManager = taskManager;
|
||||
this.jobOwnershipService = jobOwnershipService;
|
||||
this.endpointResolver = endpointResolver;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
private String currentUserId() {
|
||||
return userService != null ? userService.getCurrentUsername() : null;
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
@@ -89,7 +98,7 @@ public class AiEngineController {
|
||||
summary = "AI engine health check",
|
||||
description = "Returns the health status of the AI engine including configured models")
|
||||
public ResponseEntity<String> health() throws IOException {
|
||||
String response = aiEngineClient.get("/health");
|
||||
String response = aiEngineClient.get("/health", currentUserId());
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
|
||||
}
|
||||
|
||||
@@ -243,7 +252,7 @@ public class AiEngineController {
|
||||
HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
|
||||
}
|
||||
String forwardedBody = withEnabledEndpoints((ObjectNode) parsed);
|
||||
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody);
|
||||
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody, currentUserId());
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -10,6 +11,12 @@ import lombok.NoArgsConstructor;
|
||||
* Body for {@code POST /api/v1/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.
|
||||
*
|
||||
* <p>{@code ownerId} is the tenant the doc belongs to (a user for personal uploads, an org for
|
||||
* shared content). {@code readPrincipals} is the explicit list of principals granted read access.
|
||||
* {@code expiresAt} is when the engine's reaper should delete this doc; {@code null} means
|
||||
* "persistent until explicit delete" (used for org-shared content). Java picks the value per doc;
|
||||
* the engine does not default it.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@@ -21,4 +28,10 @@ public class AiDocumentIngestRequest {
|
||||
private String source;
|
||||
|
||||
private List<AiPageText> pageText;
|
||||
|
||||
private String ownerId;
|
||||
|
||||
private List<String> readPrincipals;
|
||||
|
||||
private Instant expiresAt;
|
||||
}
|
||||
|
||||
+33
@@ -8,6 +8,8 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.security.authentication.AuthenticationTrustResolver;
|
||||
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
@@ -36,6 +38,7 @@ import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.saml2.CertificateUtils;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.service.AiUserDataService;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@@ -49,12 +52,22 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
|
||||
private final JwtServiceInterface jwtService;
|
||||
|
||||
private final AiUserDataService aiUserDataService;
|
||||
|
||||
private static final AuthenticationTrustResolver TRUST_RESOLVER =
|
||||
new AuthenticationTrustResolverImpl();
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
|
||||
public void onLogoutSuccess(
|
||||
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
||||
throws IOException {
|
||||
|
||||
String username = resolveUsername(request, authentication);
|
||||
if (username != null) {
|
||||
aiUserDataService.purgeUserDocuments(username);
|
||||
}
|
||||
|
||||
if (!response.isCommitted()) {
|
||||
if (authentication != null) {
|
||||
if (authentication instanceof Saml2Authentication samlAuthentication) {
|
||||
@@ -88,6 +101,26 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the right name to purge under. JWT cookie wins if present and parseable; we fall through
|
||||
* to whatever Spring handed us only when there's no cookie. Spring's anonymous principal is
|
||||
* filtered out via {@link AuthenticationTrustResolver} so we don't purge under that
|
||||
* pseudo-user.
|
||||
*/
|
||||
private String resolveUsername(HttpServletRequest request, Authentication authentication) {
|
||||
if (jwtService != null) {
|
||||
String fromCookie = jwtService.extractUsernameFromRequestAllowExpired(request);
|
||||
if (fromCookie != null) {
|
||||
return fromCookie;
|
||||
}
|
||||
}
|
||||
if (authentication == null || TRUST_RESOLVER.isAnonymous(authentication)) {
|
||||
return null;
|
||||
}
|
||||
String name = authentication.getName();
|
||||
return (name != null && !name.isBlank()) ? name : null;
|
||||
}
|
||||
|
||||
// Redirect for SAML2 authentication logout
|
||||
private void getRedirect_saml2(
|
||||
HttpServletRequest request,
|
||||
|
||||
+8
-2
@@ -93,6 +93,7 @@ public class SecurityConfiguration {
|
||||
licenseSettingsService;
|
||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final stirling.software.proprietary.service.AiUserDataService aiUserDataService;
|
||||
|
||||
public SecurityConfiguration(
|
||||
PersistentLoginRepository persistentLoginRepository,
|
||||
@@ -115,7 +116,8 @@ public class SecurityConfiguration {
|
||||
OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver,
|
||||
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
|
||||
stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
PasswordEncoder passwordEncoder,
|
||||
stirling.software.proprietary.service.AiUserDataService aiUserDataService) {
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.userService = userService;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
@@ -135,6 +137,7 @@ public class SecurityConfiguration {
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.aiUserDataService = aiUserDataService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,7 +325,10 @@ public class SecurityConfiguration {
|
||||
.matcher("/logout"))
|
||||
.logoutSuccessHandler(
|
||||
new CustomLogoutSuccessHandler(
|
||||
securityProperties, appConfig, jwtService))
|
||||
securityProperties,
|
||||
appConfig,
|
||||
jwtService,
|
||||
aiUserDataService))
|
||||
.clearAuthentication(true)
|
||||
.invalidateHttpSession(true)
|
||||
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));
|
||||
|
||||
+6
-2
@@ -44,6 +44,7 @@ import stirling.software.proprietary.security.service.RefreshRateLimitService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||
import stirling.software.proprietary.service.AiUserDataService;
|
||||
|
||||
/** REST API Controller for authentication operations. */
|
||||
@RestController
|
||||
@@ -62,6 +63,7 @@ public class AuthController {
|
||||
private final RefreshRateLimitService refreshRateLimitService;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final AiUserDataService aiUserDataService;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
@@ -281,11 +283,13 @@ public class AuthController {
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<?> logout(HttpServletResponse response) {
|
||||
public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) {
|
||||
try {
|
||||
String username = jwtService.extractUsernameFromRequestAllowExpired(request);
|
||||
SecurityContextHolder.clearContext();
|
||||
aiUserDataService.purgeUserDocuments(username);
|
||||
|
||||
log.debug("User logged out successfully");
|
||||
log.debug("User logged out successfully (username={})", username);
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
|
||||
|
||||
|
||||
+15
@@ -349,6 +349,21 @@ public class JwtService implements JwtServiceInterface {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String extractUsernameFromRequestAllowExpired(HttpServletRequest request) {
|
||||
try {
|
||||
String token = extractToken(request);
|
||||
if (token == null || token.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String username = extractUsernameAllowExpired(token);
|
||||
return (username != null && !username.isBlank()) ? username : null;
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not extract username from request JWT: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isJwtEnabled() {
|
||||
return v2Enabled;
|
||||
|
||||
+13
@@ -93,6 +93,19 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
String extractToken(HttpServletRequest request);
|
||||
|
||||
/**
|
||||
* Read the username off the request's JWT, allowing an expired token. Returns null when no
|
||||
* token is present, the token can't be parsed, or the resulting username is blank.
|
||||
*
|
||||
* <p>Used by flows that need to identify the user without depending on {@code
|
||||
* SecurityContextHolder} - for example logout, where the security filter chain may have left
|
||||
* the anonymous principal in place by the time the handler runs.
|
||||
*
|
||||
* @param request HTTP servlet request
|
||||
* @return username from the token, or null when one can't be safely derived
|
||||
*/
|
||||
String extractUsernameFromRequestAllowExpired(HttpServletRequest request);
|
||||
|
||||
/**
|
||||
* Check if JWT authentication is enabled
|
||||
*
|
||||
|
||||
+61
-20
@@ -43,22 +43,23 @@ public class AiEngineClient {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
public String post(String path, String jsonBody) throws IOException {
|
||||
public String post(String path, String jsonBody, String userId) throws IOException {
|
||||
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
|
||||
return postWithTimeout(path, jsonBody, Duration.ofSeconds(config.getTimeoutSeconds()));
|
||||
return postWithTimeout(
|
||||
path, jsonBody, Duration.ofSeconds(config.getTimeoutSeconds()), userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
public String postLongRunning(String path, String jsonBody, String userId) throws IOException {
|
||||
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
|
||||
return postWithTimeout(
|
||||
path, jsonBody, Duration.ofSeconds(config.getLongRunningTimeoutSeconds()));
|
||||
path, jsonBody, Duration.ofSeconds(config.getLongRunningTimeoutSeconds()), userId);
|
||||
}
|
||||
|
||||
private String postWithTimeout(String path, String jsonBody, Duration timeout)
|
||||
private String postWithTimeout(String path, String jsonBody, Duration timeout, String userId)
|
||||
throws IOException {
|
||||
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
|
||||
if (!config.isEnabled()) {
|
||||
@@ -69,22 +70,32 @@ public class AiEngineClient {
|
||||
String url = config.getUrl().stripTrailing() + path;
|
||||
log.debug("Proxying AI engine request to {} (timeout {}s)", url, timeout.toSeconds());
|
||||
|
||||
HttpRequest request =
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = sendRequest(request);
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
|
||||
addUserHeader(builder, userId);
|
||||
HttpResponse<String> response = sendRequest(builder.build());
|
||||
|
||||
log.debug("AI engine responded with status {}", response.statusCode());
|
||||
checkResponseStatus(response);
|
||||
return response.body();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the X-User-Id header so the engine can scope per-user storage (RAG documents, search
|
||||
* results) to the caller. Skipped when {@code userId} is blank: the engine treats the request
|
||||
* as anonymous and refuses any route that requires tenancy.
|
||||
*/
|
||||
private static void addUserHeader(HttpRequest.Builder builder, String userId) {
|
||||
if (userId != null && !userId.isBlank()) {
|
||||
builder.header("X-User-Id", userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a JSON body and consume the response as a stream of NDJSON lines. Each line is passed to
|
||||
* {@code lineConsumer} in arrival order; the call returns when the engine closes the stream.
|
||||
@@ -94,7 +105,8 @@ public class AiEngineClient {
|
||||
* practice line arrival keeps the connection logically alive: as long as the engine emits
|
||||
* events, the work is progressing. Genuine engine hangs still hit the total timeout.
|
||||
*/
|
||||
public void streamPost(String path, String jsonBody, Consumer<String> lineConsumer)
|
||||
public void streamPost(
|
||||
String path, String jsonBody, String userId, Consumer<String> lineConsumer)
|
||||
throws IOException {
|
||||
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
|
||||
if (!config.isEnabled()) {
|
||||
@@ -109,14 +121,15 @@ public class AiEngineClient {
|
||||
url,
|
||||
timeout.toSeconds());
|
||||
|
||||
HttpRequest request =
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/x-ndjson")
|
||||
.timeout(timeout)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||
.build();
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
|
||||
addUserHeader(builder, userId);
|
||||
HttpRequest request = builder.build();
|
||||
|
||||
HttpResponse<Stream<String>> response;
|
||||
try {
|
||||
@@ -149,7 +162,36 @@ public class AiEngineClient {
|
||||
}
|
||||
}
|
||||
|
||||
public String get(String path) throws IOException {
|
||||
/**
|
||||
* DELETE with no body. Used for purging the caller's RAG content on logout. Wraps the same
|
||||
* error envelope as {@link #post} / {@link #get} so callers see a consistent set of {@code
|
||||
* ResponseStatusException}s.
|
||||
*/
|
||||
public String delete(String path, String userId) 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 DELETE request to {}", url);
|
||||
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||
.DELETE();
|
||||
addUserHeader(builder, userId);
|
||||
HttpResponse<String> response = sendRequest(builder.build());
|
||||
|
||||
log.debug("AI engine responded with status {}", response.statusCode());
|
||||
checkResponseStatus(response);
|
||||
return response.body();
|
||||
}
|
||||
|
||||
public String get(String path, String userId) throws IOException {
|
||||
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
|
||||
if (!config.isEnabled()) {
|
||||
throw new ResponseStatusException(
|
||||
@@ -159,15 +201,14 @@ public class AiEngineClient {
|
||||
String url = config.getUrl().stripTrailing() + path;
|
||||
log.debug("Proxying AI engine GET request to {}", url);
|
||||
|
||||
HttpRequest request =
|
||||
HttpRequest.Builder builder =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = sendRequest(request);
|
||||
.GET();
|
||||
addUserHeader(builder, userId);
|
||||
HttpResponse<String> response = sendRequest(builder.build());
|
||||
|
||||
log.debug("AI engine responded with status {}", response.statusCode());
|
||||
checkResponseStatus(response);
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Lifecycle hooks for a user's AI document data on the Python engine.
|
||||
*
|
||||
* <p>Today: cleanup on logout. The engine also runs a TTL reaper that catches sessions ended
|
||||
* without a clean logout (tab close, JWT expiry, engine restart), so this service is the happy-path
|
||||
* purge, not a hard guarantee. Calls are fire-and-forget on a background thread (Spring's default
|
||||
* {@code @Async} executor) so an unavailable engine never delays the caller's response.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiUserDataService {
|
||||
|
||||
private static final String PURGE_PATH = "/api/v1/documents/by-owner";
|
||||
|
||||
private final AiEngineClient aiEngineClient;
|
||||
|
||||
/**
|
||||
* Tell the engine to delete every collection owned by {@code userId}: vector chunks, page text,
|
||||
* ACL rows, and the owner row itself. Runs asynchronously so the calling thread (typically a
|
||||
* logout handler) returns immediately; engine errors are logged on the worker thread and never
|
||||
* propagated. The engine's TTL reaper backstops any miss within ~24h.
|
||||
*/
|
||||
@Async
|
||||
public void purgeUserDocuments(String userId) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
log.debug("Skipping user document purge: no user id");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
aiEngineClient.delete(PURGE_PATH, userId);
|
||||
log.debug("Requested document purge for user {}", userId);
|
||||
} catch (ResponseStatusException e) {
|
||||
log.warn("AI engine refused document purge for {}: {}", userId, e.getReason());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to purge documents for {}: {}", userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
-4
@@ -1,6 +1,8 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -9,6 +11,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -24,14 +27,15 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import io.github.pixee.security.Filenames;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
@@ -49,6 +53,7 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
|
||||
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.security.util.DesktopClientUtils;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.WorkflowArtifact;
|
||||
@@ -59,7 +64,6 @@ import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiWorkflowService {
|
||||
|
||||
private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents";
|
||||
@@ -74,6 +78,56 @@ public class AiWorkflowService {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final FileIdStrategy fileIdStrategy;
|
||||
private final AiEngineEndpointResolver endpointResolver;
|
||||
private final UserServiceInterface userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public AiWorkflowService(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
AiEngineClient aiEngineClient,
|
||||
PdfContentExtractor pdfContentExtractor,
|
||||
ObjectMapper objectMapper,
|
||||
InternalApiClient internalApiClient,
|
||||
FileStorage fileStorage,
|
||||
ToolMetadataService toolMetadataService,
|
||||
TempFileManager tempFileManager,
|
||||
FileIdStrategy fileIdStrategy,
|
||||
AiEngineEndpointResolver endpointResolver,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.objectMapper = objectMapper;
|
||||
this.internalApiClient = internalApiClient;
|
||||
this.fileStorage = fileStorage;
|
||||
this.toolMetadataService = toolMetadataService;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.fileIdStrategy = fileIdStrategy;
|
||||
this.endpointResolver = endpointResolver;
|
||||
this.userService = userService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long an AI-workflow-ingested personal doc lives on the engine before the reaper deletes
|
||||
* it. Mirrors the configured web JWT lifetime, so a stale cookie can never see data the user
|
||||
* has lost their session to. Org-shared content (when we add it) bypasses this and sends a null
|
||||
* {@code expiresAt} so it's persistent.
|
||||
*/
|
||||
private Duration personalDocTtl() {
|
||||
int minutes = DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
|
||||
return Duration.ofMinutes(minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the currently-authenticated user's id for X-User-Id propagation to the AI engine.
|
||||
* Returns null when security is disabled (no UserServiceInterface bean) or no one is logged in.
|
||||
* The engine rejects per-user routes (ingest, search) when this is null; non-tenant routes
|
||||
* (health, orchestrate without RAG) still work.
|
||||
*/
|
||||
private String currentUserId() {
|
||||
return userService != null ? userService.getCurrentUsername() : null;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ProgressListener {
|
||||
@@ -298,10 +352,21 @@ public class AiWorkflowService {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Personal-doc semantics for AI workflows today: caller owns the doc and is its only
|
||||
// grantee, with a session-bounded expiry so the reaper cleans up if logout misses.
|
||||
// When org / shared-doc ingestion lands, the caller chooses owner, grantees, and
|
||||
// expiry (null = persistent) explicitly.
|
||||
String callerId = currentUserId();
|
||||
AiDocumentIngestRequest ingestRequest =
|
||||
new AiDocumentIngestRequest(file.getId(), file.getName(), pages);
|
||||
new AiDocumentIngestRequest(
|
||||
file.getId(),
|
||||
file.getName(),
|
||||
pages,
|
||||
callerId,
|
||||
callerId == null ? List.of() : List.of(callerId),
|
||||
Instant.now().plus(personalDocTtl()));
|
||||
String body = objectMapper.writeValueAsString(ingestRequest);
|
||||
aiEngineClient.postLongRunning(DOCUMENTS_ENDPOINT, body);
|
||||
aiEngineClient.postLongRunning(DOCUMENTS_ENDPOINT, body, callerId);
|
||||
log.debug(
|
||||
"Ingested document: id={}, name={}, pages={}",
|
||||
file.getId(),
|
||||
@@ -699,6 +764,7 @@ public class AiWorkflowService {
|
||||
aiEngineClient.streamPost(
|
||||
"/api/v1/orchestrator",
|
||||
requestBody,
|
||||
currentUserId(),
|
||||
line -> handleStreamLine(line, listener, resultHolder, errorHolder));
|
||||
|
||||
if (errorHolder[0] != null) {
|
||||
|
||||
+22
-4
@@ -8,13 +8,14 @@ import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.model.api.ai.Evidence;
|
||||
import stirling.software.proprietary.model.api.ai.Folio;
|
||||
import stirling.software.proprietary.model.api.ai.FolioManifest;
|
||||
@@ -40,7 +41,6 @@ import tools.jackson.databind.ObjectMapper;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MathAuditorOrchestrator {
|
||||
|
||||
private static final String EXAMINE_PATH = "/api/v1/ai/math-auditor-agent/examine";
|
||||
@@ -50,6 +50,24 @@ public class MathAuditorOrchestrator {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final PdfContentExtractor pdfContentExtractor;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
public MathAuditorOrchestrator(
|
||||
AiEngineClient aiEngineClient,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
PdfContentExtractor pdfContentExtractor,
|
||||
ObjectMapper objectMapper,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.objectMapper = objectMapper;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
private String currentUserId() {
|
||||
return userService != null ? userService.getCurrentUsername() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a full math audit against the supplied PDF file.
|
||||
@@ -110,7 +128,7 @@ public class MathAuditorOrchestrator {
|
||||
EXAMINE_PATH,
|
||||
manifest.sessionId(),
|
||||
manifest.round());
|
||||
String responseBody = aiEngineClient.post(EXAMINE_PATH, requestBody);
|
||||
String responseBody = aiEngineClient.post(EXAMINE_PATH, requestBody, currentUserId());
|
||||
return objectMapper.readValue(responseBody, Requisition.class);
|
||||
}
|
||||
|
||||
@@ -123,7 +141,7 @@ public class MathAuditorOrchestrator {
|
||||
evidence.sessionId(),
|
||||
evidence.round(),
|
||||
evidence.finalRound());
|
||||
String responseBody = aiEngineClient.post(path, requestBody);
|
||||
String responseBody = aiEngineClient.post(path, requestBody, currentUserId());
|
||||
return objectMapper.readValue(responseBody, Verdict.class);
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -10,18 +10,19 @@ import java.util.UUID;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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.common.service.UserServiceInterface;
|
||||
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;
|
||||
@@ -50,7 +51,6 @@ import tools.jackson.databind.ObjectMapper;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PdfCommentAgentOrchestrator {
|
||||
|
||||
private static final String GENERATE_PATH = "/api/v1/ai/pdf-comment-agent/generate";
|
||||
@@ -80,6 +80,26 @@ public class PdfCommentAgentOrchestrator {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PdfAnnotationService pdfAnnotationService;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
public PdfCommentAgentOrchestrator(
|
||||
AiEngineClient aiEngineClient,
|
||||
PdfTextChunkExtractor pdfTextChunkExtractor,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
ObjectMapper objectMapper,
|
||||
PdfAnnotationService pdfAnnotationService,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.pdfTextChunkExtractor = pdfTextChunkExtractor;
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.objectMapper = objectMapper;
|
||||
this.pdfAnnotationService = pdfAnnotationService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
private String currentUserId() {
|
||||
return userService != null ? userService.getCurrentUsername() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full PDF comment generation flow.
|
||||
@@ -164,7 +184,7 @@ public class PdfCommentAgentOrchestrator {
|
||||
PdfCommentEngineRequest engineRequest =
|
||||
new PdfCommentEngineRequest(sessionId, prompt, chunks);
|
||||
String requestBody = objectMapper.writeValueAsString(engineRequest);
|
||||
String responseBody = aiEngineClient.post(GENERATE_PATH, requestBody);
|
||||
String responseBody = aiEngineClient.post(GENERATE_PATH, requestBody, currentUserId());
|
||||
PdfCommentEngineResponse engineResponse =
|
||||
objectMapper.readValue(responseBody, PdfCommentEngineResponse.class);
|
||||
|
||||
|
||||
+2
-1
@@ -79,7 +79,8 @@ class AuthControllerLoginTest {
|
||||
totpService,
|
||||
refreshRateLimitService,
|
||||
securityProperties,
|
||||
applicationProperties);
|
||||
applicationProperties,
|
||||
new stirling.software.proprietary.service.AiUserDataService(null));
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -251,6 +251,35 @@ class JwtServiceTest {
|
||||
assertNull(jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractUsernameFromRequestAllowExpiredReturnsUsername() throws Exception {
|
||||
String username = "alice";
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
|
||||
String token = jwtService.generateToken(username, Collections.emptyMap());
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
|
||||
|
||||
assertEquals(username, jwtService.extractUsernameFromRequestAllowExpired(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractUsernameFromRequestAllowExpiredReturnsNullWhenNoToken() {
|
||||
when(request.getHeader("Authorization")).thenReturn(null);
|
||||
|
||||
assertNull(jwtService.extractUsernameFromRequestAllowExpired(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractUsernameFromRequestAllowExpiredReturnsNullOnGarbageToken() {
|
||||
// The logout path depends on this helper returning null - never throwing - so a
|
||||
// malformed JWT never blocks a user from logging out. Any parse/validation problem
|
||||
// collapses to "no resolvable user".
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer not-a-real-jwt");
|
||||
|
||||
assertNull(jwtService.extractUsernameFromRequestAllowExpired(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateTokenWithKeyId() throws Exception {
|
||||
String username = "testuser";
|
||||
|
||||
+4
-4
@@ -46,7 +46,7 @@ class AiEngineClientTest {
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}", null));
|
||||
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
assertSame(cause, ex.getCause(), "Original cause should be preserved for diagnostics");
|
||||
@@ -58,7 +58,7 @@ class AiEngineClientTest {
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}", null));
|
||||
|
||||
assertEquals(HttpStatus.GATEWAY_TIMEOUT, ex.getStatusCode());
|
||||
assertSame(cause, ex.getCause());
|
||||
@@ -70,7 +70,7 @@ class AiEngineClientTest {
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.get("/x"));
|
||||
assertThrows(ResponseStatusException.class, () -> client.get("/x", null));
|
||||
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
@@ -80,7 +80,7 @@ class AiEngineClientTest {
|
||||
applicationProperties.getAiEngine().setEnabled(false);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}", null));
|
||||
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
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.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Tests the wiring of {@link AiUserDataService}: the path/userId combination forwarded to the
|
||||
* engine, plus the swallowing-not-throwing behaviour for engine failures. Doesn't try to assert
|
||||
* {@code @Async} dispatch - that's Spring infrastructure and only fires through the proxy, which a
|
||||
* unit-level test bypasses by design.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AiUserDataServiceTest {
|
||||
|
||||
private static final String PURGE_PATH = "/api/v1/documents/by-owner";
|
||||
|
||||
@Mock private AiEngineClient aiEngineClient;
|
||||
|
||||
private AiUserDataService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AiUserDataService(aiEngineClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
void purgesUnderTheGivenUserIdOnTheByOwnerEndpoint() throws IOException {
|
||||
service.purgeUserDocuments("alice");
|
||||
verify(aiEngineClient, times(1)).delete(eq(PURGE_PATH), eq("alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noOpWhenUserIdIsNull() throws IOException {
|
||||
service.purgeUserDocuments(null);
|
||||
verify(aiEngineClient, never()).delete(eq(PURGE_PATH), eq(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noOpWhenUserIdIsBlank() throws IOException {
|
||||
service.purgeUserDocuments(" ");
|
||||
verify(aiEngineClient, never()).delete(eq(PURGE_PATH), eq(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void swallowsIoExceptionFromEngine() throws IOException {
|
||||
doThrow(new IOException("connection refused"))
|
||||
.when(aiEngineClient)
|
||||
.delete(eq(PURGE_PATH), eq("alice"));
|
||||
// Must not throw - the logout path depends on this swallowing failures so the user
|
||||
// can still log out when the engine is unreachable.
|
||||
service.purgeUserDocuments("alice");
|
||||
verify(aiEngineClient, times(1)).delete(eq(PURGE_PATH), eq("alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void swallowsResponseStatusExceptionFromEngine() throws IOException {
|
||||
doThrow(new ResponseStatusException(HttpStatus.BAD_GATEWAY, "engine returned 502"))
|
||||
.when(aiEngineClient)
|
||||
.delete(eq(PURGE_PATH), eq("alice"));
|
||||
service.purgeUserDocuments("alice");
|
||||
verify(aiEngineClient, times(1)).delete(eq(PURGE_PATH), eq("alice"));
|
||||
}
|
||||
}
|
||||
+12
-7
@@ -8,6 +8,7 @@ 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.ArgumentMatchers.nullable;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -117,7 +118,9 @@ class AiWorkflowServiceTest {
|
||||
toolMetadataService,
|
||||
tempFileManager,
|
||||
fileIdStrategy,
|
||||
endpointResolver);
|
||||
endpointResolver,
|
||||
null,
|
||||
new ApplicationProperties());
|
||||
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@@ -479,18 +482,20 @@ class AiWorkflowServiceTest {
|
||||
{"outcome":"answer","answer":"done","evidence":[]}
|
||||
""";
|
||||
}
|
||||
Consumer<String> consumer = inv.getArgument(2);
|
||||
Consumer<String> consumer = inv.getArgument(3);
|
||||
consumer.accept(wrapAsResultEvent(responseJson));
|
||||
return null;
|
||||
})
|
||||
.when(aiEngineClient)
|
||||
.streamPost(eq("/api/v1/orchestrator"), anyString(), any());
|
||||
.streamPost(eq("/api/v1/orchestrator"), anyString(), nullable(String.class), any());
|
||||
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(input, "summarise this"));
|
||||
|
||||
assertEquals(AiWorkflowOutcome.ANSWER, result.getOutcome());
|
||||
verify(aiEngineClient, times(1)).postLongRunning(eq("/api/v1/documents"), anyString());
|
||||
verify(aiEngineClient, times(2)).streamPost(eq("/api/v1/orchestrator"), anyString(), any());
|
||||
verify(aiEngineClient, times(1))
|
||||
.postLongRunning(eq("/api/v1/documents"), anyString(), nullable(String.class));
|
||||
verify(aiEngineClient, times(2))
|
||||
.streamPost(eq("/api/v1/orchestrator"), anyString(), nullable(String.class), any());
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
@@ -498,12 +503,12 @@ class AiWorkflowServiceTest {
|
||||
private void stubOrchestrator(String responseJson) throws IOException {
|
||||
doAnswer(
|
||||
inv -> {
|
||||
Consumer<String> consumer = inv.getArgument(2);
|
||||
Consumer<String> consumer = inv.getArgument(3);
|
||||
consumer.accept(wrapAsResultEvent(responseJson));
|
||||
return null;
|
||||
})
|
||||
.when(aiEngineClient)
|
||||
.streamPost(eq("/api/v1/orchestrator"), anyString(), any());
|
||||
.streamPost(eq("/api/v1/orchestrator"), anyString(), nullable(String.class), any());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+9
-7
@@ -6,6 +6,7 @@ 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.ArgumentMatchers.nullable;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -72,7 +73,8 @@ class PdfCommentAgentOrchestratorTest {
|
||||
pdfTextChunkExtractor,
|
||||
pdfDocumentFactory,
|
||||
objectMapper,
|
||||
pdfAnnotationService);
|
||||
pdfAnnotationService,
|
||||
null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -96,7 +98,7 @@ class PdfCommentAgentOrchestratorTest {
|
||||
new PdfCommentInstruction(
|
||||
"p1-c0", "Comment on page 1", null, null)),
|
||||
"reviewed");
|
||||
when(aiEngineClient.post(anyString(), anyString()))
|
||||
when(aiEngineClient.post(anyString(), anyString(), nullable(String.class)))
|
||||
.thenReturn(objectMapper.writeValueAsString(engineResponse));
|
||||
|
||||
AnnotatedPdf result = orchestrator.applyComments(input, "please comment");
|
||||
@@ -130,7 +132,7 @@ class PdfCommentAgentOrchestratorTest {
|
||||
new PdfCommentInstruction("p0-c0", "Valid", null, null),
|
||||
new PdfCommentInstruction("p999-c999", "Bogus", null, null)),
|
||||
"mixed");
|
||||
when(aiEngineClient.post(anyString(), anyString()))
|
||||
when(aiEngineClient.post(anyString(), anyString(), nullable(String.class)))
|
||||
.thenReturn(objectMapper.writeValueAsString(engineResponse));
|
||||
|
||||
AnnotatedPdf result = orchestrator.applyComments(input, "test");
|
||||
@@ -156,7 +158,7 @@ class PdfCommentAgentOrchestratorTest {
|
||||
|
||||
PdfCommentEngineResponse engineResponse =
|
||||
new PdfCommentEngineResponse("s", List.of(), "no comments worth making");
|
||||
when(aiEngineClient.post(anyString(), anyString()))
|
||||
when(aiEngineClient.post(anyString(), anyString(), nullable(String.class)))
|
||||
.thenReturn(objectMapper.writeValueAsString(engineResponse));
|
||||
|
||||
AnnotatedPdf result = orchestrator.applyComments(input, "test");
|
||||
@@ -184,7 +186,7 @@ class PdfCommentAgentOrchestratorTest {
|
||||
ResponseStatusException.class,
|
||||
() -> orchestrator.applyComments(input, "whatever"));
|
||||
assertEquals(400, ex.getStatusCode().value());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), nullable(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -197,7 +199,7 @@ class PdfCommentAgentOrchestratorTest {
|
||||
ResponseStatusException.class,
|
||||
() -> orchestrator.applyComments(input, tooLong));
|
||||
assertEquals(400, ex.getStatusCode().value());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), nullable(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -209,7 +211,7 @@ class PdfCommentAgentOrchestratorTest {
|
||||
ResponseStatusException.class,
|
||||
() -> orchestrator.applyComments(input, " "));
|
||||
assertEquals(400, ex.getStatusCode().value());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), nullable(String.class));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user