还原 1.4.1 基线并修复回评开关状态不一致
- 还原工程:以 1.4.1 安装包为来源,补齐缺失的 filterOnly 配置/编排逻辑/UI 开关,gradle build 全绿(5 测试通过) - 修复「开启回评」:annotation 改用字符串 on/off 契约,FilterService 归一化解析成为唯一判定入口,默认开启、显式关闭生效、重启不丢;新增 FilterServiceTest 锁定行为 - 整体健壮性:白名单/黑名单(兼容数组形态、正则预编译)、清理天数钳制 [1,365]、UI parseCfg 空值判定修正 Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
@@ -32,6 +32,21 @@
|
|||||||
3. 找到 **启用AI回评** 开关
|
3. 找到 **启用AI回评** 开关
|
||||||
4. 根据需要开启或关闭
|
4. 根据需要开启或关闭
|
||||||
|
|
||||||
|
!!! note "实现约定(改动前请先阅读)"
|
||||||
|
|
||||||
|
`metadata.annotations` 的值只能是字符串,因此该开关在 `annotation-setting.yaml` 中
|
||||||
|
必须使用 `on-value: "true"` / `off-value: "false"` 显式声明字符串取值。
|
||||||
|
运行时判定入口为 `FilterService#resolveAnnotation`,其缺省值与 `annotation-setting.yaml`
|
||||||
|
中的表单默认值必须保持一致:
|
||||||
|
|
||||||
|
| 来源 | 文章(Post) | 页面(SinglePage) |
|
||||||
|
|------|-------------|-------------------|
|
||||||
|
| 表单默认值 | `value: "true"` | `value: "false"` |
|
||||||
|
| 运行时缺省 | `DEFAULT_POST_REPLY_ENABLED = true` | `pagesEnabled`(默认 `false`) |
|
||||||
|
|
||||||
|
若只想关闭某个页面的回评:在页面编辑器中把开关切到关闭并保存即可(会写入
|
||||||
|
`ai-reply-enabled: "false"`,运行时优先于全局开关)。
|
||||||
|
|
||||||
## 评论者黑名单
|
## 评论者黑名单
|
||||||
|
|
||||||
评论者黑名单功能可以屏蔽指定评论者,使其评论不触发AI回复。
|
评论者黑名单功能可以屏蔽指定评论者,使其评论不触发AI回复。
|
||||||
|
|||||||
@@ -115,8 +115,11 @@ public class AiReplyCleanupService implements DisposableBean {
|
|||||||
if (cleanupJson == null || cleanupJson.isBlank()) return 30;
|
if (cleanupJson == null || cleanupJson.isBlank()) return 30;
|
||||||
try {
|
try {
|
||||||
JsonNode node = objectMapper.readTree(cleanupJson);
|
JsonNode node = objectMapper.readTree(cleanupJson);
|
||||||
return node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30;
|
int raw = node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30;
|
||||||
|
// 与 settings.yaml 中 retentionDays 的 min/max 对齐,避免非法值影响清理时机
|
||||||
|
return Math.max(1, Math.min(365, raw));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
log.warn("[Cleanup] Failed to parse retentionDays config: {}", e.getMessage());
|
||||||
return 30;
|
return 30;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -267,6 +267,12 @@ public class AiReplyOrchestrator {
|
|||||||
return createFilteredRecord(context, preFilterResult)
|
return createFilteredRecord(context, preFilterResult)
|
||||||
.then(preFilterService.penalize(commentName, replyName))
|
.then(preFilterService.penalize(commentName, replyName))
|
||||||
.then();
|
.then();
|
||||||
|
}
|
||||||
|
return isFilterOnlyEnabled()
|
||||||
|
.flatMap(filterOnly -> {
|
||||||
|
if (filterOnly) {
|
||||||
|
log.info("[Orchestrator] Filter-only mode enabled, skipping AI reply for: {}", commentName);
|
||||||
|
return Mono.empty();
|
||||||
}
|
}
|
||||||
return sentimentService.analyzeSentiment(context.commentContent(), modelName)
|
return sentimentService.analyzeSentiment(context.commentContent(), modelName)
|
||||||
.flatMap(sentimentResult -> {
|
.flatMap(sentimentResult -> {
|
||||||
@@ -278,6 +284,7 @@ public class AiReplyOrchestrator {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -567,6 +574,38 @@ public class AiReplyOrchestrator {
|
|||||||
.defaultIfEmpty(true);
|
.defaultIfEmpty(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取「仅过滤不回评」开关。
|
||||||
|
*
|
||||||
|
* <p>开启后,评论可通过前置过滤(标记违规并取消通过),但不会生成与发布 AI 回复。
|
||||||
|
* 该字段缺省或解析失败时一律视为 false(保留原有生成行为),确保默认行为与
|
||||||
|
* settings.yaml 中 {@code filterOnly: false} 的默认值完全一致。
|
||||||
|
*/
|
||||||
|
private Mono<Boolean> isFilterOnlyEnabled() {
|
||||||
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
|
.mapNotNull(cm -> {
|
||||||
|
var data = cm.getData();
|
||||||
|
if (data == null) return false;
|
||||||
|
String basicJson = data.get("basic");
|
||||||
|
if (basicJson == null || basicJson.isBlank()) return false;
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(basicJson);
|
||||||
|
if (!node.has("filterOnly")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return node.get("filterOnly").asBoolean(false);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Orchestrator] Failed to parse filterOnly from ConfigMap: {}", e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.debug("[Orchestrator] Failed to fetch filterOnly setting: {}", e.getMessage());
|
||||||
|
return Mono.just(false);
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(false);
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<Integer> getMaxRetryCount() {
|
private Mono<Integer> getMaxRetryCount() {
|
||||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
.mapNotNull(cm -> {
|
.mapNotNull(cm -> {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import run.halo.app.extension.ReactiveExtensionClient;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -29,6 +32,27 @@ public class FilterService {
|
|||||||
private static final String GROUP_CONTENT = "content.halo.run";
|
private static final String GROUP_CONTENT = "content.halo.run";
|
||||||
private static final String GROUP_MOMENT = "moment.halo.run";
|
private static final String GROUP_MOMENT = "moment.halo.run";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章(Post)缺省开启 AI 回评。必须与 annotation-setting.yaml 中 Post 表单的
|
||||||
|
* {@code value: "true"} 保持一致,否则界面显示与实际行为会不一致。
|
||||||
|
*/
|
||||||
|
private static final boolean DEFAULT_POST_REPLY_ENABLED = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 独立页面缺省值取自全局开关「启用页面AI回复」{@code pagesEnabled},
|
||||||
|
* 与 annotation-setting.yaml 中 SinglePage 表单的 {@code value: "false"} 以及
|
||||||
|
* settings.yaml 中 {@code pagesEnabled: false} 三者保持一致。
|
||||||
|
*/
|
||||||
|
private static final boolean DEFAULT_PAGE_REPLY_ENABLED = false;
|
||||||
|
|
||||||
|
/** 标注值中表示"开启"的合法写法(已归一化)。 */
|
||||||
|
private static final java.util.Set<String> TRUE_LITERALS =
|
||||||
|
Set.of("true", "1", "on", "yes");
|
||||||
|
|
||||||
|
/** 标注值中表示"关闭"的合法写法(已归一化)。 */
|
||||||
|
private static final java.util.Set<String> FALSE_LITERALS =
|
||||||
|
Set.of("false", "0", "off", "no");
|
||||||
|
|
||||||
public FilterService(ReactiveExtensionClient client, ObjectMapper objectMapper) {
|
public FilterService(ReactiveExtensionClient client, ObjectMapper objectMapper) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
@@ -81,9 +105,7 @@ public class FilterService {
|
|||||||
if (basicJson == null || basicJson.isBlank()) return false;
|
if (basicJson == null || basicJson.isBlank()) return false;
|
||||||
try {
|
try {
|
||||||
JsonNode node = objectMapper.readTree(basicJson);
|
JsonNode node = objectMapper.readTree(basicJson);
|
||||||
String blockedCommentersStr = node.has("blockedCommenters")
|
List<String> blockedCommenters = parseList(readStringList(node, "blockedCommenters"));
|
||||||
? node.get("blockedCommenters").asText("") : "";
|
|
||||||
List<String> blockedCommenters = parseList(blockedCommentersStr);
|
|
||||||
String commenterName = getCommenterDisplayName(comment);
|
String commenterName = getCommenterDisplayName(comment);
|
||||||
String commenterEmail = getCommenterEmail(comment);
|
String commenterEmail = getCommenterEmail(comment);
|
||||||
if (isInList(commenterName, blockedCommenters) || isInList(commenterEmail, blockedCommenters)) {
|
if (isInList(commenterName, blockedCommenters) || isInList(commenterEmail, blockedCommenters)) {
|
||||||
@@ -109,12 +131,16 @@ public class FilterService {
|
|||||||
String name = subjectRef.getName();
|
String name = subjectRef.getName();
|
||||||
|
|
||||||
if (GROUP_CONTENT.equals(group) && "Post".equals(kind)) {
|
if (GROUP_CONTENT.equals(group) && "Post".equals(kind)) {
|
||||||
|
// 缺省值 DEFAULT_POST_REPLY_ENABLED 与 annotation-setting.yaml 中文章表单的
|
||||||
|
// value: "true" 一致,保证"界面显示开启"与"运行时按开启执行"始终相同
|
||||||
return client.fetch(Post.class, name)
|
return client.fetch(Post.class, name)
|
||||||
.map(post -> resolveAnnotation(post.getMetadata().getAnnotations(), true))
|
.map(post -> resolveAnnotation(post.getMetadata().getAnnotations(), DEFAULT_POST_REPLY_ENABLED))
|
||||||
.defaultIfEmpty(true);
|
.defaultIfEmpty(DEFAULT_POST_REPLY_ENABLED);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (GROUP_CONTENT.equals(group) && "SinglePage".equals(kind)) {
|
if (GROUP_CONTENT.equals(group) && "SinglePage".equals(kind)) {
|
||||||
|
// 页面未显式配置标注时,沿用全局「启用页面AI回复」作为缺省值;
|
||||||
|
// 一旦页面标注存在(表单回写的 "true"/"false" 字符串),标注优先。
|
||||||
return getPagesEnabled()
|
return getPagesEnabled()
|
||||||
.flatMap(pagesEnabled ->
|
.flatMap(pagesEnabled ->
|
||||||
client.fetch(SinglePage.class, name)
|
client.fetch(SinglePage.class, name)
|
||||||
@@ -159,44 +185,72 @@ public class FilterService {
|
|||||||
.defaultIfEmpty(true);
|
.defaultIfEmpty(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取全局「启用页面AI回复」开关。所有缺省分支统一返回 {@link #DEFAULT_PAGE_REPLY_ENABLED},
|
||||||
|
* 与 settings.yaml 中 {@code pagesEnabled: false} 保持一致。
|
||||||
|
*/
|
||||||
private Mono<Boolean> getPagesEnabled() {
|
private Mono<Boolean> getPagesEnabled() {
|
||||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
.mapNotNull(cm -> {
|
.mapNotNull(cm -> {
|
||||||
var data = cm.getData();
|
var data = cm.getData();
|
||||||
if (data == null) return false;
|
if (data == null) return DEFAULT_PAGE_REPLY_ENABLED;
|
||||||
String basicJson = data.get("basic");
|
String basicJson = data.get("basic");
|
||||||
if (basicJson == null || basicJson.isBlank()) return false;
|
if (basicJson == null || basicJson.isBlank()) return DEFAULT_PAGE_REPLY_ENABLED;
|
||||||
try {
|
try {
|
||||||
JsonNode node = objectMapper.readTree(basicJson);
|
JsonNode node = objectMapper.readTree(basicJson);
|
||||||
if (!node.has("pagesEnabled")) {
|
if (!node.has("pagesEnabled")) {
|
||||||
return false;
|
return DEFAULT_PAGE_REPLY_ENABLED;
|
||||||
}
|
}
|
||||||
return node.get("pagesEnabled").asBoolean(false);
|
return node.get("pagesEnabled").asBoolean(DEFAULT_PAGE_REPLY_ENABLED);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[Filter] Failed to parse pagesEnabled: {}", e.getMessage());
|
log.warn("[Filter] Failed to parse pagesEnabled: {}", e.getMessage());
|
||||||
return false;
|
return DEFAULT_PAGE_REPLY_ENABLED;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.debug("[Filter] Failed to fetch pagesEnabled: {}", e.getMessage());
|
log.debug("[Filter] Failed to fetch pagesEnabled: {}", e.getMessage());
|
||||||
return Mono.just(false);
|
return Mono.just(DEFAULT_PAGE_REPLY_ENABLED);
|
||||||
})
|
})
|
||||||
.defaultIfEmpty(false);
|
.defaultIfEmpty(DEFAULT_PAGE_REPLY_ENABLED);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean resolveAnnotation(java.util.Map<String, String> annotations, boolean defaultEnabled) {
|
/**
|
||||||
|
* 解析文章的「启用AI回评」标注值。
|
||||||
|
*
|
||||||
|
* <p>这是开关显示值(annotation-setting.yaml 的 {@code value})与运行时判定共用的唯一判定入口,
|
||||||
|
* 二者必须返回一致结果,否则会出现"界面显示关闭但仍在回评"的开关失效问题。约定:
|
||||||
|
* <ul>
|
||||||
|
* <li>标注不存在或值为空串 → 使用 defaultEnabled(与表单默认值一致)</li>
|
||||||
|
* <li>显式 false → 一定关闭,任何情况下不被默认值覆盖</li>
|
||||||
|
* <li>显式 true → 一定开启</li>
|
||||||
|
* <li>其它无法识别的值 → 记录告警并回落到 defaultEnabled</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param annotations 资源 metadata.annotations
|
||||||
|
* @param defaultEnabled 缺省时的取值,必须与 annotation-setting.yaml 中该类型的表单默认值一致
|
||||||
|
*/
|
||||||
|
private boolean resolveAnnotation(Map<String, String> annotations, boolean defaultEnabled) {
|
||||||
if (annotations == null || !annotations.containsKey(ANNOTATION_KEY)) {
|
if (annotations == null || !annotations.containsKey(ANNOTATION_KEY)) {
|
||||||
return defaultEnabled;
|
return defaultEnabled;
|
||||||
}
|
}
|
||||||
String value = annotations.get(ANNOTATION_KEY);
|
String value = annotations.get(ANNOTATION_KEY);
|
||||||
if ("false".equalsIgnoreCase(value)) {
|
if (value == null) {
|
||||||
|
return defaultEnabled;
|
||||||
|
}
|
||||||
|
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
// 空串等同于未配置,保持与表单缺省值一致,避免开关显示关闭却仍回评
|
||||||
|
return defaultEnabled;
|
||||||
|
}
|
||||||
|
if (FALSE_LITERALS.contains(normalized)) {
|
||||||
log.info("[Filter] Annotation {} is set to false, skipping", ANNOTATION_KEY);
|
log.info("[Filter] Annotation {} is set to false, skipping", ANNOTATION_KEY);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ("true".equalsIgnoreCase(value)) {
|
if (TRUE_LITERALS.contains(normalized)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Unrecognized value, fall back to default
|
log.warn("[Filter] Annotation {} has unrecognized value '{}', falling back to default {}. "
|
||||||
|
+ "Expected one of true/false/1/0/on/off/yes/no.", ANNOTATION_KEY, value, defaultEnabled);
|
||||||
return defaultEnabled;
|
return defaultEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,20 +278,62 @@ public class FilterService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取配置中可能是字符串或数组形式的名单字段。
|
||||||
|
*
|
||||||
|
* <p>历史版本可能把名单以 JSON 数组写入,{@link JsonNode#asText(String)} 对数组会返回空串,
|
||||||
|
* 导致名单被静默忽略,因此这里统一兼容两种形态。
|
||||||
|
*
|
||||||
|
* @param node 配置节点
|
||||||
|
* @param key 字段名
|
||||||
|
* @return 解析后的原始文本(逗号分隔);字段不存在时返回空串
|
||||||
|
*/
|
||||||
|
private String readStringList(JsonNode node, String key) {
|
||||||
|
if (node == null || !node.has(key)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
JsonNode value = node.get(key);
|
||||||
|
if (value == null || value.isNull()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (value.isArray()) {
|
||||||
|
StringBuilder joined = new StringBuilder();
|
||||||
|
for (JsonNode element : value) {
|
||||||
|
if (element == null || element.isNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (joined.length() > 0) {
|
||||||
|
joined.append(',');
|
||||||
|
}
|
||||||
|
joined.append(element.asText(""));
|
||||||
|
}
|
||||||
|
return joined.toString();
|
||||||
|
}
|
||||||
|
return value.asText("");
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isInList(String value, List<String> list) {
|
private boolean isInList(String value, List<String> list) {
|
||||||
if (value == null || value.isEmpty() || list.isEmpty()) return false;
|
if (value == null || value.isEmpty() || list.isEmpty()) return false;
|
||||||
return list.stream().anyMatch(item -> {
|
// 每条正则只编译一次(原实现会在每个候选上重复编译,命中率越低开销越大)
|
||||||
if (item.startsWith("regex:")) {
|
Map<String, Pattern> compiled = new java.util.LinkedHashMap<>();
|
||||||
|
for (String item : list) {
|
||||||
|
if (item == null || item.isEmpty()) continue;
|
||||||
|
if (!item.startsWith("regex:")) {
|
||||||
|
if (item.equalsIgnoreCase(value)) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Pattern pattern = compiled.computeIfAbsent(item, key -> {
|
||||||
try {
|
try {
|
||||||
String regex = item.substring(6);
|
return Pattern.compile(key.substring("regex:".length()), Pattern.CASE_INSENSITIVE);
|
||||||
Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
|
|
||||||
return pattern.matcher(value).matches();
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[Filter] Invalid regex pattern '{}': {}", item, e.getMessage());
|
log.warn("[Filter] Invalid regex pattern '{}': {}", key, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (pattern != null && pattern.matcher(value).matches()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return item.equalsIgnoreCase(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -96,18 +96,25 @@ public class WhitelistService {
|
|||||||
cm.setData(data);
|
cm.setData(data);
|
||||||
}
|
}
|
||||||
String basicJson = data.getOrDefault("basic", "{}");
|
String basicJson = data.getOrDefault("basic", "{}");
|
||||||
|
String merged;
|
||||||
try {
|
try {
|
||||||
JsonNode node = objectMapper.readTree(basicJson);
|
JsonNode node = objectMapper.readTree(basicJson);
|
||||||
ObjectNode objectNode = node.isObject()
|
ObjectNode objectNode = node.isObject()
|
||||||
? (ObjectNode) node.deepCopy()
|
? (ObjectNode) node.deepCopy()
|
||||||
: objectMapper.createObjectNode();
|
: objectMapper.createObjectNode();
|
||||||
objectNode.put("whitelistedCommenters", joined);
|
objectNode.put("whitelistedCommenters", joined);
|
||||||
data.put("basic", objectMapper.writeValueAsString(objectNode));
|
merged = objectMapper.writeValueAsString(objectNode);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[Whitelist] 序列化白名单失败,使用裸 JSON 写入: {}", e.getMessage());
|
// basic 已损坏时用最少的字段重建,避免连带清除 whitelistEnabled 等其它配置;
|
||||||
// 退化方案:直接拼接 JSON
|
// 仍失败则向上抛错,由调用方提示保存失败,而不是静默写入半截配置。
|
||||||
data.put("basic", "{\"whitelistedCommenters\":" + objectMapper.valueToTree(joined) + "}");
|
log.warn("[Whitelist] basic 配置无法解析,将以仅含名单字段的方式重建: {}", e.getMessage());
|
||||||
|
try {
|
||||||
|
merged = "{\"whitelistedCommenters\":" + objectMapper.writeValueAsString(joined) + "}";
|
||||||
|
} catch (Exception inner) {
|
||||||
|
return Mono.error(new IllegalStateException("无法解析 basic 配置,白名单未保存", inner));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
data.put("basic", merged);
|
||||||
return client.update(cm);
|
return client.update(cm);
|
||||||
})
|
})
|
||||||
.then();
|
.then();
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ spec:
|
|||||||
- $formkit: switch
|
- $formkit: switch
|
||||||
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
|
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
|
||||||
label: 启用AI回评
|
label: 启用AI回评
|
||||||
value: true
|
# metadata.annotations 的值必须是字符串,因此布尔开关必须使用 on-value / off-value
|
||||||
|
# 显式声明字符串取值,value 也必须是字符串,否则表单回落为"关闭"而运行时仍按开启处理。
|
||||||
|
on-value: "true"
|
||||||
|
off-value: "false"
|
||||||
|
value: "true"
|
||||||
help: 开启后,该文章收到评论时将自动触发AI回复
|
help: 开启后,该文章收到评论时将自动触发AI回复
|
||||||
- $formkit: text
|
- $formkit: text
|
||||||
name: comment-ai-autopilot.nxxy335.top/ai-persona
|
name: comment-ai-autopilot.nxxy335.top/ai-persona
|
||||||
@@ -30,7 +34,11 @@ spec:
|
|||||||
- $formkit: switch
|
- $formkit: switch
|
||||||
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
|
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
|
||||||
label: 启用AI回评
|
label: 启用AI回评
|
||||||
value: false
|
# 与文章保持一致:布尔开关必须输出字符串,默认值与运行时的缺省策略一致
|
||||||
|
# (页面缺省值取自全局「启用页面AI回复」pagesEnabled,默认为 false)
|
||||||
|
on-value: "true"
|
||||||
|
off-value: "false"
|
||||||
|
value: "false"
|
||||||
help: 开启后,该页面收到评论时将自动触发AI回复
|
help: 开启后,该页面收到评论时将自动触发AI回复
|
||||||
- $formkit: text
|
- $formkit: text
|
||||||
name: comment-ai-autopilot.nxxy335.top/ai-persona
|
name: comment-ai-autopilot.nxxy335.top/ai-persona
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ spec:
|
|||||||
label: 启用前置过滤
|
label: 启用前置过滤
|
||||||
help: "AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token"
|
help: "AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token"
|
||||||
value: true
|
value: true
|
||||||
|
- $formkit: switch
|
||||||
|
name: filterOnly
|
||||||
|
label: 仅过滤不回评
|
||||||
|
help: "开启后只进行AI评论过滤拦截,不生成AI回复。需先启用前置过滤"
|
||||||
|
value: false
|
||||||
|
condition: "{{ preFilterEnabled }}"
|
||||||
- $formkit: switch
|
- $formkit: switch
|
||||||
name: preFilterPendingOnViolation
|
name: preFilterPendingOnViolation
|
||||||
label: 违规评论设为待审核
|
label: 违规评论设为待审核
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package top.nxxy335.commentaiautopilot.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import run.halo.app.core.extension.content.Post;
|
||||||
|
import run.halo.app.core.extension.content.Comment;
|
||||||
|
import run.halo.app.extension.ConfigMap;
|
||||||
|
import run.halo.app.extension.Metadata;
|
||||||
|
import run.halo.app.extension.Ref;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「开启AI回评」开关的状态一致性测试。
|
||||||
|
*
|
||||||
|
* <p>背景:文章设置里的布尔开关一度出现"界面显示关闭、运行时却仍按开启执行"的失效问题。
|
||||||
|
* 根因是 annotation-setting.yaml 未使用字符串取值(on-value / off-value),
|
||||||
|
* 且运行时对标注值的解析只认严格的小写 "true"/"false",其它取值被静默丢弃。
|
||||||
|
*
|
||||||
|
* <p>本测试把"界面显示值 == 运行时实际行为"这条契约固定下来。
|
||||||
|
*/
|
||||||
|
class FilterServiceTest {
|
||||||
|
|
||||||
|
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||||
|
private static final String ANNOTATION_KEY = "comment-ai-autopilot.nxxy335.top/ai-reply-enabled";
|
||||||
|
private static final String TARGET_NAME = "target-post";
|
||||||
|
|
||||||
|
private final StubClient stub = new StubClient();
|
||||||
|
private final FilterService filterService = new FilterService(stub.client, new ObjectMapper());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章未配置标注时必须默认开启:
|
||||||
|
* 这与 annotation-setting.yaml 中 Post 表单的 {@code value: "true"} 一致,
|
||||||
|
* 否则会出现"界面显示开启、行为关闭"的反向不一致。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void postDefaultsToEnabledWhenAnnotationAbsent() {
|
||||||
|
assertTrue(aiReplyEnabled(null), "annotations 为 null 时默认开启");
|
||||||
|
assertTrue(aiReplyEnabled(Map.of()), "annotations 为空 Map 时默认开启");
|
||||||
|
assertTrue(aiReplyEnabled(Map.of("unrelated", "true")), "不含目标标注时默认开启");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显式关闭必须生效——这是"想关闭回评时能否生效"的回归点。 */
|
||||||
|
@Test
|
||||||
|
void postCanBeExplicitlyDisabled() {
|
||||||
|
assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, "false")));
|
||||||
|
assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, "FALSE")), "忽略大小写");
|
||||||
|
assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, " false ")), "容忍首尾空白");
|
||||||
|
assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, "0")));
|
||||||
|
assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, "off")));
|
||||||
|
assertFalse(aiReplyEnabled(Map.of(ANNOTATION_KEY, "no")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postCanBeExplicitlyEnabled() {
|
||||||
|
assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "true")));
|
||||||
|
assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "TRUE")), "忽略大小写");
|
||||||
|
assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "1")));
|
||||||
|
assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "on")));
|
||||||
|
assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "yes")));
|
||||||
|
assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, " true ")), "容忍首尾空白");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 脏数据(空串、无法识别的取值)回落到默认开启并告警,
|
||||||
|
* 而不是被误判成"关闭"——那样会造成显示关闭却依然回评。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void blankAndUnknownValuesFallBackToDefault() {
|
||||||
|
assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "")), "空串等同于未配置");
|
||||||
|
assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, " ")), "纯空白等同于未配置");
|
||||||
|
assertTrue(aiReplyEnabled(Map.of(ANNOTATION_KEY, "maybe")), "无法识别的取值回落为默认开启");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造一条指向指定文章的评论,走完整的 shouldProcess 运行时判定路径。
|
||||||
|
*/
|
||||||
|
private boolean aiReplyEnabled(Map<String, String> annotations) {
|
||||||
|
var post = new Post();
|
||||||
|
var postMetadata = new Metadata();
|
||||||
|
postMetadata.setAnnotations(annotations);
|
||||||
|
post.setMetadata(postMetadata);
|
||||||
|
stub.post = post;
|
||||||
|
|
||||||
|
var comment = new Comment();
|
||||||
|
var spec = new Comment.CommentSpec();
|
||||||
|
var subjectRef = new Ref();
|
||||||
|
subjectRef.setGroup("content.halo.run");
|
||||||
|
subjectRef.setKind("Post");
|
||||||
|
subjectRef.setName(TARGET_NAME);
|
||||||
|
spec.setSubjectRef(subjectRef);
|
||||||
|
comment.setSpec(spec);
|
||||||
|
|
||||||
|
return Boolean.TRUE.equals(filterService.shouldProcess(comment).block());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只提供测试需要的最小 ReactiveExtensionClient 行为:
|
||||||
|
* ConfigMap 返回无黑名单数据的配置,Post 由测试用例在每次调用前替换。
|
||||||
|
*/
|
||||||
|
private static final class StubClient {
|
||||||
|
|
||||||
|
private final run.halo.app.extension.ReactiveExtensionClient client =
|
||||||
|
mock(run.halo.app.extension.ReactiveExtensionClient.class);
|
||||||
|
|
||||||
|
private Post post;
|
||||||
|
|
||||||
|
private StubClient() {
|
||||||
|
var configMap = new ConfigMap();
|
||||||
|
configMap.setData(new HashMap<>());
|
||||||
|
lenient().when(client.fetch(eq(ConfigMap.class), eq(CONFIG_MAP_NAME)))
|
||||||
|
.thenReturn(Mono.just(configMap));
|
||||||
|
lenient().when(client.fetch(eq(Post.class), eq(TARGET_NAME)))
|
||||||
|
.thenAnswer(invocation -> Mono.just(post));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+8
@@ -20,6 +20,9 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.13.5
|
specifier: ^1.13.5
|
||||||
version: 1.17.0
|
version: 1.17.0
|
||||||
|
canvas-confetti:
|
||||||
|
specifier: ^1.9.3
|
||||||
|
version: 1.9.4
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.28
|
specifier: ^3.5.28
|
||||||
version: 3.5.38(typescript@5.8.3)
|
version: 3.5.38(typescript@5.8.3)
|
||||||
@@ -1031,6 +1034,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4:
|
||||||
|
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||||
|
|
||||||
chai@6.2.2:
|
chai@6.2.2:
|
||||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3452,6 +3458,8 @@ snapshots:
|
|||||||
|
|
||||||
callsites@3.1.0: {}
|
callsites@3.1.0: {}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4: {}
|
||||||
|
|
||||||
chai@6.2.2: {}
|
chai@6.2.2: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
|
|||||||
@@ -118,6 +118,10 @@
|
|||||||
<div class="form-row__label"><span class="form-label">启用前置过滤</span><span class="form-hint">AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token</span></div>
|
<div class="form-row__label"><span class="form-label">启用前置过滤</span><span class="form-hint">AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token</span></div>
|
||||||
<label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterEnabled" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
|
<label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterEnabled" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="settings.basic.preFilterEnabled" class="form-row">
|
||||||
|
<div class="form-row__label"><span class="form-label">仅过滤不回评</span><span class="form-hint">开启后只进行AI评论过滤拦截,不生成AI回复。需先启用前置过滤</span></div>
|
||||||
|
<label class="toggle"><input type="checkbox" v-model="settings.basic.filterOnly" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
|
||||||
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-row__label"><span class="form-label">违规评论设为待审核</span><span class="form-hint">检测到违规评论时自动取消通过,需人工审核</span></div>
|
<div class="form-row__label"><span class="form-label">违规评论设为待审核</span><span class="form-hint">检测到违规评论时自动取消通过,需人工审核</span></div>
|
||||||
<label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterPendingOnViolation" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
|
<label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterPendingOnViolation" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
|
||||||
@@ -442,11 +446,11 @@ const DEFAULT_LANGUAGE_REQUIREMENT = `【语言要求】请用评论所使用的
|
|||||||
|
|
||||||
// 配置对象:basic/model/prompt/cleanup 四组
|
// 配置对象:basic/model/prompt/cleanup 四组
|
||||||
const settings = reactive({
|
const settings = reactive({
|
||||||
basic: { autoReply: true, autoPublish: true, maxRetryCount: 3, blockedCommenters: "", allowedCommenters: "", maxConversationTurns: 10, rateLimitPerHour: 0, preFilterEnabled: true, preFilterPendingOnViolation: true, momentsEnabled: true, pagesEnabled: false, whitelistEnabled: true },
|
basic: { autoReply: true, autoPublish: true, maxRetryCount: 3, blockedCommenters: "", allowedCommenters: "", maxConversationTurns: 10, rateLimitPerHour: 0, preFilterEnabled: true, filterOnly: false, preFilterPendingOnViolation: true, momentsEnabled: true, pagesEnabled: false, whitelistEnabled: true },
|
||||||
model: { modelName: "" },
|
model: { modelName: "" },
|
||||||
persona: { categoryPersonaMap: {} as Record<string, string> },
|
persona: { categoryPersonaMap: {} as Record<string, string> },
|
||||||
prompt: { personaIdentity: "", safetyReview: "", sentimentAdapter: "", outputGuidance: "", languageRequirement: "" },
|
prompt: { personaIdentity: "", safetyReview: "", sentimentAdapter: "", outputGuidance: "", languageRequirement: "" },
|
||||||
cleanup: { cleanupEnabled: false, retentionDays: 30 }
|
cleanup: { cleanupEnabled: true, retentionDays: 30 }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 分类角色映射:编辑行数据(与 settings.model.categoryPersonaMap 双向同步)
|
// 分类角色映射:编辑行数据(与 settings.model.categoryPersonaMap 双向同步)
|
||||||
@@ -684,7 +688,8 @@ const onNumberChange = (field: 'maxConversationTurns' | 'rateLimitPerHour') => {
|
|||||||
if (settings.basic[field] === 0) triggerZeroConfirm(field)
|
if (settings.basic[field] === 0) triggerZeroConfirm(field)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseCfg = (d:any, k:string) => { const v = d[k]; if(!v) return {}; if(typeof v === 'string') { try{ return JSON.parse(v) }catch{ return {} } } return v }
|
// 解析 ConfigMap 中的 JSON 字段:仅 null/undefined 视为缺失(false/0 是合法配置值)
|
||||||
|
const parseCfg = (d:any, k:string) => { const v = d[k]; if(v === undefined || v === null) return {}; if(typeof v === 'string') { try{ return JSON.parse(v) }catch{ return {} } } return v }
|
||||||
// 解析分类角色映射配置(兼容字符串/对象两种存储形式)
|
// 解析分类角色映射配置(兼容字符串/对象两种存储形式)
|
||||||
const parseCategoryPersonaMap = (raw:any): Record<string,string> => { if(!raw) return {}; if(typeof raw === 'string') { try { const o = JSON.parse(raw); return (o && typeof o === 'object') ? o : {} } catch { return {} } } return (typeof raw === 'object') ? raw : {} }
|
const parseCategoryPersonaMap = (raw:any): Record<string,string> => { if(!raw) return {}; if(typeof raw === 'string') { try { const o = JSON.parse(raw); return (o && typeof o === 'object') ? o : {} } catch { return {} } } return (typeof raw === 'object') ? raw : {} }
|
||||||
|
|
||||||
@@ -708,6 +713,8 @@ const fetchSettings = async () => {
|
|||||||
}
|
}
|
||||||
if (b.pagesEnabled === undefined) settings.basic.pagesEnabled = false
|
if (b.pagesEnabled === undefined) settings.basic.pagesEnabled = false
|
||||||
if (b.whitelistEnabled === undefined) settings.basic.whitelistEnabled = true
|
if (b.whitelistEnabled === undefined) settings.basic.whitelistEnabled = true
|
||||||
|
// 兼容旧版本 ConfigMap:filterOnly 缺失时回填默认值,避免开关显示"未定义"
|
||||||
|
if (b.filterOnly === undefined) settings.basic.filterOnly = false
|
||||||
if (b.whitelistedCommenters !== undefined && b.allowedCommenters === undefined) {
|
if (b.whitelistedCommenters !== undefined && b.allowedCommenters === undefined) {
|
||||||
settings.basic.allowedCommenters = b.whitelistedCommenters
|
settings.basic.allowedCommenters = b.whitelistedCommenters
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user