From 9f55dfacfcc76b8eea8e3a3ad69d14d43c1dac5d Mon Sep 17 00:00:00 2001 From: Pengzhike <1799237435@qq.com> Date: Thu, 11 Jun 2026 15:12:44 +0800 Subject: [PATCH] chore(lint): reduce backend detekt baseline to 29 --- .../com/silk/backend/ai/AIStepwiseAgent.kt | 429 +++++++++--------- .../com/silk/backend/ai/DirectModelAgent.kt | 41 +- .../silk/backend/pdf/PDFReportGenerator.kt | 30 +- .../todos/GroupTodoExtractionService.kt | 167 +++++-- .../silk/backend/agents/acp/AcpClientTest.kt | 4 +- config/lint/detekt/backend.xml | 9 - .../active/lint-baseline-reduction.md | 21 +- ...06-11-lint-baseline-reduction-slice-149.md | 17 + ...06-11-lint-baseline-reduction-slice-150.md | 16 + ...06-11-lint-baseline-reduction-slice-151.md | 17 + ...06-11-lint-baseline-reduction-slice-152.md | 16 + ...06-11-lint-baseline-reduction-slice-153.md | 17 + ...06-11-lint-baseline-reduction-slice-154.md | 17 + ...06-11-lint-baseline-reduction-slice-155.md | 17 + 14 files changed, 502 insertions(+), 316 deletions(-) create mode 100644 docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-149.md create mode 100644 docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-150.md create mode 100644 docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-151.md create mode 100644 docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-152.md create mode 100644 docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-153.md create mode 100644 docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-154.md create mode 100644 docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-155.md diff --git a/backend/src/main/kotlin/com/silk/backend/ai/AIStepwiseAgent.kt b/backend/src/main/kotlin/com/silk/backend/ai/AIStepwiseAgent.kt index fe8b5314..c9fd8d33 100644 --- a/backend/src/main/kotlin/com/silk/backend/ai/AIStepwiseAgent.kt +++ b/backend/src/main/kotlin/com/silk/backend/ai/AIStepwiseAgent.kt @@ -27,6 +27,11 @@ class AIStepwiseAgent( private val apiKey: String = AIConfig.API_KEY, private val sessionName: String = "default_room" ) { + private companion object { + const val STREAM_IDLE_TIMEOUT_MS = 30000L + const val MAX_STREAM_EMPTY_LINES = 5 + } + private val logger = LoggerFactory.getLogger(AIStepwiseAgent::class.java) private val httpClient = HttpClient.newBuilder() @@ -562,158 +567,145 @@ $conclusion // logger.info(" Prompt长度: ${prompt.length} 字符") // logger.info(" 超时设置: ${AIConfig.TIMEOUT}ms (${AIConfig.TIMEOUT/1000}秒)") + val startTime = System.currentTimeMillis() + logger.info("🚀 发送请求时间: ${java.text.SimpleDateFormat("HH:mm:ss").format(java.util.Date(startTime))}") + val response = executeStreamingRequest(buildStreamingApiRequest(prompt), startTime) + logStreamingResponse(startTime, response.statusCode()) + ensureStreamingSuccess(response.statusCode()) + return readStreamingResponseBody(response, onChunk) + } + + private fun buildStreamingApiRequest(prompt: String): HttpRequest { val requestBody = ApiRequest( model = AIConfig.MODEL, - messages = listOf( - ApiMessage(role = "user", content = prompt) - ), + messages = listOf(ApiMessage(role = "user", content = prompt)), temperature = 0.7, - maxTokens = 65536, // ✅ 提升到65536,允许生成超详细的诊断内容 - stream = true // 启用流式输出 + maxTokens = 65536, + stream = true ) - - val startTime = System.currentTimeMillis() - logger.info("🚀 发送请求时间: ${java.text.SimpleDateFormat("HH:mm:ss").format(java.util.Date(startTime))}") - - val request = HttpRequest.newBuilder() + + return HttpRequest.newBuilder() .uri(URI.create(AIConfig.requireApiBaseUrl())) .header("Authorization", "Bearer $apiKey") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json.encodeToString(requestBody))) .timeout(Duration.ofMillis(AIConfig.TIMEOUT)) .build() - - // 使用 InputStream 处理流式响应 - val response = try { - httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()) - } catch (e: Exception) { - val elapsed = System.currentTimeMillis() - startTime - logger.error("❌ HTTP 请求失败 (耗时 ${elapsed}ms): ${e.message}", e) - throw e - } - - val responseTime = System.currentTimeMillis() - val requestDuration = responseTime - startTime + } + + private fun executeStreamingRequest( + request: HttpRequest, + startTime: Long + ): HttpResponse = try { + httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()) + } catch (e: Exception) { + val elapsed = System.currentTimeMillis() - startTime + logger.error("❌ HTTP 请求失败 (耗时 ${elapsed}ms): ${e.message}", e) + throw e + } + + private fun logStreamingResponse(startTime: Long, statusCode: Int) { + val requestDuration = System.currentTimeMillis() - startTime logger.info("✅ 收到 HTTP 响应 (耗时 ${requestDuration}ms)") - logger.info(" 状态码: ${response.statusCode()}") - - if (response.statusCode() != 200) { - logger.error("❌ API 返回错误状态码: ${response.statusCode()}") - error("API 调用失败:${response.statusCode()}") + logger.info(" 状态码: $statusCode") + } + + private fun ensureStreamingSuccess(statusCode: Int) { + if (statusCode == 200) { + return } - - val fullText = StringBuilder() - var lastDataTime = System.currentTimeMillis() - val idleTimeoutMs = 30000L // 30秒空闲超时 - var dataChunkCount = 0 // 数据块计数 - - // 移除日志输出以提升性能 - // logger.info("📖 开始读取流式响应...") - - // 使用 withTimeout 包裹整个读取过程,防止永久挂起 + logger.error("❌ API 返回错误状态码: $statusCode") + error("API 调用失败:$statusCode") + } + + private suspend fun readStreamingResponseBody( + response: HttpResponse, + onChunk: suspend (String) -> Unit + ): String { + val streamState = StreamingReadState() + try { - kotlinx.coroutines.withTimeout(AIConfig.TIMEOUT + 10000) { // 总超时:70秒 - // 逐行读取 SSE(Server-Sent Events)数据 + kotlinx.coroutines.withTimeout(AIConfig.TIMEOUT + 10000) { response.body().bufferedReader().use { reader -> - var line: String? - var emptyLineCount = 0 // 连续空行计数 - var lineCount = 0 - - while (true) { - // 检查空闲超时 - val idleTime = System.currentTimeMillis() - lastDataTime - if (idleTime > idleTimeoutMs) { - // 移除详细日志输出 - // logger.warn("⚠️ 流式读取空闲超时(${idleTime}ms 无新数据),主动中断") - // logger.warn(" 已接收数据: ${fullText.length} 字符, ${dataChunkCount} 个数据块") - break - } - - // 移除心跳日志以提升性能 - // if (idleTime > 0 && idleTime % 10000 < 100) { - // logger.info("💓 流式读取中... (已等待 ${idleTime/1000}秒, 已接收 ${fullText.length} 字符)") - // } - - // 非阻塞式读取一行 - line = try { - reader.readLine() - } catch (e: Exception) { - logger.warn("⚠️ 读取行失败: ${e.message}", e) - break - } - - lineCount++ - - // 流结束 - if (line == null) { - // 移除日志输出以提升性能 - // logger.info("✅ 流正常结束(收到 null)") - // logger.info(" 共读取 $lineCount 行, 接收 ${fullText.length} 字符") - break - } - - // 跟踪空行(连续多个空行可能表示流结束) - if (line.trim().isEmpty()) { - emptyLineCount++ - if (emptyLineCount > 5) { - logger.warn("⚠️ 检测到连续 $emptyLineCount 个空行,可能流已结束") - break - } - continue - } else { - emptyLineCount = 0 - } - - // SSE 格式:data: {"choices":[{"delta":{"content":"文本"},...}]} - if (line.startsWith("data: ")) { - lastDataTime = System.currentTimeMillis() // 更新最后接收数据时间 - dataChunkCount++ - - val jsonData = line.substring(6).trim() - - // 跳过特殊标记 - if (jsonData == "[DONE]") { - // 移除日志输出以提升性能 - // logger.info("✅ 收到 [DONE] 标记,流正常结束 - 共接收 $dataChunkCount 个数据块, ${fullText.length} 字符") - break - } - - try { - // 解析流式响应 - val streamResponse = json.decodeFromString(jsonData) - val content = streamResponse.choices.firstOrNull()?.delta?.content - - if (content != null) { - fullText.append(content) - // 实时回调,发送到前端 - onChunk(content) - - // 移除日志输出以提升性能 - // if (dataChunkCount % 200 == 0) { - // logger.info("📊 AI模型流式输出进度: $dataChunkCount 数据块, ${fullText.length} 字符") - // } - } - } catch (e: Exception) { - // 移除详细日志输出 - // logger.warn("⚠️ 解析流式数据失败 (行$lineCount): ${line.take(100)}...") - } - } - - // 短暂让出 CPU,避免 CPU 100% + while (readStreamingLine(reader, streamState, onChunk)) { kotlinx.coroutines.delay(1) } } } } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - logger.error("❌ 流式读取总超时(70秒),当前已接收 ${fullText.length} 字符", e) - // 返回已接收的部分数据 + logger.error("❌ 流式读取总超时(70秒),当前已接收 ${streamState.fullText.length} 字符", e) } catch (e: Exception) { logger.error("❌ 流式读取异常: ${e.message}", e) throw e } - - return fullText.toString() + + return streamState.fullText.toString() + } + + private suspend fun readStreamingLine( + reader: java.io.BufferedReader, + streamState: StreamingReadState, + onChunk: suspend (String) -> Unit + ): Boolean { + if (System.currentTimeMillis() - streamState.lastDataTime > STREAM_IDLE_TIMEOUT_MS) { + return false + } + + val line = readStreamingLineOrNull(reader) ?: return false + streamState.lineCount++ + + if (line.trim().isEmpty()) { + streamState.emptyLineCount++ + return streamState.emptyLineCount <= MAX_STREAM_EMPTY_LINES + } + + streamState.emptyLineCount = 0 + if (!line.startsWith("data: ")) { + return true + } + + streamState.lastDataTime = System.currentTimeMillis() + streamState.dataChunkCount++ + return consumeStreamingDataLine(line.substring(6).trim(), streamState, onChunk) + } + + private fun readStreamingLineOrNull(reader: java.io.BufferedReader): String? = try { + reader.readLine() + } catch (e: Exception) { + logger.warn("⚠️ 读取行失败: ${e.message}", e) + null + } + + private suspend fun consumeStreamingDataLine( + jsonData: String, + streamState: StreamingReadState, + onChunk: suspend (String) -> Unit + ): Boolean { + if (jsonData == "[DONE]") { + return false + } + + parseStreamingContent(jsonData)?.let { content -> + streamState.fullText.append(content) + onChunk(content) + } + return true + } + + private fun parseStreamingContent(jsonData: String): String? = try { + val streamResponse = json.decodeFromString(jsonData) + streamResponse.choices.firstOrNull()?.delta?.content + } catch (_: Exception) { + null } + + private class StreamingReadState( + val fullText: StringBuilder = StringBuilder(), + var lastDataTime: Long = System.currentTimeMillis(), + var emptyLineCount: Int = 0, + var lineCount: Int = 0, + var dataChunkCount: Int = 0 + ) /** * 生成完整的总结报告 @@ -1373,83 +1365,13 @@ $doctorMessage callback: suspend (content: String, isComplete: Boolean) -> Unit ) { try { - val requestBody = ApiRequest( - model = AIConfig.MODEL, - messages = listOf( - ApiMessage(role = "user", content = prompt) - ), - temperature = 0.7, - maxTokens = 4096, // ✅ GLM-5 的 max_tokens 限制,避免 4K 超出导致截断 - stream = true // 启用streaming + val response = httpClient.send( + buildQuickResponseRequest(prompt), + HttpResponse.BodyHandlers.ofLines() ) - - val request = HttpRequest.newBuilder() - .uri(URI.create("${AIConfig.requireApiBaseUrl()}/chat/completions")) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer $apiKey") - .timeout(Duration.ofMillis(60000)) - .POST(HttpRequest.BodyPublishers.ofString(json.encodeToString(requestBody))) - .build() - - // Streaming响应处理 - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofLines()) - + if (response.statusCode() == 200) { - val lines = response.body().toList() - val accumulatedContent = StringBuilder() - var lastSentContent = "" // ✅ 记录上次发送的内容 - var isDone = false // ✅ 标记是否已完成 - - for (line in lines) { - if (line.startsWith("data: ")) { - val data = line.removePrefix("data: ").trim() - - if (data == "[DONE]") { - isDone = true - break - } - - try { - val streamResponse = json.decodeFromString(data) - val delta = streamResponse.choices.firstOrNull()?.delta - - // ✅ GLM-5 模型:content 字段是乱码,reasoning 字段才是真正的中文回答 - // 只读取 reasoning 字段,忽略 content - val content = delta?.reasoning ?: "" - - if (content.isNotEmpty()) { - accumulatedContent.append(content) - - // ✅ 累积3行后发送一次临时消息(更频繁更新) - val newlineCount = accumulatedContent.count { it == '\n' } - if (newlineCount >= 3 && accumulatedContent.length > lastSentContent.length) { - // ✅ 发送增量内容(只发送新增的部分) - val incrementalContent = accumulatedContent.substring(lastSentContent.length) - callback(incrementalContent, false) - lastSentContent = accumulatedContent.toString() - delay(50) // 减少延迟,提升响应速度 - } - } - } catch (e: Exception) { - // 忽略解析错误,继续处理下一行 - } - } - } - - // 确保发送最后的增量内容(如果有) - if (accumulatedContent.length > lastSentContent.length) { - val finalIncrement = accumulatedContent.substring(lastSentContent.length) - if (finalIncrement.isNotEmpty()) { - // 先发送最后的增量内容 - callback(finalIncrement, false) - delay(50) - } - } - - // ✅ 只发送一次完成标记(包含完整内容) - if (isDone) { - callback(accumulatedContent.toString(), true) - } + deliverQuickResponse(response.body().toList(), callback) } else { logger.error("❌ AI API返回错误: ${response.statusCode()}") callback("⚠️ AI暂时无法回答,请稍后重试", true) @@ -1459,6 +1381,107 @@ $doctorMessage callback("⚠️ AI暂时无法回答,请稍后重试", true) } } + + private fun buildQuickResponseRequest(prompt: String): HttpRequest { + val requestBody = ApiRequest( + model = AIConfig.MODEL, + messages = listOf(ApiMessage(role = "user", content = prompt)), + temperature = 0.7, + maxTokens = 4096, + stream = true + ) + + return HttpRequest.newBuilder() + .uri(URI.create("${AIConfig.requireApiBaseUrl()}/chat/completions")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer $apiKey") + .timeout(Duration.ofMillis(60000)) + .POST(HttpRequest.BodyPublishers.ofString(json.encodeToString(requestBody))) + .build() + } + + private suspend fun deliverQuickResponse( + lines: List, + callback: suspend (content: String, isComplete: Boolean) -> Unit + ) { + val streamState = QuickResponseStreamState() + + for (line in lines) { + if (consumeQuickResponseLine(line, streamState, callback)) { + break + } + } + + flushQuickResponseTail(streamState, callback) + if (streamState.isDone) { + callback(streamState.accumulatedContent.toString(), true) + } + } + + private suspend fun consumeQuickResponseLine( + line: String, + streamState: QuickResponseStreamState, + callback: suspend (content: String, isComplete: Boolean) -> Unit + ): Boolean { + if (!line.startsWith("data: ")) { + return false + } + + val data = line.removePrefix("data: ").trim() + if (data == "[DONE]") { + streamState.isDone = true + return true + } + + parseQuickResponseChunk(data)?.let { content -> + streamState.accumulatedContent.append(content) + emitQuickResponseIncrementIfNeeded(streamState, callback) + } + return false + } + + private fun parseQuickResponseChunk(data: String): String? = try { + val streamResponse = json.decodeFromString(data) + streamResponse.choices.firstOrNull()?.delta?.reasoning?.takeIf { it.isNotEmpty() } + } catch (_: Exception) { + null + } + + private suspend fun emitQuickResponseIncrementIfNeeded( + streamState: QuickResponseStreamState, + callback: suspend (content: String, isComplete: Boolean) -> Unit + ) { + val newlineCount = streamState.accumulatedContent.count { it == '\n' } + if (newlineCount < 3 || streamState.accumulatedContent.length <= streamState.lastSentContent.length) { + return + } + + val incrementalContent = streamState.accumulatedContent.substring(streamState.lastSentContent.length) + callback(incrementalContent, false) + streamState.lastSentContent = streamState.accumulatedContent.toString() + delay(50) + } + + private suspend fun flushQuickResponseTail( + streamState: QuickResponseStreamState, + callback: suspend (content: String, isComplete: Boolean) -> Unit + ) { + if (streamState.accumulatedContent.length <= streamState.lastSentContent.length) { + return + } + + val finalIncrement = streamState.accumulatedContent.substring(streamState.lastSentContent.length) + if (finalIncrement.isNotEmpty()) { + callback(finalIncrement, false) + delay(50) + } + } + + private class QuickResponseStreamState( + val accumulatedContent: StringBuilder = StringBuilder(), + var lastSentContent: String = "", + var isDone: Boolean = false + ) } /** diff --git a/backend/src/main/kotlin/com/silk/backend/ai/DirectModelAgent.kt b/backend/src/main/kotlin/com/silk/backend/ai/DirectModelAgent.kt index cd3b516e..ab538a57 100644 --- a/backend/src/main/kotlin/com/silk/backend/ai/DirectModelAgent.kt +++ b/backend/src/main/kotlin/com/silk/backend/ai/DirectModelAgent.kt @@ -264,24 +264,15 @@ class DirectModelAgent( appendLine("群聊历史已保存到 `chat_history.md`,你可以用 Grep 搜索历史消息。") } - val response = try { + val response = runCatching { // 优先使用 Claude CLI(内置 web_search、Grep、Read、glob 工具,原生支持 [citation:N] 引用) - try { - chatViaClaudeProcess(toolContext, callback) - } catch (e: Exception) { - logger.warn("⚠️ [DirectModelAgent] Claude CLI 调用失败,回退到 API 路径: ${e.message}") - val apiKey = AIConfig.ANTHROPIC_API_KEY - if (apiKey.isNotBlank()) { - chatViaAnthropicApi(apiKey, toolContext, callback) - } else { - throw e - } + runClaudeOrApi(toolContext, callback) + }.getOrElse { error -> + if (error is CancellationException) { + throw error } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - logger.error("❌ [DirectModelAgent] AI 调用失败: ${e.message}") - callback("error", "❌ AI 调用失败: ${e.message}", true) + logger.error("❌ [DirectModelAgent] AI 调用失败: ${error.message}") + callback("error", "❌ AI 调用失败: ${error.message}", true) "抱歉,处理您的问题时发生了错误。" } @@ -291,6 +282,24 @@ class DirectModelAgent( return response } + private suspend fun runClaudeOrApi( + toolContext: String, + callback: suspend (String, String, Boolean) -> Unit + ): String = runCatching { + chatViaClaudeProcess(toolContext, callback) + }.getOrElse { error -> + if (error is CancellationException) { + throw error + } + logger.warn("⚠️ [DirectModelAgent] Claude CLI 调用失败,回退到 API 路径: ${error.message}") + val apiKey = AIConfig.ANTHROPIC_API_KEY + if (apiKey.isNotBlank()) { + chatViaAnthropicApi(apiKey, toolContext, callback) + } else { + throw error + } + } + private suspend fun chatViaClaudeProcess( toolContext: String, callback: suspend (String, String, Boolean) -> Unit diff --git a/backend/src/main/kotlin/com/silk/backend/pdf/PDFReportGenerator.kt b/backend/src/main/kotlin/com/silk/backend/pdf/PDFReportGenerator.kt index 46d62cf7..17508af1 100644 --- a/backend/src/main/kotlin/com/silk/backend/pdf/PDFReportGenerator.kt +++ b/backend/src/main/kotlin/com/silk/backend/pdf/PDFReportGenerator.kt @@ -43,35 +43,7 @@ class PDFReportGenerator { private fun secondaryColor() = DeviceRgb(76, 175, 80) // 绿色 private fun headerBgColor() = DeviceRgb(245, 245, 245) // 浅灰色 private val commonChineseDiagnosisKeywords = listOf("不寐", "虚劳", "头痛") - // 中文字体路径(静态配置)- 优先使用对中英文都支持好的字体 - private val chineseFontPath: String? by lazy { - // macOS 系统自带的字体,优先选择对中英文都支持好的 - val fontPaths = listOf( - "/Library/Fonts/Arial Unicode.ttf", // ✅ Arial Unicode MS - 对中英文都支持好 - "/System/Library/Fonts/PingFang.ttc,0", // PingFang SC Regular - "/System/Library/Fonts/STHeiti Light.ttc,0", // 华文黑体 - "/System/Library/Fonts/Supplemental/Songti.ttc,0" // 宋体 - ) - - // 尝试找到第一个可用的字体 - for (fontPath in fontPaths) { - try { - val file = java.io.File(fontPath.split(",")[0]) - if (file.exists()) { - logger.info("✅ 找到中文字体: {}", fontPath) - return@lazy fontPath - } - } catch (e: Exception) { - // 尝试下一个字体 - continue - } - } - - // 如果都失败,返回 null(使用内置字体) - logger.warn("⚠️ 未找到系统字体,将使用内置字体") - null - } - + /** * 为每个 PDF 文档创建独立的中文字体对象 * ✅ 使用正确的策略:内置CJK字体不需要embed diff --git a/backend/src/main/kotlin/com/silk/backend/todos/GroupTodoExtractionService.kt b/backend/src/main/kotlin/com/silk/backend/todos/GroupTodoExtractionService.kt index 660770c0..8e590358 100644 --- a/backend/src/main/kotlin/com/silk/backend/todos/GroupTodoExtractionService.kt +++ b/backend/src/main/kotlin/com/silk/backend/todos/GroupTodoExtractionService.kt @@ -7,9 +7,11 @@ import com.silk.backend.database.UserRepository import com.silk.backend.database.UserTodoItemDto import com.silk.backend.models.ChatHistory import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -18,6 +20,7 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.io.File +import java.io.IOException import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest @@ -201,12 +204,13 @@ actionType / actionDetail(能填就填,影响手机端是否显示「运行 val userPrompt = "用户显示名:$userName(userId=${userId.take(8)}…)\n\n$transcript" - val raw = try { - callLlm(system, userPrompt, apiKey, temperature = 0.2) - } catch (e: Exception) { - println("❌ [GroupTodoExtractionService] LLM 调用失败: ${e.message}") - null - } + val raw = callLlmOrNull( + system = system, + user = userPrompt, + apiKey = apiKey, + temperature = 0.2, + failurePrefix = "❌ [GroupTodoExtractionService] LLM 调用失败" + ) val parseResult = raw?.let { parseTodoJsonStrict(it) } val llmDrafts = parseResult?.first ?: emptyList() @@ -328,10 +332,12 @@ actionType / actionDetail(能填就填,影响手机端是否显示「运行 格式:{"todos":[{"id":"必须是输入中的id","title":"...","actionType":"alarm","actionDetail":"07:00","sourceGroupId":"可省略","sourceGroupName":"可省略","done":false}]} 输出的每条 id 必须出现在输入 JSON 的 id 集合中。""" - val raw = try { - callLlm(system, payload, apiKey) - } catch (e: Exception) { - println("❌ [GroupTodoExtractionService] 去冗 LLM 失败: ${e.message}") + val raw = callLlmOrNull( + system = system, + user = payload, + apiKey = apiKey, + failurePrefix = "❌ [GroupTodoExtractionService] 去冗 LLM 失败" + ) ?: run { UserTodoStore.dedupeByLogicalKeyInPlace(userId) return } @@ -366,52 +372,79 @@ actionType / actionDetail(能填就填,影响手机端是否显示「运行 val out = mutableListOf() val seenOutIds = mutableSetOf() for (el in arr) { - val o = el.jsonObject - val id = o["id"]?.jsonPrimitive?.content?.trim() ?: continue - if (id !in validIds) { - println("⚠️ [GroupTodoExtractionService] 去冗跳过未知 id: ${id.take(12)}…") - continue - } - if (id in seenOutIds) continue - seenOutIds.add(id) - val orig = originalsById[id] ?: continue - val title = o["title"]?.jsonPrimitive?.content?.trim() ?: continue - if (title.isEmpty() || title.length > 500) continue - val gid = o["sourceGroupId"]?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotEmpty() } - val gname = o["sourceGroupName"]?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotEmpty() } - val at = o["actionType"]?.jsonPrimitive?.content?.trim()?.lowercase() - ?.takeIf { it.isNotEmpty() && it != "null" } - val ad = o["actionDetail"]?.jsonPrimitive?.content?.trim() - ?.takeIf { it.isNotEmpty() } - val donePrim = o["done"]?.jsonPrimitive - val done = when { - donePrim == null -> orig.done - donePrim.booleanOrNull != null -> donePrim.booleanOrNull == true - else -> donePrim.content.equals("true", ignoreCase = true) || donePrim.content == "1" - } - out.add( - UserTodoItemDto( - id = id, - title = title, - sourceGroupId = gid ?: orig.sourceGroupId, - sourceGroupName = gname ?: orig.sourceGroupName, - actionType = at?.ifBlank { null }, - actionDetail = ad?.ifBlank { null }, - createdAt = orig.createdAt, - updatedAt = now, - done = done, - executedAt = orig.executedAt, - reminderId = orig.reminderId - ) - ) + val parsed = parseCompactTodoEntry(el, validIds, seenOutIds, originalsById, now) ?: continue + out.add(parsed) } if (out.isEmpty()) null else out - } catch (e: Exception) { + } catch (e: SerializationException) { + println("⚠️ [GroupTodoExtractionService] 去冗 JSON 解析失败: ${e.message}") + null + } catch (e: IllegalArgumentException) { println("⚠️ [GroupTodoExtractionService] 去冗 JSON 解析失败: ${e.message}") null } } + private fun parseCompactTodoEntry( + element: kotlinx.serialization.json.JsonElement, + validIds: Set, + seenOutIds: MutableSet, + originalsById: Map, + now: Long + ): UserTodoItemDto? { + val todoObject = element.jsonObject + val id = todoObject["id"]?.jsonPrimitive?.content?.trim() ?: return null + if (id !in validIds) { + println("⚠️ [GroupTodoExtractionService] 去冗跳过未知 id: ${id.take(12)}…") + return null + } + if (!seenOutIds.add(id)) return null + + val original = originalsById[id] ?: return null + val title = todoObject["title"]?.jsonPrimitive?.content?.trim() + ?.takeIf(::isCompactTodoTitleValid) + ?: return null + + return UserTodoItemDto( + id = id, + title = title, + sourceGroupId = parseCompactTodoOptionalText(todoObject, "sourceGroupId") ?: original.sourceGroupId, + sourceGroupName = parseCompactTodoOptionalText(todoObject, "sourceGroupName") ?: original.sourceGroupName, + actionType = parseCompactTodoActionType(todoObject), + actionDetail = parseCompactTodoOptionalText(todoObject, "actionDetail"), + createdAt = original.createdAt, + updatedAt = now, + done = resolveCompactTodoDone(todoObject, original), + executedAt = original.executedAt, + reminderId = original.reminderId + ) + } + + private fun isCompactTodoTitleValid(title: String): Boolean = title.isNotEmpty() && title.length <= 500 + + private fun parseCompactTodoOptionalText( + todoObject: kotlinx.serialization.json.JsonObject, + key: String + ): String? = todoObject[key] + ?.jsonPrimitive + ?.content + ?.trim() + ?.takeIf { it.isNotEmpty() } + + private fun parseCompactTodoActionType(todoObject: kotlinx.serialization.json.JsonObject): String? = + parseCompactTodoOptionalText(todoObject, "actionType") + ?.lowercase() + ?.takeIf { it != "null" } + + private fun resolveCompactTodoDone( + todoObject: kotlinx.serialization.json.JsonObject, + original: UserTodoItemDto + ): Boolean { + val doneValue = todoObject["done"]?.jsonPrimitive ?: return original.done + doneValue.booleanOrNull?.let { return it } + return doneValue.content.equals("true", ignoreCase = true) || doneValue.content == "1" + } + private fun loadChatHistoryForGroup(groupId: String): ChatHistory? { val sessionName = "group_$groupId" for (base in historyBaseDirs) { @@ -901,12 +934,46 @@ actionType / actionDetail(能填就填,影响手机端是否显示「运行 ) } out to true - } catch (e: Exception) { + } catch (e: SerializationException) { + println("⚠️ [GroupTodoExtractionService] JSON 解析失败: ${e.message}") + emptyList() to false + } catch (e: IllegalArgumentException) { println("⚠️ [GroupTodoExtractionService] JSON 解析失败: ${e.message}") emptyList() to false } } + private fun callLlmOrNull( + system: String, + user: String, + apiKey: String, + temperature: Double = 0.35, + failurePrefix: String + ): String? = try { + callLlm(system, user, apiKey, temperature) + } catch (e: CancellationException) { + throw e + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + println("$failurePrefix: ${e.message}") + null + } catch (e: IOException) { + println("$failurePrefix: ${e.message}") + null + } catch (e: SerializationException) { + println("$failurePrefix: ${e.message}") + null + } catch (e: IllegalArgumentException) { + println("$failurePrefix: ${e.message}") + null + } catch (e: IllegalStateException) { + println("$failurePrefix: ${e.message}") + null + } catch (e: SecurityException) { + println("$failurePrefix: ${e.message}") + null + } + private fun extractJsonObject(text: String): String? { val t = text.trim() val fence = Regex("```(?:json)?\\s*([\\s\\S]*?)```", RegexOption.IGNORE_CASE).find(t) diff --git a/backend/src/test/kotlin/com/silk/backend/agents/acp/AcpClientTest.kt b/backend/src/test/kotlin/com/silk/backend/agents/acp/AcpClientTest.kt index a348f08a..94ca6ab6 100644 --- a/backend/src/test/kotlin/com/silk/backend/agents/acp/AcpClientTest.kt +++ b/backend/src/test/kotlin/com/silk/backend/agents/acp/AcpClientTest.kt @@ -200,8 +200,8 @@ class AcpClientTest { @Test fun `handler throwing does not kill receive loop`() = runTest { val transport = InMemoryAcpTransport() - val client = AcpClient(transport, scope = backgroundScope) - client.onSessionUpdate("s1") { throw RuntimeException("boom") } + val client = AcpClient(transport, scope = backgroundScope) + client.onSessionUpdate("s1") { throw IllegalStateException("boom") } // first notification — handler throws; loop must survive transport.pushFromServer( diff --git a/config/lint/detekt/backend.xml b/config/lint/detekt/backend.xml index 89e3f856..9d79e28c 100644 --- a/config/lint/detekt/backend.xml +++ b/config/lint/detekt/backend.xml @@ -2,11 +2,8 @@ - CyclomaticComplexMethod:AIStepwiseAgent.kt$AIStepwiseAgent$private suspend fun callAIApiStreaming( prompt: String, onChunk: suspend (String) -> Unit ): String - CyclomaticComplexMethod:AIStepwiseAgent.kt$AIStepwiseAgent$suspend fun generateQuickResponse( prompt: String, callback: suspend (content: String, isComplete: Boolean) -> Unit ) CyclomaticComplexMethod:AIStepwiseAgent.kt$AIStepwiseAgent$suspend fun processDoctorDiagnosisUpdate( chatHistory: List<ChatHistoryEntry>, doctorMessage: String, callback: suspend (stepType: String, message: String, currentStep: Int?, totalSteps: Int?) -> Unit, userName: String = "用户", groupDisplayName: String? = null ) CyclomaticComplexMethod:AgentRuntime.kt$AgentRuntime$private suspend fun handleCommand( ctx: GroupAgentContext, cmd: SilkCommand, broadcastFn: suspend (Message) -> Unit, ) - CyclomaticComplexMethod:GroupTodoExtractionService.kt$GroupTodoExtractionService$private fun parseCompactTodoJson( raw: String, validIds: Set<String>, originalsById: Map<String, UserTodoItemDto> ): List<UserTodoItemDto>? CyclomaticComplexMethod:GroupTodoExtractionService.kt$GroupTodoExtractionService$suspend fun refreshTodosForUser(userId: String) CyclomaticComplexMethod:Routing.kt$fun Application.configureRouting() CyclomaticComplexMethod:UserTodoStore.kt$UserTodoStore$fun updateItem( userId: String, itemId: String, done: Boolean? = null, title: String? = null, actionType: String? = null, actionDetail: String? = null, executedAt: Long? = null, reminderId: Long? = null, clearReminderId: Boolean = false, taskKind: String? = null, repeatRule: String? = null, repeatAnchor: String? = null, activeFrom: Long? = null, activeTo: Long? = null, templateId: String? = null, lifecycleState: String? = null, closedAt: Long? = null, lastEvidenceAt: Long? = null, explicitIntent: Boolean? = null, dateBucket: String? = null, reopenCount: Int? = null ): Boolean @@ -24,21 +21,15 @@ LoopWithTooManyJumpStatements:DirectModelAgent.kt$DirectModelAgent$for LoopWithTooManyJumpStatements:GroupTodoExtractionService.kt$GroupTodoExtractionService$for LoopWithTooManyJumpStatements:UserTodoStore.kt$UserTodoStore$for - NestedBlockDepth:AIStepwiseAgent.kt$AIStepwiseAgent$suspend fun generateQuickResponse( prompt: String, callback: suspend (content: String, isComplete: Boolean) -> Unit ) SwallowedException:AIStepwiseAgent.kt$AIStepwiseAgent$e: Exception SwallowedException:PDFReportGenerator.kt$PDFReportGenerator$e: Exception SwallowedException:Routing.kt$e: Exception - ThrowsCount:AIStepwiseAgent.kt$AIStepwiseAgent$private suspend fun callAIApiStreaming( prompt: String, onChunk: suspend (String) -> Unit ): String TooGenericExceptionCaught:AIStepwiseAgent.kt$AIStepwiseAgent$e: Exception TooGenericExceptionCaught:AgentRuntime.kt$AgentRuntime$e: Exception - TooGenericExceptionCaught:DirectModelAgent.kt$DirectModelAgent$e: Exception - TooGenericExceptionCaught:GroupTodoExtractionService.kt$GroupTodoExtractionService$e: Exception TooGenericExceptionCaught:PDFReportGenerator.kt$PDFReportGenerator$closeEx: Exception TooGenericExceptionCaught:PDFReportGenerator.kt$PDFReportGenerator$e: Exception TooGenericExceptionCaught:Routing.kt$e: Exception TooGenericExceptionCaught:WeaviateClient.kt$WeaviateClient$e: Exception TooGenericExceptionCaught:WebSocketConfig.kt$ChatServer$e: Exception - TooGenericExceptionThrown:AcpClientTest.kt$AcpClientTest$throw RuntimeException("boom") - UnusedPrivateProperty:PDFReportGenerator.kt$PDFReportGenerator$// 中文字体路径(静态配置)- 优先使用对中英文都支持好的字体 private val chineseFontPath: String? by lazy { // macOS 系统自带的字体,优先选择对中英文都支持好的 val fontPaths = listOf( "/Library/Fonts/Arial Unicode.ttf", // ✅ Arial Unicode MS - 对中英文都支持好 "/System/Library/Fonts/PingFang.ttc,0", // PingFang SC Regular "/System/Library/Fonts/STHeiti Light.ttc,0", // 华文黑体 "/System/Library/Fonts/Supplemental/Songti.ttc,0" // 宋体 ) // 尝试找到第一个可用的字体 for (fontPath in fontPaths) { try { val file = java.io.File(fontPath.split(",")[0]) if (file.exists()) { logger.info("✅ 找到中文字体: {}", fontPath) return@lazy fontPath } } catch (e: Exception) { // 尝试下一个字体 continue } } // 如果都失败,返回 null(使用内置字体) logger.warn("⚠️ 未找到系统字体,将使用内置字体") null } diff --git a/docs/context/planning/exec-plans/active/lint-baseline-reduction.md b/docs/context/planning/exec-plans/active/lint-baseline-reduction.md index 3dba54c7..32187cac 100644 --- a/docs/context/planning/exec-plans/active/lint-baseline-reduction.md +++ b/docs/context/planning/exec-plans/active/lint-baseline-reduction.md @@ -38,9 +38,9 @@ ## Current Snapshot -当前 detekt baseline 余量(2026-06-11,Slice 148 后): +当前 detekt baseline 余量(2026-06-11,Slice 155 后): -- `backend.xml`: 38 +- `backend.xml`: 29 - `frontend-androidApp.xml`: 0 - `frontend-webApp.xml`: 0 - `frontend-shared.xml`: 0 @@ -107,20 +107,27 @@ - `GroupTodoExtractionService.kt` 又移除 1 条 `CyclomaticComplexMethod` 和 1 条 `NestedBlockDepth` baseline;`extractRecurringTemplateDrafts(...)` 现已拆成“逐群收集 / 单行判定 / draft 构造 / 标题与时间格式化” helper,继续保持工作日习惯 / 纪念日识别、`matchedLines` 截断、`long_term_template` 合同和 `workday`/`yearly` repeat 语义不变。 - `GroupTodoExtractionService.kt` 又移除 1 条 `CyclomaticComplexMethod` 和 1 条 `NestedBlockDepth` baseline;`heuristicFromSlices(...)` 现已拆成“逐群收集 / 单行候选识别 / 单条 draft 构造 / alarm fallback 状态控制” helper,继续保持 checklist 提取、alarm 文本弱兜底、同一消息只接受一次非 checklist alarm fallback、标题截断与 `alarm`/`none` 判定语义不变。 - `GroupTodoExtractionService.kt` 又移除 1 条 `NestedBlockDepth` baseline;`buildTranscriptString(...)` 现已拆成“逐群追加 / 逐消息展开 / 单行标准化” helper,继续保持 transcript 头格式、逐行 `[sender]: content` 输出、空行跳过与 `MAX_TRANSCRIPT_CHARS` 截断语义不变。 +- `GroupTodoExtractionService.kt` 又移除 1 条 `CyclomaticComplexMethod` baseline;`parseCompactTodoJson(...)` 现已拆成单条输出解析、可选字段提取与 `done` 状态回填 helper,继续保持“只接受输入中已有 id、未知/重复 id 跳过、title 长度校验、原始 createdAt/executedAt/reminderId 透传”的去冗写回合同不变。 +- `AcpClientTest.kt` 已移除 1 条 `TooGenericExceptionThrown` baseline;receive-loop 存活性测试里的 handler 失败现在改抛 `IllegalStateException("boom")`,继续保持“单个 handler 崩溃不拖垮后续 RPC”的既有测试语义不变。 +- `GroupTodoExtractionService.kt` 已移除 1 条 `TooGenericExceptionCaught` baseline;LLM 抽取/去冗入口不再依赖 `catch (e: Exception)`,改为 `callLlmOrNull(...)` 对 cancellation 透传、对 `InterruptedException` / `IOException` / `SerializationException` / `IllegalStateException` 等失败分层回退,JSON 解析也只对 decode/shape 异常降级,继续保持“抽取失败回退启发式、去冗失败走本地 logical-key 去重”的既有容错合同不变。 +- `PDFReportGenerator.kt` 已移除 1 条 `UnusedPrivateProperty` baseline;未接线的 `chineseFontPath` 懒加载探测已删除,实际生效的字体路径仍由 `createChineseFont()` 的内置 CJK 字体分支承担,不改变 PDF 生成路径或字体回退合同。 +- `DirectModelAgent.kt` 已移除 1 条 `TooGenericExceptionCaught` baseline;`chat(...)` 与 Claude CLI fallback 入口改为 `runCatching` 收口,继续透传 `CancellationException`,并保持 Claude CLI 失败后优先回退 Anthropic API、前端错误回写、回复持久化与 citation 后处理合同不变。 +- `AIStepwiseAgent.kt` 已移除 1 条 `CyclomaticComplexMethod` 和 1 条 `NestedBlockDepth` baseline;`generateQuickResponse(...)` 现已拆成请求构造、SSE 行消费、增量触发与尾包 flush helper,继续保持 `/chat/completions` streaming、`reasoning` 字段读取、按累计换行数推送增量以及仅在 `[DONE]` 后回调完成消息的既有 quick-response 合同不变。 +- `AIStepwiseAgent.kt` 又移除 1 条 `CyclomaticComplexMethod` 和 1 条 `ThrowsCount` baseline;`callAIApiStreaming(...)` 现已收敛为主流程编排,HTTP 请求构造、状态校验、SSE 行读取与 chunk 消费都已下沉到 helper,继续保持非 200 失败、超时返回已收集文本和逐 chunk 回调前端的既有诊断流式合同不变。 - `AIStepwiseAgent.kt` 已移除 1 条 `CyclomaticComplexMethod` baseline;`generateFallbackReport(...)` 现已拆成“报告头 / 通用 section 拼接 / 仅成功步骤附正文” helper,继续保持章节标题、step key 映射、成功步骤输出顺序与失败步骤留空的既有 fallback 报告合同不变。 -- `backend` 已无 `WildcardImport`、`UnusedPrivateMember`、`UnusedParameter`、`EmptyFunctionBlock`、`AcpUpdateMapper.kt` 的 `CyclomaticComplexMethod`、`AIStepwiseAgent.kt` 的 `generateFallbackReport(...)` 复杂度基线、`AnthropicClient.kt` 的 `TooGenericExceptionCaught` / `NestedBlockDepth` / `convertMessage(...)` 复杂度基线、`AsrRoutes.kt` 的 `SwallowedException`、`ChatHistoryBackupManager.kt` 的 `PrintStackTrace` / `SwallowedException`、`ChatHistoryManager.kt` 的 `TooGenericExceptionCaught`、`DirectModelAgent.kt` 的 `normalizeCitedReferences(...)` 复杂度基线、`ExternalSearchService.kt` 的 `TooGenericExceptionCaught`、`FileRoutes.kt` 的 `fileRoutes(...)` / `indexFileToWeaviate(...)` / `chunkText(...)` 复杂度基线、`GroupTodoExtractionService.kt` 的 `buildTranscriptString(...)` / `dedupeDrafts(...)` / `extractRecurringTemplateDrafts(...)` / `heuristicFromSlices(...)` / `extractRoughHourMinute(...)` 与 `ComplexCondition` 基线、`UserTodoStore.kt` 的 `ComplexCondition`、`SearchDrivenAgent.kt` 的 `SwallowedException` / `TooGenericExceptionCaught` / `NestedBlockDepth`、`ToolPolicyManager.kt` 的 `SwallowedException`、`UserTodoStore.kt` 的 `isTemplateDueToday(...)` / `normalizeActionDetailForKey(...)` / `extractTimeFromTitle(...)` / `mergeShortInstanceByState(...)` / `tryMergeByContainedNormTitle(...)` 复杂度基线、`WeaviateClient.kt` 的 `indexDocument(...)` 复杂度基线与 `PrintStackTrace`,以及 `WebSocketConfig.kt` 的 `ComplexCondition` / `PrintStackTrace` / `SwallowedException` baseline 和一条陈旧的 `TooGenericExceptionCaught(ex)` 残留;剩余主要是 `CyclomaticComplexMethod` 8、`TooGenericExceptionCaught` 13、`NestedBlockDepth` 1、`SwallowedException` 3、`LoopWithTooManyJumpStatements` 6、`LargeClass` 8。 +- `backend` 已无 `WildcardImport`、`UnusedPrivateMember`、`UnusedParameter`、`EmptyFunctionBlock`、`AcpUpdateMapper.kt` 的 `CyclomaticComplexMethod`、`AIStepwiseAgent.kt` 的 `generateFallbackReport(...)` / `generateQuickResponse(...)` / `callAIApiStreaming(...)` 复杂度基线与 `callAIApiStreaming(...)` 的 `ThrowsCount`、`AnthropicClient.kt` 的 `TooGenericExceptionCaught` / `NestedBlockDepth` / `convertMessage(...)` 复杂度基线、`AsrRoutes.kt` 的 `SwallowedException`、`ChatHistoryBackupManager.kt` 的 `PrintStackTrace` / `SwallowedException`、`ChatHistoryManager.kt` 的 `TooGenericExceptionCaught`、`DirectModelAgent.kt` 的 `normalizeCitedReferences(...)` 复杂度基线与 broad-catch 基线、`ExternalSearchService.kt` 的 `TooGenericExceptionCaught`、`FileRoutes.kt` 的 `fileRoutes(...)` / `indexFileToWeaviate(...)` / `chunkText(...)` 复杂度基线、`GroupTodoExtractionService.kt` 的 `buildTranscriptString(...)` / `dedupeDrafts(...)` / `extractRecurringTemplateDrafts(...)` / `heuristicFromSlices(...)` / `extractRoughHourMinute(...)` 与 `ComplexCondition` 基线、`UserTodoStore.kt` 的 `ComplexCondition`、`SearchDrivenAgent.kt` 的 `SwallowedException` / `TooGenericExceptionCaught` / `NestedBlockDepth`、`ToolPolicyManager.kt` 的 `SwallowedException`、`UserTodoStore.kt` 的 `isTemplateDueToday(...)` / `normalizeActionDetailForKey(...)` / `extractTimeFromTitle(...)` / `mergeShortInstanceByState(...)` / `tryMergeByContainedNormTitle(...)` 复杂度基线、`WeaviateClient.kt` 的 `indexDocument(...)` 复杂度基线与 `PrintStackTrace`,以及 `WebSocketConfig.kt` 的 `ComplexCondition` / `PrintStackTrace` / `SwallowedException` baseline 和一条陈旧的 `TooGenericExceptionCaught(ex)` 残留;剩余主要是 `CyclomaticComplexMethod` 6、`TooGenericExceptionCaught` 12、`SwallowedException` 3、`LoopWithTooManyJumpStatements` 6、`LargeClass` 8。 ## Current Status -- Slice 1-139 完成历史均已归档到 `docs/context/planning/exec-plans/completed/`。 +- Slice 1-155 完成历史均已归档到 `docs/context/planning/exec-plans/completed/`。 - Android / Web / Desktop / Shared baseline 已清零;active plan 现在只保留 backend 的剩余 detekt 收敛。 - Android 侧既有 `JdkImageTransform` / `jlink` 环境阻塞仍未改变;这不影响 baseline 已清零这一事实。 ## Next Slices -- Slice 149 候选:优先继续处理 `GroupTodoExtractionService.kt` 的 `parseCompactTodoJson(...)`,保持 Todo 面同文件、单函数推进,不碰 refresh 主流程。 -- Slice 150 候选:如果转向 backend 异常语义,优先处理 `Routing.kt` 中单一路由族、可明确区分 parse / validation 的 catch 点,不直接碰整文件聚合的 `Exception` baseline。 -- Slice 151 候选:如果换文件收复杂度,优先找 `Routing.kt` 之外的单职责 backend 文件,不把 `LargeClass` / `LoopWithTooManyJumpStatements` 这类重构型条目混进异常语义 slice。 +- Slice 156 候选:如果转向 backend 异常语义,优先处理 `Routing.kt` 中单一路由族、可明确区分 parse / validation 的 catch 点,不直接碰整文件聚合的 `Exception` baseline。 +- Slice 157 候选:如果换文件收复杂度,优先找 `Routing.kt` 之外的单职责 backend 文件,不把 `LargeClass` / `LoopWithTooManyJumpStatements` 这类重构型条目混进异常语义 slice。 +- Slice 158 候选:继续留意 `GroupTodoExtractionService.kt` 非 refresh 主流程尾项,或 `AIStepwiseAgent.kt`/`UserTodoStore.kt` 中还能单独下掉一条的 helper 面。 - 如果某一步发现需要新增 baseline,先停下来判断是否应关规则、补测试或拆小 PR,不要直接把新增项写进 baseline。 ## Handoff Notes diff --git a/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-149.md b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-149.md new file mode 100644 index 00000000..efbded4b --- /dev/null +++ b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-149.md @@ -0,0 +1,17 @@ +# Lint Baseline Reduction Slice 149 + +这份归档保留 `lint-baseline-reduction` 的 Slice 149 完成历史,记录本轮在 `backend/src/main/kotlin/com/silk/backend/todos/GroupTodoExtractionService.kt` 上继续收敛 detekt 的 `CyclomaticComplexMethod` 基线。活跃待办留在 [active/lint-baseline-reduction.md](../active/lint-baseline-reduction.md)。 + +## Completed Work + +- 删除 `config/lint/detekt/backend.xml` 中 `GroupTodoExtractionService.kt$private fun parseCompactTodoJson(...)` 对应的 1 条 `CyclomaticComplexMethod` baseline。 +- `parseCompactTodoJson(...)` 改为只负责遍历输出数组;单条 todo 解析、可选文本字段提取、`actionType` 规范化与 `done` 回填都下沉到 helper。 +- 保持去冗写回合同不变:只接受输入中已有 id,未知/重复 id 继续跳过,空标题或超长标题继续丢弃,原始 `createdAt` / `executedAt` / `reminderId` 继续透传。 + +## Validation + +- `./gradlew :backend:detekt --no-daemon --warning-mode none --console=plain` + +## Notes + +- 这一轮继续沿用“同文件、单函数、单职责”的 Todo lint 收敛方式,没有把 `refreshTodosForUser(...)` 或文件级 broad catch 一起混进来。 diff --git a/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-150.md b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-150.md new file mode 100644 index 00000000..f16570da --- /dev/null +++ b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-150.md @@ -0,0 +1,16 @@ +# Lint Baseline Reduction Slice 150 + +这份归档保留 `lint-baseline-reduction` 的 Slice 150 完成历史,记录本轮在 `backend/src/test/kotlin/com/silk/backend/agents/acp/AcpClientTest.kt` 上继续收敛 detekt 的 `TooGenericExceptionThrown` 基线。活跃待办留在 [active/lint-baseline-reduction.md](../active/lint-baseline-reduction.md)。 + +## Completed Work + +- 删除 `config/lint/detekt/backend.xml` 中 `AcpClientTest.kt$throw RuntimeException("boom")` 对应的 1 条 `TooGenericExceptionThrown` baseline。 +- `handler throwing does not kill receive loop` 测试里把 broad throw 收紧为 `IllegalStateException("boom")`,保留“handler 失败后 receive loop 继续存活、后续 RPC 仍可完成”的测试意图不变。 + +## Validation + +- `./gradlew :backend:detekt --no-daemon --warning-mode none --console=plain` + +## Notes + +- 这一轮先收掉 backend detekt 里唯一的测试侧 `TooGenericExceptionThrown` 尾项,没有把 `Routing.kt` / `GroupTodoExtractionService.kt` 的主源码异常面混进来。 diff --git a/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-151.md b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-151.md new file mode 100644 index 00000000..d4323b59 --- /dev/null +++ b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-151.md @@ -0,0 +1,17 @@ +# Lint Baseline Reduction Slice 151 + +这份归档保留 `lint-baseline-reduction` 的 Slice 151 完成历史,记录本轮在 `backend/src/main/kotlin/com/silk/backend/todos/GroupTodoExtractionService.kt` 上继续收敛 detekt 的 `TooGenericExceptionCaught` 基线。活跃待办留在 [active/lint-baseline-reduction.md](../active/lint-baseline-reduction.md)。 + +## Completed Work + +- 删除 `config/lint/detekt/backend.xml` 中 `GroupTodoExtractionService.kt$e: Exception` 对应的 1 条 `TooGenericExceptionCaught` baseline。 +- LLM 抽取与去冗入口统一改走 `callLlmOrNull(...)`:正常 `CancellationException` 继续透传,`InterruptedException` 会恢复线程中断标志,其余已知 I/O / 解析 / 状态异常按原语义记录失败并回退。 +- `parseCompactTodoJson(...)` 与 `parseTodoJsonStrict(...)` 现在只对 JSON decode / shape 异常降级,不再用 broad catch 吞掉其它运行时错误。 + +## Validation + +- `./gradlew :backend:detekt --no-daemon --warning-mode none --console=plain` + +## Notes + +- 这一轮继续沿用 “同文件、单职责、先异常语义后大重构” 的策略,没有碰 `refreshTodosForUser(...)` 的复杂度主项,也没有把 `Routing.kt` 的聚合 catch 混进来。 diff --git a/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-152.md b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-152.md new file mode 100644 index 00000000..f40285c5 --- /dev/null +++ b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-152.md @@ -0,0 +1,16 @@ +# Lint Baseline Reduction Slice 152 + +这份归档保留 `lint-baseline-reduction` 的 Slice 152 完成历史,记录本轮在 `backend/src/main/kotlin/com/silk/backend/pdf/PDFReportGenerator.kt` 上继续收敛 detekt 的 `UnusedPrivateProperty` 基线。活跃待办留在 [active/lint-baseline-reduction.md](../active/lint-baseline-reduction.md)。 + +## Completed Work + +- 删除 `config/lint/detekt/backend.xml` 中 `PDFReportGenerator.kt$chineseFontPath` 对应的 1 条 `UnusedPrivateProperty` baseline。 +- 移除了未接线的 `chineseFontPath` 懒加载探测逻辑;当前 PDF 生成实际仍走 `createChineseFont()` 的内置 CJK 字体路径,没有改变报告导出主流程。 + +## Validation + +- `./gradlew :backend:detekt --no-daemon --warning-mode none --console=plain` + +## Notes + +- 这一轮只收掉明确未使用的私有属性,没有把 `PDFReportGenerator.kt` 里其它异常语义或大函数问题混进来。 diff --git a/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-153.md b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-153.md new file mode 100644 index 00000000..dfb5ca28 --- /dev/null +++ b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-153.md @@ -0,0 +1,17 @@ +# Lint Baseline Reduction Slice 153 + +这份归档保留 `lint-baseline-reduction` 的 Slice 153 完成历史,记录本轮在 `backend/src/main/kotlin/com/silk/backend/ai/DirectModelAgent.kt` 上继续收敛 detekt 的 `TooGenericExceptionCaught` 基线。活跃待办留在 [active/lint-baseline-reduction.md](../active/lint-baseline-reduction.md)。 + +## Completed Work + +- 删除 `config/lint/detekt/backend.xml` 中 `DirectModelAgent.kt$e: Exception` 对应的 1 条 `TooGenericExceptionCaught` baseline。 +- `chat(...)` 与 Claude CLI fallback 入口改为 `runCatching` 收口;正常 `CancellationException` 继续透传,其它失败仍会记录日志、给前端回写错误消息,并在可用时回退到 Anthropic API 路径。 +- 保持工具上下文、`chat_history.md` 写入、最终回复落库和 citation 后处理合同不变,没有把 `DirectModelAgent` 的其它复杂度项混进这一 slice。 + +## Validation + +- `./gradlew :backend:detekt --no-daemon --warning-mode none --console=plain` + +## Notes + +- 这一轮优先收掉 backend 中较独立的 broad-catch 文件,没有直接切入 `Routing.kt` 或 `WebSocketConfig.kt` 的聚合 catch 面。 diff --git a/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-154.md b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-154.md new file mode 100644 index 00000000..b3fde3dd --- /dev/null +++ b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-154.md @@ -0,0 +1,17 @@ +# Lint Baseline Reduction Slice 154 + +这份归档保留 `lint-baseline-reduction` 的 Slice 154 完成历史,记录本轮在 `backend/src/main/kotlin/com/silk/backend/ai/AIStepwiseAgent.kt` 上继续收敛 detekt 的 `CyclomaticComplexMethod` 与 `NestedBlockDepth` 基线。活跃待办留在 [active/lint-baseline-reduction.md](../active/lint-baseline-reduction.md)。 + +## Completed Work + +- 删除 `config/lint/detekt/backend.xml` 中 `AIStepwiseAgent.kt$generateQuickResponse(...)` 对应的 1 条 `CyclomaticComplexMethod` 和 1 条 `NestedBlockDepth` baseline。 +- `generateQuickResponse(...)` 现在只负责发送请求并分发状态;请求构造、SSE 行消费、增量回调触发和尾包 flush 都下沉到 helper。 +- 保持 quick-response 合同不变:仍然走 `/chat/completions` streaming,仍然优先读取 `reasoning` 字段,仍按累计换行数分批回调增量文本,并只在收到 `[DONE]` 后发送一次完成消息。 + +## Validation + +- `./gradlew :backend:detekt --no-daemon --warning-mode none --console=plain` + +## Notes + +- 这一轮只收敛 `generateQuickResponse(...)` 这一处函数级复杂度,没有把 `callAIApiStreaming(...)`、`processDoctorDiagnosisUpdate(...)` 或文件级 broad-catch 一并混入。 diff --git a/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-155.md b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-155.md new file mode 100644 index 00000000..7e758096 --- /dev/null +++ b/docs/context/planning/exec-plans/completed/2026-06-11-lint-baseline-reduction-slice-155.md @@ -0,0 +1,17 @@ +# Lint Baseline Reduction Slice 155 + +这份归档保留 `lint-baseline-reduction` 的 Slice 155 完成历史,记录本轮在 `backend/src/main/kotlin/com/silk/backend/ai/AIStepwiseAgent.kt` 上继续收敛 detekt 的 `CyclomaticComplexMethod` 与 `ThrowsCount` 基线。活跃待办留在 [active/lint-baseline-reduction.md](../active/lint-baseline-reduction.md)。 + +## Completed Work + +- 删除 `config/lint/detekt/backend.xml` 中 `AIStepwiseAgent.kt$callAIApiStreaming(...)` 对应的 1 条 `CyclomaticComplexMethod` 和 1 条 `ThrowsCount` baseline。 +- `callAIApiStreaming(...)` 现在只负责请求主流程编排;请求构造、HTTP 调用、状态码校验、SSE 行读取和单条 `data:` 消费都已下沉到 helper。 +- 保持诊断主链合同不变:仍使用同一 API 路径与 timeout,仍保留非 200 即失败、读取超时回退已收集文本、S​​SE `data: [DONE]` 停止流读取,以及逐 chunk 透传前端回调的既有行为。 + +## Validation + +- `./gradlew :backend:detekt --no-daemon --warning-mode none --console=plain` + +## Notes + +- 这一轮继续沿用“同文件、单函数、单职责”的 AIStepwiseAgent 收敛方式,没有把 `processDoctorDiagnosisUpdate(...)` 或文件级 broad-catch 一并混入。