fix: AI分类完全不可用,移除system()调用改用合并prompt方式 (v1.1.2)

This commit is contained in:
sunny-335
2026-06-24 16:34:02 +08:00
parent 122069e221
commit 5337a62a13
8 changed files with 138 additions and 18 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文 - **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
- **多语言适配** — 根据评论语言自动用对应语言回复 - **多语言适配** — 根据评论语言自动用对应语言回复
- **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气 - **情感分析** — 分析评论情感倾向(非常正面/正面/中性/负面/非常负面),根据情感调整回复语气
- **前置过滤(合规检测)** — AI 回复前对评论进行合规性分类,自动拦截广告/辱骂攻击/敏感内容/无意义内容,违规评论停止生成 AI 回复以节省 Token,可选自动将违规评论设为待审核状态 - **前置过滤(合规检测)** — AI 回复前对评论进行合规性分类,自动拦截广告/辱骂攻击/敏感内容/乱码,违规评论停止生成 AI 回复以节省 Token,可选自动将违规评论设为待审核状态
- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作 - **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作
- **失败重试** — AI 生成失败时自动重试,指数退避策略 - **失败重试** — AI 生成失败时自动重试,指数退避策略
- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话 - **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
+16
View File
@@ -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 ## v1.1.0
> 2026-06-23 > 2026-06-23
+15
View File
@@ -92,6 +92,21 @@
前置过滤默认启用。AI 会对评论进行分类判断,若 AI 服务不可用或分类失败,为安全起见会拦截评论而非放行。如果你发现正常评论被误拦截,可以在设置中关闭"启用前置过滤"开关。被拦截的评论会在日志页生成一条"已拦截"状态的记录,可查看具体分类标签和拦截原因。 前置过滤默认启用。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 1. **停止生成 AI 回复** — 不会消耗后续 Token
+1 -1
View File
@@ -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 # Fix Windows Gradle Worker Daemon exit code 268435659 when running pnpm via Exec tasks
org.gradle.daemon=false org.gradle.daemon=false
@@ -70,12 +70,12 @@ public class AiFoundationClient {
try { try {
return AiFoundationDelegate.classify(extensionGetter, systemPrompt, userPrompt, choices, modelName); return AiFoundationDelegate.classify(extensionGetter, systemPrompt, userPrompt, choices, modelName);
} catch (NoClassDefFoundError e) { } 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(); return Mono.empty();
} }
}) })
.onErrorResume(NoClassDefFoundError.class, e -> { .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(); return Mono.empty();
}); });
} }
@@ -33,40 +33,122 @@ class AiFoundationDelegate {
.flatMap(model -> model.generateText( .flatMap(model -> model.generateText(
GenerateTextRequest.builder().prompt(prompt).maxRetries(2).build())) GenerateTextRequest.builder().prompt(prompt).maxRetries(2).build()))
.map(GenerateTextResult::getText)) .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 -> { .onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage()); log.warn("[Delegate] chat not available: {}", e.getMessage());
return Mono.empty(); return Mono.empty();
}); });
} }
/**
* 使用 AI 进行文本分类。
* 优先使用 OutputSpec.choice 结构化输出,失败时退回到普通 chat 并解析响应。
*/
static Mono<String> classify(ExtensionGetter extensionGetter, String systemPrompt, static Mono<String> classify(ExtensionGetter extensionGetter, String systemPrompt,
String userPrompt, List<String> choices, String modelName) { String userPrompt, List<String> 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<String> classifyWithChoice(ExtensionGetter extensionGetter, String systemPrompt,
String userPrompt, List<String> choices, String modelName) {
// 合并 system prompt 和 user prompt,避免使用 system() 方法
String combinedPrompt = systemPrompt + "\n\n" + userPrompt;
return extensionGetter.getEnabledExtension(AiModelService.class) return extensionGetter.getEnabledExtension(AiModelService.class)
.flatMap(service -> service.languageModel(modelName != null ? modelName : "") .flatMap(service -> service.languageModel(modelName != null ? modelName : "")
.flatMap(model -> model.generateText( .flatMap(model -> model.generateText(
GenerateTextRequest.builder() GenerateTextRequest.builder()
.system(systemPrompt) .prompt(combinedPrompt)
.prompt(userPrompt)
.output(OutputSpec.choice(choices)) .output(OutputSpec.choice(choices))
.maxRetries(2) .maxRetries(2)
.build())) .build()))
.map(result -> { .flatMap(result -> {
Object output = result.getOutput(); 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 -> { .onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage()); log.warn("[Delegate] classifyWithChoice failed, will fallback to chat: {}", e.getMessage());
return Mono.empty(); return Mono.empty();
}); });
} }
/**
* 使用普通 chat 调用进行分类,从响应文本中提取匹配的分类值。
* 作为 OutputSpec.choice 不可用时的降级方案。
* 不使用 system() 方法,将 system prompt 合并到 user prompt 中,
* 与可用的 chat() 方法保持一致的调用方式。
*/
private static Mono<String> classifyWithChat(ExtensionGetter extensionGetter, String systemPrompt,
String userPrompt, List<String> 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<String> 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<Boolean> isAvailable(ExtensionGetter extensionGetter) { static Mono<Boolean> isAvailable(ExtensionGetter extensionGetter) {
return extensionGetter.getEnabledExtension(AiModelService.class) return extensionGetter.getEnabledExtension(AiModelService.class)
.hasElement() .hasElement()
.onErrorResume(e -> { .onErrorResume(e -> {
log.debug("AI Foundation not available: {}", e.getMessage()); log.debug("[Delegate] AI Foundation not available: {}", e.getMessage());
return Mono.just(false); return Mono.just(false);
}); });
} }
@@ -44,16 +44,17 @@ public class CommentPreFilterService {
SPAM, "检测到推广链接、产品推销或引流信息", SPAM, "检测到推广链接、产品推销或引流信息",
ABUSE, "检测到辱骂、人身攻击、恶意挑衅或歧视性言论", ABUSE, "检测到辱骂、人身攻击、恶意挑衅或歧视性言论",
SENSITIVE, "检测到政治敏感、违法违规或色情暴力内容", SENSITIVE, "检测到政治敏感、违法违规或色情暴力内容",
MEANINGLESS, "检测到纯乱码无意义字符或与文章完全无关的废话" MEANINGLESS, "检测到纯乱码无意义字符堆砌"
); );
private static final String CLASSIFY_SYSTEM_PROMPT = """ private static final String CLASSIFY_SYSTEM_PROMPT = """
你是评论内容合规检测员。请判断以下评论属于哪个类别: 你是评论内容合规检测员。请判断以下评论属于哪个类别:
- 正常:正常的评论、提问、讨论、赞美 - 正常:正常的评论、提问、讨论、赞美、闲聊等,即使与文章主题无关也算正常
- 广告:包含推广链接、产品推销、引流信息等 - 广告:包含推广链接、产品推销、引流信息等
- 辱骂攻击:包含辱骂、人身攻击、恶意挑衅、歧视性言论等 - 辱骂攻击:包含辱骂、人身攻击、恶意挑衅、歧视性言论等
- 敏感内容:涉及政治敏感、违法违规、色情暴力等 - 敏感内容:涉及政治敏感、违法违规、色情暴力等
- 无意义:纯乱码、无意义字符堆砌、与文章完全无关的废话 - 无意义:纯乱码、无意义字符堆砌(如随机符号、键盘乱敲)
注意:与文章主题无关的闲聊、灌水、打招呼等属于"正常",不要误判为"无意义"
只返回类别名称,不要返回其他内容。"""; 只返回类别名称,不要返回其他内容。""";
public CommentPreFilterService(ReactiveExtensionClient client, 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()))); 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) return aiFoundationClient.classify(CLASSIFY_SYSTEM_PROMPT, userPrompt, CLASSIFY_CHOICES, modelName)
.doOnNext(result -> log.info("[PreFilter] AI classify returned: '{}'", result))
.map(result -> { .map(result -> {
if (CLEAN.equals(result)) { if (CLEAN.equals(result)) {
log.info("[PreFilter] Comment passed: category={}", result); log.info("[PreFilter] Comment passed: category={}", result);
return new PreFilterResult(true, CLEAN, "评论合规"); 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 desc = CATEGORY_DESCRIPTIONS.getOrDefault(result, "检测到违规内容");
String snippet = truncated.substring(0, Math.min(50, truncated.length())); String snippet = truncated.substring(0, Math.min(50, truncated.length()));
String reason = desc + " — 「" + snippet + ""; String reason = desc + " — 「" + snippet + "";
@@ -99,7 +106,7 @@ public class CommentPreFilterService {
// 分类失败时拦截评论(安全优先),而非放行 // 分类失败时拦截评论(安全优先),而非放行
.defaultIfEmpty(new PreFilterResult(false, MEANINGLESS, "AI分类服务不可用,安全拦截")) .defaultIfEmpty(new PreFilterResult(false, MEANINGLESS, "AI分类服务不可用,安全拦截"))
.onErrorResume(e -> { .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分类服务异常,安全拦截")); return Mono.just(new PreFilterResult(false, MEANINGLESS, "AI分类服务异常,安全拦截"));
}); });
}); });
+1 -1
View File
@@ -30,4 +30,4 @@ spec:
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE" url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
settingName: "comment-ai-autopilot-settings" settingName: "comment-ai-autopilot-settings"
configMapName: "comment-ai-autopilot-configmap" configMapName: "comment-ai-autopilot-configmap"
version: "1.1.0" version: "1.1.2"