还原 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回评** 开关
|
||||
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回复。
|
||||
|
||||
@@ -115,8 +115,11 @@ public class AiReplyCleanupService implements DisposableBean {
|
||||
if (cleanupJson == null || cleanupJson.isBlank()) return 30;
|
||||
try {
|
||||
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) {
|
||||
log.warn("[Cleanup] Failed to parse retentionDays config: {}", e.getMessage());
|
||||
return 30;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -267,6 +267,12 @@ public class AiReplyOrchestrator {
|
||||
return createFilteredRecord(context, preFilterResult)
|
||||
.then(preFilterService.penalize(commentName, replyName))
|
||||
.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)
|
||||
.flatMap(sentimentResult -> {
|
||||
@@ -278,6 +284,7 @@ public class AiReplyOrchestrator {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -567,6 +574,38 @@ public class AiReplyOrchestrator {
|
||||
.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() {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
|
||||
@@ -14,6 +14,9 @@ import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
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_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) {
|
||||
this.client = client;
|
||||
this.objectMapper = objectMapper;
|
||||
@@ -81,9 +105,7 @@ public class FilterService {
|
||||
if (basicJson == null || basicJson.isBlank()) return false;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(basicJson);
|
||||
String blockedCommentersStr = node.has("blockedCommenters")
|
||||
? node.get("blockedCommenters").asText("") : "";
|
||||
List<String> blockedCommenters = parseList(blockedCommentersStr);
|
||||
List<String> blockedCommenters = parseList(readStringList(node, "blockedCommenters"));
|
||||
String commenterName = getCommenterDisplayName(comment);
|
||||
String commenterEmail = getCommenterEmail(comment);
|
||||
if (isInList(commenterName, blockedCommenters) || isInList(commenterEmail, blockedCommenters)) {
|
||||
@@ -109,12 +131,16 @@ public class FilterService {
|
||||
String name = subjectRef.getName();
|
||||
|
||||
if (GROUP_CONTENT.equals(group) && "Post".equals(kind)) {
|
||||
// 缺省值 DEFAULT_POST_REPLY_ENABLED 与 annotation-setting.yaml 中文章表单的
|
||||
// value: "true" 一致,保证"界面显示开启"与"运行时按开启执行"始终相同
|
||||
return client.fetch(Post.class, name)
|
||||
.map(post -> resolveAnnotation(post.getMetadata().getAnnotations(), true))
|
||||
.defaultIfEmpty(true);
|
||||
.map(post -> resolveAnnotation(post.getMetadata().getAnnotations(), DEFAULT_POST_REPLY_ENABLED))
|
||||
.defaultIfEmpty(DEFAULT_POST_REPLY_ENABLED);
|
||||
}
|
||||
|
||||
if (GROUP_CONTENT.equals(group) && "SinglePage".equals(kind)) {
|
||||
// 页面未显式配置标注时,沿用全局「启用页面AI回复」作为缺省值;
|
||||
// 一旦页面标注存在(表单回写的 "true"/"false" 字符串),标注优先。
|
||||
return getPagesEnabled()
|
||||
.flatMap(pagesEnabled ->
|
||||
client.fetch(SinglePage.class, name)
|
||||
@@ -159,44 +185,72 @@ public class FilterService {
|
||||
.defaultIfEmpty(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取全局「启用页面AI回复」开关。所有缺省分支统一返回 {@link #DEFAULT_PAGE_REPLY_ENABLED},
|
||||
* 与 settings.yaml 中 {@code pagesEnabled: false} 保持一致。
|
||||
*/
|
||||
private Mono<Boolean> getPagesEnabled() {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return false;
|
||||
if (data == null) return DEFAULT_PAGE_REPLY_ENABLED;
|
||||
String basicJson = data.get("basic");
|
||||
if (basicJson == null || basicJson.isBlank()) return false;
|
||||
if (basicJson == null || basicJson.isBlank()) return DEFAULT_PAGE_REPLY_ENABLED;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(basicJson);
|
||||
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) {
|
||||
log.warn("[Filter] Failed to parse pagesEnabled: {}", e.getMessage());
|
||||
return false;
|
||||
return DEFAULT_PAGE_REPLY_ENABLED;
|
||||
}
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
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)) {
|
||||
return defaultEnabled;
|
||||
}
|
||||
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);
|
||||
return false;
|
||||
}
|
||||
if ("true".equalsIgnoreCase(value)) {
|
||||
if (TRUE_LITERALS.contains(normalized)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -224,20 +278,62 @@ public class FilterService {
|
||||
.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) {
|
||||
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 {
|
||||
String regex = item.substring(6);
|
||||
Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
|
||||
return pattern.matcher(value).matches();
|
||||
return Pattern.compile(key.substring("regex:".length()), Pattern.CASE_INSENSITIVE);
|
||||
} 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 item.equalsIgnoreCase(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,18 +96,25 @@ public class WhitelistService {
|
||||
cm.setData(data);
|
||||
}
|
||||
String basicJson = data.getOrDefault("basic", "{}");
|
||||
String merged;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(basicJson);
|
||||
ObjectNode objectNode = node.isObject()
|
||||
? (ObjectNode) node.deepCopy()
|
||||
: objectMapper.createObjectNode();
|
||||
objectNode.put("whitelistedCommenters", joined);
|
||||
data.put("basic", objectMapper.writeValueAsString(objectNode));
|
||||
merged = objectMapper.writeValueAsString(objectNode);
|
||||
} catch (Exception e) {
|
||||
log.warn("[Whitelist] 序列化白名单失败,使用裸 JSON 写入: {}", e.getMessage());
|
||||
// 退化方案:直接拼接 JSON
|
||||
data.put("basic", "{\"whitelistedCommenters\":" + objectMapper.valueToTree(joined) + "}");
|
||||
// basic 已损坏时用最少的字段重建,避免连带清除 whitelistEnabled 等其它配置;
|
||||
// 仍失败则向上抛错,由调用方提示保存失败,而不是静默写入半截配置。
|
||||
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);
|
||||
})
|
||||
.then();
|
||||
|
||||
@@ -10,7 +10,11 @@ spec:
|
||||
- $formkit: switch
|
||||
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
|
||||
label: 启用AI回评
|
||||
value: true
|
||||
# metadata.annotations 的值必须是字符串,因此布尔开关必须使用 on-value / off-value
|
||||
# 显式声明字符串取值,value 也必须是字符串,否则表单回落为"关闭"而运行时仍按开启处理。
|
||||
on-value: "true"
|
||||
off-value: "false"
|
||||
value: "true"
|
||||
help: 开启后,该文章收到评论时将自动触发AI回复
|
||||
- $formkit: text
|
||||
name: comment-ai-autopilot.nxxy335.top/ai-persona
|
||||
@@ -30,7 +34,11 @@ spec:
|
||||
- $formkit: switch
|
||||
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
|
||||
label: 启用AI回评
|
||||
value: false
|
||||
# 与文章保持一致:布尔开关必须输出字符串,默认值与运行时的缺省策略一致
|
||||
# (页面缺省值取自全局「启用页面AI回复」pagesEnabled,默认为 false)
|
||||
on-value: "true"
|
||||
off-value: "false"
|
||||
value: "false"
|
||||
help: 开启后,该页面收到评论时将自动触发AI回复
|
||||
- $formkit: text
|
||||
name: comment-ai-autopilot.nxxy335.top/ai-persona
|
||||
|
||||
@@ -56,6 +56,12 @@ spec:
|
||||
label: 启用前置过滤
|
||||
help: "AI回复前检测评论合规性,拦截广告/辱骂/敏感内容,节省Token"
|
||||
value: true
|
||||
- $formkit: switch
|
||||
name: filterOnly
|
||||
label: 仅过滤不回评
|
||||
help: "开启后只进行AI评论过滤拦截,不生成AI回复。需先启用前置过滤"
|
||||
value: false
|
||||
condition: "{{ preFilterEnabled }}"
|
||||
- $formkit: switch
|
||||
name: preFilterPendingOnViolation
|
||||
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:
|
||||
specifier: ^1.13.5
|
||||
version: 1.17.0
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.3
|
||||
version: 1.9.4
|
||||
vue:
|
||||
specifier: ^3.5.28
|
||||
version: 3.5.38(typescript@5.8.3)
|
||||
@@ -1031,6 +1034,9 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
canvas-confetti@1.9.4:
|
||||
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3452,6 +3458,8 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
canvas-confetti@1.9.4: {}
|
||||
|
||||
chai@6.2.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>
|
||||
<label class="toggle"><input type="checkbox" v-model="settings.basic.preFilterEnabled" /><span class="toggle__track"><span class="toggle__thumb"></span></span></label>
|
||||
</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__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>
|
||||
@@ -442,11 +446,11 @@ const DEFAULT_LANGUAGE_REQUIREMENT = `【语言要求】请用评论所使用的
|
||||
|
||||
// 配置对象:basic/model/prompt/cleanup 四组
|
||||
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: "" },
|
||||
persona: { categoryPersonaMap: {} as Record<string, string> },
|
||||
prompt: { personaIdentity: "", safetyReview: "", sentimentAdapter: "", outputGuidance: "", languageRequirement: "" },
|
||||
cleanup: { cleanupEnabled: false, retentionDays: 30 }
|
||||
cleanup: { cleanupEnabled: true, retentionDays: 30 }
|
||||
})
|
||||
|
||||
// 分类角色映射:编辑行数据(与 settings.model.categoryPersonaMap 双向同步)
|
||||
@@ -684,7 +688,8 @@ const onNumberChange = (field: 'maxConversationTurns' | 'rateLimitPerHour') => {
|
||||
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 : {} }
|
||||
|
||||
@@ -708,6 +713,8 @@ const fetchSettings = async () => {
|
||||
}
|
||||
if (b.pagesEnabled === undefined) settings.basic.pagesEnabled = false
|
||||
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) {
|
||||
settings.basic.allowedCommenters = b.whitelistedCommenters
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user