diff --git a/ali-agentic-adk-java/ali-agentic-adk-core/pom.xml b/ali-agentic-adk-java/ali-agentic-adk-core/pom.xml index 2e0147b0..44acdfb4 100644 --- a/ali-agentic-adk-java/ali-agentic-adk-core/pom.xml +++ b/ali-agentic-adk-java/ali-agentic-adk-core/pom.xml @@ -64,6 +64,26 @@ + + + org.springframework + spring-context + ${springframework.version} + provided + + + + org.springframework + spring-aop + ${springframework.version} + provided + + + org.aspectj + aspectjweaver + 1.9.22.1 + provided + com.google.adk google-adk @@ -237,4 +257,4 @@ - \ No newline at end of file + diff --git a/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationAction.java b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationAction.java new file mode 100644 index 00000000..f9f7ced3 --- /dev/null +++ b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationAction.java @@ -0,0 +1,10 @@ +package com.alibaba.agentic.core.moderation; + +/** + * Moderation action to take when content hits a rule. + */ +public enum ModerationAction { + ALLOW, + MASK, + BLOCK +} diff --git a/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationDecision.java b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationDecision.java new file mode 100644 index 00000000..e490931e --- /dev/null +++ b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationDecision.java @@ -0,0 +1,31 @@ +package com.alibaba.agentic.core.moderation; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +import java.util.Collections; +import java.util.List; + +@Data +@Accessors(chain = true) +@AllArgsConstructor +@NoArgsConstructor +public class ModerationDecision { + private ModerationAction action; // ALLOW | MASK | BLOCK + private List categories; // matched categories/keywords + private String reason; // human-readable reason + + public static ModerationDecision allow() { + return new ModerationDecision(ModerationAction.ALLOW, Collections.emptyList(), null); + } + + public static ModerationDecision mask(List categories, String reason) { + return new ModerationDecision(ModerationAction.MASK, categories, reason); + } + + public static ModerationDecision block(List categories, String reason) { + return new ModerationDecision(ModerationAction.BLOCK, categories, reason); + } +} diff --git a/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationProperties.java b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationProperties.java new file mode 100644 index 00000000..6e0f29f1 --- /dev/null +++ b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationProperties.java @@ -0,0 +1,47 @@ +package com.alibaba.agentic.core.moderation; + +import lombok.Data; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +public class ModerationProperties { + /** enable moderation globally */ + private boolean enabled = false; + /** observe-only mode: log hits but don't block */ + private boolean observeOnly = true; + /** default action when rules hit (if provider doesn't give one) */ + private ModerationAction defaultAction = ModerationAction.MASK; + /** optional external blocklist path (file system) */ + private String blocklistPath; + /** timeout for provider checks (ms) - for future async providers */ + private int timeoutMillis = 500; // not used in rule-based + + public static ModerationProperties fromEnv() { + ModerationProperties p = new ModerationProperties(); + String enabled = getenv("ADK_MODERATION_ENABLED"); + String observe = getenv("ADK_MODERATION_OBSERVE_ONLY"); + String action = getenv("ADK_MODERATION_DEFAULT_ACTION"); + String blocklist = getenv("ADK_MODERATION_BLOCKLIST_PATH"); + String timeout = getenv("ADK_MODERATION_TIMEOUT_MS"); + + if (enabled != null) p.setEnabled("true".equalsIgnoreCase(enabled)); + if (observe != null) p.setObserveOnly("true".equalsIgnoreCase(observe)); + if (action != null) { + try { + p.setDefaultAction(ModerationAction.valueOf(action.trim().toUpperCase())); + } catch (Exception ignored) {} + } + if (blocklist != null && !blocklist.isBlank()) p.setBlocklistPath(blocklist); + if (timeout != null) { + try { p.setTimeoutMillis(Integer.parseInt(timeout)); } catch (Exception ignored) {} + } + return p; + } + + private static String getenv(String key) { + String v = System.getProperty(key); + if (v == null) v = System.getenv(key); + return v; + } +} diff --git a/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationProvider.java b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationProvider.java new file mode 100644 index 00000000..1742c8e6 --- /dev/null +++ b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationProvider.java @@ -0,0 +1,24 @@ +package com.alibaba.agentic.core.moderation; + +import com.alibaba.agentic.core.engine.delegation.domain.LlmRequest; + +/** + * Provider abstraction for content moderation. + */ +public interface ModerationProvider { + + /** + * Check the incoming prompt (messages) before sending to the model. + */ + ModerationDecision checkPrompt(LlmRequest request); + + /** + * Check a single output text chunk from the model (streamed). + */ + ModerationDecision checkChunk(String text); + + /** + * Check the final aggregated output text. + */ + ModerationDecision checkFinal(String text); +} diff --git a/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationService.java b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationService.java new file mode 100644 index 00000000..35e05329 --- /dev/null +++ b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/ModerationService.java @@ -0,0 +1,58 @@ +package com.alibaba.agentic.core.moderation; + +import com.alibaba.agentic.core.engine.delegation.domain.LlmRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class ModerationService { + + private final ModerationProvider provider; + private final RuleBasedModerationProvider defaultProvider; + + private final ModerationProperties properties; + + public ModerationService() { + // env-based defaults when DI is not yet in effect + this.properties = ModerationProperties.fromEnv(); + this.defaultProvider = new RuleBasedModerationProvider(this.properties); + this.provider = this.defaultProvider; + } + + @Autowired(required = false) + public ModerationService(ModerationProvider provider, + RuleBasedModerationProvider defaultProvider) { + this.properties = ModerationProperties.fromEnv(); + this.provider = provider != null ? provider : defaultProvider; + this.defaultProvider = defaultProvider; + } + + public boolean isEnabled() { + return properties.isEnabled(); + } + + public boolean isObserveOnly() { + return properties.isObserveOnly(); + } + + public ModerationDecision checkPrompt(LlmRequest req) { + return provider.checkPrompt(req); + } + + public ModerationDecision checkChunk(String text) { + return provider.checkChunk(text); + } + + public ModerationDecision checkFinal(String text) { + return provider.checkFinal(text); + } + + public String mask(String text) { + if (provider instanceof RuleBasedModerationProvider) { + return ((RuleBasedModerationProvider) provider).mask(text); + } + return text; + } +} diff --git a/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/RuleBasedModerationProvider.java b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/RuleBasedModerationProvider.java new file mode 100644 index 00000000..53fe8ba1 --- /dev/null +++ b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/RuleBasedModerationProvider.java @@ -0,0 +1,131 @@ +package com.alibaba.agentic.core.moderation; + +import com.alibaba.agentic.core.engine.delegation.domain.LlmRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +@Component +@Slf4j +public class RuleBasedModerationProvider implements ModerationProvider { + + private final ModerationProperties properties; + private Set blockWords = new HashSet<>(); + + public RuleBasedModerationProvider() { + // fallback to env-based when not wired via DI + this.properties = ModerationProperties.fromEnv(); + } + + public RuleBasedModerationProvider(ModerationProperties properties) { + this.properties = properties; + } + + @PostConstruct + public void init() { + loadBlocklist(); + } + + private void loadBlocklist() { + List lines = new ArrayList<>(); + // 1) optional external file + if (properties.getBlocklistPath() != null) { + File f = new File(properties.getBlocklistPath()); + if (f.exists() && f.isFile()) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(f), StandardCharsets.UTF_8))) { + lines.addAll(br.lines().collect(Collectors.toList())); + } catch (Exception e) { + log.warn("Failed to load external blocklist: {}", f.getAbsolutePath(), e); + } + } + } + // 2) classpath default + try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream("moderation/blocklist.txt")) { + if (is != null) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + lines.addAll(br.lines().collect(Collectors.toList())); + } + } + } catch (Exception e) { + log.debug("No default blocklist found in classpath."); + } + // normalize + blockWords = lines.stream() + .map(String::trim) + .filter(s -> !s.isBlank() && !s.startsWith("#")) + .collect(Collectors.toSet()); + log.info("RuleBasedModerationProvider loaded block words: {}", blockWords.size()); + } + + @Override + public ModerationDecision checkPrompt(LlmRequest request) { + if (request == null || request.getMessages() == null) { + return ModerationDecision.allow(); + } + String text = request.getMessages().stream() + .filter(Objects::nonNull) + .map(LlmRequest.Message::getContent) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + return decide(text); + } + + @Override + public ModerationDecision checkChunk(String text) { + return decide(text); + } + + @Override + public ModerationDecision checkFinal(String text) { + return decide(text); + } + + private ModerationDecision decide(String text) { + if (text == null || text.isBlank()) return ModerationDecision.allow(); + List hits = new ArrayList<>(); + String lower = text.toLowerCase(Locale.ROOT); + for (String w : blockWords) { + if (w.isEmpty()) continue; + if (lower.contains(w.toLowerCase(Locale.ROOT))) { + hits.add(w); + } + } + if (hits.isEmpty()) return ModerationDecision.allow(); + + // default behavior: use configured defaultAction + ModerationAction action = properties.getDefaultAction() == null ? ModerationAction.MASK : properties.getDefaultAction(); + String reason = "Matched blocked words: " + String.join(", ", hits); + if (action == ModerationAction.BLOCK) return ModerationDecision.block(hits, reason); + if (action == ModerationAction.MASK) return ModerationDecision.mask(hits, reason); + return ModerationDecision.allow(); + } + + public String mask(String text) { + if (text == null || text.isBlank()) return text; + String result = text; + for (String w : blockWords) { + if (w.isEmpty()) continue; + String pattern = java.util.regex.Pattern.quote(w); + result = result.replaceAll("(?i)" + pattern, repeat('*', Math.min(w.length(), 4))); + } + return result; + } + + private static String repeat(char c, int count) { + char[] arr = new char[count]; + Arrays.fill(arr, c); + return new String(arr); + } + + public ModerationProperties properties() { + return properties; + } +} diff --git a/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/aspect/LlmModerationAspect.java b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/aspect/LlmModerationAspect.java new file mode 100644 index 00000000..4d288314 --- /dev/null +++ b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/aspect/LlmModerationAspect.java @@ -0,0 +1,126 @@ +package com.alibaba.agentic.core.moderation.aspect; + +import com.alibaba.agentic.core.engine.delegation.domain.LlmRequest; +import com.alibaba.agentic.core.engine.delegation.domain.LlmResponse; +import com.alibaba.agentic.core.moderation.ModerationAction; +import com.alibaba.agentic.core.moderation.ModerationDecision; +import com.alibaba.agentic.core.moderation.ModerationService; +import io.reactivex.rxjava3.core.Flowable; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Aspect +@Component +@Order(Integer.MIN_VALUE) // run as early as possible +@Slf4j +public class LlmModerationAspect { + + @Autowired + private ModerationService moderationService; + + @Pointcut("execution(io.reactivex.rxjava3.core.Flowable com.alibaba.agentic.core.models.BasicLlm.invoke(..))") + public void llmInvoke() {} + + @Around("llmInvoke()") + public Object aroundLlmInvoke(ProceedingJoinPoint pjp) throws Throwable { + if (moderationService == null || !moderationService.isEnabled()) { + return pjp.proceed(); + } + + Object[] args = pjp.getArgs(); + if (args == null || args.length < 1 || !(args[0] instanceof LlmRequest)) { + return pjp.proceed(); + } + + LlmRequest req = (LlmRequest) args[0]; + + // 1) pre-check prompt (input) + ModerationDecision pre = moderationService.checkPrompt(req); + if (pre != null && pre.getAction() == ModerationAction.BLOCK && !moderationService.isObserveOnly()) { + log.info("Moderation blocked input: {}", pre.getReason()); + return Flowable.just(blockedResponse(pre.getReason())); + } + if (pre != null && pre.getAction() == ModerationAction.MASK && !moderationService.isObserveOnly()) { + // mask user messages before calling model + if (req.getMessages() != null) { + List masked = req.getMessages().stream().filter(Objects::nonNull).map(m -> { + String content = m.getContent(); + String newContent = moderationService.mask(content); + return new LlmRequest.Message(m.getRole(), newContent); + }).collect(Collectors.toList()); + args[0] = new LlmRequest() + .setModel(req.getModel()) + .setModelName(req.getModelName()) + .setMaxTokens(req.getMaxTokens()) + .setTemperature(req.getTemperature()) + .setTopP(req.getTopP()) + .setStop(req.getStop()) + .setStream(req.getStream()) + .setUser(req.getUser()) + .setMessages(masked) + .setExtraParams(req.getExtraParams()); + } + } + + // proceed + Object result = pjp.proceed(args); + if (!(result instanceof Flowable)) return result; + + Flowable flow = (Flowable) result; + // 2) post-check chunks and final + return flow.map(item -> { + if (!(item instanceof LlmResponse)) return item; + LlmResponse resp = (LlmResponse) item; + // collect text fields + String outputText = extractText(resp); + ModerationDecision post = moderationService.checkChunk(outputText); + if (post != null && post.getAction() == ModerationAction.BLOCK && !moderationService.isObserveOnly()) { + log.info("Moderation blocked output chunk: {}", post.getReason()); + return blockedResponse(post.getReason()); + } + if (post != null && post.getAction() == ModerationAction.MASK && !moderationService.isObserveOnly()) { + return maskResponse(resp); + } + return resp; + }); + } + + private static String extractText(LlmResponse resp) { + if (resp.getChoices() == null) return ""; + return resp.getChoices().stream().map(c -> { + if (c.getText() != null) return c.getText(); + if (c.getMessage() != null) return c.getMessage().getContent(); + return ""; + }).filter(Objects::nonNull).collect(Collectors.joining("\n")); + } + + private LlmResponse maskResponse(LlmResponse resp) { + if (resp.getChoices() != null) { + for (LlmResponse.Choice c : resp.getChoices()) { + if (c.getText() != null) c.setText(moderationService.mask(c.getText())); + if (c.getMessage() != null && c.getMessage().getContent() != null) { + c.getMessage().setContent(moderationService.mask(c.getMessage().getContent())); + } + } + } + return resp; + } + + private static LlmResponse blockedResponse(String reason) { + LlmResponse.ErrorInfo error = new LlmResponse.ErrorInfo(); + error.setCode("MODERATION_BLOCKED"); + error.setType("content_safety"); + error.setMessage(reason != null ? reason : "The content was blocked by moderation policy."); + return new LlmResponse().setError(error); + } +} diff --git a/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/pipe/ContentModerationPipe.java b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/pipe/ContentModerationPipe.java new file mode 100644 index 00000000..5785b47d --- /dev/null +++ b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/java/com/alibaba/agentic/core/moderation/pipe/ContentModerationPipe.java @@ -0,0 +1,40 @@ +package com.alibaba.agentic.core.moderation.pipe; + +import com.alibaba.agentic.core.executor.Result; +import com.alibaba.agentic.core.moderation.ModerationService; +import com.alibaba.agentic.core.runner.pipe.PipeInterface; +import com.alibaba.agentic.core.runner.pipeline.PipelineRequest; +import io.reactivex.rxjava3.core.Flowable; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * A no-op moderation pipe placeholder that can be extended later. + * Currently, it only logs when moderation is enabled and does not alter pipeline execution. + */ +@Component +@Order(Integer.MIN_VALUE) +@Slf4j +public class ContentModerationPipe implements PipeInterface { + + @Autowired + private ModerationService moderationService; + + @Override + public Flowable doPipe(PipelineRequest pipelineRequest) { + // Intentionally no-op to avoid interfering with flow execution order. + // The real moderation is implemented in LlmModerationAspect. + if (moderationService != null && moderationService.isEnabled()) { + log.debug("ContentModerationPipe active in observe-only:{}", moderationService.isObserveOnly()); + } + return Flowable.empty(); + } + + @Override + public boolean ignore(PipelineRequest pipelineRequest) { + // run early but produce no output; do not block downstream pipes + return false; + } +} diff --git a/ali-agentic-adk-java/ali-agentic-adk-core/src/main/resources/moderation/blocklist.txt b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/resources/moderation/blocklist.txt new file mode 100644 index 00000000..152e5263 --- /dev/null +++ b/ali-agentic-adk-java/ali-agentic-adk-core/src/main/resources/moderation/blocklist.txt @@ -0,0 +1,15 @@ +# Default simple blocklist (demo). Lines starting with # are comments. +# Keep this conservative; production lists should be externally managed. +password +ssn +credit card +身份证 +银行卡 +邮箱 +phone number +暴力 +涉黄 +涉恐 +恐怖 +仇恨 +