ProfileSearchController.java
package de.mirkosertic.powerstaff.profilesearch.api;
import de.mirkosertic.powerstaff.profilesearch.command.LlmService;
import de.mirkosertic.powerstaff.profilesearch.command.ProfileSearchCommandService;
import de.mirkosertic.powerstaff.profilesearch.command.ProfileSearchProperties;
import de.mirkosertic.powerstaff.profilesearch.query.ChatListView;
import de.mirkosertic.powerstaff.profilesearch.query.McpSearchException;
import de.mirkosertic.powerstaff.profilesearch.query.MessageView;
import de.mirkosertic.powerstaff.profilesearch.query.ProfileSearchCriteria;
import de.mirkosertic.powerstaff.profilesearch.query.ProfileSearchPage;
import de.mirkosertic.powerstaff.profilesearch.query.ProfileSearchQueryService;
import de.mirkosertic.powerstaff.project.command.RememberedProjectInfo;
import de.mirkosertic.powerstaff.project.command.RememberedProjectService;
import de.mirkosertic.powerstaff.shared.query.TagQueryService;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.web.util.UriComponentsBuilder;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.security.Principal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
@Controller
@RequestMapping("/profilesearch")
public class ProfileSearchController {
private static final int PAGE_SIZE = 20;
private final ProfileSearchCommandService commandService;
private final ProfileSearchQueryService queryService;
private final LlmService llmService;
private final RememberedProjectService rememberedProjectService;
private final ProfileSearchProperties profileSearchProperties;
private final TagQueryService tagQueryService;
private final ObjectMapper objectMapper;
public ProfileSearchController(final ProfileSearchCommandService commandService,
final ProfileSearchQueryService queryService,
final LlmService llmService,
final RememberedProjectService rememberedProjectService,
final ProfileSearchProperties profileSearchProperties,
final TagQueryService tagQueryService,
final ObjectMapper objectMapper) {
this.commandService = commandService;
this.queryService = queryService;
this.llmService = llmService;
this.rememberedProjectService = rememberedProjectService;
this.profileSearchProperties = profileSearchProperties;
this.tagQueryService = tagQueryService;
this.objectMapper = objectMapper;
}
@GetMapping
public String index() {
return "redirect:/profilesearch/chat";
}
@GetMapping("/chat")
public void chatIndex(final Principal principal, final HttpServletResponse response) throws IOException {
final String userId = principal.getName();
final var latestChatId = queryService.findLatestChatByUser(userId);
if (latestChatId.isPresent()) {
response.sendRedirect("/profilesearch/chat/" + latestChatId.get());
} else {
final Long projectId = rememberedProjectService.get(userId).orElse(null);
final Long chatId = commandService.createChat(userId, projectId);
response.sendRedirect("/profilesearch/chat/" + chatId);
}
}
@GetMapping("/search")
public String search(@RequestParam(required = false) final String searchTerm,
@RequestParam(required = false) final Long salaryPerDayFrom,
@RequestParam(required = false) final Long salaryPerDayTo,
@RequestParam(required = false) final String tagIds,
@RequestParam(required = false) final String sortField,
@RequestParam(required = false) final String sortDir,
@RequestParam(required = false) final Boolean semanticSearch,
@RequestParam(defaultValue = "0.8") final float similarityThreshold,
@RequestParam(defaultValue = "0") final int offset,
final Principal principal,
final Model model,
final HttpServletResponse response) {
final ProfileSearchCriteria criteria = new ProfileSearchCriteria(searchTerm, salaryPerDayFrom, salaryPerDayTo, tagIds, sortField, sortDir, semanticSearch, similarityThreshold);
final boolean empty = (criteria.searchTerm() == null || criteria.searchTerm().isBlank())
&& criteria.salaryPerDayFrom() == null
&& criteria.salaryPerDayTo() == null
&& (criteria.tagIds() == null || criteria.tagIds().isBlank());
if (empty) {
model.addAttribute("validationError", "Bitte mindestens ein Suchkriterium angeben.");
model.addAttribute("results", List.of());
model.addAttribute("totalCount", 0L);
model.addAttribute("criteria", criteria);
model.addAttribute("sortField", criteria.sortField());
model.addAttribute("sortDir", criteria.sortDir());
model.addAttribute("nextUrl", null);
model.addAttribute("allTags", tagQueryService.findAll());
model.addAttribute("rememberedProject", buildRememberedProjectInfo(principal));
return "profilesearch/search-page";
}
final String returnTo = buildSearchReturnUrl(criteria);
if (offset > 0) {
final ProfileSearchPage page = queryService.searchFreelancers(criteria, offset, PAGE_SIZE);
final var results = page.results();
final long total = page.totalHits();
final int nextOffset = offset + PAGE_SIZE;
if (nextOffset < total) {
response.setHeader("X-Next-Url", buildSearchMoreUrl(criteria, nextOffset));
}
model.addAttribute("results", results);
model.addAttribute("returnTo", returnTo);
return "profilesearch/search-results :: results";
}
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
final ProfileSearchPage page = queryService.searchFreelancers(criteria, 0, PAGE_SIZE);
final var results = page.results();
final long total = page.totalHits();
final String nextUrl = PAGE_SIZE < total ? buildSearchMoreUrl(criteria, PAGE_SIZE) : null;
model.addAttribute("results", results);
model.addAttribute("totalCount", total);
model.addAttribute("criteria", criteria);
model.addAttribute("sortField", criteria.sortField());
model.addAttribute("sortDir", criteria.sortDir());
model.addAttribute("nextUrl", nextUrl);
model.addAttribute("allTags", tagQueryService.findAll());
model.addAttribute("rememberedProject", buildRememberedProjectInfo(principal));
model.addAttribute("returnTo", returnTo);
return "profilesearch/search-page";
}
private String buildSearchReturnUrl(final ProfileSearchCriteria c) {
final var b = UriComponentsBuilder.fromPath("/profilesearch/search");
if (c.searchTerm() != null && !c.searchTerm().isBlank()) b.queryParam("searchTerm", c.searchTerm());
if (c.salaryPerDayFrom() != null) b.queryParam("salaryPerDayFrom", c.salaryPerDayFrom());
if (c.salaryPerDayTo() != null) b.queryParam("salaryPerDayTo", c.salaryPerDayTo());
if (c.tagIds() != null && !c.tagIds().isBlank()) b.queryParam("tagIds", c.tagIds());
if (c.sortField() != null) b.queryParam("sortField", c.sortField());
if (c.sortDir() != null) b.queryParam("sortDir", c.sortDir());
return b.encode().build().toUriString();
}
private String buildSearchMoreUrl(final ProfileSearchCriteria c, final int offset) {
final var b = UriComponentsBuilder.fromPath("/profilesearch/search").queryParam("offset", offset);
if (c.searchTerm() != null && !c.searchTerm().isBlank()) {
b.queryParam("searchTerm", c.searchTerm());
}
if (c.salaryPerDayFrom() != null) {
b.queryParam("salaryPerDayFrom", c.salaryPerDayFrom());
}
if (c.salaryPerDayTo() != null) {
b.queryParam("salaryPerDayTo", c.salaryPerDayTo());
}
if (c.tagIds() != null && !c.tagIds().isBlank()) {
b.queryParam("tagIds", c.tagIds());
}
if (c.sortField() != null) {
b.queryParam("sortField", c.sortField());
}
if (c.sortDir() != null) {
b.queryParam("sortDir", c.sortDir());
}
if (Boolean.TRUE.equals(c.semanticSearch())) {
b.queryParam("semanticSearch", "true");
b.queryParam("similarityThreshold", c.effectiveSimilarityThreshold());
}
return b.encode().build().toUriString();
}
@GetMapping("/chat/{chatId}")
public String chat(@PathVariable final Long chatId,
@RequestParam(defaultValue = "0") final int offset,
final Principal principal,
final Model model,
final HttpServletResponse response) {
final String userId = principal.getName();
final List<ChatListView> sidebar = queryService.findChatsByUser(userId, offset, PAGE_SIZE);
final long totalChats = queryService.countChatsByUser(userId);
final List<MessageView> messages = queryService.findMessagesByChat(chatId);
// Set X-Next-Url header for infinite scroll if more data available
final int nextOffset = offset + PAGE_SIZE;
if (nextOffset < totalChats) {
response.setHeader("X-Next-Url", "/profilesearch/chat/" + chatId + "?offset=" + nextOffset);
}
model.addAttribute("chatId", chatId);
model.addAttribute("messages", messages);
model.addAttribute("sidebar", sidebar);
model.addAttribute("totalChats", totalChats);
model.addAttribute("pageSize", PAGE_SIZE);
model.addAttribute("rememberedProject", buildRememberedProjectInfo(principal));
// Return fragment for infinite scroll requests, full page otherwise
return offset == 0 ? "profilesearch/form" : "profilesearch/sidebar-entry";
}
@PostMapping("/chat/new")
public String newChat(final Principal principal) {
final String userId = principal.getName();
final Long projectId = rememberedProjectService.get(userId).orElse(null);
final Long chatId = commandService.createChat(userId, projectId);
return "redirect:/profilesearch/chat/" + chatId;
}
@DeleteMapping("/chat/{chatId}")
@ResponseBody
public Map<String, String> deleteChat(@PathVariable final Long chatId, final Principal principal) {
final String userId = principal.getName();
commandService.deleteChat(chatId);
// Navigate to most recently modified remaining chat, or create a new one
final var nextChatId = queryService.findLatestChatByUser(userId);
final String redirectTo;
if (nextChatId.isPresent()) {
redirectTo = "/profilesearch/chat/" + nextChatId.get();
} else {
final Long projectId = rememberedProjectService.get(userId).orElse(null);
final Long newId = commandService.createChat(userId, projectId);
redirectTo = "/profilesearch/chat/" + newId;
}
return Map.of("redirectTo", redirectTo);
}
record SendRequest(String message) {}
record SendResponse(Long id, String role, String content, String jsonPayload) {}
record SendResponseWrapper(List<SendResponse> messages, int promptTokens, int completionTokens, int maxContextTokens) {}
@PostMapping(value = "/chat/{chatId}/stream",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@ResponseBody
public SseEmitter streamMessage(@PathVariable final Long chatId,
@RequestBody final SendRequest request,
final Principal principal,
final HttpSession session) {
final SseEmitter emitter = new SseEmitter(180_000L);
final AtomicReference<Thread> threadRef = new AtomicReference<>();
final Runnable cancelStream = () -> {
final Thread t = threadRef.get();
if (t != null) {
t.interrupt();
}
};
emitter.onCompletion(cancelStream);
emitter.onTimeout(cancelStream);
emitter.onError(e -> cancelStream.run());
final Thread thread = Thread.ofVirtual().unstarted(() -> {
try {
final var context = queryService.buildLlmContext(principal.getName());
final int maxCtx = profileSearchProperties.getMaxContextTokens();
llmService.sendMessageStreaming(principal, session.getId(),
chatId.toString(), context, request.message(),
event -> {
try {
// Enrich MessageComplete with maxContextTokens (known only in controller)
final LlmService.ChatStreamEvent enriched = switch (event) {
case final LlmService.ChatStreamEvent.MessageComplete m ->
new LlmService.ChatStreamEvent.MessageComplete(
m.id(), m.promptTokens(), m.completionTokens(), maxCtx);
default -> event;
};
final String eventName = switch (enriched) {
case final LlmService.ChatStreamEvent.ThinkingToken t -> "thinking_token";
case final LlmService.ChatStreamEvent.ContentToken c -> "content_token";
case final LlmService.ChatStreamEvent.ToolCall tc -> "tool_call";
case final LlmService.ChatStreamEvent.ToolResult tr -> "tool_result";
case final LlmService.ChatStreamEvent.MessageComplete m -> "message_complete";
case final LlmService.ChatStreamEvent.StreamError e -> "error";
};
emitter.send(SseEmitter.event()
.name(eventName)
.data(objectMapper.writeValueAsString(enriched)));
} catch (final IOException ex) {
throw new UncheckedIOException(ex);
}
});
emitter.send(SseEmitter.event().name("done").data("{}"));
emitter.complete();
} catch (final UncheckedIOException ignored) {
// Client hat Verbindung getrennt (IOException beim emitter.send())
} catch (final RuntimeException ex) {
// blockLast() wirft RuntimeException(InterruptedException) bei Thread-Interrupt
if (ex.getCause() instanceof InterruptedException || Thread.currentThread().isInterrupted()) {
return;
}
try {
emitter.send(SseEmitter.event().name("error")
.data("{\"message\":\"" + ex.getMessage() + "\"}"));
} catch (final IOException ignored) {
// ignore
}
emitter.completeWithError(ex);
} catch (final IOException ex) {
emitter.completeWithError(ex);
}
});
threadRef.set(thread);
thread.start();
return emitter;
}
@PostMapping(value = "/chat/{chatId}/send", consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public SendResponseWrapper sendMessage(@PathVariable final Long chatId, @RequestBody final SendRequest request,
final Principal principal,
final HttpSession session) {
final String userId = principal.getName();
// Build LLM context and load full message history for LLM
final var context = queryService.buildLlmContext(userId);
// Call LLM
final List<LlmService.Reply> replies = llmService.sendMessage(principal, session.getId(), Long.toString(chatId), context, request.message());
final List<SendResponse> responses = replies.stream()
.map(r -> new SendResponse(r.id(), r.role(), r.message(), r.jsonPayload()))
.toList();
int promptTokens = 0;
int completionTokens = 0;
for (final LlmService.Reply reply : replies) {
if (reply.promptTokens() != null && reply.completionTokens() != null) {
promptTokens = reply.promptTokens();
completionTokens = reply.completionTokens();
}
}
return new SendResponseWrapper(responses, promptTokens, completionTokens, profileSearchProperties.getMaxContextTokens());
}
private RememberedProjectInfo buildRememberedProjectInfo(final Principal principal) {
if (principal == null) return null;
return rememberedProjectService.getRememberedProjectInfo(principal.getName()).orElse(null);
}
/**
* Exception-Handler für MCP-Suchfehler.
* <p>
* Zeigt dem Benutzer eine verständliche Fehlermeldung an, wenn die MCP-basierte
* Profilsuche nach allen Retry-Versuchen fehlgeschlagen ist.
*/
@ExceptionHandler(McpSearchException.class)
public String handleMcpSearchException(final McpSearchException ex,
@RequestParam(required = false) final String searchTerm,
@RequestParam(required = false) final Long salaryPerDayFrom,
@RequestParam(required = false) final Long salaryPerDayTo,
@RequestParam(required = false) final String tagIds,
@RequestParam(required = false) final String sortField,
@RequestParam(required = false) final String sortDir,
@RequestParam(required = false) final Boolean semanticSearch,
@RequestParam(required = false) final Float similarityThreshold,
final Principal principal,
final Model model) {
final ProfileSearchCriteria criteria = new ProfileSearchCriteria(
searchTerm, salaryPerDayFrom, salaryPerDayTo, tagIds, sortField, sortDir, semanticSearch, similarityThreshold);
model.addAttribute("error", ex.getMessage());
model.addAttribute("results", List.of());
model.addAttribute("totalCount", 0L);
model.addAttribute("criteria", criteria);
model.addAttribute("sortField", criteria.sortField());
model.addAttribute("sortDir", criteria.sortDir());
model.addAttribute("nextUrl", null);
model.addAttribute("allTags", tagQueryService.findAll());
model.addAttribute("rememberedProject", buildRememberedProjectInfo(principal));
return "profilesearch/search-page";
}
}