Files
svn-log-tool/src/main/java/com/svnlog/web/service/AiWorkflowService.java

1594 lines
69 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.svnlog.web.service;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.DefaultIndexedColorMap;
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
import org.apache.poi.xssf.usermodel.XSSFColor;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.svnlog.web.dto.AiAnalyzeRequest;
import com.svnlog.web.model.TaskResult;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
@Service
public class AiWorkflowService {
private static final Logger LOGGER = LoggerFactory.getLogger(AiWorkflowService.class);
private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
private static final String DEEPSEEK_MODEL_CHAT = "deepseek-chat";
private static final String DEEPSEEK_MODEL_THINK = "deepseek-reasoner";
private static final String DEFAULT_TEAM = "系统部";
private static final String DEFAULT_CONTACT = "杨志强\n(系统平台组)";
private static final String DEFAULT_DEVELOPER = "刘靖";
private static final String[] FIXED_PROJECTS = {
"PRS-7050场站智慧管控",
"PRS-7950在线巡视",
"PRS-7950在线巡视电科院测试版"
};
private static final String FIXED_PROJECT_VALUE =
"PRS-7950在线巡视电科院测试版/PRS-7950在线巡视/PRS-7050场站智慧管控";
private static final Pattern NUMBERED_ITEM_PATTERN = Pattern.compile("^\\s*(\\d+)[\\.、\\)]\\s*(.+)$");
private static final Pattern BULLET_ITEM_PATTERN = Pattern.compile("^\\s*[-*•]\\s*(.+)$");
private static final Pattern REVISION_ITEM_PATTERN = Pattern.compile("^\\s*(?:\\*\\s*)?r\\d+\\s*[-:]+\\s*(.+)$");
private static final Pattern FILE_SECTION_PATTERN =
Pattern.compile("(?s)=== 文件:\\s*(.*?)\\s*===\\s*(.*?)(?=(?:\\n\\s*=== 文件:)|\\z)");
private static final Pattern COMMIT_ENTRY_PATTERN =
Pattern.compile("(?s)###\\s+r(\\d+)\\b.*?\\*\\*提交信息\\*\\*:\\s*```\\s*(.*?)\\s*```");
private static final Pattern REVISION_REF_PATTERN = Pattern.compile("r\\d+");
private static final Pattern TOTAL_RECORDS_PATTERN = Pattern.compile("总记录数\\*\\*:\\s*(\\d+)\\s*条");
private static final Pattern REVISION_HEADING_PATTERN = Pattern.compile("^###\\s+r\\d+.*$");
private static final int STREAM_PERSIST_INTERVAL = 8;
private static final int SUMMARY_RECORDS_PER_ITEM = 4;
private static final int SUMMARY_MIN_ITEMS = 2;
private static final int SUMMARY_MAX_ITEMS = 200;
private static final int DEEPSEEK_CHAT_MAX_TOKENS_PRIMARY = 8000;
private static final int DEEPSEEK_CHAT_MAX_TOKENS_RETRY = 8000;
private static final int DEEPSEEK_REASONER_MAX_TOKENS_PRIMARY = 64000;
private static final int DEEPSEEK_REASONER_MAX_TOKENS_RETRY = 64000;
private static final int EXCEL_CELL_MAX_LENGTH = 32767;
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.readTimeout(180, TimeUnit.SECONDS)
.build();
private final OutputFileService outputFileService;
private final SettingsService settingsService;
private final AiInputValidator aiInputValidator;
private final RetrySupport retrySupport = new RetrySupport();
public AiWorkflowService(OutputFileService outputFileService,
SettingsService settingsService,
AiInputValidator aiInputValidator) {
this.outputFileService = outputFileService;
this.settingsService = settingsService;
this.aiInputValidator = aiInputValidator;
}
public TaskResult analyzeAndExport(AiAnalyzeRequest request, TaskContext context) throws Exception {
final Path outputRoot = outputFileService.getOutputRoot();
final List<String> requestedPaths = request.getFilePaths() == null
? java.util.Collections.<String>emptyList()
: request.getFilePaths();
context.setProgress(10, "正在读取 Markdown 文件,输出目录: " + outputRoot);
context.setProgress(12, "待处理文件: " + joinPaths(requestedPaths));
final List<Path> markdownFiles = resolveUserFiles(request.getFilePaths());
context.setProgress(18, "路径解析完成: " + joinResolvedPaths(markdownFiles));
aiInputValidator.validate(markdownFiles);
final String content = readMarkdownFiles(markdownFiles);
final Map<String, List<CommitEntry>> projectCommits = buildProjectCommitMap(content);
final Map<String, Integer> projectLogCounts = buildProjectLogCountsFromCommits(projectCommits);
final Map<String, Integer> projectMinItems = buildProjectMinItems(projectLogCounts);
context.setProgress(35, "正在请求 DeepSeek 分析");
final String period = request.getPeriod() != null && !request.getPeriod().trim().isEmpty()
? request.getPeriod().trim()
: new SimpleDateFormat("yyyy年MM月").format(new Date());
final String apiKey = settingsService.pickActiveKey(request.getApiKey());
if (apiKey == null || apiKey.trim().isEmpty()) {
throw new IllegalStateException("未配置 DeepSeek API Key可在设置页配置或请求中传入");
}
final Map<String, LinkedHashSet<String>> groupedFromAi = createGroupedItems();
for (int i = 0; i < FIXED_PROJECTS.length; i++) {
final String project = FIXED_PROJECTS[i];
final int targetItems = projectMinItems.containsKey(project)
? projectMinItems.get(project).intValue()
: SUMMARY_MIN_ITEMS;
final List<CommitEntry> sourceCommits = projectCommits.get(project);
final int stepProgress = 35 + (i * 10);
context.setProgress(stepProgress, "正在执行 Chat 压缩总结: " + project);
context.setAiStreamStatus("STREAMING");
List<ProjectSummaryItem> stageOneItems = new ArrayList<ProjectSummaryItem>();
try {
final String stageOnePrompt = buildProjectCompressionPrompt(project, sourceCommits, period, targetItems);
final String stageOneResponse = callDeepSeek(apiKey, stageOnePrompt, context, DEEPSEEK_MODEL_CHAT);
stageOneItems = parseProjectSummaryItems(stageOneResponse, project);
} catch (Exception ex) {
LOGGER.warn("DeepSeek chat stage failed, fallback to local compression: project={}", project, ex);
context.emitEvent("phase", buildEventPayload("Chat 压缩失败,已切换本地压缩: " + project));
}
if (stageOneItems.isEmpty()) {
final LinkedHashSet<String> localStageOne = buildLocalSummariesFromCommits(
project,
sourceCommits,
Math.max(targetItems, Math.min(targetItems * 2, sourceCommits == null ? 0 : sourceCommits.size()))
);
for (String item : localStageOne) {
stageOneItems.add(new ProjectSummaryItem(item, new LinkedHashSet<String>()));
}
}
context.setProgress(stepProgress + 5, "正在执行 Think 精炼总结: " + project);
try {
final String stageTwoPrompt = buildProjectRefinePrompt(project, period, targetItems, sourceCommits, stageOneItems);
final String stageTwoResponse = callDeepSeek(apiKey, stageTwoPrompt, context, DEEPSEEK_MODEL_THINK);
final List<ProjectSummaryItem> stageTwoItems = parseProjectSummaryItems(stageTwoResponse, project);
groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageTwoItems, targetItems));
} catch (Exception ex) {
LOGGER.warn("DeepSeek think stage failed, fallback to chat merge: project={}", project, ex);
context.emitEvent("phase", buildEventPayload("Think 精炼失败,使用 Chat 压缩结果: " + project));
groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageOneItems, targetItems));
}
}
final JsonObject payload = buildBasePayload(period);
applyGroupedItemsToPayload(payload, groupedFromAi);
enforceMinimumSummaryItems(payload, content, projectMinItems);
context.setProgress(75, "正在生成 Excel 文件");
final String filename = buildOutputFilename(request.getOutputFileName());
final String relative = "excel/" + filename;
final Path outputFile = outputFileService.resolveInOutput(relative);
Files.createDirectories(outputFile.getParent());
writeExcel(outputFile, payload, period);
context.setProgress(100, "AI 分析已完成");
final TaskResult result = new TaskResult("工作量统计已生成");
result.addFile(relative);
return result;
}
private String readMarkdownFiles(List<Path> filePaths) throws IOException {
final StringBuilder builder = new StringBuilder();
for (Path path : filePaths) {
final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
builder.append("\n\n=== 文件: ").append(path.getFileName().toString()).append(" ===\n");
builder.append(content);
}
return builder.toString();
}
private List<Path> resolveUserFiles(List<String> userPaths) throws IOException {
java.util.ArrayList<Path> files = new java.util.ArrayList<Path>();
if (userPaths == null) {
return files;
}
for (String userPath : userPaths) {
files.add(resolveUserFile(userPath));
}
return files;
}
private Path resolveUserFile(String userPath) throws IOException {
if (userPath == null || userPath.trim().isEmpty()) {
throw new IllegalArgumentException("文件路径不能为空");
}
final String normalizedInput = userPath.trim();
final Path outputRoot = outputFileService.getOutputRoot();
final Path rootPath = Paths.get("").toAbsolutePath().normalize();
final Path docsRoot = rootPath.resolve("docs").normalize();
// 优先按输出目录相对路径解析(例如 md/*.md、excel/*.xlsx
final Path outputCandidate = outputFileService.resolveInOutput(normalizedInput);
if (Files.exists(outputCandidate) && Files.isRegularFile(outputCandidate)) {
return outputCandidate;
}
// 兼容绝对路径或历史路径输入,但仍限制在允许目录内
final Path raw = Paths.get(normalizedInput);
final Path candidate = raw.isAbsolute() ? raw.normalize() : rootPath.resolve(raw).normalize();
if (candidate.startsWith(outputRoot) || candidate.startsWith(docsRoot)) {
if (Files.exists(candidate) && Files.isRegularFile(candidate)) {
return candidate;
}
}
final boolean outputCandidateExists = Files.exists(outputCandidate);
final boolean outputCandidateIsFile = outputCandidateExists && Files.isRegularFile(outputCandidate);
final boolean rootCandidateExists = Files.exists(candidate);
final boolean rootCandidateIsFile = rootCandidateExists && Files.isRegularFile(candidate);
throw new IllegalArgumentException(
"文件不存在或不在允许目录:" + normalizedInput
+ " | outputCandidate=" + outputCandidate
+ " (exists=" + outputCandidateExists + ", file=" + outputCandidateIsFile + ")"
+ " | rootCandidate=" + candidate
+ " (exists=" + rootCandidateExists + ", file=" + rootCandidateIsFile + ")"
+ " | outputRoot=" + outputRoot
+ " | docsRoot=" + docsRoot
);
}
private String joinPaths(List<String> paths) {
if (paths == null || paths.isEmpty()) {
return "(empty)";
}
final StringBuilder builder = new StringBuilder();
for (int i = 0; i < paths.size(); i++) {
if (i > 0) {
builder.append(", ");
}
builder.append(paths.get(i));
}
return builder.toString();
}
private String joinResolvedPaths(List<Path> paths) {
if (paths == null || paths.isEmpty()) {
return "(empty)";
}
final StringBuilder builder = new StringBuilder();
for (int i = 0; i < paths.size(); i++) {
if (i > 0) {
builder.append(", ");
}
builder.append(paths.get(i).toString());
}
return builder.toString();
}
private String buildProjectCompressionPrompt(String project,
List<CommitEntry> sourceCommits,
String period,
int targetItems) {
final StringBuilder workItems = new StringBuilder();
final LinkedHashSet<String> revisionSet = new LinkedHashSet<String>();
int index = 1;
if (sourceCommits != null) {
for (CommitEntry entry : sourceCommits) {
if (entry == null || entry.message.trim().isEmpty()) {
continue;
}
revisionSet.add(entry.revision);
workItems.append(index++)
.append(". [")
.append(entry.revision)
.append("] ")
.append(entry.message.trim())
.append('\n');
}
}
if (workItems.length() == 0) {
workItems.append("1. 本项目本周期存在代码提交,请按变更点进行归纳。\n");
}
final String allRevisions = revisionSet.isEmpty() ? "(none)" : joinValues(revisionSet, ", ");
final int compressedTarget = Math.max(targetItems, Math.min(Math.max(targetItems * 2, 6), Math.max(targetItems, revisionSet.size())));
return "你是项目管理助手,请仅根据以下“提交信息”先做压缩归纳。\n"
+ "工作周期: " + period + "\n"
+ "项目: " + project + "\n"
+ "目标压缩条数: " + Math.max(1, compressedTarget) + "\n"
+ "总提交数: " + revisionSet.size() + "\n"
+ "必须覆盖全部 revision不可遗漏: " + allRevisions + "\n"
+ "要求:\n"
+ "1. 仅输出 JSON不要输出额外文字\n"
+ "2. 不能包含 SVN 地址、账号、版本范围、作者、时间等元信息\n"
+ "3. 不要逐条复述提交,要把相近提交合并成压缩总结\n"
+ "4. 返回结构固定:{\"project\":\"" + project + "\",\"items\":[{\"summary\":\"...\",\"sources\":[\"r123\"]}]}\n"
+ "5. items 里每条都必须给出 sourcessources 只能来自上面给定 revision 列表\n"
+ "6. summary 仅描述功能开发/优化/修复,不输出流水账\n"
+ "提交信息列表:\n"
+ workItems.toString();
}
private String buildProjectRefinePrompt(String project,
String period,
int targetItems,
List<CommitEntry> sourceCommits,
List<ProjectSummaryItem> stageOneItems) {
final LinkedHashSet<String> revisionSet = new LinkedHashSet<String>();
if (sourceCommits != null) {
for (CommitEntry commit : sourceCommits) {
if (commit == null || commit.revision == null || commit.revision.trim().isEmpty()) {
continue;
}
revisionSet.add(commit.revision.trim());
}
}
final StringBuilder stageOneBuilder = new StringBuilder();
int index = 1;
if (stageOneItems != null) {
for (ProjectSummaryItem item : stageOneItems) {
if (item == null || item.summary == null || item.summary.trim().isEmpty()) {
continue;
}
stageOneBuilder.append(index++)
.append(". ")
.append(item.summary.trim())
.append(" | sources=")
.append(item.sources == null || item.sources.isEmpty() ? "[]" : joinValues(item.sources, ","))
.append('\n');
}
}
if (stageOneBuilder.length() == 0) {
stageOneBuilder.append("1. 本项目存在有效提交,请按目标条数进行归纳。\n");
}
return "你是项目管理助手,请将“阶段一压缩结果”精炼为固定条数总结。\n"
+ "工作周期: " + period + "\n"
+ "项目: " + project + "\n"
+ "最终条数(必须严格等于): " + Math.max(1, targetItems) + "\n"
+ "必须覆盖全部 revision不可遗漏: "
+ (revisionSet.isEmpty() ? "(none)" : joinValues(revisionSet, ", "))
+ "\n"
+ "要求:\n"
+ "1. 仅输出 JSON不要输出额外文字\n"
+ "2. 返回结构固定:{\"project\":\"" + project + "\",\"items\":[{\"summary\":\"...\",\"sources\":[\"r123\"]}]}\n"
+ "3. items 数量必须严格等于最终条数\n"
+ "4. 每条都必须有 sources且 sources 仅可使用给定 revision\n"
+ "5. 不要逐条抄写提交,必须做合并归纳\n"
+ "阶段一压缩结果:\n"
+ stageOneBuilder.toString();
}
private List<ProjectSummaryItem> parseProjectSummaryItems(String aiResponse, String project) {
final List<ProjectSummaryItem> items = new ArrayList<ProjectSummaryItem>();
final JsonObject object = extractJson(aiResponse);
if (object.has("items") && object.get("items").isJsonArray()) {
final JsonArray rawItems = object.getAsJsonArray("items");
for (JsonElement item : rawItems) {
if (item == null || item.isJsonNull()) {
continue;
}
if (item.isJsonObject()) {
final JsonObject jsonItem = item.getAsJsonObject();
final String summary = cleanWorkItem(
firstNonBlank(
firstNonBlank(
optString(jsonItem, "summary"),
optString(jsonItem, "content")
),
optString(jsonItem, "text")
)
);
final LinkedHashSet<String> sources = parseSources(jsonItem.get("sources"));
if (!summary.isEmpty()) {
items.add(new ProjectSummaryItem(summary, sources));
}
} else {
final String summary = cleanWorkItem(item.getAsString());
if (!summary.isEmpty()) {
items.add(new ProjectSummaryItem(summary, new LinkedHashSet<String>()));
}
}
}
}
if (object.has("content") && !object.get("content").isJsonNull()) {
final String content = object.get("content").getAsString();
final Map<String, LinkedHashSet<String>> tmp = createGroupedItems();
collectItems(tmp, project, content);
final LinkedHashSet<String> parsed = tmp.get(project);
if (parsed != null) {
for (String value : parsed) {
items.add(new ProjectSummaryItem(value, new LinkedHashSet<String>()));
}
}
}
if (object.has("records") && object.get("records").isJsonArray()) {
final JsonArray records = object.getAsJsonArray("records");
for (JsonElement element : records) {
if (element == null || !element.isJsonObject()) {
continue;
}
final JsonObject record = element.getAsJsonObject();
final String content = optString(record, "content");
if (content == null || content.trim().isEmpty()) {
continue;
}
final Map<String, LinkedHashSet<String>> tmp = createGroupedItems();
collectItems(tmp, project, content);
final LinkedHashSet<String> parsed = tmp.get(project);
if (parsed != null) {
for (String value : parsed) {
items.add(new ProjectSummaryItem(value, new LinkedHashSet<String>()));
}
}
}
}
return items;
}
private LinkedHashSet<String> mergeProjectItems(String project,
List<CommitEntry> sourceCommits,
List<ProjectSummaryItem> aiItems,
int targetItems) {
final LinkedHashMap<String, String> revisionToMessage = new LinkedHashMap<String, String>();
if (sourceCommits != null) {
for (CommitEntry commit : sourceCommits) {
if (commit == null || commit.revision == null || commit.revision.isEmpty()) {
continue;
}
if (commit.message == null || commit.message.trim().isEmpty()) {
continue;
}
revisionToMessage.put(commit.revision, commit.message);
}
}
final LinkedHashSet<String> allRevisions = new LinkedHashSet<String>(revisionToMessage.keySet());
final LinkedHashSet<String> covered = new LinkedHashSet<String>();
final LinkedHashSet<String> merged = new LinkedHashSet<String>();
final LinkedHashSet<String> aiItemsWithoutSources = new LinkedHashSet<String>();
if (aiItems != null) {
for (ProjectSummaryItem aiItem : aiItems) {
if (aiItem == null || aiItem.summary == null || aiItem.summary.trim().isEmpty()) {
continue;
}
final LinkedHashSet<String> validSources = new LinkedHashSet<String>();
for (String source : aiItem.sources) {
if (allRevisions.contains(source)) {
validSources.add(source);
}
}
if (validSources.isEmpty()) {
aiItemsWithoutSources.add(cleanWorkItem(aiItem.summary));
continue;
}
covered.addAll(validSources);
merged.add(cleanWorkItem(aiItem.summary));
}
}
if (merged.isEmpty() && !aiItemsWithoutSources.isEmpty()) {
merged.addAll(aiItemsWithoutSources);
covered.addAll(allRevisions);
}
final LinkedHashSet<String> uncovered = new LinkedHashSet<String>(allRevisions);
uncovered.removeAll(covered);
final List<CommitEntry> uncoveredCommits = new ArrayList<CommitEntry>();
for (String revision : uncovered) {
final String message = revisionToMessage.get(revision);
if (message == null || message.trim().isEmpty()) {
continue;
}
uncoveredCommits.add(new CommitEntry(revision, message));
}
if (!uncoveredCommits.isEmpty()) {
merged.addAll(buildLocalSummariesFromCommits(project, uncoveredCommits, Math.max(1, targetItems)));
}
if (merged.isEmpty()) {
merged.addAll(buildCommitFallbackItems(project, sourceCommits, targetItems));
}
return normalizeToTargetItemCount(project, merged, targetItems);
}
private LinkedHashSet<String> buildCommitFallbackItems(String project, List<CommitEntry> sourceCommits, int targetItems) {
final LinkedHashSet<String> items = buildLocalSummariesFromCommits(project, sourceCommits, Math.max(1, targetItems));
return normalizeToTargetItemCount(project, items, targetItems);
}
private LinkedHashSet<String> buildLocalSummariesFromCommits(String project,
List<CommitEntry> commits,
int targetItems) {
final LinkedHashSet<String> items = new LinkedHashSet<String>();
final List<String> messages = new ArrayList<String>();
if (commits != null) {
for (CommitEntry entry : commits) {
if (entry == null || entry.message == null || entry.message.trim().isEmpty()) {
continue;
}
messages.add(entry.message.trim());
}
}
if (messages.isEmpty()) {
return items;
}
final int summaryCount = Math.max(1, Math.min(Math.max(1, targetItems), messages.size()));
final int chunkSize = Math.max(1, (messages.size() + summaryCount - 1) / summaryCount);
for (int i = 0; i < messages.size(); i += chunkSize) {
final int to = Math.min(messages.size(), i + chunkSize);
final LinkedHashSet<String> condensed = new LinkedHashSet<String>();
for (int j = i; j < to; j++) {
final String value = condenseMessageForSummary(messages.get(j));
if (!value.isEmpty()) {
condensed.add(value);
}
}
if (condensed.isEmpty()) {
continue;
}
final String summary = joinValues(condensed, "");
items.add(cleanWorkItem(summary));
if (items.size() >= summaryCount) {
break;
}
}
return items;
}
private LinkedHashSet<String> normalizeToTargetItemCount(String project,
LinkedHashSet<String> rawItems,
int targetItems) {
final int safeTarget = Math.max(1, targetItems);
final List<String> values = new ArrayList<String>();
if (rawItems != null) {
for (String item : rawItems) {
final String cleaned = cleanWorkItem(item);
if (!cleaned.isEmpty()) {
values.add(cleaned);
}
}
}
final LinkedHashSet<String> result = new LinkedHashSet<String>();
if (values.isEmpty()) {
int index = 1;
while (result.size() < safeTarget) {
result.add(buildFallbackItem(project, index++));
}
return result;
}
if (values.size() <= safeTarget) {
result.addAll(values);
int index = 1;
while (result.size() < safeTarget) {
result.add(buildFallbackItem(project, index++));
}
return result;
}
final int chunkSize = Math.max(1, (values.size() + safeTarget - 1) / safeTarget);
for (int i = 0; i < values.size(); i += chunkSize) {
final int to = Math.min(values.size(), i + chunkSize);
final LinkedHashSet<String> condensed = new LinkedHashSet<String>();
for (int j = i; j < to; j++) {
condensed.add(condenseMessageForSummary(values.get(j)));
}
final String merged = cleanWorkItem(joinValues(condensed, ""));
if (!merged.isEmpty()) {
result.add(merged);
}
if (result.size() >= safeTarget) {
break;
}
}
int index = 1;
while (result.size() < safeTarget) {
result.add(buildFallbackItem(project, index++));
}
return result;
}
private String condenseMessageForSummary(String message) {
if (message == null) {
return "";
}
String value = message.trim();
value = value.replaceAll("(?i)^(feat|fix|chore|refactor|test|docs)(\\([^)]*\\))?:\\s*", "");
value = value.replaceAll("\\s+", " ");
value = value.replaceAll("[;。]+$", "");
return cleanWorkItem(value);
}
private LinkedHashSet<String> parseSources(JsonElement element) {
final LinkedHashSet<String> sources = new LinkedHashSet<String>();
if (element == null || element.isJsonNull()) {
return sources;
}
if (element.isJsonArray()) {
for (JsonElement source : element.getAsJsonArray()) {
if (source == null || source.isJsonNull()) {
continue;
}
addRevisionFromText(sources, source.getAsString());
}
return sources;
}
addRevisionFromText(sources, element.getAsString());
return sources;
}
private void addRevisionFromText(LinkedHashSet<String> sources, String text) {
if (sources == null || text == null || text.trim().isEmpty()) {
return;
}
final Matcher matcher = REVISION_REF_PATTERN.matcher(text);
while (matcher.find()) {
sources.add(matcher.group().toLowerCase());
}
}
private Map<String, List<CommitEntry>> buildProjectCommitMap(String markdownContent) {
final Map<String, List<CommitEntry>> grouped = createProjectCommitMap();
if (markdownContent == null || markdownContent.trim().isEmpty()) {
return grouped;
}
final Matcher fileMatcher = FILE_SECTION_PATTERN.matcher(markdownContent);
while (fileMatcher.find()) {
final String filename = fileMatcher.group(1);
final String section = fileMatcher.group(2);
final String project = normalizeProject(filename);
if (project == null) {
continue;
}
final List<CommitEntry> commits = grouped.get(project);
if (commits == null) {
continue;
}
final Matcher commitMatcher = COMMIT_ENTRY_PATTERN.matcher(section == null ? "" : section);
while (commitMatcher.find()) {
final String revision = "r" + commitMatcher.group(1);
final String message = normalizeCommitMessage(commitMatcher.group(2));
if (message.isEmpty()) {
continue;
}
commits.add(new CommitEntry(revision, message));
}
}
return grouped;
}
private Map<String, Integer> buildProjectLogCountsFromCommits(Map<String, List<CommitEntry>> projectCommits) {
final Map<String, Integer> counts = new LinkedHashMap<String, Integer>();
for (String project : FIXED_PROJECTS) {
final List<CommitEntry> commits = projectCommits == null ? null : projectCommits.get(project);
counts.put(project, Integer.valueOf(commits == null ? 0 : commits.size()));
}
return counts;
}
private Map<String, List<CommitEntry>> createProjectCommitMap() {
final Map<String, List<CommitEntry>> map = new LinkedHashMap<String, List<CommitEntry>>();
for (String project : FIXED_PROJECTS) {
map.put(project, new ArrayList<CommitEntry>());
}
return map;
}
private String normalizeCommitMessage(String raw) {
if (raw == null) {
return "";
}
String cleaned = raw.replace("\r", "\n").trim();
cleaned = cleaned.replaceAll("(?m)^\\s*[-*]\\s*", "");
cleaned = cleaned.replaceAll("\\s+", " ");
return cleanWorkItem(cleaned);
}
private String joinValues(Iterable<String> values, String delimiter) {
final StringBuilder builder = new StringBuilder();
if (values == null) {
return "";
}
for (String value : values) {
if (value == null || value.trim().isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append(delimiter);
}
builder.append(value.trim());
}
return builder.toString();
}
private JsonObject buildBasePayload(String period) {
final JsonObject payload = new JsonObject();
payload.addProperty("team", DEFAULT_TEAM);
payload.addProperty("contact", DEFAULT_CONTACT);
payload.addProperty("developer", DEFAULT_DEVELOPER);
payload.addProperty("period", period);
final JsonArray records = new JsonArray();
final JsonObject record = new JsonObject();
record.addProperty("sequence", 1);
record.addProperty("project", FIXED_PROJECT_VALUE);
record.addProperty("content", "");
records.add(record);
payload.add("records", records);
return payload;
}
private Map<String, Integer> buildProjectLogCounts(List<Path> markdownFiles) throws IOException {
final Map<String, Integer> counts = new LinkedHashMap<String, Integer>();
for (String project : FIXED_PROJECTS) {
counts.put(project, Integer.valueOf(0));
}
for (Path path : markdownFiles) {
final String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
final String project = normalizeProject(path.getFileName().toString());
if (project == null) {
continue;
}
int count = extractTotalRecordsCount(content);
if (count <= 0) {
count = estimateRevisionCount(content);
}
counts.put(project, Integer.valueOf(Math.max(count, 0)));
}
return counts;
}
private Map<String, Integer> buildProjectMinItems(Map<String, Integer> projectLogCounts) {
final Map<String, Integer> minimums = new LinkedHashMap<String, Integer>();
for (String project : FIXED_PROJECTS) {
final int logCount = projectLogCounts.containsKey(project)
? projectLogCounts.get(project).intValue()
: 0;
minimums.put(project, Integer.valueOf(computeRequiredSummaryItems(
logCount,
SUMMARY_RECORDS_PER_ITEM,
SUMMARY_MIN_ITEMS,
SUMMARY_MAX_ITEMS
)));
}
return minimums;
}
private int computeRequiredSummaryItems(int totalRecords,
int recordsPerItem,
int minItems,
int maxItems) {
final int safePerItem = Math.max(1, recordsPerItem);
final int safeMin = Math.max(0, minItems);
final int safeMax = Math.max(safeMin, maxItems);
final int raw = (Math.max(0, totalRecords) + safePerItem - 1) / safePerItem;
return Math.max(safeMin, Math.min(raw, safeMax));
}
private int extractTotalRecordsCount(String markdown) {
if (markdown == null || markdown.trim().isEmpty()) {
return 0;
}
final Matcher matcher = TOTAL_RECORDS_PATTERN.matcher(markdown);
if (matcher.find()) {
try {
return Integer.parseInt(matcher.group(1));
} catch (Exception ignored) {
return 0;
}
}
return 0;
}
private int estimateRevisionCount(String markdown) {
if (markdown == null || markdown.trim().isEmpty()) {
return 0;
}
int count = 0;
final String[] lines = markdown.split("\\r?\\n");
for (String line : lines) {
if (line == null) {
continue;
}
if (REVISION_HEADING_PATTERN.matcher(line.trim()).matches()) {
count++;
}
}
return count;
}
private String callDeepSeek(String apiKey, String prompt, TaskContext context, String model) throws IOException {
final String modelName = model == null || model.trim().isEmpty() ? DEEPSEEK_MODEL_CHAT : model.trim();
final int primaryMaxTokens = resolvePrimaryMaxTokens(modelName);
final int retryMaxTokens = resolveRetryMaxTokens(modelName);
try {
final DeepSeekStreamResult primary = retrySupport.execute(
() -> callDeepSeekOnce(apiKey, prompt, context, modelName, primaryMaxTokens),
3,
1000L
);
if (!"length".equalsIgnoreCase(primary.finishReason)) {
return primary.answer;
}
// 输出达到 token 上限但 JSON 已完整时直接使用,避免误报失败。
if (isValidJsonObjectText(primary.answer)) {
LOGGER.warn("DeepSeek finish_reason=length, but JSON is complete; using primary response");
return primary.answer;
}
context.emitEvent("phase", buildEventPayload(
"DeepSeek(" + modelName + ") 输出长度触顶自动重试max_tokens=" + retryMaxTokens + ""
));
final DeepSeekStreamResult retried = retrySupport.execute(
() -> callDeepSeekOnce(apiKey, prompt, context, modelName, retryMaxTokens),
2,
1200L
);
if ("length".equalsIgnoreCase(retried.finishReason) && !isValidJsonObjectText(retried.answer)) {
throw new IllegalStateException(
"DeepSeek 输出被截断finish_reason=length请缩短输入日志范围后重试"
);
}
return retried.answer;
} catch (IOException e) {
context.setAiStreamStatus("FALLBACK");
throw e;
} catch (Exception e) {
context.setAiStreamStatus("FALLBACK");
throw new IOException(e.getMessage(), e);
}
}
private int resolvePrimaryMaxTokens(String modelName) {
if (DEEPSEEK_MODEL_THINK.equalsIgnoreCase(modelName)) {
return DEEPSEEK_REASONER_MAX_TOKENS_PRIMARY;
}
return DEEPSEEK_CHAT_MAX_TOKENS_PRIMARY;
}
private int resolveRetryMaxTokens(String modelName) {
if (DEEPSEEK_MODEL_THINK.equalsIgnoreCase(modelName)) {
return DEEPSEEK_REASONER_MAX_TOKENS_RETRY;
}
return DEEPSEEK_CHAT_MAX_TOKENS_RETRY;
}
private DeepSeekStreamResult callDeepSeekOnce(String apiKey,
String prompt,
TaskContext context,
String model,
int maxTokens) throws Exception {
final JsonObject message = new JsonObject();
message.addProperty("role", "user");
message.addProperty("content", prompt);
final JsonArray messages = new JsonArray();
messages.add(message);
final JsonObject body = new JsonObject();
body.addProperty("model", model);
body.add("messages", messages);
body.addProperty("max_tokens", maxTokens);
body.addProperty("stream", true);
final JsonObject responseFormat = new JsonObject();
responseFormat.addProperty("type", "json_object");
body.add("response_format", responseFormat);
final JsonObject streamOptions = new JsonObject();
streamOptions.addProperty("include_usage", true);
body.add("stream_options", streamOptions);
final Request request = new Request.Builder()
.url(DEEPSEEK_API_URL)
.addHeader("Authorization", "Bearer " + apiKey)
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
String errorBody = "";
if (response.body() != null) {
errorBody = response.body().string();
}
String detail = "DeepSeek API 调用失败: " + response.code() + " " + errorBody;
if (response.code() == 429 || response.code() >= 500) {
throw new RetrySupport.RetryableException(detail);
}
throw new IllegalStateException(detail);
}
if (response.body() == null) {
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
}
final StringBuilder answerBuilder = new StringBuilder();
final StringBuilder reasoningBuilder = new StringBuilder();
final okhttp3.ResponseBody responseBody = response.body();
final okio.BufferedSource source = responseBody.source();
String finishReason = "";
int reasoningDeltaCount = 0;
int answerDeltaCount = 0;
Long usagePromptTokens = null;
Long usageCompletionTokens = null;
Long usageTotalTokens = null;
String finalMessageContent = "";
context.emitEvent("phase", buildEventPayload("正在流式接收 DeepSeek 输出"));
while (!source.exhausted()) {
final String line = source.readUtf8Line();
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
continue;
}
final String dataLine = line.substring(5).trim();
if ("[DONE]".equals(dataLine)) {
break;
}
final JsonObject data = JsonParser.parseString(dataLine).getAsJsonObject();
if (data.has("usage") && data.get("usage").isJsonObject()) {
final JsonObject usage = data.getAsJsonObject("usage");
usagePromptTokens = optLong(usage, "prompt_tokens");
usageCompletionTokens = optLong(usage, "completion_tokens");
usageTotalTokens = optLong(usage, "total_tokens");
final Map<String, Object> usagePayload = new LinkedHashMap<String, Object>();
usagePayload.put("promptTokens", usagePromptTokens);
usagePayload.put("completionTokens", usageCompletionTokens);
usagePayload.put("totalTokens", usageTotalTokens);
usagePayload.put("cacheHitTokens", optLong(usage, "prompt_cache_hit_tokens"));
usagePayload.put("cacheMissTokens", optLong(usage, "prompt_cache_miss_tokens"));
context.emitEvent("usage", usagePayload);
}
final JsonArray choices = data.getAsJsonArray("choices");
if (choices == null || choices.size() == 0) {
continue;
}
final JsonObject first = choices.get(0).getAsJsonObject();
if (first.has("message") && first.get("message").isJsonObject()) {
final String content = optString(first.getAsJsonObject("message"), "content");
if (content != null && !content.trim().isEmpty()) {
finalMessageContent = content.trim();
}
}
if (first.has("delta") && first.get("delta").isJsonObject()) {
final JsonObject delta = first.getAsJsonObject("delta");
final String reasoning = optString(delta, "reasoning_content");
if (reasoning != null && !reasoning.isEmpty()) {
reasoningDeltaCount++;
reasoningBuilder.append(reasoning);
context.emitEvent("reasoning_delta", buildTextPayload(reasoning));
if (reasoningDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
}
}
final String answer = optString(delta, "content");
if (answer != null && !answer.isEmpty()) {
answerDeltaCount++;
answerBuilder.append(answer);
context.emitEvent("answer_delta", buildTextPayload(answer));
if (answerDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
}
}
}
if (first.has("finish_reason") && !first.get("finish_reason").isJsonNull()) {
finishReason = first.get("finish_reason").getAsString();
}
}
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
throw new RetrySupport.RetryableException("DeepSeek 资源不足,请稍后重试");
}
String answer = answerBuilder.toString().trim();
if (answer.isEmpty() && finalMessageContent != null && !finalMessageContent.isEmpty()) {
answer = finalMessageContent;
}
if (answer.isEmpty()) {
throw new IllegalStateException(
"DeepSeek 未返回有效 content 内容"
+ " | stage_model=" + model
+ " | api_model=" + model
+ " | finish_reason=" + finishReason
+ " | prompt_tokens=" + usagePromptTokens
+ " | completion_tokens=" + usageCompletionTokens
+ " | total_tokens=" + usageTotalTokens
);
}
context.updateAiOutput(reasoningBuilder.toString(), answer);
context.setAiStreamStatus("DONE");
LOGGER.info(
"DeepSeek stream deltas: model={}, reasoning={}, answer={}, finishReason={}, maxTokens={}",
model,
reasoningDeltaCount,
answerDeltaCount,
finishReason,
maxTokens
);
return new DeepSeekStreamResult(answer, finishReason);
}
}
private boolean isValidJsonObjectText(String text) {
if (text == null || text.trim().isEmpty()) {
return false;
}
try {
extractJson(text);
return true;
} catch (Exception ignored) {
return false;
}
}
private static final class CommitEntry {
private final String revision;
private final String message;
private CommitEntry(String revision, String message) {
this.revision = revision == null ? "" : revision.trim().toLowerCase();
this.message = message == null ? "" : message.trim();
}
}
private static final class ProjectSummaryItem {
private final String summary;
private final LinkedHashSet<String> sources;
private ProjectSummaryItem(String summary, LinkedHashSet<String> sources) {
this.summary = summary == null ? "" : summary.trim();
this.sources = sources == null ? new LinkedHashSet<String>() : sources;
}
}
private static final class DeepSeekStreamResult {
private final String answer;
private final String finishReason;
private DeepSeekStreamResult(String answer, String finishReason) {
this.answer = answer == null ? "" : answer;
this.finishReason = finishReason == null ? "" : finishReason;
}
}
private Map<String, Object> buildTextPayload(String text) {
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("text", text);
return payload;
}
private Map<String, Object> buildEventPayload(String message) {
final Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("message", message);
return payload;
}
private Long optLong(JsonObject object, String key) {
if (object == null || key == null || !object.has(key) || object.get(key).isJsonNull()) {
return null;
}
try {
return Long.valueOf(object.get(key).getAsLong());
} catch (Exception ignored) {
return null;
}
}
private JsonObject extractJson(String rawResponse) {
String trimmed = rawResponse == null ? "" : rawResponse.trim();
if (trimmed.startsWith("```json")) {
trimmed = trimmed.substring(7).trim();
} else if (trimmed.startsWith("```")) {
trimmed = trimmed.substring(3).trim();
}
if (trimmed.endsWith("```")) {
trimmed = trimmed.substring(0, trimmed.length() - 3).trim();
}
return JsonParser.parseString(trimmed).getAsJsonObject();
}
private String buildOutputFilename(String outputFileName) {
if (outputFileName != null && !outputFileName.trim().isEmpty()) {
String name = outputFileName.trim();
if (!name.toLowerCase().endsWith(".xlsx")) {
name = name + ".xlsx";
}
return sanitize(name);
}
return new SimpleDateFormat("yyyyMM").format(new Date()) + "工作量统计.xlsx";
}
private void writeExcel(Path outputFile, JsonObject payload, String defaultPeriod) throws IOException {
final String period = payload.has("period")
? firstNonBlank(optString(payload, "period"), defaultPeriod)
: defaultPeriod;
final String team = DEFAULT_TEAM;
final String contact = DEFAULT_CONTACT;
final String developer = DEFAULT_DEVELOPER;
final String project = FIXED_PROJECT_VALUE;
final String content = buildContentFromPayload(payload);
validateExcelCellLength(content, "具体工作内容");
try (Workbook workbook = new XSSFWorkbook()) {
final Sheet sheet = workbook.createSheet("工作量统计");
final CellStyle headerStyle = createHeaderStyle(workbook);
final CellStyle textStyle = createTextStyle(workbook);
final CellStyle developerPeriodStyle = createDeveloperPeriodStyle(workbook);
final CellStyle projectNameStyle = createProjectNameStyle(workbook);
final CellStyle contentStyle = createContentStyle(workbook);
final String[] headers = {"序号", "所属班组", "技术对接", "开发人员", "工作周期", "开发项目名称", "具体工作内容"};
final Row header = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
final Cell cell = header.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
}
final Row row = sheet.createRow(1);
row.setHeightInPoints(calculateRowHeight(content));
createCell(row, 0, 1, textStyle);
createCell(row, 1, team, textStyle);
createCell(row, 2, contact, textStyle);
createCell(row, 3, developer, developerPeriodStyle);
createCell(row, 4, period, developerPeriodStyle);
createCell(row, 5, project, projectNameStyle);
createCell(row, 6, content, contentStyle);
sheet.setColumnWidth(0, 2200);
sheet.setColumnWidth(1, 4200);
sheet.setColumnWidth(2, 5200);
sheet.setColumnWidth(3, 4200);
sheet.setColumnWidth(4, 4600);
sheet.setColumnWidth(5, 12000);
sheet.setColumnWidth(6, 26000);
Files.createDirectories(outputFile.getParent());
try (OutputStream out = Files.newOutputStream(outputFile)) {
workbook.write(out);
}
}
}
private CellStyle createHeaderStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setBold(true);
font.setFontName("SimSun");
font.setColor(IndexedColors.BLACK.getIndex());
style.setFont(font);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
return style;
}
private CellStyle createTextStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setFontName("SimSun");
style.setFont(font);
style.setAlignment(HorizontalAlignment.LEFT);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setBorderBottom(BorderStyle.THIN);
style.setWrapText(false);
return style;
}
private CellStyle createDeveloperPeriodStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setFontName("SimSun");
font.setBold(false);
style.setFont(font);
style.setAlignment(HorizontalAlignment.CENTER);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setWrapText(false);
style.setBorderBottom(BorderStyle.THIN);
setSolidFillColor(style, "FEE4FF");
return style;
}
private CellStyle createProjectNameStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setFontName("宋体");
font.setBold(true);
style.setFont(font);
style.setAlignment(HorizontalAlignment.GENERAL);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setWrapText(true);
style.setBorderBottom(BorderStyle.THIN);
setSolidFillColor(style, "FFFF00");
return style;
}
private CellStyle createContentStyle(Workbook workbook) {
final CellStyle style = workbook.createCellStyle();
final Font font = workbook.createFont();
font.setFontName("NSimSun");
font.setBold(true);
style.setFont(font);
style.setAlignment(HorizontalAlignment.LEFT);
style.setVerticalAlignment(VerticalAlignment.TOP);
style.setWrapText(true);
style.setBorderBottom(BorderStyle.THIN);
setSolidFillColor(style, "FFFF00");
return style;
}
private void setSolidFillColor(CellStyle style, String rgbHex) {
if (!(style instanceof XSSFCellStyle) || rgbHex == null || rgbHex.trim().isEmpty()) {
return;
}
final String normalized = rgbHex.trim();
if (normalized.length() != 6) {
return;
}
final byte[] rgb = new byte[3];
try {
rgb[0] = (byte) Integer.parseInt(normalized.substring(0, 2), 16);
rgb[1] = (byte) Integer.parseInt(normalized.substring(2, 4), 16);
rgb[2] = (byte) Integer.parseInt(normalized.substring(4, 6), 16);
} catch (Exception ignored) {
return;
}
final XSSFCellStyle xssfStyle = (XSSFCellStyle) style;
xssfStyle.setFillForegroundColor(new XSSFColor(rgb, new DefaultIndexedColorMap()));
xssfStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
}
private void createCell(Row row, int idx, String value, CellStyle style) {
final Cell cell = row.createCell(idx);
cell.setCellValue(value == null ? "" : value);
cell.setCellStyle(style);
}
private void validateExcelCellLength(String value, String fieldName) {
final String safe = value == null ? "" : value;
if (safe.length() > EXCEL_CELL_MAX_LENGTH) {
throw new IllegalArgumentException(
"Excel 单元格内容超长: "
+ (fieldName == null ? "未知字段" : fieldName)
+ " 长度=" + safe.length()
+ ",最大允许=" + EXCEL_CELL_MAX_LENGTH
+ "。请减少本次汇总范围后重试。"
);
}
}
private void createCell(Row row, int idx, int value, CellStyle style) {
final Cell cell = row.createCell(idx);
cell.setCellValue(value);
cell.setCellStyle(style);
}
private int getAsInt(JsonElement element, int defaultValue) {
if (element == null) {
return defaultValue;
}
try {
return element.getAsInt();
} catch (Exception e) {
return defaultValue;
}
}
private String optString(JsonObject object, String key) {
if (object == null || !object.has(key) || object.get(key).isJsonNull()) {
return "";
}
return object.get(key).getAsString();
}
private String buildContentFromPayload(JsonObject payload) {
final Map<String, LinkedHashSet<String>> groupedItems = createGroupedItems();
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
for (JsonElement element : records) {
if (element == null || !element.isJsonObject()) {
continue;
}
final JsonObject record = element.getAsJsonObject();
final String recordProject = optString(record, "project");
final String recordContent = optString(record, "content");
collectItems(groupedItems, recordProject, recordContent);
}
final StringBuilder builder = new StringBuilder();
for (String project : FIXED_PROJECTS) {
final LinkedHashSet<String> items = groupedItems.get(project);
if (items == null || items.isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append("\n\n");
}
builder.append(project).append('\n');
int index = 1;
for (String item : items) {
builder.append(index++).append(". ").append(item).append('\n');
}
}
return builder.toString().trim();
}
private void enforceMinimumSummaryItems(JsonObject payload,
String markdownContent,
Map<String, Integer> projectMinItems) {
final Map<String, LinkedHashSet<String>> groupedFromPayload = createGroupedItems();
final JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : new JsonArray();
for (JsonElement element : records) {
if (element == null || !element.isJsonObject()) {
continue;
}
final JsonObject record = element.getAsJsonObject();
collectItems(
groupedFromPayload,
optString(record, "project"),
optString(record, "content")
);
}
final Map<String, LinkedHashSet<String>> groupedFromMarkdown = parseItemsFromMarkdown(markdownContent);
for (String project : FIXED_PROJECTS) {
final int targetItems = projectMinItems.containsKey(project)
? projectMinItems.get(project).intValue()
: 2;
final LinkedHashSet<String> current = groupedFromPayload.get(project);
final LinkedHashSet<String> source = groupedFromMarkdown.get(project);
if (current == null) {
continue;
}
if (current.isEmpty() && source != null) {
for (String item : source) {
current.add(item);
}
}
final LinkedHashSet<String> normalized = normalizeToTargetItemCount(project, current, targetItems);
current.clear();
current.addAll(normalized);
}
applyGroupedItemsToPayload(payload, groupedFromPayload);
}
private Map<String, LinkedHashSet<String>> parseItemsFromMarkdown(String markdownContent) {
final Map<String, LinkedHashSet<String>> groupedItems = createGroupedItems();
String currentProject = null;
final String[] lines = markdownContent == null ? new String[0] : markdownContent.split("\\r?\\n");
for (String rawLine : lines) {
final String line = rawLine == null ? "" : rawLine.trim();
if (line.isEmpty()) {
continue;
}
if (line.startsWith("=== 文件:")) {
currentProject = normalizeProject(line);
continue;
}
final String headingProject = parseHeadingProject(line);
if (headingProject != null) {
currentProject = headingProject;
continue;
}
final String extracted = parseWorkItem(line);
if (extracted == null || extracted.isEmpty()) {
continue;
}
final String targetProject = currentProject == null ? FIXED_PROJECTS[0] : currentProject;
groupedItems.get(targetProject).add(extracted);
}
return groupedItems;
}
private void applyGroupedItemsToPayload(JsonObject payload, Map<String, LinkedHashSet<String>> groupedItems) {
final StringBuilder mergedContent = new StringBuilder();
for (String project : FIXED_PROJECTS) {
final LinkedHashSet<String> items = groupedItems.get(project);
if (items == null || items.isEmpty()) {
continue;
}
if (mergedContent.length() > 0) {
mergedContent.append("\n\n");
}
mergedContent.append(project).append('\n');
int index = 1;
for (String item : items) {
mergedContent.append(index++).append(". ").append(item).append('\n');
}
}
final String merged = mergedContent.toString().trim();
JsonArray records = payload.has("records") ? payload.getAsJsonArray("records") : null;
if (records == null) {
records = new JsonArray();
payload.add("records", records);
}
JsonObject record;
if (records.size() > 0 && records.get(0).isJsonObject()) {
record = records.get(0).getAsJsonObject();
} else {
record = new JsonObject();
record.addProperty("sequence", 1);
records.add(record);
}
record.addProperty("project", FIXED_PROJECT_VALUE);
record.addProperty("content", merged);
}
private String buildFallbackItem(String project, int index) {
return "补充总结" + index + ":基于" + project + "本周期提交记录提炼的功能优化与问题修复工作";
}
private Map<String, LinkedHashSet<String>> createGroupedItems() {
final Map<String, LinkedHashSet<String>> groupedItems =
new LinkedHashMap<String, LinkedHashSet<String>>();
for (String project : FIXED_PROJECTS) {
groupedItems.put(project, new LinkedHashSet<String>());
}
return groupedItems;
}
private void collectItems(Map<String, LinkedHashSet<String>> groupedItems, String projectText, String content) {
final String fallbackProject = normalizeProject(projectText);
String currentProject = fallbackProject;
final String[] lines = content == null ? new String[0] : content.split("\\r?\\n");
for (String rawLine : lines) {
final String line = rawLine == null ? "" : rawLine.trim();
if (line.isEmpty()) {
continue;
}
final String headingProject = parseHeadingProject(line);
if (headingProject != null) {
currentProject = headingProject;
continue;
}
final String extracted = parseWorkItem(line);
if (extracted == null || extracted.isEmpty()) {
continue;
}
final String targetProject = currentProject == null ? FIXED_PROJECTS[0] : currentProject;
groupedItems.get(targetProject).add(extracted);
}
}
private String parseHeadingProject(String line) {
if (line.startsWith("#")) {
final String stripped = line.replaceFirst("^#+\\s*", "");
return normalizeProject(stripped);
}
return normalizeProject(line);
}
private String parseWorkItem(String line) {
if (isMetaLine(line)) {
return null;
}
Matcher matcher = NUMBERED_ITEM_PATTERN.matcher(line);
if (matcher.matches()) {
return cleanWorkItem(matcher.group(2));
}
matcher = BULLET_ITEM_PATTERN.matcher(line);
if (matcher.matches()) {
return cleanWorkItem(matcher.group(1));
}
matcher = REVISION_ITEM_PATTERN.matcher(line);
if (matcher.matches()) {
return cleanWorkItem(matcher.group(1));
}
if (line.length() > 6 && !line.startsWith("=") && !line.startsWith("```")) {
return cleanWorkItem(line);
}
return null;
}
private boolean isMetaLine(String line) {
return line.startsWith("SVN")
|| line.startsWith("仓库")
|| line.startsWith("分支")
|| line.startsWith("版本")
|| line.startsWith("提交总数")
|| line.startsWith("日志详情")
|| line.startsWith("作者")
|| line.startsWith("时间")
|| line.startsWith("消息")
|| line.startsWith("文件")
|| line.startsWith("=== 文件:");
}
private String cleanWorkItem(String item) {
String cleaned = item == null ? "" : item.trim();
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
cleaned = cleaned.replaceAll("^\\[[^\\]]+\\]", "");
cleaned = cleaned.replaceAll("\\s+", " ");
return cleaned.trim();
}
private String normalizeProject(String value) {
if (value == null) {
return null;
}
final String input = value.trim();
if (input.isEmpty()) {
return null;
}
if (input.contains("7050")) {
return "PRS-7050场站智慧管控";
}
if (input.contains("电科院")) {
return "PRS-7950在线巡视电科院测试版";
}
if (input.contains("7950")) {
return "PRS-7950在线巡视";
}
return null;
}
private float calculateRowHeight(String content) {
final String safeContent = content == null ? "" : content;
final String[] lines = safeContent.split("\\r?\\n");
final int visibleLines = Math.max(lines.length, 1);
final float lineHeight = 19.0f;
final float minHeight = 220.0f;
return Math.max(minHeight, visibleLines * lineHeight);
}
private String firstNonBlank(String preferred, String fallback) {
if (preferred != null && !preferred.trim().isEmpty()) {
return preferred.trim();
}
return fallback == null ? "" : fallback;
}
private String sanitize(String value) {
return value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_.-]", "_");
}
}