001/*
002 *  Copyright (c) 2023-2026, Agents-Flex (fuhai999@gmail.com).
003 *  <p>
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *  <p>
008 *  http://www.apache.org/licenses/LICENSE-2.0
009 *  <p>
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package com.agentsflex.core.document.splitter;
017
018import com.agentsflex.core.document.Document;
019import com.agentsflex.core.document.DocumentSplitter;
020import com.agentsflex.core.document.id.DocumentIdGenerator;
021import com.agentsflex.core.model.chat.ChatModel;
022import com.agentsflex.core.model.chat.ChatOptions;
023import org.slf4j.Logger;
024import org.slf4j.LoggerFactory;
025
026import java.util.ArrayList;
027import java.util.Collections;
028import java.util.List;
029import java.util.stream.Collectors;
030
031/**
032 * AIDocumentSplitter:基于大模型(AI/LLM)的语义文档拆分器。
033 * 使用 "---" 作为段落分隔符,避免 JSON 解析风险。
034 * 支持注入 fallback 拆分器以提高鲁棒性。
035 */
036public class AIDocumentSplitter implements DocumentSplitter {
037
038    private static final Logger log = LoggerFactory.getLogger(AIDocumentSplitter.class);
039
040    private static final String DEFAULT_SPLIT_PROMPT_TEMPLATE =
041        "你是一个专业的文档处理助手,请将以下长文档按语义拆分为多个逻辑连贯的段落块。\n" +
042            "要求:\n" +
043            "1. 每个块应保持主题/语义完整性,避免在句子中间切断。\n" +
044            "2. 每个块长度建议在 200-500 字之间(可根据内容灵活调整)。\n" +
045            "3. **不要添加任何解释、编号、前缀或后缀**。\n" +
046            "4. **仅用三连短横线 \"---\" 作为块之间的分隔符**,格式如下:\n" +
047            "\n" +
048            "块1内容\n" +
049            "---\n" +
050            "块2内容\n" +
051            "---\n" +
052            "块3内容\n" +
053            "\n" +
054            "注意:开头不要有 ---,结尾也不要有多余的 ---。\n" +
055            "\n" +
056            "文档内容如下:\n" +
057            "{document}";
058
059    private static final String CHUNK_SEPARATOR = "---";
060
061    private final ChatModel chatModel;
062    private String splitPromptTemplate = DEFAULT_SPLIT_PROMPT_TEMPLATE;
063    private ChatOptions chatOptions = new ChatOptions.Builder().temperature(0.2f).build();
064    private int maxChunks = 20;
065    private int maxTotalLength = 10000;
066
067    // 可配置的 fallback 拆分器
068    private DocumentSplitter fallbackSplitter;
069
070    public AIDocumentSplitter(ChatModel chatModel) {
071        this.chatModel = chatModel;
072    }
073
074    @Override
075    public List<Document> split(Document document, DocumentIdGenerator idGenerator) {
076        if (document == null || document.getContent() == null || document.getContent().trim().isEmpty()) {
077            return Collections.emptyList();
078        }
079
080        String content = document.getContent().trim();
081        if (content.length() > maxTotalLength) {
082            log.warn("文档过长({} 字符),已截断至 {} 字符", content.length(), maxTotalLength);
083            content = content.substring(0, maxTotalLength);
084        }
085
086        List<String> chunks;
087        try {
088            String prompt = splitPromptTemplate.replace("{document}", content);
089            String llmOutput = chatModel.chat(prompt, chatOptions);
090
091            chunks = parseChunksBySeparator(llmOutput, CHUNK_SEPARATOR);
092        } catch (Exception e) {
093            log.error("AI 拆分失败,使用 fallback 拆分器", e);
094            if (fallbackSplitter == null) {
095                log.error("没有可用的 fallback 拆分器,请检查配置");
096                return Collections.emptyList();
097            }
098            List<Document> fallbackDocs = fallbackSplitter.split(document, idGenerator);
099            if (fallbackDocs.size() > maxChunks) {
100                return new ArrayList<>(fallbackDocs.subList(0, maxChunks));
101            }
102            return fallbackDocs;
103        }
104
105        List<String> validChunks = chunks.stream()
106            .map(String::trim)
107            .filter(s -> !s.isEmpty())
108            .limit(maxChunks)
109            .collect(Collectors.toList());
110
111        List<Document> result = new ArrayList<>();
112        for (String chunk : validChunks) {
113            Document doc = new Document();
114            doc.setContent(chunk);
115            doc.setTitle(document.getTitle());
116            if (idGenerator != null) {
117                doc.setId(idGenerator.generateId(doc));
118            }
119            result.add(doc);
120        }
121
122        return result;
123    }
124
125    private List<String> parseChunksBySeparator(String text, String separator) {
126        if (text == null || text.trim().isEmpty()) {
127            return Collections.emptyList();
128        }
129
130        String[] parts = text.split(separator, -1);
131        List<String> chunks = new ArrayList<>();
132        for (String part : parts) {
133            String trimmed = part.trim();
134            if (!trimmed.isEmpty()) {
135                chunks.add(trimmed);
136            }
137        }
138
139        if (chunks.size() == 1 && text.contains(separator)) {
140            return tryAlternativeSplit(text, separator);
141        }
142
143        return chunks;
144    }
145
146    private List<String> tryAlternativeSplit(String text, String separator) {
147        String normalized = text.replaceAll("\\s*---\\s*", "---");
148        return parseChunksBySeparator(normalized, separator);
149    }
150
151    // ===== Getters & Setters =====
152
153    public void setFallbackSplitter(DocumentSplitter fallbackSplitter) {
154        this.fallbackSplitter = fallbackSplitter;
155    }
156
157    public void setSplitPromptTemplate(String splitPromptTemplate) {
158        if (splitPromptTemplate != null && !splitPromptTemplate.trim().isEmpty()) {
159            this.splitPromptTemplate = splitPromptTemplate;
160        }
161    }
162
163    public void setChatOptions(ChatOptions chatOptions) {
164        if (chatOptions != null) {
165            this.chatOptions = chatOptions;
166        }
167    }
168
169    public void setMaxChunks(int maxChunks) {
170        this.maxChunks = Math.max(1, maxChunks);
171    }
172
173    public void setMaxTotalLength(int maxTotalLength) {
174        this.maxTotalLength = Math.max(100, maxTotalLength);
175    }
176}