1594 lines
69 KiB
Java
1594 lines
69 KiB
Java
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 里每条都必须给出 sources;sources 只能来自上面给定 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_.-]", "_");
|
||
}
|
||
}
|