feat: v1.0.0-beta.2 - ExtensionGetter integration, UI revamp, bug fixes

- Replace cross-ClassLoader reflection with ExtensionGetter.getEnabledExtension(AiModelService.class)
- Declare optional pluginDependencies (ai-foundation?: "*") and recommended-apps annotation
- Use OutputSpec.choice for structured classification (sentiment, review safety/quality)
- Use GenerateTextRequest with maxRetries=2 for reliable chat generation
- Add multi-turn conversation context (conversation_history placeholder)
- Fix RateLimitService thread leak (implement DisposableBean)
- Two-stage AI review: safety check + 1-5 quality score mapped to 0-100
- Remove redundant dashboard cards (sentiment distribution, 7-day trend, avg score)
- Redesign settings page with tabbed navigation (basic/persona/model/prompt/cleanup)
- Fix settings layout (move tab bar out of grid container)
- Fix button icon+text alignment via :deep(.btn-content) inline-flex
- Add review score grade labels (excellent/good/fair/poor) in logs
This commit is contained in:
sunny-335
2026-06-17 19:40:10 +08:00
parent bf989ca3f2
commit 767efdccbb
15 changed files with 811 additions and 817 deletions
@@ -216,100 +216,25 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
}
private Mono<ServerResponse> getStats(ServerRequest request) {
String range = request.queryParam("range").orElse("7");
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
.collectList()
.map(allReplies -> {
// 根据 range 计算截止时间
ZoneId zoneId = ZoneId.systemDefault();
LocalDate today = LocalDate.now(zoneId);
Instant cutoffInstant;
int trendDays;
if ("all".equals(range)) {
cutoffInstant = null; // 不做时间过滤
trendDays = 30; // "all" 时趋势也展示最近30天
} else {
int days = Integer.parseInt(range);
cutoffInstant = today.minusDays(days).atStartOfDay(zoneId).toInstant();
trendDays = days;
}
// 根据 range 过滤记录
List<AiCommentReply> replies;
if (cutoffInstant != null) {
replies = allReplies.stream()
.filter(r -> {
Instant ts = r.getMetadata().getCreationTimestamp();
return ts != null && !ts.isBefore(cutoffInstant);
})
.toList();
} else {
replies = allReplies;
}
.map(replies -> {
long total = replies.size();
long passCount = replies.stream()
.filter(r -> "PASS".equals(r.getSpec().getStatus())).count();
long failCount = replies.stream()
.filter(r -> "FAIL".equals(r.getSpec().getStatus())).count();
double avgScore = replies.stream()
.filter(r -> r.getSpec().getScore() != null && r.getSpec().getScore() > 0)
.mapToInt(r -> r.getSpec().getScore())
.average().orElse(0.0);
long reviewingCount = replies.stream()
.filter(r -> "PASS".equals(r.getSpec().getStatus())
&& !Boolean.TRUE.equals(r.getSpec().getPublished()))
.count();
Map<String, Long> sentimentDistribution = new HashMap<>();
sentimentDistribution.put("POSITIVE", 0L);
sentimentDistribution.put("NEUTRAL", 0L);
sentimentDistribution.put("NEGATIVE", 0L);
sentimentDistribution.put("UNKNOWN", 0L);
for (var r : replies) {
String sentiment = r.getSpec().getSentiment();
if (sentiment == null || sentiment.isBlank()) {
sentimentDistribution.merge("UNKNOWN", 1L, Long::sum);
} else {
sentimentDistribution.merge(sentiment, 1L, Long::sum);
}
}
// 计算 dailyTrend
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
Map<LocalDate, Long> dailyMap = new HashMap<>();
for (int i = 0; i < trendDays; i++) {
dailyMap.put(today.minusDays(i), 0L);
}
for (var r : replies) {
Instant timestamp = r.getMetadata().getCreationTimestamp();
if (timestamp != null) {
try {
LocalDate date = timestamp.atZone(zoneId).toLocalDate();
if (dailyMap.containsKey(date)) {
dailyMap.merge(date, 1L, Long::sum);
}
} catch (Exception ignored) {
}
}
}
List<DailyCount> dailyTrend = new ArrayList<>();
for (int i = 0; i < trendDays; i++) {
LocalDate date = today.minusDays(i);
dailyTrend.add(new DailyCount(date.format(formatter), dailyMap.get(date)));
}
return new StatsResponse(total, passCount, failCount, avgScore,
reviewingCount, sentimentDistribution, dailyTrend);
return new StatsResponse(total, passCount, failCount, reviewingCount);
})
.onErrorResume(e -> {
log.warn("Failed to fetch stats: {}", e.getMessage());
return Mono.just(new StatsResponse(0, 0, 0, 0.0, 0L,
Map.of("POSITIVE", 0L, "NEUTRAL", 0L, "NEGATIVE", 0L, "UNKNOWN", 0L),
List.of()));
return Mono.just(new StatsResponse(0, 0, 0, 0));
})
.flatMap(stats -> ServerResponse.ok().bodyValue(stats));
}
@@ -346,16 +271,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
)));
}
public record DailyCount(String date, long count) {}
public record StatsResponse(
long total,
long passCount,
long failCount,
double avgScore,
long reviewingCount,
Map<String, Long> sentimentDistribution,
List<DailyCount> dailyTrend
long reviewingCount
) {}
public record PersonaResponse(
@@ -1,85 +1,58 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.aifoundation.AiModelService;
import run.halo.aifoundation.chat.GenerateTextRequest;
import run.halo.aifoundation.chat.GenerateTextResult;
import run.halo.aifoundation.schema.OutputSpec;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.List;
/**
* AI Foundation client that uses runtime class loading and reflection
* to call the AI Foundation plugin's AiModelService.
* AI Foundation client that uses Halo's {@link ExtensionGetter} to obtain the
* {@link AiModelService} extension provided by the ai-foundation plugin.
* <p>
* This approach avoids classloader identity issues by loading AiModelService
* from ai-foundation's own classloader, so that Spring's getBeansOfType()
* can correctly match the implementation bean.
* This is the recommended way to integrate with AI Foundation, see
* <a href="https://github.com/halo-dev/plugin-ai-foundation/blob/main/dev/dev.md">dev guide</a>.
* <p>
* No @ConditionalOnClass or pluginDependencies needed.
* Always registered as a bean; availability is checked at runtime.
* Requires the following declaration in plugin.yaml:
* <pre>
* spec:
* pluginDependencies:
* ai-foundation?: "*"
* </pre>
* The dependency is optional, so the plugin still loads when AI Foundation is
* not installed; availability is checked at runtime and all calls return empty
* in that case.
*/
@Slf4j
@Component
public class AiFoundationClient {
private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation";
private static final String AI_MODEL_SERVICE_CLASS = "run.halo.aifoundation.AiModelService";
private final ExtensionGetter extensionGetter;
private final ReactiveExtensionClient client;
private final ApplicationContext applicationContext;
public AiFoundationClient(ReactiveExtensionClient client, ApplicationContext applicationContext) {
this.client = client;
this.applicationContext = applicationContext;
public AiFoundationClient(ExtensionGetter extensionGetter) {
this.extensionGetter = extensionGetter;
}
/**
* Call AI Foundation to generate a chat response using the specified model.
* Uses {@link GenerateTextRequest} with {@code maxRetries=2} so that
* transient model errors are retried by the SDK.
*
* @param prompt the prompt text
* @param modelName the AiModel metadata.name, null or blank to use default model
* @return the generated text, or empty if AI Foundation is unavailable
*/
public Mono<String> chat(String prompt, String modelName) {
return isAiFoundationEnabled()
.flatMap(enabled -> {
if (!enabled) {
log.warn("AI Foundation plugin is not installed or not enabled, skipping AI reply");
return Mono.empty();
}
return doChat(prompt, modelName);
});
}
/**
* Check if AI Foundation is available: plugin installed, enabled, and AiModelService bean found.
*/
public Mono<Boolean> isAvailable() {
return isAiFoundationEnabled()
.flatMap(enabled -> {
if (!enabled) return Mono.just(false);
return findAiModelService().hasElement();
});
}
private Mono<Boolean> isAiFoundationEnabled() {
return client.fetch(Plugin.class, AI_FOUNDATION_PLUGIN_NAME)
.map(plugin -> plugin.getSpec().getEnabled())
.defaultIfEmpty(false)
.onErrorResume(e -> {
log.debug("Failed to check AI Foundation plugin status: {}", e.getMessage());
return Mono.just(false);
});
}
private Mono<String> doChat(String prompt, String modelName) {
return findAiModelService()
.flatMap(service -> invokeLanguageModel(service, modelName)
.flatMap(model -> invokeGenerateText(model, prompt))
)
return aiModelService()
.flatMap(service -> service.languageModel(modelName != null ? modelName : "")
.flatMap(model -> model.generateText(
GenerateTextRequest.builder().prompt(prompt).maxRetries(2).build()))
.map(GenerateTextResult::getText))
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
.onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
@@ -88,132 +61,68 @@ public class AiFoundationClient {
}
/**
* Get PluginManager via the pluginWrapper bean registered in our plugin context.
* Halo's DefaultPluginApplicationContextFactory registers pluginWrapper as a singleton:
* beanFactory.registerSingleton("pluginWrapper", pluginWrapper);
* Then PluginWrapper.getPluginManager() gives us the PluginManager instance.
* Call AI Foundation to classify text into one of the given choices using
* structured output ({@link OutputSpec#choice(List)}).
* <p>
* This is the recommended way to do classification per the dev guide,
* as it is more reliable than prompt parsing.
*
* @param systemPrompt system prompt describing the task
* @param userPrompt the user input to classify
* @param choices the allowed classification values
* @param modelName the AiModel metadata.name, null or blank to use default model
* @return the selected choice string, or empty if AI Foundation is unavailable
*/
private Object findPluginManager() {
try {
Object pluginWrapper = applicationContext.getBean("pluginWrapper");
Method getPluginManagerMethod = pluginWrapper.getClass().getMethod("getPluginManager");
getPluginManagerMethod.setAccessible(true);
Object pm = getPluginManagerMethod.invoke(pluginWrapper);
if (pm != null) {
log.info("Found PluginManager via pluginWrapper bean: {}", pm.getClass().getName());
}
return pm;
} catch (NoSuchMethodException e) {
log.warn("pluginWrapper does not have getPluginManager() method: {}", e.getMessage());
} catch (Exception e) {
log.warn("Failed to get PluginManager via pluginWrapper: {}", e.getMessage());
}
log.warn("PluginManager not found");
return null;
public Mono<String> classify(String systemPrompt, String userPrompt,
List<String> choices, String modelName) {
return aiModelService()
.flatMap(service -> service.languageModel(modelName != null ? modelName : "")
.flatMap(model -> model.generateText(
GenerateTextRequest.builder()
.system(systemPrompt)
.prompt(userPrompt)
.output(OutputSpec.choice(choices))
.maxRetries(2)
.build()))
.map(result -> {
Object output = result.getOutput();
return output != null ? String.valueOf(output).trim() : "";
}))
.doOnError(e -> log.error("AI Foundation classify failed: {}", e.getMessage()))
.onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
return Mono.empty();
});
}
/**
* Find the AiModelService bean from ai-foundation's PluginApplicationContext.
* Uses PluginManager.getPlugin() to get the plugin wrapper, then reflection
* to get the plugin's ApplicationContext.
* Check if AI Foundation is available: plugin installed and an
* AiModelService extension is enabled.
*/
private Mono<Object> findAiModelService() {
return Mono.fromCallable(() -> {
Object pm = findPluginManager();
if (pm == null) return null;
// Call pm.getPlugin("ai-foundation") via reflection
Method getPluginMethod = pm.getClass().getMethod("getPlugin", String.class);
getPluginMethod.setAccessible(true);
Object pluginWrapper = getPluginMethod.invoke(pm, AI_FOUNDATION_PLUGIN_NAME);
if (pluginWrapper == null) {
log.debug("ai-foundation plugin not found in PluginManager");
return null;
}
// Call pluginWrapper.getPlugin() to get the plugin instance
Method getPluginInstanceMethod = pluginWrapper.getClass().getMethod("getPlugin");
getPluginInstanceMethod.setAccessible(true);
Object pluginInstance = getPluginInstanceMethod.invoke(pluginWrapper);
if (pluginInstance == null) {
log.debug("ai-foundation plugin instance is null");
return null;
}
// Get the plugin's ApplicationContext via reflection on SpringPlugin
// DefaultSpringPlugin is package-private, so we need setAccessible
Method getCtxMethod = pluginInstance.getClass().getMethod("getApplicationContext");
getCtxMethod.setAccessible(true);
ApplicationContext pluginAppContext = (ApplicationContext) getCtxMethod.invoke(pluginInstance);
// Get the plugin classloader
Method getClassLoaderMethod = pluginWrapper.getClass().getMethod("getPluginClassLoader");
getClassLoaderMethod.setAccessible(true);
ClassLoader pluginClassLoader = (ClassLoader) getClassLoaderMethod.invoke(pluginWrapper);
// Load AiModelService from ai-foundation's classloader
Class<?> aiModelServiceClass = pluginClassLoader.loadClass(AI_MODEL_SERVICE_CLASS);
// Find the AiModelService bean in ai-foundation's ApplicationContext
Map<String, ?> beans = pluginAppContext.getBeansOfType(aiModelServiceClass);
if (beans.isEmpty()) {
log.debug("AiModelService bean not found in ai-foundation's ApplicationContext");
return null;
}
log.info("Found AiModelService bean in ai-foundation's ApplicationContext");
Object result = beans.values().iterator().next();
return (Object) result;
}).doOnError(e -> log.error("Failed to find AiModelService: {}", e.getMessage()));
public Mono<Boolean> isAvailable() {
return aiModelService().hasElement()
.onErrorResume(e -> {
log.debug("AI Foundation not available: {}", e.getMessage());
return Mono.just(false);
});
}
/**
* Call service.languageModel(modelName) or service.languageModel() via reflection.
* Returns Mono&lt;LanguageModel&gt; from ai-foundation's classloader.
* Obtain the enabled AiModelService extension via ExtensionGetter.
* <p>
* Wrapped in {@link Mono#defer} with a {@link NoClassDefFoundError} guard so
* that the plugin still works when the optional ai-foundation dependency is
* not installed (the AiModelService API class is then absent from the
* classloader).
*/
private Mono<Object> invokeLanguageModel(Object service, String modelName) {
return Mono.fromCallable(() -> {
Method method;
if (modelName != null && !modelName.isBlank()) {
method = service.getClass().getMethod("languageModel", String.class);
method.setAccessible(true);
return method.invoke(service, modelName);
} else {
method = service.getClass().getMethod("languageModel");
method.setAccessible(true);
return method.invoke(service);
private Mono<AiModelService> aiModelService() {
return Mono.defer(() -> {
try {
return extensionGetter.getEnabledExtension(AiModelService.class);
} catch (NoClassDefFoundError e) {
log.debug("AI Foundation API not on classpath: {}", e.getMessage());
return Mono.empty();
}
}).flatMap(result -> {
if (result instanceof Mono<?> mono) return mono;
return Mono.justOrEmpty(result);
});
}
/**
* Call model.generateText(prompt) via reflection, then extract text from result.
* Returns the generated text string.
*/
private Mono<String> invokeGenerateText(Object model, String prompt) {
return Mono.fromCallable(() -> {
Method method = model.getClass().getMethod("generateText", String.class);
method.setAccessible(true);
return method.invoke(model, prompt);
}).flatMap(result -> {
if (result instanceof Mono<?> mono) {
return mono.map(this::extractText);
}
return Mono.justOrEmpty(extractText(result));
});
}
private String extractText(Object result) {
if (result == null) return null;
try {
Method getText = result.getClass().getMethod("getText");
getText.setAccessible(true);
return (String) getText.invoke(result);
} catch (Exception e) {
throw new RuntimeException("Failed to call getText() on GenerateTextResult: " + e.getMessage(), e);
}
}
}
@@ -42,6 +42,55 @@ public class ContextExtractor {
});
}
/**
* Fetch previous replies in the comment thread to provide conversation history.
* Only includes replies created before the triggering reply.
*/
private Mono<String> fetchConversationHistory(String commentName, String triggerReplyName) {
if (triggerReplyName == null || triggerReplyName.isBlank()) {
return Mono.just("");
}
return client.fetch(Reply.class, triggerReplyName)
.flatMap(triggerReply -> {
var triggerTime = triggerReply.getMetadata().getCreationTimestamp();
return client.list(Reply.class,
reply -> {
if (!commentName.equals(reply.getSpec().getCommentName())) {
return false;
}
if (triggerReplyName.equals(reply.getMetadata().getName())) {
return false;
}
// Only include replies created before the trigger reply
var replyTime = reply.getMetadata().getCreationTimestamp();
return replyTime != null && triggerTime != null
&& !replyTime.isAfter(triggerTime);
},
null)
.collectList()
.map(replies -> {
if (replies.isEmpty()) return "";
// Sort by creation time
replies.sort(java.util.Comparator.comparing(
r -> r.getMetadata().getCreationTimestamp()));
var sb = new StringBuilder();
for (var r : replies) {
var owner = r.getSpec().getOwner();
String name = (owner != null && owner.getDisplayName() != null)
? owner.getDisplayName() : "匿名用户";
boolean isAi = owner != null && owner.getAnnotations() != null
&& "true".equals(owner.getAnnotations().get("comment-ai-autopilot.nxxy335.top/is-ai"));
String role = isAi ? "AI" : "用户";
String content = extractReplyContent(r);
sb.append(role).append("(").append(name).append("): ")
.append(content).append("\n");
}
return sb.toString();
});
})
.defaultIfEmpty("");
}
private Mono<CommentContext> buildContext(Comment comment, boolean isAiConversation) {
var commentContent = extractCommentContent(comment);
var commentOwner = extractCommentOwner(comment);
@@ -63,7 +112,8 @@ public class ContextExtractor {
null,
isAiConversation,
formatPostDate(post),
commentCount
commentCount,
""
))
)
)
@@ -78,7 +128,8 @@ public class ContextExtractor {
null,
isAiConversation,
"",
0
0,
""
));
}
@@ -93,7 +144,8 @@ public class ContextExtractor {
null,
isAiConversation,
"",
0
0,
""
));
}
@@ -101,55 +153,68 @@ public class ContextExtractor {
var replyContent = extractReplyContent(reply);
var replyOwner = extractReplyOwner(reply);
var subjectRef = comment.getSpec().getSubjectRef();
var commentName = comment.getMetadata().getName();
var replyName = reply.getMetadata().getName();
// Fetch conversation history for AI conversations
Mono<String> historyMono = isAiConversation
? fetchConversationHistory(commentName, replyName)
: Mono.just("");
if (subjectRef != null && "Post".equals(subjectRef.getKind())) {
String postName = subjectRef.getName();
return client.fetch(Post.class, postName)
.flatMap(post -> getPostContent(postName)
.flatMap(content -> getCommentCount(comment.getMetadata().getName())
.map(commentCount -> new CommentContext(
comment.getMetadata().getName(),
postName,
post.getSpec().getSlug(),
replyContent,
replyOwner,
post.getSpec().getTitle(),
content,
reply.getMetadata().getName(),
isAiConversation,
formatPostDate(post),
commentCount
))
.flatMap(content -> getCommentCount(commentName)
.flatMap(commentCount -> historyMono
.map(history -> new CommentContext(
commentName,
postName,
post.getSpec().getSlug(),
replyContent,
replyOwner,
post.getSpec().getTitle(),
content,
replyName,
isAiConversation,
formatPostDate(post),
commentCount,
history
))
)
)
)
.defaultIfEmpty(new CommentContext(
comment.getMetadata().getName(),
commentName,
postName,
"",
replyContent,
replyOwner,
"",
"",
reply.getMetadata().getName(),
replyName,
isAiConversation,
"",
0
0,
""
));
}
return Mono.just(new CommentContext(
comment.getMetadata().getName(),
"",
"",
replyContent,
replyOwner,
"",
"",
reply.getMetadata().getName(),
isAiConversation,
"",
0
));
return historyMono
.map(history -> new CommentContext(
commentName,
"",
"",
replyContent,
replyOwner,
"",
"",
replyName,
isAiConversation,
"",
0,
history
));
}
private String extractCommentContent(Comment comment) {
@@ -245,6 +310,7 @@ public class ContextExtractor {
String replyTo,
boolean isAiConversation,
String postDate,
int commentCount
int commentCount,
String conversationHistory
) {}
}
@@ -75,6 +75,7 @@ public class PromptBuilder {
文章(仅供理解上下文,不要复述):
{{article}}
{{conversation_history}}
评论:
{{comment}}
""";
@@ -103,6 +104,7 @@ public class PromptBuilder {
.replace("{{post_date}}", context.postDate() != null ? context.postDate() : "")
.replace("{{comment_count}}", String.valueOf(context.commentCount()))
.replace("{{article}}", context.postTitle() + "\n" + context.postContent())
.replace("{{conversation_history}}", formatConversationHistory(context))
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
return prompt;
@@ -133,6 +135,7 @@ public class PromptBuilder {
.replace("{{post_date}}", context.postDate() != null ? context.postDate() : "")
.replace("{{comment_count}}", String.valueOf(context.commentCount()))
.replace("{{article}}", context.postTitle() + "\n" + context.postContent())
.replace("{{conversation_history}}", formatConversationHistory(context))
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
if (sentiment == null || "NEUTRAL".equals(sentiment)) {
@@ -147,6 +150,18 @@ public class PromptBuilder {
});
}
/**
* Format conversation history for inclusion in the prompt.
* Returns empty string if no history is available.
*/
private String formatConversationHistory(ContextExtractor.CommentContext context) {
String history = context.conversationHistory();
if (history == null || history.isBlank()) {
return "";
}
return "对话历史(供理解上下文):\n" + history + "\n";
}
private Mono<String> getPromptTemplate() {
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.mapNotNull(cm -> {
@@ -1,6 +1,7 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
@@ -8,15 +9,18 @@ import java.util.concurrent.atomic.AtomicInteger;
@Slf4j
@Component
public class RateLimitService {
public class RateLimitService implements DisposableBean {
private final ConcurrentHashMap<Long, AtomicInteger> windowMap = new ConcurrentHashMap<>();
private final Thread cleanupThread;
private volatile boolean running = true;
public RateLimitService() {
// 每5分钟清理过期窗口,防止内存泄漏
Thread cleanupThread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
cleanupThread = new Thread(() -> {
while (running && !Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(5 * 60 * 1000);
if (!running) break;
cleanup();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
@@ -55,4 +59,13 @@ public class RateLimitService {
log.debug("[RateLimit] Cleaned up {} expired windows", removed);
}
}
@Override
public void destroy() {
running = false;
if (cleanupThread != null) {
cleanupThread.interrupt();
}
log.info("[RateLimit] Cleanup thread stopped");
}
}
@@ -4,6 +4,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.List;
@Component
@Slf4j
public class ReviewService {
@@ -14,58 +16,135 @@ public class ReviewService {
this.aiFoundationClient = aiFoundationClient;
}
private static final String REVIEW_PROMPT_TEMPLATE = """
请你以内容安全审核员的身份,严格评估你刚刚生成的这段回复:
/**
* Safety classification choices.
* Using OutputSpec.choice for reliable structured output.
*/
private static final String SAFE = "安全";
private static final String UNSAFE = "不安全";
private static final List<String> SAFETY_CHOICES = List.of(SAFE, UNSAFE);
文章:
%s
/**
* Quality rating choices for content quality assessment.
* Used to produce a meaningful score (1-5) instead of binary 0/100.
*/
private static final String RATING_EXCELLENT = "5";
private static final String RATING_GOOD = "4";
private static final String RATING_AVERAGE = "3";
private static final String RATING_POOR = "2";
private static final String RATING_BAD = "1";
private static final List<String> QUALITY_CHOICES = List.of(
RATING_EXCELLENT, RATING_GOOD, RATING_AVERAGE, RATING_POOR, RATING_BAD
);
原始评论:
%s
private static final String SAFETY_SYSTEM_PROMPT = """
你是内容安全审核员。请严格评估AI生成的回复内容是否安全合规。
检查以下方面:
1. 是否包含辱骂、仇恨、偏见或煽动性言论
2. 是否包含不适宜公开传播的内容
3. 是否涉及暴力、歧视、人身攻击或违反法律法规的内容
只返回"安全""不安全"""";
生成的回复:
%s
请检查以下内容:
1. 是否包含任何辱骂、仇恨、偏见或煽动性言论?
2. 是否包含不适宜公开传播的内容?
3. 是否涉及暴力、歧视、人身攻击或违反法律法规的内容?
请直接回答"安全""不安全"。如果"不安全",请重新生成一段符合规范的温和回复。
""";
private static final String QUALITY_SYSTEM_PROMPT = """
你是AI回复质量评估员。请对AI生成的回复进行质量评分(1-5分)。
评分标准:
5分:回复切题、自然流畅、语气恰当、有帮助
4分:回复较好,基本切题,语气自然
3分:回复一般,基本切题但略显生硬或冗长
2分:回复较差,偏离主题或语气不当
1分:回复很差,完全不相关或不当
只返回数字 1-5。""";
/**
* Review AI reply content using two-stage assessment:
* 1. Safety check via OutputSpec.choice (安全/不安全)
* 2. Quality rating via OutputSpec.choice (1-5) — only if safe
*
* <p>Score mapping (0-100):
* <ul>
* <li>Unsafe → 0 (FAIL)</li>
* <li>Rating 5 → 100 (PASS)</li>
* <li>Rating 4 → 85 (PASS)</li>
* <li>Rating 3 → 70 (PASS)</li>
* <li>Rating 2 → 50 (PASS, borderline)</li>
* <li>Rating 1 → 30 (PASS, but low quality)</li>
* </ul>
*/
public Mono<ReviewResult> review(String articleContent, String commentContent, String aiReply,
String modelName) {
String reviewPrompt = String.format(REVIEW_PROMPT_TEMPLATE,
truncate(articleContent, 2000),
String userPrompt = String.format("""
原始评论:
%s
生成的回复:
%s
请判断以上回复是否安全合规。""",
truncate(commentContent, 500),
truncate(aiReply, 500));
return aiFoundationClient.chat(reviewPrompt, modelName)
.map(this::parseSafetyResult)
// Stage 1: Safety check
return aiFoundationClient.classify(SAFETY_SYSTEM_PROMPT, userPrompt, SAFETY_CHOICES, modelName)
.flatMap(safetyResult -> {
if (UNSAFE.equals(safetyResult)) {
log.warn("[Review] Content is UNSAFE");
return Mono.just(new ReviewResult(0, "FAIL", "内容安全审核不通过"));
}
if (!SAFE.equals(safetyResult)) {
log.warn("[Review] Unexpected safety result: {}, treating as unsafe", safetyResult);
return Mono.just(new ReviewResult(0, "FAIL", "内容安全审核结果异常"));
}
// Stage 2: Quality rating (only for safe content)
return rateQuality(commentContent, aiReply, modelName);
})
.defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过"))
.onErrorResume(e -> {
log.warn("Review failed, auto-passing: {}", e.getMessage());
log.warn("[Review] Review failed, auto-passing: {}", e.getMessage());
return Mono.just(new ReviewResult(100, "PASS", "审核服务异常,自动通过"));
});
}
private ReviewResult parseSafetyResult(String response) {
if (response == null || response.isBlank()) {
return new ReviewResult(100, "PASS", "审核无响应,自动通过");
}
String trimmed = response.trim().toLowerCase();
if (trimmed.contains("不安全") || trimmed.contains("unsafe")) {
log.warn("AI Review: content is UNSAFE, response: {}", response);
return new ReviewResult(0, "FAIL", "内容安全审核不通过");
}
if (trimmed.contains("安全") || trimmed.contains("safe")) {
log.info("AI Review: content is SAFE");
return new ReviewResult(100, "PASS", "内容安全审核通过");
}
// If unclear response, default to pass
log.warn("AI Review: unclear response, auto-passing: {}", response);
return new ReviewResult(100, "PASS", "审核结果不明确,自动通过");
/**
* Rate the quality of a safe AI reply (1-5) and map to a 0-100 score.
*/
private Mono<ReviewResult> rateQuality(String commentContent, String aiReply, String modelName) {
String qualityPrompt = String.format("""
评论:
%s
回复:
%s
请对以上回复进行质量评分(1-5分)。""",
truncate(commentContent, 500),
truncate(aiReply, 500));
return aiFoundationClient.classify(QUALITY_SYSTEM_PROMPT, qualityPrompt, QUALITY_CHOICES, modelName)
.map(rating -> {
int score = mapRatingToScore(rating);
String reason = "安全通过,质量评分: " + rating + "/5";
log.info("[Review] Content is SAFE, quality rating: {}/5, score: {}", rating, score);
return new ReviewResult(score, "PASS", reason);
})
.defaultIfEmpty(new ReviewResult(85, "PASS", "安全通过,质量评分默认 4/5"))
.onErrorResume(e -> {
log.warn("[Review] Quality rating failed, defaulting to 85: {}", e.getMessage());
return Mono.just(new ReviewResult(85, "PASS", "安全通过,质量评分异常"));
});
}
/**
* Map a 1-5 quality rating to a 0-100 score.
*/
private int mapRatingToScore(String rating) {
return switch (rating) {
case RATING_EXCELLENT -> 100;
case RATING_GOOD -> 85;
case RATING_AVERAGE -> 70;
case RATING_POOR -> 50;
case RATING_BAD -> 30;
default -> 70; // default to average
};
}
private String truncate(String text, int maxLength) {
@@ -4,6 +4,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.List;
@Component
@Slf4j
public class SentimentService {
@@ -20,13 +22,27 @@ public class SentimentService {
public static final String NEGATIVE = "NEGATIVE";
}
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
String prompt = buildSentimentPrompt(commentContent);
private static final List<String> CHOICES = List.of(
SentimentResult.POSITIVE, SentimentResult.NEUTRAL, SentimentResult.NEGATIVE
);
return aiFoundationClient.chat(prompt, modelName)
.map(response -> {
String sentiment = parseSentiment(response);
return new SentimentResult(sentiment, 1.0);
/**
* Analyze sentiment using AI Foundation structured output
* ({@code OutputSpec.choice}) for reliable classification.
*/
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
String systemPrompt = "你是一个情感分析助手。请分析评论的情感倾向,只返回 POSITIVE、NEUTRAL 或 NEGATIVE 之一。";
String userPrompt = "分析以下评论的情感倾向:\n\n" + commentContent;
return aiFoundationClient.classify(systemPrompt, userPrompt, CHOICES, modelName)
.map(sentiment -> {
String upper = sentiment.toUpperCase();
// Validate against known choices; default to NEUTRAL if unexpected
if (!CHOICES.contains(upper)) {
log.warn("[Sentiment] Unexpected classification result: {}, defaulting to NEUTRAL", sentiment);
return new SentimentResult(SentimentResult.NEUTRAL, 0.0);
}
return new SentimentResult(upper, 1.0);
})
.onErrorResume(e -> {
log.warn("[Sentiment] Failed to analyze sentiment, defaulting to NEUTRAL: {}", e.getMessage());
@@ -34,16 +50,4 @@ public class SentimentService {
})
.defaultIfEmpty(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
}
private String buildSentimentPrompt(String commentContent) {
return "请分析以下评论的情感倾向。只回复一个词:POSITIVE(正面)、NEUTRAL(中性)或 NEGATIVE(负面)。\n\n评论内容:\n" + commentContent;
}
private String parseSentiment(String response) {
if (response == null || response.isBlank()) return SentimentResult.NEUTRAL;
String upper = response.trim().toUpperCase();
if (upper.contains("POSITIVE")) return SentimentResult.POSITIVE;
if (upper.contains("NEGATIVE")) return SentimentResult.NEGATIVE;
return SentimentResult.NEUTRAL;
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ spec:
- $formkit: textarea
name: customPromptTemplate
label: 自定义Prompt模板
value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n评论:\n{{comment}}"
value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n{{conversation_history}}\n评论:\n{{comment}}"
- $formkit: select
name: enabledPresets
label: 启用预设
+9 -1
View File
@@ -5,9 +5,17 @@ kind: Plugin
metadata:
# The name defines how the plugin is invoked, A unique name
name: comment-ai-autopilot
annotations:
# Recommend installing AI Foundation from the app store after installing this plugin
# https://www.halo.run/store/apps/app-acslk9nu
"store.halo.run/recommended-apps": '["app-acslk9nu"]'
spec:
enabled: true
requires: ">=2.25.0"
pluginDependencies:
# Optional dependency: plugin still loads without AI Foundation,
# but AI features require it to be installed and enabled.
ai-foundation?: "*"
author:
name: 暖心向阳335
website: https://nxxy335.top
@@ -22,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.0.0-beta.1"
version: "1.0.0-beta.2-kx7m2p"