diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/pipeline/WikiLlmStepExecutor.java b/mateclaw-server/src/main/java/vip/mate/wiki/pipeline/WikiLlmStepExecutor.java new file mode 100644 index 00000000..6e6d271e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/pipeline/WikiLlmStepExecutor.java @@ -0,0 +1,75 @@ +package vip.mate.wiki.pipeline; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.stereotype.Component; +import vip.mate.wiki.job.WikiModelRoutingService; + +import java.util.List; + +/** + * Pipeline step executor that calls a chat model. The step config supplies a + * {@code prompt} (system instruction) and an optional {@code model_id}; the + * previous step's output is passed as the user message so steps compose. The + * model is resolved through the existing wiki model routing. + * + * @author MateClaw Team + */ +@Slf4j +@Component +public class WikiLlmStepExecutor implements WikiStepExecutor { + + private final WikiModelRoutingService modelRoutingService; + + public WikiLlmStepExecutor(WikiModelRoutingService modelRoutingService) { + this.modelRoutingService = modelRoutingService; + } + + @Override + public String type() { + return "llm"; + } + + @Override + public String execute(WikiStepContext context) { + String prompt = stringConfig(context, "prompt"); + if (prompt == null || prompt.isBlank()) { + throw new IllegalArgumentException("llm step '" + context.stepId() + "' has no prompt"); + } + Long modelId = longConfig(context, "model_id"); + ChatModel chatModel = modelRoutingService.buildChatModel(modelId); + if (chatModel == null) { + throw new IllegalStateException("No chat model available for pipeline step " + context.stepId()); + } + String userContent = context.previousOutput() == null || context.previousOutput().isBlank() + ? "(no prior output)" : context.previousOutput(); + ChatResponse resp = chatModel.call(new Prompt(List.of( + new SystemMessage(prompt), new UserMessage(userContent)))); + if (resp == null || resp.getResult() == null || resp.getResult().getOutput() == null + || resp.getResult().getOutput().getText() == null) { + throw new IllegalStateException("Chat model returned no text for step " + context.stepId()); + } + return resp.getResult().getOutput().getText(); + } + + private String stringConfig(WikiStepContext context, String key) { + Object v = context.stepConfig() == null ? null : context.stepConfig().get(key); + return v == null ? null : String.valueOf(v); + } + + private Long longConfig(WikiStepContext context, String key) { + Object v = context.stepConfig() == null ? null : context.stepConfig().get(key); + if (v == null) { + return null; + } + try { + return Long.parseLong(String.valueOf(v)); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/pipeline/WikiLlmStepExecutorTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/pipeline/WikiLlmStepExecutorTest.java new file mode 100644 index 00000000..1c09da30 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/pipeline/WikiLlmStepExecutorTest.java @@ -0,0 +1,56 @@ +package vip.mate.wiki.pipeline; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import vip.mate.wiki.job.WikiModelRoutingService; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link WikiLlmStepExecutor}: it builds a prompt from the step + * config and previous output, calls the routed model, and returns its text. + * Model routing is mocked to return a fixed-response ChatModel. + */ +class WikiLlmStepExecutorTest { + + private WikiLlmStepExecutor executor(String modelReply) { + WikiModelRoutingService routing = mock(WikiModelRoutingService.class); + ChatModel chat = prompt -> new ChatResponse(List.of(new Generation(new AssistantMessage(modelReply)))); + when(routing.buildChatModel(any())).thenReturn(chat); + return new WikiLlmStepExecutor(routing); + } + + private WikiStepContext ctx(Map config, String previousOutput) { + return new WikiStepContext(1L, 42L, "s1", config, previousOutput); + } + + @Test + void callsModelAndReturnsText() throws Exception { + WikiLlmStepExecutor e = executor("pattern summary"); + String out = e.execute(ctx(Map.of("prompt", "Summarize the episodes"), "ep1, ep2")); + assertEquals("pattern summary", out); + } + + @Test + void missingPrompt_throws() { + WikiLlmStepExecutor e = executor("x"); + assertThrows(IllegalArgumentException.class, () -> e.execute(ctx(Map.of(), "prior"))); + } + + @Test + void typeIsLlm() { + assertTrue("llm".equals(executor("x").type())); + } +}