diff --git a/README.md b/README.md index d39f37c..0470a97 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文 - **多语言适配** — 根据评论语言自动用对应语言回复 - **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气 -- **前置过滤(合规检测)** — AI 回复前对评论进行合规性分类,自动拦截广告/辱骂攻击/敏感内容/无意义内容,违规评论停止生成 AI 回复以节省 Token,可选自动将违规评论设为待审核状态 +- **前置过滤(合规检测)** — AI 回复前对评论进行合规性分类,自动拦截广告/辱骂攻击/敏感内容/乱码,违规评论停止生成 AI 回复以节省 Token,可选自动将违规评论设为待审核状态 - **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作 - **失败重试** — AI 生成失败时自动重试,指数退避策略 - **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b38d540..73b7972 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,21 @@ # 更新日志 +## v1.1.2 + +> 2026-06-24 + +### Bug 修复 + +- **修复 AI 分类完全不可用** — `classifyWithChoice` 和 `classifyWithChat` 均使用了 `GenerateTextRequest.Builder.system()` 方法,而该方法在当前 AI Foundation 版本中不被支持或导致运行时错误,导致所有评论均被拦截并显示"AI分类服务不可用,安全拦截"。现改为将 system prompt 合并到 user prompt 中,与可用的 `chat()` 方法保持一致的调用方式 +- **修复 `classifyWithChoice` NPE** — `.map()` 返回 `null` 时触发 Reactor 内部 NullPointerException,改为 `.flatMap()` + `Mono.empty()` 正确触发 fallback + +### 改进 + +- **分类调用诊断日志增强** — 在 `AiFoundationDelegate`、`AiFoundationClient`、`CommentPreFilterService` 中增加关键诊断日志(分类开始、fallback 触发、分类结果、异常详情),便于排查分类链路问题 +- **AI 分类空结果处理** — 当 AI 返回空字符串时单独拦截,区别于"服务不可用"场景 + +--- + ## v1.1.0 > 2026-06-23 diff --git a/docs/guide/faq.md b/docs/guide/faq.md index f006530..44f261f 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -92,6 +92,21 @@ 前置过滤默认启用。AI 会对评论进行分类判断,若 AI 服务不可用或分类失败,为安全起见会拦截评论而非放行。如果你发现正常评论被误拦截,可以在设置中关闭"启用前置过滤"开关。被拦截的评论会在日志页生成一条"已拦截"状态的记录,可查看具体分类标签和拦截原因。 +## 所有评论都显示"AI分类服务不可用,安全拦截"怎么办? + +这表示 AI 分类调用链路存在问题,可能的原因: + +1. **AI Foundation 插件未安装或未启用** — 请确保 AI Foundation 插件已正确安装并启用 +2. **AI Foundation 中未配置模型** — 请在 AI Foundation 中配置至少一个 AI 模型 +3. **模型名称配置错误** — 检查插件设置中的模型名称是否与 AI Foundation 中的 AiModel 资源名称一致,留空则使用默认模型 +4. **AI Foundation 版本过旧** — 请确保使用最新版本的 AI Foundation 插件 + +::: tip 排查步骤 +1. 检查插件设置页面顶部的 AI Foundation 连接状态 +2. 查看插件日志中 `[Delegate]` 和 `[PreFilter]` 前缀的诊断信息 +3. 确认 AI 回复功能(非前置过滤)是否正常工作 — 如果 AI 回复也无法生成,说明是 AI Foundation 连接问题 +::: + ## 被前置过滤拦截的评论会怎样? 1. **停止生成 AI 回复** — 不会消耗后续 Token diff --git a/gradle.properties b/gradle.properties index 9bc42e9..b0c9890 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.1.0 +version=1.1.2 # Fix Windows Gradle Worker Daemon exit code 268435659 when running pnpm via Exec tasks org.gradle.daemon=false diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java index d156fce..b7c32ad 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java @@ -70,12 +70,12 @@ public class AiFoundationClient { try { return AiFoundationDelegate.classify(extensionGetter, systemPrompt, userPrompt, choices, modelName); } catch (NoClassDefFoundError e) { - log.debug("AI Foundation API not on classpath: {}", e.getMessage()); + log.warn("[Client] AI Foundation API not on classpath (classify): {}", e.getMessage()); return Mono.empty(); } }) .onErrorResume(NoClassDefFoundError.class, e -> { - log.warn("AI Foundation not available: {}", e.getMessage()); + log.warn("[Client] AI Foundation NoClassDefFoundError during classify: {}", e.getMessage()); return Mono.empty(); }); } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationDelegate.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationDelegate.java index 9fb7649..d33276c 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationDelegate.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationDelegate.java @@ -33,40 +33,122 @@ class AiFoundationDelegate { .flatMap(model -> model.generateText( GenerateTextRequest.builder().prompt(prompt).maxRetries(2).build())) .map(GenerateTextResult::getText)) - .doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage())) + .doOnError(e -> log.error("[Delegate] chat call failed: {}", e.getMessage())) .onErrorResume(e -> { - log.warn("AI Foundation not available: {}", e.getMessage()); + log.warn("[Delegate] chat not available: {}", e.getMessage()); return Mono.empty(); }); } + /** + * 使用 AI 进行文本分类。 + * 优先使用 OutputSpec.choice 结构化输出,失败时退回到普通 chat 并解析响应。 + */ static Mono classify(ExtensionGetter extensionGetter, String systemPrompt, String userPrompt, List choices, String modelName) { + log.info("[Delegate] Starting classification, modelName='{}'", modelName); + return classifyWithChoice(extensionGetter, systemPrompt, userPrompt, choices, modelName) + .switchIfEmpty( + Mono.defer(() -> { + log.info("[Delegate] classifyWithChoice returned empty, falling back to classifyWithChat"); + return classifyWithChat(extensionGetter, systemPrompt, userPrompt, choices, modelName); + }) + ) + .doOnNext(result -> log.info("[Delegate] Classification succeeded: '{}'", result)) + .doOnSuccess(result -> { + if (result == null) { + log.warn("[Delegate] Classification completed with no result (both methods returned empty)"); + } + }); + } + + /** + * 使用 OutputSpec.choice 结构化输出分类(部分模型不支持)。 + * 注意:不使用 system() 方法,因为部分 AI Foundation 版本可能不支持, + * 将 system prompt 合并到 user prompt 中。 + */ + private static Mono classifyWithChoice(ExtensionGetter extensionGetter, String systemPrompt, + String userPrompt, List choices, String modelName) { + // 合并 system prompt 和 user prompt,避免使用 system() 方法 + String combinedPrompt = systemPrompt + "\n\n" + userPrompt; return extensionGetter.getEnabledExtension(AiModelService.class) .flatMap(service -> service.languageModel(modelName != null ? modelName : "") .flatMap(model -> model.generateText( GenerateTextRequest.builder() - .system(systemPrompt) - .prompt(userPrompt) + .prompt(combinedPrompt) .output(OutputSpec.choice(choices)) .maxRetries(2) .build())) - .map(result -> { + .flatMap(result -> { Object output = result.getOutput(); - return output != null ? String.valueOf(output).trim() : ""; + if (output != null) { + String value = String.valueOf(output).trim(); + if (!value.isEmpty()) { + log.debug("[Delegate] classifyWithChoice got output: '{}'", value); + return Mono.just(value); + } + } + // output 为空可能是模型不支持结构化输出,返回 empty 触发 fallback + log.info("[Delegate] classifyWithChoice: output is null/empty, triggering fallback"); + return Mono.empty(); })) - .doOnError(e -> log.error("AI Foundation classify failed: {}", e.getMessage())) .onErrorResume(e -> { - log.warn("AI Foundation not available: {}", e.getMessage()); + log.warn("[Delegate] classifyWithChoice failed, will fallback to chat: {}", e.getMessage()); return Mono.empty(); }); } + /** + * 使用普通 chat 调用进行分类,从响应文本中提取匹配的分类值。 + * 作为 OutputSpec.choice 不可用时的降级方案。 + * 不使用 system() 方法,将 system prompt 合并到 user prompt 中, + * 与可用的 chat() 方法保持一致的调用方式。 + */ + private static Mono classifyWithChat(ExtensionGetter extensionGetter, String systemPrompt, + String userPrompt, List choices, String modelName) { + // 合并 system prompt 和 user prompt,与 chat() 方法保持一致的调用方式 + String combinedPrompt = systemPrompt + "\n\n" + userPrompt; + return extensionGetter.getEnabledExtension(AiModelService.class) + .flatMap(service -> service.languageModel(modelName != null ? modelName : "") + .flatMap(model -> model.generateText( + GenerateTextRequest.builder() + .prompt(combinedPrompt) + .maxRetries(2) + .build())) + .map(GenerateTextResult::getText) + .map(text -> extractChoice(text, choices))) + .doOnError(e -> log.error("[Delegate] classifyWithChat failed: {}", e.getMessage())) + .onErrorResume(e -> { + log.warn("[Delegate] classifyWithChat error: {}", e.getMessage()); + return Mono.empty(); + }); + } + + /** + * 从 chat 响应文本中提取匹配的分类值。 + * 优先精确匹配,其次包含匹配。 + */ + static String extractChoice(String text, List choices) { + if (text == null || text.isBlank()) return ""; + String trimmed = text.trim(); + // 精确匹配 + for (String choice : choices) { + if (trimmed.equals(choice)) return choice; + } + // 包含匹配(响应可能包含额外文字,如"该评论属于:广告") + for (String choice : choices) { + if (trimmed.contains(choice)) return choice; + } + // 无匹配,返回原始文本(让调用方处理) + log.warn("[Delegate] No matching choice found in response: {}", trimmed); + return trimmed; + } + static Mono isAvailable(ExtensionGetter extensionGetter) { return extensionGetter.getEnabledExtension(AiModelService.class) .hasElement() .onErrorResume(e -> { - log.debug("AI Foundation not available: {}", e.getMessage()); + log.debug("[Delegate] AI Foundation not available: {}", e.getMessage()); return Mono.just(false); }); } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentPreFilterService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentPreFilterService.java index cb6e5a5..eae7ebe 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentPreFilterService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentPreFilterService.java @@ -44,16 +44,17 @@ public class CommentPreFilterService { SPAM, "检测到推广链接、产品推销或引流信息", ABUSE, "检测到辱骂、人身攻击、恶意挑衅或歧视性言论", SENSITIVE, "检测到政治敏感、违法违规或色情暴力内容", - MEANINGLESS, "检测到纯乱码、无意义字符或与文章完全无关的废话" + MEANINGLESS, "检测到纯乱码或无意义字符堆砌" ); private static final String CLASSIFY_SYSTEM_PROMPT = """ 你是评论内容合规检测员。请判断以下评论属于哪个类别: - - 正常:正常的评论、提问、讨论、赞美等 + - 正常:正常的评论、提问、讨论、赞美、闲聊等,即使与文章主题无关也算正常 - 广告:包含推广链接、产品推销、引流信息等 - 辱骂攻击:包含辱骂、人身攻击、恶意挑衅、歧视性言论等 - 敏感内容:涉及政治敏感、违法违规、色情暴力等 - - 无意义:纯乱码、无意义字符堆砌、与文章完全无关的废话 + - 无意义:纯乱码、无意义字符堆砌(如随机符号、键盘乱敲) + 注意:与文章主题无关的闲聊、灌水、打招呼等属于"正常",不要误判为"无意义"。 只返回类别名称,不要返回其他内容。"""; public CommentPreFilterService(ReactiveExtensionClient client, @@ -85,11 +86,17 @@ public class CommentPreFilterService { log.info("[PreFilter] Checking comment (enabled=true): {}", truncated.substring(0, Math.min(50, truncated.length()))); return aiFoundationClient.classify(CLASSIFY_SYSTEM_PROMPT, userPrompt, CLASSIFY_CHOICES, modelName) + .doOnNext(result -> log.info("[PreFilter] AI classify returned: '{}'", result)) .map(result -> { if (CLEAN.equals(result)) { log.info("[PreFilter] Comment passed: category={}", result); return new PreFilterResult(true, CLEAN, "评论合规"); } + // 空结果视为分类失败 + if (result == null || result.isBlank()) { + log.warn("[PreFilter] AI classify returned empty/blank result, blocking for safety"); + return new PreFilterResult(false, MEANINGLESS, "AI分类返回空结果,安全拦截"); + } String desc = CATEGORY_DESCRIPTIONS.getOrDefault(result, "检测到违规内容"); String snippet = truncated.substring(0, Math.min(50, truncated.length())); String reason = desc + " — 「" + snippet + "」"; @@ -99,7 +106,7 @@ public class CommentPreFilterService { // 分类失败时拦截评论(安全优先),而非放行 .defaultIfEmpty(new PreFilterResult(false, MEANINGLESS, "AI分类服务不可用,安全拦截")) .onErrorResume(e -> { - log.warn("[PreFilter] Detection error, BLOCKING comment for safety: {}", e.getMessage()); + log.warn("[PreFilter] Detection error, BLOCKING comment for safety: {}", e.getMessage(), e); return Mono.just(new PreFilterResult(false, MEANINGLESS, "AI分类服务异常,安全拦截")); }); }); diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index 20c15de..a178dba 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -30,4 +30,4 @@ spec: url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE" settingName: "comment-ai-autopilot-settings" configMapName: "comment-ai-autopilot-configmap" - version: "1.1.0" + version: "1.1.2"