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 requestedPaths = request.getFilePaths() == null ? java.util.Collections.emptyList() : request.getFilePaths(); context.setProgress(10, "正在读取 Markdown 文件,输出目录: " + outputRoot); context.setProgress(12, "待处理文件: " + joinPaths(requestedPaths)); final List markdownFiles = resolveUserFiles(request.getFilePaths()); context.setProgress(18, "路径解析完成: " + joinResolvedPaths(markdownFiles)); aiInputValidator.validate(markdownFiles); final String content = readMarkdownFiles(markdownFiles); final Map> projectCommits = buildProjectCommitMap(content); final Map projectLogCounts = buildProjectLogCountsFromCommits(projectCommits); final Map 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> 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 sourceCommits = projectCommits.get(project); final int stepProgress = 35 + (i * 10); context.setProgress(stepProgress, "正在执行 Chat 压缩总结: " + project); context.setAiStreamStatus("STREAMING"); List stageOneItems = new ArrayList(); 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 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())); } } 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 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 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 resolveUserFiles(List userPaths) throws IOException { java.util.ArrayList files = new java.util.ArrayList(); 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 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 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 sourceCommits, String period, int targetItems) { final StringBuilder workItems = new StringBuilder(); final LinkedHashSet revisionSet = new LinkedHashSet(); 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 里每条都必须给出 sources;sources 只能来自上面给定 revision 列表\n" + "6. summary 仅描述功能开发/优化/修复,不输出流水账\n" + "提交信息列表:\n" + workItems.toString(); } private String buildProjectRefinePrompt(String project, String period, int targetItems, List sourceCommits, List stageOneItems) { final LinkedHashSet revisionSet = new LinkedHashSet(); 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 parseProjectSummaryItems(String aiResponse, String project) { final List items = new ArrayList(); 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 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())); } } } } if (object.has("content") && !object.get("content").isJsonNull()) { final String content = object.get("content").getAsString(); final Map> tmp = createGroupedItems(); collectItems(tmp, project, content); final LinkedHashSet parsed = tmp.get(project); if (parsed != null) { for (String value : parsed) { items.add(new ProjectSummaryItem(value, new LinkedHashSet())); } } } 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> tmp = createGroupedItems(); collectItems(tmp, project, content); final LinkedHashSet parsed = tmp.get(project); if (parsed != null) { for (String value : parsed) { items.add(new ProjectSummaryItem(value, new LinkedHashSet())); } } } } return items; } private LinkedHashSet mergeProjectItems(String project, List sourceCommits, List aiItems, int targetItems) { final LinkedHashMap revisionToMessage = new LinkedHashMap(); 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 allRevisions = new LinkedHashSet(revisionToMessage.keySet()); final LinkedHashSet covered = new LinkedHashSet(); final LinkedHashSet merged = new LinkedHashSet(); final LinkedHashSet aiItemsWithoutSources = new LinkedHashSet(); if (aiItems != null) { for (ProjectSummaryItem aiItem : aiItems) { if (aiItem == null || aiItem.summary == null || aiItem.summary.trim().isEmpty()) { continue; } final LinkedHashSet validSources = new LinkedHashSet(); 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 uncovered = new LinkedHashSet(allRevisions); uncovered.removeAll(covered); final List uncoveredCommits = new ArrayList(); 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 buildCommitFallbackItems(String project, List sourceCommits, int targetItems) { final LinkedHashSet items = buildLocalSummariesFromCommits(project, sourceCommits, Math.max(1, targetItems)); return normalizeToTargetItemCount(project, items, targetItems); } private LinkedHashSet buildLocalSummariesFromCommits(String project, List commits, int targetItems) { final LinkedHashSet items = new LinkedHashSet(); final List messages = new ArrayList(); 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 condensed = new LinkedHashSet(); 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 normalizeToTargetItemCount(String project, LinkedHashSet rawItems, int targetItems) { final int safeTarget = Math.max(1, targetItems); final List values = new ArrayList(); if (rawItems != null) { for (String item : rawItems) { final String cleaned = cleanWorkItem(item); if (!cleaned.isEmpty()) { values.add(cleaned); } } } final LinkedHashSet result = new LinkedHashSet(); 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 condensed = new LinkedHashSet(); 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 parseSources(JsonElement element) { final LinkedHashSet sources = new LinkedHashSet(); 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 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> buildProjectCommitMap(String markdownContent) { final Map> 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 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 buildProjectLogCountsFromCommits(Map> projectCommits) { final Map counts = new LinkedHashMap(); for (String project : FIXED_PROJECTS) { final List commits = projectCommits == null ? null : projectCommits.get(project); counts.put(project, Integer.valueOf(commits == null ? 0 : commits.size())); } return counts; } private Map> createProjectCommitMap() { final Map> map = new LinkedHashMap>(); for (String project : FIXED_PROJECTS) { map.put(project, new ArrayList()); } 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 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 buildProjectLogCounts(List markdownFiles) throws IOException { final Map counts = new LinkedHashMap(); 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 buildProjectMinItems(Map projectLogCounts) { final Map minimums = new LinkedHashMap(); 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 usagePayload = new LinkedHashMap(); 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 sources; private ProjectSummaryItem(String summary, LinkedHashSet sources) { this.summary = summary == null ? "" : summary.trim(); this.sources = sources == null ? new LinkedHashSet() : 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 buildTextPayload(String text) { final Map payload = new LinkedHashMap(); payload.put("text", text); return payload; } private Map buildEventPayload(String message) { final Map payload = new LinkedHashMap(); 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> 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 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 projectMinItems) { final Map> 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> groupedFromMarkdown = parseItemsFromMarkdown(markdownContent); for (String project : FIXED_PROJECTS) { final int targetItems = projectMinItems.containsKey(project) ? projectMinItems.get(project).intValue() : 2; final LinkedHashSet current = groupedFromPayload.get(project); final LinkedHashSet source = groupedFromMarkdown.get(project); if (current == null) { continue; } if (current.isEmpty() && source != null) { for (String item : source) { current.add(item); } } final LinkedHashSet normalized = normalizeToTargetItemCount(project, current, targetItems); current.clear(); current.addAll(normalized); } applyGroupedItemsToPayload(payload, groupedFromPayload); } private Map> parseItemsFromMarkdown(String markdownContent) { final Map> 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> groupedItems) { final StringBuilder mergedContent = new StringBuilder(); for (String project : FIXED_PROJECTS) { final LinkedHashSet 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> createGroupedItems() { final Map> groupedItems = new LinkedHashMap>(); for (String project : FIXED_PROJECTS) { groupedItems.put(project, new LinkedHashSet()); } return groupedItems; } private void collectItems(Map> 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_.-]", "_"); } }