feat(wiki): LLM pipeline step executor via model routing

This commit is contained in:
matevip 2026-05-31 07:56:45 +08:00
parent 85f5df394b
commit 15a8b2d73c
2 changed files with 131 additions and 0 deletions

View File

@ -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;
}
}
}

View File

@ -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<String, Object> 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()));
}
}