SearchApiController.java
package com.taxonomy.search.controller;
import com.taxonomy.dto.GraphSearchResult;
import com.taxonomy.dto.TaxonomyNodeDto;
import com.taxonomy.search.service.SearchFacade;
import com.taxonomy.versioning.service.RepositoryStateService;
import com.taxonomy.workspace.service.WorkspaceContext;
import com.taxonomy.workspace.service.WorkspaceResolver;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
@Tag(name = "Search")
public class SearchApiController {
private static final Logger log = LoggerFactory.getLogger(SearchApiController.class);
private final SearchFacade searchFacade;
private final MessageSource messageSource;
private final WorkspaceResolver workspaceResolver;
private final RepositoryStateService repositoryStateService;
public SearchApiController(SearchFacade searchFacade,
MessageSource messageSource,
WorkspaceResolver workspaceResolver,
RepositoryStateService repositoryStateService) {
this.searchFacade = searchFacade;
this.messageSource = messageSource;
this.workspaceResolver = workspaceResolver;
this.repositoryStateService = repositoryStateService;
}
@Operation(summary = "Full-text search", description = "Search taxonomy nodes using full-text Lucene search", tags = {"Search"})
@GetMapping("/search")
public ResponseEntity<List<TaxonomyNodeDto>> search(
@Parameter(description = "Search query") @RequestParam String q,
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "50") int maxResults) {
ResponseEntity<List<TaxonomyNodeDto>> guard = checkInitialized();
if (guard != null) return guard;
if (q == null || q.isBlank()) return ResponseEntity.badRequest().build();
return ResponseEntity.ok(searchFacade.fullTextSearch(q, maxResults));
}
@Operation(summary = "Semantic search", description = "Search taxonomy nodes using embedding similarity (KNN). Requires a ready local embedding index.", tags = {"Search"})
@GetMapping("/search/semantic")
public ResponseEntity<List<TaxonomyNodeDto>> semanticSearch(
@Parameter(description = "Natural-language query") @RequestParam String q,
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "20") int maxResults) {
ResponseEntity<List<TaxonomyNodeDto>> guard = checkInitialized();
if (guard != null) return guard;
if (q == null || q.isBlank()) return ResponseEntity.badRequest().build();
guard = checkSemanticReady();
if (guard != null) return guard;
return ResponseEntity.ok(searchFacade.semanticSearch(q, maxResults));
}
@Operation(summary = "Hybrid search", description = "Combines full-text Lucene and semantic KNN results via Reciprocal Rank Fusion. Falls back to full-text only when embedding is unavailable.", tags = {"Search"})
@GetMapping("/search/hybrid")
public ResponseEntity<List<TaxonomyNodeDto>> hybridSearch(
@Parameter(description = "Natural-language query") @RequestParam String q,
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "20") int maxResults) {
ResponseEntity<List<TaxonomyNodeDto>> guard = checkInitialized();
if (guard != null) return guard;
if (q == null || q.isBlank()) return ResponseEntity.badRequest().build();
return ResponseEntity.ok(searchFacade.hybridSearch(q, maxResults));
}
@Operation(summary = "Find similar nodes", description = "Find taxonomy nodes semantically similar to a given node", tags = {"Search"})
@GetMapping("/search/similar/{code}")
public ResponseEntity<List<TaxonomyNodeDto>> findSimilar(
@Parameter(description = "Taxonomy node code") @PathVariable String code,
@Parameter(description = "Maximum number of similar nodes") @RequestParam(defaultValue = "10") int topK) {
ResponseEntity<List<TaxonomyNodeDto>> guard = checkInitialized();
if (guard != null) return guard;
guard = checkSemanticReady();
if (guard != null) return guard;
return ResponseEntity.ok(searchFacade.findSimilarNodes(code, topK));
}
@Operation(summary = "Embedding model status", description = "Returns model and semantic-index readiness independently", tags = {"Embedding"})
@GetMapping("/embedding/status")
public ResponseEntity<Map<String, Object>> embeddingStatus() {
return ResponseEntity.ok(searchFacade.getEmbeddingStatus());
}
@Operation(summary = "Graph-semantic search", description = "Combines node and relation KNN queries with explicit workspace relation visibility.", tags = {"Search"})
@GetMapping("/search/graph")
public ResponseEntity<GraphSearchResult> graphSearch(
@Parameter(description = "Natural-language query") @RequestParam String q,
@Parameter(description = "Maximum number of node results") @RequestParam(defaultValue = "20") int maxResults) {
ResponseEntity<GraphSearchResult> guard = checkInitialized();
if (guard != null) return guard;
if (q == null || q.isBlank()) return ResponseEntity.badRequest().build();
return ResponseEntity.ok(searchFacade.graphSearch(q, maxResults, currentWorkspaceContext()));
}
@SuppressWarnings("unchecked")
private <T> ResponseEntity<T> checkInitialized() {
if (!searchFacade.isInitialized()) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", messageSource.getMessage("error.loading", null,
"Taxonomy data is still loading. Please wait.", LocaleContextHolder.getLocale()));
body.put("status", searchFacade.getInitStatus());
return (ResponseEntity<T>) ResponseEntity.status(503).body(body);
}
return null;
}
@SuppressWarnings("unchecked")
private <T> ResponseEntity<T> checkSemanticReady() {
if (!searchFacade.isSemanticSearchReady()) {
Map<String, Object> body = new LinkedHashMap<>(searchFacade.getEmbeddingStatus());
body.put("error", messageSource.getMessage("error.semantic.notReady", null,
"Semantic search is not ready yet", LocaleContextHolder.getLocale()));
return (ResponseEntity<T>) ResponseEntity.status(503).body(body);
}
return null;
}
private WorkspaceContext currentWorkspaceContext() {
String username = workspaceResolver.resolveCurrentUsername();
try {
repositoryStateService.ensureWorkspaceState(username);
WorkspaceContext context = workspaceResolver.resolveCurrentContext();
return context != null ? context : WorkspaceContext.SHARED;
} catch (Exception error) {
log.warn("Falling back to shared search context for user '{}': {}",
username, error.toString());
return WorkspaceContext.SHARED;
}
}
}