diff --git a/backend/ruoyi-modules/ruoyi-aihr/pom.xml b/backend/ruoyi-modules/ruoyi-aihr/pom.xml
index c65c4edf..58ed6587 100644
--- a/backend/ruoyi-modules/ruoyi-aihr/pom.xml
+++ b/backend/ruoyi-modules/ruoyi-aihr/pom.xml
@@ -59,6 +59,18 @@
3.2.2
+
+ org.apache.pdfbox
+ pdfbox
+ 3.0.5
+
+
+
+ org.apache.poi
+ poi-ooxml
+ 5.4.1
+
+
org.springframework.boot
spring-boot-starter-test
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java
index 7d18d457..ba2d5f39 100644
--- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java
@@ -29,6 +29,9 @@ public final class AihrSopDto {
) {
}
+ public record AuthorizedKnowledgeHit(Long fragmentId, String title, String content) {
+ }
+
public record SummaryCardRequest(String queryText, String category) {
}
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/KnowledgeDocumentParser.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/KnowledgeDocumentParser.java
new file mode 100644
index 00000000..222a7420
--- /dev/null
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/KnowledgeDocumentParser.java
@@ -0,0 +1,47 @@
+package org.dromara.aihr.knowledge.parse;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Stateless byte-document parser shared by knowledge ingestion flows.
+ */
+public interface KnowledgeDocumentParser {
+
+ ParsedDocument parse(String fileName, String contentType, byte[] bytes);
+
+ default ParsedDocument parse(String fileName, String contentType, InputStream input) {
+ if (input == null) {
+ throw new ParseException(Failure.EMPTY, "document content is empty");
+ }
+ try {
+ return parse(fileName, contentType, input.readAllBytes());
+ } catch (IOException e) {
+ throw new ParseException(Failure.INVALID, "document reading failed", e);
+ }
+ }
+
+ enum Failure {
+ EMPTY,
+ TOO_LARGE,
+ INVALID
+ }
+
+ final class ParseException extends IllegalArgumentException {
+ private final Failure failure;
+
+ public ParseException(Failure failure, String message) {
+ super(message);
+ this.failure = failure;
+ }
+
+ public ParseException(Failure failure, String message, Throwable cause) {
+ super(message, cause);
+ this.failure = failure;
+ }
+
+ public Failure failure() {
+ return failure;
+ }
+ }
+}
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/ParsedDocument.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/ParsedDocument.java
new file mode 100644
index 00000000..fb69725b
--- /dev/null
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/ParsedDocument.java
@@ -0,0 +1,38 @@
+package org.dromara.aihr.knowledge.parse;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public record ParsedDocument(String text, String mimeType, Map metadata) {
+
+ public ParsedDocument {
+ text = text == null ? "" : text;
+ mimeType = mimeType == null ? "application/octet-stream" : mimeType;
+ metadata = metadata == null ? Map.of() : Map.copyOf(metadata);
+ }
+
+ public List chunks(int blockSize, int overlap) {
+ if (blockSize <= 0 || overlap < 0 || overlap >= blockSize) {
+ throw new IllegalArgumentException("invalid chunk settings");
+ }
+ if (text.isBlank()) {
+ return List.of();
+ }
+
+ int[] codePoints = text.codePoints().toArray();
+ List chunks = new ArrayList<>();
+ int step = blockSize - overlap;
+ for (int start = 0; start < codePoints.length; start += step) {
+ int end = Math.min(codePoints.length, start + blockSize);
+ String chunk = new String(codePoints, start, end - start).trim();
+ if (!chunk.isEmpty()) {
+ chunks.add(chunk);
+ }
+ if (end == codePoints.length) {
+ break;
+ }
+ }
+ return List.copyOf(chunks);
+ }
+}
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/TikaKnowledgeDocumentParser.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/TikaKnowledgeDocumentParser.java
new file mode 100644
index 00000000..d21f742d
--- /dev/null
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/parse/TikaKnowledgeDocumentParser.java
@@ -0,0 +1,167 @@
+package org.dromara.aihr.knowledge.parse;
+
+import org.apache.tika.exception.WriteLimitReachedException;
+import org.apache.tika.detect.Detector;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.io.BoundedInputStream;
+import org.apache.tika.io.TemporaryResources;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.mime.MimeTypes;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.sax.BodyContentHandler;
+import org.xml.sax.ContentHandler;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+
+@Component
+public class TikaKnowledgeDocumentParser implements KnowledgeDocumentParser {
+
+ static final int DEFAULT_MAX_EXPANDED_CHARS = 2_000_000;
+ static final long MAX_INPUT_BYTES = 100L * 1024 * 1024;
+
+ private final int maxExpandedChars;
+
+ public TikaKnowledgeDocumentParser() {
+ this(DEFAULT_MAX_EXPANDED_CHARS);
+ }
+
+ TikaKnowledgeDocumentParser(int maxExpandedChars) {
+ if (maxExpandedChars <= 0) {
+ throw new IllegalArgumentException("max expanded characters must be positive");
+ }
+ this.maxExpandedChars = maxExpandedChars;
+ }
+
+ @Override
+ public ParsedDocument parse(String fileName, String contentType, byte[] bytes) {
+ if (bytes == null || bytes.length == 0) {
+ throw new ParseException(Failure.EMPTY, "document content is empty");
+ }
+ return parse(fileName, contentType, new ByteArrayInputStream(bytes));
+ }
+
+ @Override
+ public ParsedDocument parse(String fileName, String contentType, InputStream input) {
+ if (input == null) {
+ throw new ParseException(Failure.EMPTY, "document content is empty");
+ }
+
+ Metadata metadata = new Metadata();
+ if (fileName != null && !fileName.isBlank()) {
+ metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName.trim());
+ }
+ AutoDetectParser parser = new AutoDetectParser();
+ Detector detector = parser.getDetector();
+ parser.setDetector((stream, currentMetadata) -> safeDetect(detector, stream, currentMetadata));
+ BodyContentHandler handler = new BodyContentHandler(maxExpandedChars + 1);
+ BoundedInputStream bounded = new BoundedInputStream(MAX_INPUT_BYTES + 1, input);
+ try (TemporaryResources temporaryResources = new TemporaryResources();
+ TikaInputStream tikaInput = TikaInputStream.get(bounded, temporaryResources, metadata)) {
+ tikaInput.mark(Integer.MAX_VALUE);
+ MediaType detected = parser.getDetector().detect(tikaInput, metadata);
+ tikaInput.reset();
+ String mimeType = resolvedMimeType(detected, contentType);
+ metadata.set(Metadata.CONTENT_TYPE, mimeType);
+
+ ParseContext context = new ParseContext();
+ context.set(Parser.class, parser);
+ context.set(EmbeddedDocumentExtractor.class, NO_EMBEDDED_DOCUMENTS);
+ parser.parse(tikaInput, handler, metadata, context);
+
+ rejectOversizedInput(bounded);
+ return parsedDocument(handler, metadata, mimeType);
+ } catch (Exception e) {
+ if (e instanceof ParseException parseException) {
+ throw parseException;
+ }
+ if (bounded.hasHitBound() || bounded.getPos() > MAX_INPUT_BYTES) {
+ throw new ParseException(Failure.TOO_LARGE, "document input exceeds limit", e);
+ }
+ if (WriteLimitReachedException.isWriteLimitReached(e)) {
+ throw new ParseException(Failure.TOO_LARGE, "document expanded text exceeds limit", e);
+ }
+ throw new ParseException(Failure.INVALID, "document parsing failed", e);
+ }
+ }
+
+ private static MediaType safeDetect(Detector detector, InputStream input, Metadata metadata) throws IOException {
+ input.mark(Integer.MAX_VALUE);
+ try {
+ return detector.detect(input, metadata);
+ } catch (Exception exception) {
+ if (!(exception instanceof org.apache.commons.compress.archivers.ArchiveException)) {
+ if (exception instanceof IOException ioException) throw ioException;
+ if (exception instanceof RuntimeException runtimeException) throw runtimeException;
+ throw new IOException("document type detection failed", exception);
+ }
+ input.reset();
+ return MimeTypes.getDefaultMimeTypes().detect(input, metadata);
+ }
+ }
+
+ private ParsedDocument parsedDocument(BodyContentHandler handler, Metadata metadata, String mimeType) {
+ String text = handler.toString().trim();
+ if (text.isEmpty()) {
+ throw new ParseException(Failure.EMPTY, "document contains no text");
+ }
+ if (text.length() > maxExpandedChars) {
+ throw new ParseException(Failure.TOO_LARGE, "document expanded text exceeds limit");
+ }
+ return new ParsedDocument(text, mimeType, metadataMap(metadata));
+ }
+
+ private static String resolvedMimeType(MediaType detected, String suppliedContentType) {
+ String detectedMime = detected == null ? "" : detected.getBaseType().toString();
+ if (!detectedMime.isBlank() && !MediaType.OCTET_STREAM.toString().equals(detectedMime)) {
+ return detectedMime;
+ }
+ String candidate = suppliedContentType;
+ if (candidate == null || candidate.isBlank()) {
+ return "application/octet-stream";
+ }
+ int parameterStart = candidate.indexOf(';');
+ String mimeType = (parameterStart >= 0 ? candidate.substring(0, parameterStart) : candidate).trim();
+ return mimeType.isEmpty() ? "application/octet-stream" : mimeType.toLowerCase(Locale.ROOT);
+ }
+
+ private static void rejectOversizedInput(BoundedInputStream bounded) {
+ if (bounded.hasHitBound() || bounded.getPos() > MAX_INPUT_BYTES) {
+ throw new ParseException(Failure.TOO_LARGE, "document input exceeds limit");
+ }
+ }
+
+ private static final EmbeddedDocumentExtractor NO_EMBEDDED_DOCUMENTS = new EmbeddedDocumentExtractor() {
+ @Override
+ public boolean shouldParseEmbedded(Metadata metadata) {
+ return false;
+ }
+
+ @Override
+ public void parseEmbedded(InputStream stream, ContentHandler handler, Metadata metadata, boolean outputHtml)
+ throws IOException {
+ // Embedded payloads are deliberately excluded to bound recursive expansion.
+ }
+ };
+
+ private static Map metadataMap(Metadata metadata) {
+ Map values = new LinkedHashMap<>();
+ for (String name : metadata.names()) {
+ String value = metadata.get(name);
+ if (value != null) {
+ values.put(name, value);
+ }
+ }
+ return values;
+ }
+}
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/config/PersonalSchedulingConfig.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/config/PersonalSchedulingConfig.java
new file mode 100644
index 00000000..644a0c5e
--- /dev/null
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/config/PersonalSchedulingConfig.java
@@ -0,0 +1,22 @@
+package org.dromara.aihr.personal.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+@Configuration(proxyBeanMethods = false)
+@EnableScheduling
+public class PersonalSchedulingConfig {
+
+ @Bean(name = "personalTaskScheduler")
+ public ThreadPoolTaskScheduler personalTaskScheduler() {
+ ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+ scheduler.setPoolSize(2);
+ scheduler.setThreadNamePrefix("personal-ingestion-");
+ scheduler.setRemoveOnCancelPolicy(true);
+ scheduler.setWaitForTasksToCompleteOnShutdown(true);
+ scheduler.setAwaitTerminationSeconds(30);
+ return scheduler;
+ }
+}
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java
new file mode 100644
index 00000000..2e276bd9
--- /dev/null
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java
@@ -0,0 +1,218 @@
+package org.dromara.aihr.personal.controller;
+
+import lombok.RequiredArgsConstructor;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.AskRequest;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.AskResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.DownloadUrlResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreateRequest;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportPptRequest;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestCreateRequest;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemCreatedResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.OcrProgressResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.PageResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.PersonalSearchRequest;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.PersonalSearchResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.SessionDetailResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.SessionResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.SpaceResponse;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.TextItemRequest;
+import org.dromara.aihr.personal.domain.PersonalAssistantDto.UrlItemRequest;
+import org.dromara.aihr.personal.service.PersonalAnswerService;
+import org.dromara.aihr.personal.service.PersonalCleanupService;
+import org.dromara.aihr.personal.service.PersonalIngestionService;
+import org.dromara.aihr.personal.service.PersonalExportService;
+import org.dromara.aihr.personal.service.PersonalPdfOcrService;
+import org.dromara.aihr.personal.service.PersonalPublishService;
+import org.dromara.aihr.personal.service.PersonalRetrievalService;
+import org.dromara.aihr.personal.service.PersonalSpaceService;
+import org.dromara.aihr.personal.service.PersonalUrlFetchService;
+import org.dromara.aihr.personal.support.PersonalOwner;
+import org.dromara.aihr.personal.support.PersonalOwnerProvider;
+import org.dromara.common.core.domain.R;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/aihr/personal-assistant")
+public class PersonalAssistantController {
+
+ private final PersonalOwnerProvider ownerProvider;
+ private final PersonalSpaceService spaceService;
+ private final PersonalIngestionService ingestionService;
+ private final PersonalUrlFetchService urlFetchService;
+ private final PersonalRetrievalService retrievalService;
+ private final PersonalAnswerService answerService;
+ private final PersonalCleanupService cleanupService;
+ private final PersonalPdfOcrService pdfOcrService;
+ private final PersonalExportService exportService;
+ private final PersonalPublishService publishService;
+
+ @GetMapping("/space")
+ public R space() {
+ return R.ok(spaceService.space(owner()));
+ }
+
+ @GetMapping("/items")
+ public R> items(@RequestParam(required = false) Integer pageNum,
+ @RequestParam(required = false) Integer pageSize,
+ @RequestParam(required = false) String status,
+ @RequestParam(required = false) String sourceType,
+ @RequestParam(required = false)
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom,
+ @RequestParam(required = false)
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo,
+ @RequestParam(required = false) String keyword) {
+ return R.ok(spaceService.items(owner(), pageNum, pageSize, status, sourceType, dateFrom, dateTo, keyword));
+ }
+
+ @PostMapping("/items/text")
+ public R createText(@RequestBody TextItemRequest request) {
+ return R.ok(ingestionService.createText(owner(), request));
+ }
+
+ @PostMapping(value = "/items/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public R createFile(@RequestPart("file") MultipartFile file,
+ @RequestParam(required = false) String title,
+ @RequestParam(required = false)
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ LocalDateTime capturedAt) {
+ return R.ok(ingestionService.createFile(owner(), file, title, capturedAt));
+ }
+
+ @PostMapping("/items/url")
+ public R createUrl(@RequestBody UrlItemRequest request) {
+ PersonalOwner owner = owner();
+ PersonalUrlFetchService.FetchResult fetched = urlFetchService.fetch(request == null ? null : request.url());
+ return R.ok(ingestionService.createUrl(owner, request, fetched));
+ }
+
+ @GetMapping("/items/{id}")
+ public R item(@PathVariable long id) {
+ PersonalOwner owner = owner();
+ return R.ok(withOcr(spaceService.itemResponse(owner, id), pdfOcrService.progress(owner, id)));
+ }
+
+ @PostMapping("/items/{id}/retry")
+ public R retry(@PathVariable long id) {
+ PersonalOwner owner = owner();
+ ingestionService.retry(owner, id);
+ return R.ok(withOcr(spaceService.itemResponse(owner, id), pdfOcrService.progress(owner, id)));
+ }
+
+ @PostMapping("/items/{id}/ocr/retry-failed")
+ public R retryFailedOcrPages(@PathVariable long id) {
+ return R.ok(pdfOcrService.retryFailedPages(owner(), id));
+ }
+
+ @DeleteMapping("/items/{id}")
+ public R