feat: support RTF knowledge uploads

This commit is contained in:
key
2026-08-03 01:49:48 +08:00
parent a07bbcca07
commit a275f98fad
8 changed files with 42 additions and 7 deletions
@@ -817,7 +817,7 @@ public class AihrSopSeedService {
}
String fileName = AihrMultipartFiles.sanitizeFileName(file.getOriginalFilename(), "knowledge.txt");
if (!supportedFile(fileName)) {
throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件");
throw new ServiceException("仅支持 txt/md/markdown/rtf/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件");
}
if (file.getSize() > 100 * 1024 * 1024) {
throw new ServiceException("文件不能超过 100MB");
@@ -2588,6 +2588,7 @@ public class AihrSopSeedService {
case "xls", "xlsx" -> "Excel";
case "ppt", "pptx" -> "PPT";
case "pdf" -> "PDF";
case "rtf" -> "RTF";
case "txt", "md", "markdown" -> "Text";
case "jpg", "jpeg", "png", "gif", "webp", "bmp" -> "图片";
case "mp4", "mov", "avi", "mkv", "webm", "m4v" -> "视频";
@@ -4894,6 +4895,7 @@ public class AihrSopSeedService {
return textFile(lower)
|| imageFile(lower)
|| AihrVideoService.videoFile(lower)
|| lower.endsWith(".rtf")
|| lower.endsWith(".pdf")
|| lower.endsWith(".doc")
|| lower.endsWith(".docx")
@@ -121,7 +121,7 @@ public class AihrUploadQueueService {
String fileName = sanitizeFileName(file.getOriginalFilename());
boolean archive = isZip(fileName);
if (!archive && !AihrSopSeedService.supportedFile(fileName)) {
throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频/zip文件");
throw new ServiceException("仅支持 txt/md/markdown/rtf/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频/zip文件");
}
if (file.getSize() > MAX_FILE_BYTES) {
throw new ServiceException(archive
@@ -44,6 +44,20 @@ class TikaKnowledgeDocumentParserTest {
);
}
@Test
void parsesRichTextFormatDocuments() {
KnowledgeDocumentParser parser = new TikaKnowledgeDocumentParser();
byte[] rtf = ("{\\rtf1 \\ansi \\deff0 {\\fonttbl {\\f0 Times New Roman;}}"
+ "\\f0 \\fs24 Interview notes: resident service experience.\\par}")
.getBytes(StandardCharsets.US_ASCII);
ParsedDocument document = parser.parse("interview.rtf", "application/rtf", rtf);
assertEquals("application/rtf", document.mimeType());
assertTrue(document.text().contains("Interview notes"));
assertFalse(document.segments().isEmpty());
}
@Test
void rejectsInvalidChunkSettings() {
ParsedDocument document = new ParsedDocument("有效内容", "text/plain", java.util.Map.of());
@@ -44,6 +44,25 @@ class AihrUploadQueueServiceTest {
assertEquals(500L * 1024 * 1024, field.getLong(null));
}
@Test
void supportsRtfAtTopLevelAndInsideZip() throws Exception {
assertTrue(AihrSopSeedService.supportedFile("interview.rtf"));
Path staging = Files.createDirectories(tempDir.resolve("rtf-staging"));
Path archive = tempDir.resolve("rtf.zip");
writeZip(archive, java.nio.charset.StandardCharsets.UTF_8, "notes/interview.rtf");
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.update(anyString(), any(Object[].class))).thenReturn(1);
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
try {
AihrUploadQueueService service = new AihrUploadQueueService(null, null, jdbcTemplate, executor, staging.toString());
assertEquals(1, expandArchive(service, itemRow(archive)));
} finally {
executor.shutdownNow();
}
}
@Test
void expandsLegacyGbkZipAtomicallyAndKeepsDuplicateEntries() throws Exception {
Path staging = Files.createDirectories(tempDir.resolve("staging"));
+1 -1
View File
@@ -135,7 +135,7 @@ SOP 文档上传第三片已经落最小后端边界:
| 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 |
| 服务端目录导入(运维) | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,仅保留给运维/调试,不在资料处理页暴露 |
| 服务端导入任务(运维) | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,调用方可查询总数、成功数、失败数、当前文件和进度,运行中任务可取消;仅保留给运维/调试,不在资料处理页暴露 |
| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;`source_attach_id` 记录从原 OSS 重试的附件。文档、图片、视频和 ZIP 单文件均 ≤500MB,生产 Spring/Undertow multipart 上限为单文件 500MB、请求 520MB,Tika 文档输入流上限同步为 500MiB;worker 限制最多 1000 个 ZIP 子文件、单个受支持子文件 ≤500MB、解压总量 ≤2GB,拒绝嵌套 ZIP/不安全路径并忽略未知格式;ZIP 文件名优先按 UTF-8 解码,旧版中文 Windows ZIP 自动回退 GBK;同名子文件自动追加编号并以单条批量写入入队;只有片段数大于 0 才记为完成并删除暂存文件,零片段或安全媒体错误保持失败态供重试;卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h;同步接口 `POST /api/knowledge/doc/upload` 不支持 ZIP,保留给 SOP 页单文件即时预览且仍按 100MB 契约执行 |
| 批量异步上传 | `POST /api/knowledge/doc/upload-async`(multipart `file`+`category`+`batchId`)、`GET /api/knowledge/doc/upload-items?batchId=`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry` | 上传只做暂存(`aihr.upload.staging`,默认 `./.data/staging`)+ 写入 `aihr_knowledge_upload_item`(0待处理/1处理中/2完成/3失败)即返回;`source_attach_id` 记录从原 OSS 重试的附件。文档(含 RTF)、图片、视频和 ZIP 单文件均 ≤500MB,生产 Spring/Undertow multipart 上限为单文件 500MB、请求 520MB,Tika 文档输入流上限同步为 500MiB;worker 限制最多 1000 个 ZIP 子文件、单个受支持子文件 ≤500MB、解压总量 ≤2GB,拒绝嵌套 ZIP/不安全路径并忽略未知格式;ZIP 文件名优先按 UTF-8 解码,旧版中文 Windows ZIP 自动回退 GBK;同名子文件自动追加编号并以单条批量写入入队;只有片段数大于 0 才记为完成并删除暂存文件,零片段或安全媒体错误保持失败态供重试;卡住 30 分钟(视频 120 分钟)由清扫重置,失败暂存文件保留 72h;同步接口 `POST /api/knowledge/doc/upload` 不支持 ZIP,保留给 SOP 页单文件即时预览且仍按 100MB 契约执行 |
| 视频解析 | 走批量异步上传,支持 `.mp4/.mov/.avi/.mkv/.webm/.m4v`,单文件 ≤500MB、时长 ≤60 分钟 | 依赖服务器 ffmpeg/ffprobe;抽音轨按 5 分钟分段调 asr 模型转写(`[mm:ss]` 时间戳),按 30s 间隔(≤40 帧)抽关键帧调 vision 模型提取画面文字(相邻重复画面去重);两类文本合并后走归类/切片/向量化;无音轨、ASR 无文本且 vision 无结果时以 `MEDIA_NO_TEXT` 进入可重试失败态,不冒充仍在排队;视频同时只加工 1 个 |
模型能力第二片已经落最小后端边界:
+1 -1
View File
@@ -237,7 +237,7 @@ export function uploadKnowledgeDocAsync(data: FormData): Promise<ApiResult<Uploa
url: '/api/knowledge/doc/upload-async',
method: 'post',
data,
timeout: 60000,
timeout: 600000,
headers: { repeatSubmit: false }
});
}
+2 -2
View File
@@ -1078,8 +1078,8 @@ import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadI
import { listKnowledgeSpaces, type KnowledgeSpace } from '@/api/aihr/space';
import { listPersonalPublishRequests, reviewPersonalPublishRequest, type PersonalPublishRequestRow } from '@/api/aihr/personal';
const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
const supportedPattern = /\.(txt|md|markdown|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i;
const acceptTypes = '.txt,.md,.markdown,.rtf,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v,.zip';
const supportedPattern = /\.(txt|md|markdown|rtf|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|webp|bmp|mp4|mov|avi|mkv|webm|m4v|zip)$/i;
const maxFileSize = 500 * 1024 * 1024;
const browserBatchWarnCount = 20;
const browserBatchWarnSize = 200 * 1024 * 1024;
+1 -1
View File
@@ -12,7 +12,7 @@
<el-option v-for="space in manageableSpaces" :key="space.code" :label="space.name" :value="space.code" />
</el-select>
<el-upload
accept=".txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
accept=".txt,.md,.markdown,.rtf,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
:auto-upload="false"
:show-file-list="false"
:on-change="handleDocSelected"