Initial commit: trueref v0.1.0-SNAPSHOT
Some checks failed
Build and publish Docker image / Build and push (push) Failing after 1m27s
Some checks failed
Build and publish Docker image / Build and push (push) Failing after 1m27s
Java 21 / Spring Boot 3.5.3 multi-module Maven project. Hybrid BM25+HNSW search with RRF, cross-encoder reranker, ONNX Runtime 1.22.0 (CPU + CUDA 12 GPU variants).
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
package com.trueref.adapter.in.mcp;
|
||||
|
||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Registers the trueref MCP tool callbacks with Spring AI's MCP WebMVC auto-configuration. The
|
||||
* {@link MethodToolCallbackProvider} scans {@link TrueRefMcpTools} for methods annotated with
|
||||
* {@link org.springframework.ai.tool.annotation.Tool} and publishes them on the MCP endpoint
|
||||
* configured in {@code application.yml} (POST {@code /mcp} via
|
||||
* {@code spring.ai.mcp.server.sse-message-endpoint}).
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(McpProperties.class)
|
||||
public class McpConfig {
|
||||
|
||||
@Bean
|
||||
public ToolCallbackProvider trueRefMcpToolCallbacks(TrueRefMcpTools tools) {
|
||||
return MethodToolCallbackProvider.builder().toolObjects(tools).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.trueref.adapter.in.mcp;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Token-budget defaults for {@code get-library-docs}. Matches Context7 semantics: clients may
|
||||
* request an explicit token budget per call; unspecified calls use {@link #tokensDefault}. All
|
||||
* requests are clamped to {@code [tokensMin, tokensMax]}.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "trueref.mcp")
|
||||
public record McpProperties(int tokensDefault, int tokensMin, int tokensMax) {
|
||||
|
||||
public McpProperties {
|
||||
if (tokensDefault <= 0) tokensDefault = 5000;
|
||||
if (tokensMin <= 0) tokensMin = 500;
|
||||
if (tokensMax <= 0) tokensMax = 50_000;
|
||||
}
|
||||
|
||||
public int clamp(int requested) {
|
||||
return Math.max(tokensMin, Math.min(tokensMax, requested));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package com.trueref.adapter.in.mcp;
|
||||
|
||||
import com.trueref.application.resolve.LibraryResolver;
|
||||
import com.trueref.domain.model.Repository;
|
||||
import com.trueref.domain.model.SearchHit;
|
||||
import com.trueref.domain.model.SearchScope;
|
||||
import com.trueref.domain.model.Version;
|
||||
import com.trueref.domain.model.VersionStatus;
|
||||
import com.trueref.domain.port.in.IndexVersion;
|
||||
import com.trueref.domain.port.in.QueryCatalog;
|
||||
import com.trueref.domain.port.in.ResolveLibraryId;
|
||||
import com.trueref.domain.port.in.SearchLibraryDocs;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Context7-compatible MCP tool handlers. The two tool names, parameter names, and response
|
||||
* shapes are intentionally 1:1 with upstream Context7 so that any MCP client written against
|
||||
* Context7 works against trueref unchanged.
|
||||
*
|
||||
* <p>Ranking and version→tag mapping are delegated to the application layer
|
||||
* ({@link ResolveLibraryId}, {@link LibraryResolver}); hybrid search goes through
|
||||
* {@link SearchLibraryDocs}; on-demand indexing is enqueued via {@link IndexVersion}.
|
||||
*/
|
||||
@Service
|
||||
public class TrueRefMcpTools {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TrueRefMcpTools.class);
|
||||
private static final int DEFAULT_MAX_HITS = 50;
|
||||
/** Matches Context7's banner retry hint (see ARCHITECTURE §7 on-demand indexing flow). */
|
||||
private static final int INDEXING_RETRY_AFTER_SEC = 30;
|
||||
|
||||
private final ResolveLibraryId resolver;
|
||||
private final LibraryResolver libraryResolver;
|
||||
private final QueryCatalog catalog;
|
||||
private final SearchLibraryDocs search;
|
||||
private final IndexVersion indexer;
|
||||
private final McpProperties props;
|
||||
|
||||
public TrueRefMcpTools(
|
||||
ResolveLibraryId resolver,
|
||||
LibraryResolver libraryResolver,
|
||||
QueryCatalog catalog,
|
||||
SearchLibraryDocs search,
|
||||
IndexVersion indexer,
|
||||
McpProperties props) {
|
||||
this.resolver = resolver;
|
||||
this.libraryResolver = libraryResolver;
|
||||
this.catalog = catalog;
|
||||
this.search = search;
|
||||
this.indexer = indexer;
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
@Tool(
|
||||
name = "resolve-library-id",
|
||||
description =
|
||||
"Resolves a package/product name to a trueref-compatible library ID and "
|
||||
+ "returns matching libraries. Context7-compatible. Each result "
|
||||
+ "includes: Title, Context7-compatible library ID (format "
|
||||
+ "/owner/repo[/version]), Description, Code Snippets, Versions, "
|
||||
+ "and a relevance Score.")
|
||||
public String resolveLibraryId(
|
||||
@ToolParam(
|
||||
description =
|
||||
"Library name to search for and retrieve a Context7-"
|
||||
+ "compatible library ID.")
|
||||
String libraryName,
|
||||
@ToolParam(
|
||||
required = false,
|
||||
description =
|
||||
"Optional natural-language query used to rank matching "
|
||||
+ "libraries by relevance.")
|
||||
@Nullable String query) {
|
||||
ResolveLibraryId.Result result =
|
||||
resolver.resolve(new ResolveLibraryId.Query(libraryName, query, null));
|
||||
if (result.matches().isEmpty()) {
|
||||
return "No matching libraries found for: " + libraryName;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (ResolveLibraryId.Match m : result.matches()) {
|
||||
appendMatchBlock(sb, m);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Tool(
|
||||
name = "get-library-docs",
|
||||
description =
|
||||
"Fetches up-to-date documentation for a library. You MUST call "
|
||||
+ "'resolve-library-id' first to obtain the exact library ID "
|
||||
+ "required to use this tool, UNLESS the user explicitly provides "
|
||||
+ "a library ID in the format /org/project or /org/project/version.")
|
||||
public String getLibraryDocs(
|
||||
@ToolParam(
|
||||
description =
|
||||
"Exact trueref-compatible library ID (format: "
|
||||
+ "/org/project or /org/project/version) "
|
||||
+ "retrieved from 'resolve-library-id'.")
|
||||
String libraryId,
|
||||
@ToolParam(
|
||||
required = false,
|
||||
description =
|
||||
"Topic to focus the documentation on (e.g. "
|
||||
+ "'routing', 'hooks', 'authentication').")
|
||||
@Nullable String topic,
|
||||
@ToolParam(
|
||||
required = false,
|
||||
description =
|
||||
"Max number of tokens to return. Clamped to "
|
||||
+ "[500, 50000]; defaults to 5000.")
|
||||
@Nullable Integer tokens) {
|
||||
ParsedId parsed = parseLibraryId(libraryId);
|
||||
if (parsed == null) {
|
||||
return "Invalid libraryId: " + libraryId
|
||||
+ ". Expected format: /org/project or /org/project/version.";
|
||||
}
|
||||
|
||||
Optional<Repository> repoOpt = catalog.listRepositories().stream()
|
||||
.filter(r -> r.name().equalsIgnoreCase(parsed.repoName()))
|
||||
.findFirst();
|
||||
if (repoOpt.isEmpty()) {
|
||||
return "No matching library found for ID: " + libraryId;
|
||||
}
|
||||
Repository repo = repoOpt.get();
|
||||
List<Version> versions = catalog.listVersions(repo.id());
|
||||
|
||||
SelectedVersion selected = selectVersion(repo, versions, parsed.version());
|
||||
if (selected.searchTarget() == null) {
|
||||
return "No indexed version available for /" + repo.name()
|
||||
+ ". Indexing has been enqueued; retry in ~"
|
||||
+ INDEXING_RETRY_AFTER_SEC + " seconds.";
|
||||
}
|
||||
|
||||
int budget = props.clamp(tokens == null ? props.tokensDefault() : tokens);
|
||||
String text = (topic == null || topic.isBlank()) ? repo.name() : topic;
|
||||
SearchLibraryDocs.Query q = new SearchLibraryDocs.Query(
|
||||
text,
|
||||
topic,
|
||||
new SearchScope(List.of(new SearchScope.RepoVersionRef(repo.id(), selected.searchTarget().id()))),
|
||||
budget,
|
||||
DEFAULT_MAX_HITS);
|
||||
SearchLibraryDocs.Result res;
|
||||
try {
|
||||
res = search.search(q);
|
||||
} catch (Exception e) {
|
||||
log.warn("MCP search failed for {}: {}", libraryId, e.toString());
|
||||
return "Search failed for " + libraryId + ": " + e.getMessage();
|
||||
}
|
||||
|
||||
return formatDocs(res.hits(), selected.banner());
|
||||
}
|
||||
|
||||
// --- helpers -----------------------------------------------------------
|
||||
|
||||
private void appendMatchBlock(StringBuilder sb, ResolveLibraryId.Match m) {
|
||||
sb.append("----------\n");
|
||||
sb.append("- Title: ").append(m.name()).append('\n');
|
||||
sb.append("- Context7-compatible library ID: ").append(m.libraryId()).append('\n');
|
||||
if (m.description() != null && !m.description().isBlank()) {
|
||||
sb.append("- Description: ").append(m.description()).append('\n');
|
||||
}
|
||||
sb.append("- Code Snippets: ").append(m.snippetCount()).append('\n');
|
||||
if (!m.availableVersions().isEmpty()) {
|
||||
sb.append("- Versions: ");
|
||||
for (int i = 0; i < m.availableVersions().size(); i++) {
|
||||
if (i > 0) sb.append(", ");
|
||||
ResolveLibraryId.VersionRef v = m.availableVersions().get(i);
|
||||
sb.append(v.tag()).append(" (").append(v.status()).append(')');
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
sb.append("- Score: ").append(String.format("%.2f", m.score())).append('\n');
|
||||
}
|
||||
|
||||
private SelectedVersion selectVersion(
|
||||
Repository repo, List<Version> versions, @Nullable String requestedVersion) {
|
||||
if (requestedVersion == null || requestedVersion.isBlank()) {
|
||||
// No version in libraryId: prefer most-recent INDEXED; else nearest DISCOVERED +
|
||||
// enqueue indexing + banner.
|
||||
Optional<Version> latestIndexed = versions.stream()
|
||||
.filter(v -> v.status() == VersionStatus.INDEXED)
|
||||
.max(Comparator.comparing(Version::tag));
|
||||
if (latestIndexed.isPresent()) {
|
||||
return new SelectedVersion(latestIndexed.get(), null);
|
||||
}
|
||||
Optional<Version> latestDiscovered = versions.stream()
|
||||
.filter(v -> v.status() == VersionStatus.DISCOVERED)
|
||||
.max(Comparator.comparing(Version::tag));
|
||||
if (latestDiscovered.isPresent()) {
|
||||
Version v = latestDiscovered.get();
|
||||
enqueueSafely(repo, v);
|
||||
return new SelectedVersion(null, indexingBanner(v.tag(), v.tag()));
|
||||
}
|
||||
return new SelectedVersion(null, null);
|
||||
}
|
||||
|
||||
// Explicit version requested: use application-layer mapper.
|
||||
Optional<Version> mapped = libraryResolver.mapVersion(repo, versions, requestedVersion);
|
||||
if (mapped.isEmpty()) {
|
||||
// Fall back to nearest INDEXED; if any, show banner for the requested version.
|
||||
Optional<Version> nearest = versions.stream()
|
||||
.filter(v -> v.status() == VersionStatus.INDEXED)
|
||||
.max(Comparator.comparing(Version::tag));
|
||||
return new SelectedVersion(
|
||||
nearest.orElse(null), nearest.map(v -> indexingBanner(requestedVersion, v.tag())).orElse(null));
|
||||
}
|
||||
Version target = mapped.get();
|
||||
if (target.status() == VersionStatus.INDEXED) {
|
||||
return new SelectedVersion(target, null);
|
||||
}
|
||||
enqueueSafely(repo, target);
|
||||
Optional<Version> nearestIndexed = versions.stream()
|
||||
.filter(v -> v.status() == VersionStatus.INDEXED)
|
||||
.max(Comparator.comparing(Version::tag));
|
||||
return new SelectedVersion(
|
||||
nearestIndexed.orElse(null),
|
||||
indexingBanner(target.tag(), nearestIndexed.map(Version::tag).orElse("none")));
|
||||
}
|
||||
|
||||
private void enqueueSafely(Repository repo, Version v) {
|
||||
if (v.status() == VersionStatus.INDEXING) return;
|
||||
try {
|
||||
indexer.enqueue(repo.id(), v.id(), false);
|
||||
} catch (Exception e) {
|
||||
log.warn("MCP on-demand indexing enqueue failed for {}@{}: {}", repo.name(), v.tag(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private String indexingBanner(String requestedTag, String fallbackTag) {
|
||||
return "[indexing] version " + requestedTag
|
||||
+ " is being indexed now; showing nearest indexed version " + fallbackTag
|
||||
+ " (retryAfterSec=" + INDEXING_RETRY_AFTER_SEC + ")";
|
||||
}
|
||||
|
||||
private String formatDocs(List<SearchHit> hits, @Nullable String banner) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (banner != null) {
|
||||
sb.append(banner).append('\n').append('\n');
|
||||
}
|
||||
sb.append("================\n");
|
||||
sb.append("CODE SNIPPETS\n");
|
||||
sb.append("================\n");
|
||||
if (hits.isEmpty()) {
|
||||
sb.append("(no matching snippets)\n");
|
||||
return sb.toString();
|
||||
}
|
||||
for (SearchHit h : hits) {
|
||||
sb.append("TITLE: ")
|
||||
.append(h.filePath())
|
||||
.append(':')
|
||||
.append(h.startLine())
|
||||
.append('-')
|
||||
.append(h.endLine())
|
||||
.append(" (")
|
||||
.append(h.language())
|
||||
.append(")\n");
|
||||
sb.append("SOURCE: ")
|
||||
.append(h.repoName())
|
||||
.append('@')
|
||||
.append(h.tag())
|
||||
.append(" — ")
|
||||
.append(h.filePath())
|
||||
.append("\n\n");
|
||||
sb.append("```").append(h.language()).append('\n');
|
||||
sb.append(h.content());
|
||||
if (!h.content().endsWith("\n")) sb.append('\n');
|
||||
sb.append("```\n");
|
||||
sb.append("----------------------------------------\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static @Nullable ParsedId parseLibraryId(String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
String s = raw.startsWith("/") ? raw.substring(1) : raw;
|
||||
String[] parts = s.split("/");
|
||||
if (parts.length == 2) {
|
||||
return new ParsedId(parts[0] + "/" + parts[1], null);
|
||||
}
|
||||
if (parts.length == 3) {
|
||||
return new ParsedId(parts[0] + "/" + parts[1], parts[2]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
record ParsedId(String repoName, @Nullable String version) {}
|
||||
|
||||
private record SelectedVersion(@Nullable Version searchTarget, @Nullable String banner) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Driving adapter: Model Context Protocol (MCP) server exposing the two Context7-compatible
|
||||
* tools ({@code resolve-library-id}, {@code get-library-docs}) over Spring AI's MCP WebMVC
|
||||
* transport. The HTTP message endpoint is wired to {@code POST /mcp} via
|
||||
* {@code spring.ai.mcp.server.sse-message-endpoint}.
|
||||
*
|
||||
* <p>Spring AI 1.0.0 ships the SSE-based WebMVC transport
|
||||
* ({@code WebMvcSseServerTransportProvider}); the 2025-03-26 "Streamable HTTP" transport is
|
||||
* not a separate selectable property in this version. Clients that POST JSON-RPC to the
|
||||
* configured message endpoint receive JSON-RPC responses; the server additionally opens an
|
||||
* SSE stream on the configured {@code sse-endpoint} for server-initiated notifications. This
|
||||
* is the closest equivalent Spring AI 1.0.0 provides to Streamable HTTP and is the
|
||||
* intended/only transport of this adapter.
|
||||
*/
|
||||
@org.jspecify.annotations.NullMarked
|
||||
package com.trueref.adapter.in.mcp;
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.trueref.adapter.in.rest;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
|
||||
/** Uniform error envelope returned by {@link GlobalExceptionHandler}. */
|
||||
@Schema(description = "Error response envelope.")
|
||||
public record ErrorResponse(String code, String message, List<FieldError> fieldErrors) {
|
||||
|
||||
public static ErrorResponse of(String code, String message) {
|
||||
return new ErrorResponse(code, message, List.of());
|
||||
}
|
||||
|
||||
@Schema(description = "A single field-level validation error.")
|
||||
public record FieldError(String field, String message) {}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.trueref.adapter.in.rest;
|
||||
|
||||
import com.trueref.domain.error.IngestionFailed;
|
||||
import com.trueref.domain.error.InvalidSearchRequest;
|
||||
import com.trueref.domain.error.RepositoryAlreadyRegistered;
|
||||
import com.trueref.domain.error.RepositoryNotFound;
|
||||
import com.trueref.domain.error.TagNotFound;
|
||||
import com.trueref.domain.error.TrueRefException;
|
||||
import com.trueref.domain.error.VersionNotFound;
|
||||
import com.trueref.domain.error.VersionNotIndexed;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
/** Central translator from domain / validation exceptions to HTTP + {@link ErrorResponse} JSON. */
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler({RepositoryNotFound.class, VersionNotFound.class, TagNotFound.class})
|
||||
public ResponseEntity<ErrorResponse> handleNotFound(TrueRefException ex) {
|
||||
return status(HttpStatus.NOT_FOUND, ex);
|
||||
}
|
||||
|
||||
@ExceptionHandler(RepositoryAlreadyRegistered.class)
|
||||
public ResponseEntity<ErrorResponse> handleConflict(RepositoryAlreadyRegistered ex) {
|
||||
return status(HttpStatus.CONFLICT, ex);
|
||||
}
|
||||
|
||||
@ExceptionHandler(VersionNotIndexed.class)
|
||||
public ResponseEntity<ErrorResponse> handleNotIndexed(VersionNotIndexed ex) {
|
||||
return status(HttpStatus.CONFLICT, ex);
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidSearchRequest.class)
|
||||
public ResponseEntity<ErrorResponse> handleInvalidSearch(InvalidSearchRequest ex) {
|
||||
return status(HttpStatus.BAD_REQUEST, ex);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
|
||||
List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(this::toFieldError)
|
||||
.toList();
|
||||
ErrorResponse body = new ErrorResponse("validation_failed", "Request validation failed", fieldErrors);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
|
||||
List<ErrorResponse.FieldError> fieldErrors = ex.getConstraintViolations().stream()
|
||||
.map(v -> new ErrorResponse.FieldError(v.getPropertyPath().toString(), v.getMessage()))
|
||||
.toList();
|
||||
ErrorResponse body = new ErrorResponse("validation_failed", "Request validation failed", fieldErrors);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
ErrorResponse body = new ErrorResponse("invalid_request", safeMessage(ex), List.of());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||
HttpStatus resolved = HttpStatus.resolve(ex.getStatusCode().value());
|
||||
HttpStatus status = resolved == null ? HttpStatus.INTERNAL_SERVER_ERROR : resolved;
|
||||
String code = ex.getReason() == null ? status.name().toLowerCase() : ex.getReason();
|
||||
return ResponseEntity.status(status).body(ErrorResponse.of(code, code));
|
||||
}
|
||||
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public ResponseEntity<Void> handleNoResource(NoResourceFoundException ex) {
|
||||
// Static resource not found (e.g. browser/.well-known probes). Return 404 without
|
||||
// logging — these are not application errors and would flood the log at ERROR level.
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
@ExceptionHandler(IngestionFailed.class)
|
||||
public ResponseEntity<ErrorResponse> handleIngestionFailed(IngestionFailed ex) {
|
||||
log.error("ingestion failed", ex);
|
||||
return status(HttpStatus.INTERNAL_SERVER_ERROR, ex);
|
||||
}
|
||||
|
||||
@ExceptionHandler(TrueRefException.class)
|
||||
public ResponseEntity<ErrorResponse> handleDomain(TrueRefException ex) {
|
||||
log.error("unhandled domain error code={}", ex.code(), ex);
|
||||
return status(HttpStatus.INTERNAL_SERVER_ERROR, ex);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
|
||||
log.error("unexpected error", ex);
|
||||
ErrorResponse body = new ErrorResponse("internal_error", "An unexpected error occurred", List.of());
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<ErrorResponse> status(HttpStatus status, TrueRefException ex) {
|
||||
return ResponseEntity.status(status).body(ErrorResponse.of(ex.code(), safeMessage(ex)));
|
||||
}
|
||||
|
||||
private ErrorResponse.FieldError toFieldError(FieldError fe) {
|
||||
String msg = fe.getDefaultMessage() == null ? "invalid" : fe.getDefaultMessage();
|
||||
return new ErrorResponse.FieldError(fe.getField(), msg);
|
||||
}
|
||||
|
||||
private static String safeMessage(Throwable t) {
|
||||
return t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.trueref.adapter.in.rest;
|
||||
|
||||
import com.trueref.adapter.in.rest.dto.JobDto;
|
||||
import com.trueref.adapter.in.rest.dto.JobLogEventDto;
|
||||
import com.trueref.domain.model.JobId;
|
||||
import com.trueref.domain.model.JobStatus;
|
||||
import com.trueref.domain.model.RepositoryId;
|
||||
import com.trueref.domain.model.VersionId;
|
||||
import com.trueref.domain.port.in.ObserveJobs;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
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 org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
/** REST + SSE resource: {@code /api/jobs}. */
|
||||
@RestController
|
||||
@RequestMapping("/api/jobs")
|
||||
@Tag(name = "jobs", description = "Inspect ingestion jobs and stream live progress via SSE.")
|
||||
public class JobController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JobController.class);
|
||||
|
||||
private final ObserveJobs observeJobs;
|
||||
|
||||
public JobController(ObserveJobs observeJobs) {
|
||||
this.observeJobs = observeJobs;
|
||||
}
|
||||
|
||||
@Operation(summary = "List jobs, optionally filtered by repo / version / status.")
|
||||
@GetMapping
|
||||
public List<JobDto> list(
|
||||
@RequestParam(value = "repoId", required = false) @Nullable String repoId,
|
||||
@RequestParam(value = "versionId", required = false) @Nullable String versionId,
|
||||
@RequestParam(value = "status", required = false) @Nullable JobStatus status,
|
||||
@RequestParam(value = "limit", defaultValue = "100") int limit) {
|
||||
RepositoryId repo = repoId == null ? null : RepositoryId.of(repoId);
|
||||
VersionId ver = versionId == null ? null : VersionId.of(versionId);
|
||||
return observeJobs.listJobs(repo, ver, status, limit).stream()
|
||||
.map(JobDto::of)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Operation(summary = "Get a single job by id.")
|
||||
@GetMapping("/{id}")
|
||||
public JobDto detail(@PathVariable("id") String id) {
|
||||
JobId jobId = JobId.of(id);
|
||||
return observeJobs
|
||||
.findJob(jobId)
|
||||
.map(JobDto::of)
|
||||
.orElseThrow(() ->
|
||||
new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "job_not_found"));
|
||||
}
|
||||
|
||||
@Operation(summary = "Server-Sent Events stream of log events for a single job.")
|
||||
@GetMapping(value = "/{id}/log", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter logStream(@PathVariable("id") String id) {
|
||||
JobId jobId = JobId.of(id);
|
||||
SseEmitter emitter = new SseEmitter(0L);
|
||||
AutoCloseable subscription = observeJobs.subscribeLogs(jobId, event -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("log").data(JobLogEventDto.of(event)));
|
||||
} catch (IOException ex) {
|
||||
emitter.completeWithError(ex);
|
||||
}
|
||||
});
|
||||
attachCleanup(emitter, subscription, "job-log " + id);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@Operation(summary = "Server-Sent Events stream of status updates for all jobs.")
|
||||
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter stream() {
|
||||
SseEmitter emitter = new SseEmitter(0L);
|
||||
|
||||
// Send an immediate ping so Tomcat flushes the response headers to the client.
|
||||
// Without this, the response buffer may not be flushed until the first job event
|
||||
// arrives, keeping the EventSource in CONNECTING state and never firing 'open'.
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("ping").data(""));
|
||||
} catch (IOException e) {
|
||||
emitter.completeWithError(e);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
AutoCloseable subscription = observeJobs.subscribeJobs(job -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("job").data(JobDto.of(job)));
|
||||
} catch (IOException ex) {
|
||||
emitter.completeWithError(ex);
|
||||
}
|
||||
});
|
||||
|
||||
// Keepalive: send a ping every 20 s to keep the connection alive through idle
|
||||
// periods and detect disconnected clients promptly.
|
||||
Thread keepalive = Thread.startVirtualThread(() -> {
|
||||
try {
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
Thread.sleep(20_000);
|
||||
emitter.send(SseEmitter.event().name("ping").data(""));
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
// normal shutdown
|
||||
} catch (Exception ignored) {
|
||||
// emitter already completed or errored; exit
|
||||
}
|
||||
});
|
||||
|
||||
Runnable cleanup = () -> {
|
||||
keepalive.interrupt();
|
||||
try {
|
||||
subscription.close();
|
||||
} catch (Exception ex) {
|
||||
log.debug("failed to close SSE subscription job-stream: {}", ex.toString());
|
||||
}
|
||||
};
|
||||
emitter.onCompletion(cleanup);
|
||||
emitter.onTimeout(cleanup);
|
||||
emitter.onError(e -> cleanup.run());
|
||||
return emitter;
|
||||
}
|
||||
|
||||
private static void attachCleanup(SseEmitter emitter, AutoCloseable subscription, String label) {
|
||||
Runnable cleanup = () -> {
|
||||
try {
|
||||
subscription.close();
|
||||
} catch (Exception ex) {
|
||||
log.debug("failed to close SSE subscription {}: {}", label, ex.toString());
|
||||
}
|
||||
};
|
||||
emitter.onCompletion(cleanup);
|
||||
emitter.onTimeout(cleanup);
|
||||
emitter.onError(e -> cleanup.run());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.trueref.adapter.in.rest;
|
||||
|
||||
import com.trueref.adapter.in.rest.dto.JobDto;
|
||||
import com.trueref.adapter.out.embedding.onnx.OnnxProperties;
|
||||
import com.trueref.domain.model.IngestionJob;
|
||||
import com.trueref.domain.model.JobStatus;
|
||||
import com.trueref.domain.model.Repository;
|
||||
import com.trueref.domain.model.Version;
|
||||
import com.trueref.domain.model.VersionStatus;
|
||||
import com.trueref.domain.port.in.ObserveJobs;
|
||||
import com.trueref.domain.port.in.QueryCatalog;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** REST resource: {@code /api/observability}. */
|
||||
@RestController
|
||||
@RequestMapping("/api/observability")
|
||||
@Tag(name = "observability", description = "UI-friendly aggregates: metrics + resource usage.")
|
||||
public class ObservabilityController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ObservabilityController.class);
|
||||
private static final int JOB_SAMPLE_LIMIT = 10_000;
|
||||
|
||||
private final ObserveJobs observeJobs;
|
||||
private final QueryCatalog queryCatalog;
|
||||
private final Path trueRefHome;
|
||||
private final boolean embedderAvailable;
|
||||
private final boolean rerankerAvailable;
|
||||
private final int gpuDeviceId;
|
||||
|
||||
public ObservabilityController(
|
||||
ObserveJobs observeJobs,
|
||||
QueryCatalog queryCatalog,
|
||||
OnnxProperties onnxProperties,
|
||||
@Value("${trueref.home:./data}") String trueRefHome,
|
||||
@Value("${trueref.embedder.available:true}") boolean embedderAvailable,
|
||||
@Value("${trueref.reranker.available:true}") boolean rerankerAvailable) {
|
||||
this.observeJobs = observeJobs;
|
||||
this.queryCatalog = queryCatalog;
|
||||
this.trueRefHome = Path.of(trueRefHome);
|
||||
this.embedderAvailable = embedderAvailable;
|
||||
this.rerankerAvailable = rerankerAvailable;
|
||||
this.gpuDeviceId = onnxProperties.gpuDeviceId();
|
||||
}
|
||||
|
||||
@Operation(summary = "Aggregated metrics for the dashboard (job counts, totals, availability).")
|
||||
@GetMapping("/metrics")
|
||||
public Map<String, Object> metrics() {
|
||||
Map<JobStatus, Long> jobsByStatus = new EnumMap<>(JobStatus.class);
|
||||
for (JobStatus status : JobStatus.values()) {
|
||||
jobsByStatus.put(status, 0L);
|
||||
}
|
||||
List<IngestionJob> jobs = observeJobs.listJobs(null, null, null, JOB_SAMPLE_LIMIT);
|
||||
for (IngestionJob job : jobs) {
|
||||
jobsByStatus.merge(job.status(), 1L, Long::sum);
|
||||
}
|
||||
|
||||
long totalChunks = 0L;
|
||||
long totalVersionsIndexed = 0L;
|
||||
long totalRepos = 0L;
|
||||
for (Repository repo : queryCatalog.listRepositories()) {
|
||||
totalRepos++;
|
||||
for (Version v : queryCatalog.listVersions(repo.id())) {
|
||||
totalChunks += v.chunkCount();
|
||||
if (v.status() == VersionStatus.INDEXED) {
|
||||
totalVersionsIndexed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("jobsByStatus", toStringKeys(jobsByStatus));
|
||||
result.put("jobsSampled", jobs.size());
|
||||
result.put("jobsSampleLimit", JOB_SAMPLE_LIMIT);
|
||||
result.put("totalRepositories", totalRepos);
|
||||
result.put("totalChunks", totalChunks);
|
||||
result.put("totalVersionsIndexed", totalVersionsIndexed);
|
||||
result.put("embedderAvailable", embedderAvailable);
|
||||
result.put("rerankerAvailable", rerankerAvailable);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Operation(summary = "Heap / index-size / cache-size snapshot.")
|
||||
@GetMapping("/resources")
|
||||
public Map<String, Object> resources() {
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
long heapMax = runtime.maxMemory();
|
||||
long heapTotal = runtime.totalMemory();
|
||||
long heapFree = runtime.freeMemory();
|
||||
long heapUsed = heapTotal - heapFree;
|
||||
|
||||
long luceneBytes = directorySizeBytes(trueRefHome.resolve("lucene"));
|
||||
long cacheBytes = directorySizeBytes(trueRefHome.resolve("embedding-cache"));
|
||||
|
||||
Map<String, Object> heap = new HashMap<>();
|
||||
heap.put("usedBytes", heapUsed);
|
||||
heap.put("totalBytes", heapTotal);
|
||||
heap.put("maxBytes", heapMax);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("heap", heap);
|
||||
result.put("luceneIndexBytes", luceneBytes);
|
||||
result.put("embeddingCacheBytes", cacheBytes);
|
||||
result.put("trueRefHome", trueRefHome.toAbsolutePath().toString());
|
||||
result.put("gpu", queryGpuMemory(gpuDeviceId));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries {@code nvidia-smi} for memory stats of the given GPU device index.
|
||||
* Returns {@code null} when nvidia-smi is absent or the device index is out of range.
|
||||
*/
|
||||
private @Nullable Map<String, Object> queryGpuMemory(int deviceId) {
|
||||
try {
|
||||
Process proc = new ProcessBuilder(
|
||||
"nvidia-smi",
|
||||
"--query-gpu=memory.used,memory.free,memory.total",
|
||||
"--format=csv,noheader,nounits",
|
||||
"-i",
|
||||
String.valueOf(deviceId))
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
String line;
|
||||
try (BufferedReader br =
|
||||
new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
line = br.readLine();
|
||||
}
|
||||
int exit = proc.waitFor();
|
||||
if (exit != 0 || line == null || line.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
// Output: "usedMiB, freeMiB, totalMiB"
|
||||
String[] parts = line.split(",");
|
||||
if (parts.length < 3) return null;
|
||||
long usedMiB = Long.parseLong(parts[0].trim());
|
||||
long freeMiB = Long.parseLong(parts[1].trim());
|
||||
long totalMiB = Long.parseLong(parts[2].trim());
|
||||
long mibToBytes = 1024L * 1024L;
|
||||
Map<String, Object> gpu = new HashMap<>();
|
||||
gpu.put("deviceId", deviceId);
|
||||
gpu.put("usedBytes", usedMiB * mibToBytes);
|
||||
gpu.put("freeBytes", freeMiB * mibToBytes);
|
||||
gpu.put("totalBytes", totalMiB * mibToBytes);
|
||||
return gpu;
|
||||
} catch (IOException | InterruptedException | NumberFormatException e) {
|
||||
log.debug("nvidia-smi query failed: {}", e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Long> toStringKeys(Map<JobStatus, Long> in) {
|
||||
Map<String, Long> out = new HashMap<>();
|
||||
in.forEach((k, v) -> out.put(k.name(), v));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static long directorySizeBytes(Path dir) {
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return 0L;
|
||||
}
|
||||
long[] total = {0L};
|
||||
try {
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
total[0] += attrs.size();
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException exc) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
log.warn("failed to walk {}: {}", dir, e.toString());
|
||||
}
|
||||
return total[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.trueref.adapter.in.rest;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.servers.Server;
|
||||
import java.util.List;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** Springdoc OpenAPI customization — title, version, summary and default local server. */
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI trueRefOpenApi() {
|
||||
Info info = new Info()
|
||||
.title("trueref API")
|
||||
.version("0.1.0")
|
||||
.summary("Self-hosted Context7-compatible ingestion + retrieval HTTP API.")
|
||||
.description(
|
||||
"REST endpoints for repository registration, ingestion orchestration, hybrid search and library resolution.");
|
||||
Server local = new Server().url("http://localhost:8080").description("Local development server");
|
||||
return new OpenAPI().info(info).servers(List.of(local));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.trueref.adapter.in.rest;
|
||||
|
||||
import com.trueref.adapter.in.rest.dto.RegisterRepositoryRequest;
|
||||
import com.trueref.adapter.in.rest.dto.RepositoryDto;
|
||||
import com.trueref.adapter.in.rest.dto.VersionDto;
|
||||
import com.trueref.domain.error.RepositoryNotFound;
|
||||
import com.trueref.domain.error.TagNotFound;
|
||||
import com.trueref.domain.model.Repository;
|
||||
import com.trueref.domain.model.RepositoryId;
|
||||
import com.trueref.domain.model.Version;
|
||||
import com.trueref.domain.port.in.DiscoverVersions;
|
||||
import com.trueref.domain.port.in.IndexVersion;
|
||||
import com.trueref.domain.port.in.QueryCatalog;
|
||||
import com.trueref.domain.port.in.RegisterRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** REST resource: {@code /api/repos}. */
|
||||
@RestController
|
||||
@RequestMapping("/api/repos")
|
||||
@Tag(name = "repositories", description = "Register, list, and index git repositories.")
|
||||
public class RepositoryController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RepositoryController.class);
|
||||
|
||||
private final RegisterRepository registerRepository;
|
||||
private final QueryCatalog queryCatalog;
|
||||
private final DiscoverVersions discoverVersions;
|
||||
private final IndexVersion indexVersion;
|
||||
|
||||
public RepositoryController(
|
||||
RegisterRepository registerRepository,
|
||||
QueryCatalog queryCatalog,
|
||||
DiscoverVersions discoverVersions,
|
||||
IndexVersion indexVersion) {
|
||||
this.registerRepository = registerRepository;
|
||||
this.queryCatalog = queryCatalog;
|
||||
this.discoverVersions = discoverVersions;
|
||||
this.indexVersion = indexVersion;
|
||||
}
|
||||
|
||||
@Operation(summary = "List all registered repositories.")
|
||||
@GetMapping
|
||||
public List<RepositoryDto> list() {
|
||||
return queryCatalog.listRepositories().stream().map(RepositoryDto::of).toList();
|
||||
}
|
||||
|
||||
@Operation(summary = "Register a new repository (remote URL or local path).")
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public RepositoryDto register(@Valid @RequestBody RegisterRepositoryRequest request) {
|
||||
log.info("registering repository name={}", request.name());
|
||||
Repository repo = registerRepository.register(request.toCommand());
|
||||
return RepositoryDto.of(repo);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get details of a single repository.")
|
||||
@GetMapping("/{id}")
|
||||
public RepositoryDto detail(@PathVariable("id") String id) {
|
||||
RepositoryId repoId = parseRepoId(id);
|
||||
return queryCatalog.findRepository(repoId).map(RepositoryDto::of).orElseThrow(() -> new RepositoryNotFound(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "Unregister a repository and soft-delete its versions.")
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void unregister(@PathVariable("id") String id) {
|
||||
RepositoryId repoId = parseRepoId(id);
|
||||
log.info("unregistering repository id={}", id);
|
||||
registerRepository.unregister(repoId);
|
||||
}
|
||||
|
||||
@Operation(summary = "Force tag discovery (git fetch + enumerate tags). Returns the resulting versions.")
|
||||
@PostMapping("/{id}/discover")
|
||||
public List<VersionDto> discover(@PathVariable("id") String id) {
|
||||
RepositoryId repoId = parseRepoId(id);
|
||||
log.info("discovering versions for repository id={}", id);
|
||||
return discoverVersions.discover(repoId).stream().map(VersionDto::of).toList();
|
||||
}
|
||||
|
||||
@Operation(summary = "List all versions (git tags) known for this repository.")
|
||||
@GetMapping("/{id}/versions")
|
||||
public List<VersionDto> versions(@PathVariable("id") String id) {
|
||||
RepositoryId repoId = parseRepoId(id);
|
||||
// Ensure 404 if the repo does not exist.
|
||||
queryCatalog.findRepository(repoId).orElseThrow(() -> new RepositoryNotFound(id));
|
||||
return queryCatalog.listVersions(repoId).stream().map(VersionDto::of).toList();
|
||||
}
|
||||
|
||||
@Operation(summary = "Enqueue indexing of a specific tag. If the tag is unknown, discovery runs first.")
|
||||
@PostMapping("/{id}/versions/{tag}/index")
|
||||
public ResponseEntity<Map<String, String>> index(
|
||||
@PathVariable("id") String id,
|
||||
@PathVariable("tag") String tag,
|
||||
@RequestBody(required = false) IndexBody body) {
|
||||
boolean force = body != null && Boolean.TRUE.equals(body.force());
|
||||
return enqueueIndex(id, tag, force);
|
||||
}
|
||||
|
||||
@Operation(summary = "Force re-indexing of a specific tag (equivalent to index with force=true).")
|
||||
@PostMapping("/{id}/versions/{tag}/reindex")
|
||||
public ResponseEntity<Map<String, String>> reindex(
|
||||
@PathVariable("id") String id, @PathVariable("tag") String tag) {
|
||||
return enqueueIndex(id, tag, true);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, String>> enqueueIndex(String id, String tag, boolean force) {
|
||||
RepositoryId repoId = parseRepoId(id);
|
||||
Repository repo = queryCatalog.findRepository(repoId).orElseThrow(() -> new RepositoryNotFound(id));
|
||||
|
||||
Optional<Version> existing = findByTag(repoId, tag);
|
||||
if (existing.isEmpty()) {
|
||||
log.info("tag {} unknown for repo {}, triggering discovery", tag, repo.name());
|
||||
discoverVersions.discover(repoId);
|
||||
existing = findByTag(repoId, tag);
|
||||
}
|
||||
Version version = existing.orElseThrow(() -> new TagNotFound(repo.name(), tag));
|
||||
|
||||
log.info("enqueueing index job repo={} tag={} force={}", repo.name(), tag, force);
|
||||
var jobId = indexVersion.enqueue(repoId, version.id(), force);
|
||||
return ResponseEntity.accepted().body(Map.of("jobId", jobId.toString()));
|
||||
}
|
||||
|
||||
private Optional<Version> findByTag(RepositoryId repoId, String tag) {
|
||||
return queryCatalog.listVersions(repoId).stream()
|
||||
.filter(v -> v.tag().equals(tag))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
private static RepositoryId parseRepoId(String id) {
|
||||
try {
|
||||
return RepositoryId.of(id);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new RepositoryNotFound(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Body of {@code POST /api/repos/{id}/versions/{tag}/index}. */
|
||||
public record IndexBody(Boolean force) {}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.trueref.adapter.in.rest;
|
||||
|
||||
import com.trueref.adapter.in.rest.dto.ResolveMatchDto;
|
||||
import com.trueref.adapter.in.rest.dto.ResolveRequest;
|
||||
import com.trueref.adapter.in.rest.dto.ResolveResponse;
|
||||
import com.trueref.domain.port.in.ResolveLibraryId;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** REST resource: {@code /api/resolve}. */
|
||||
@RestController
|
||||
@RequestMapping("/api/resolve")
|
||||
@Tag(name = "resolve", description = "Turn a fuzzy library name (and optional version) into concrete (repo, version) handles.")
|
||||
public class ResolveController {
|
||||
|
||||
private final ResolveLibraryId resolveLibraryId;
|
||||
|
||||
public ResolveController(ResolveLibraryId resolveLibraryId) {
|
||||
this.resolveLibraryId = resolveLibraryId;
|
||||
}
|
||||
|
||||
@Operation(summary = "Preview library ID resolution for the given name / version.")
|
||||
@GetMapping
|
||||
public ResolveResponse resolve(
|
||||
@RequestParam("libraryName") String libraryName,
|
||||
@RequestParam(value = "version", required = false) @Nullable String version,
|
||||
@RequestParam(value = "query", required = false) @Nullable String query) {
|
||||
if (libraryName == null || libraryName.isBlank()) {
|
||||
throw new IllegalArgumentException("libraryName must not be blank");
|
||||
}
|
||||
ResolveRequest req = new ResolveRequest(libraryName, query, version);
|
||||
ResolveLibraryId.Result result = resolveLibraryId.resolve(req.toQuery());
|
||||
return new ResolveResponse(
|
||||
result.matches().stream().map(ResolveMatchDto::of).toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.trueref.adapter.in.rest;
|
||||
|
||||
import com.trueref.adapter.in.rest.dto.SearchHitDto;
|
||||
import com.trueref.adapter.in.rest.dto.SearchRequest;
|
||||
import com.trueref.adapter.in.rest.dto.SearchResponse;
|
||||
import com.trueref.domain.port.in.SearchLibraryDocs;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.RestController;
|
||||
|
||||
/** REST resource: {@code /api/search}. */
|
||||
@RestController
|
||||
@RequestMapping("/api/search")
|
||||
@Tag(name = "search", description = "Hybrid BM25 + dense search with cross-encoder rerank.")
|
||||
public class SearchController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SearchController.class);
|
||||
|
||||
private final SearchLibraryDocs searchLibraryDocs;
|
||||
|
||||
public SearchController(SearchLibraryDocs searchLibraryDocs) {
|
||||
this.searchLibraryDocs = searchLibraryDocs;
|
||||
}
|
||||
|
||||
@Operation(summary = "Hybrid search scoped to one or more (repo, version) pairs.")
|
||||
@PostMapping
|
||||
public SearchResponse search(@Valid @RequestBody SearchRequest request) {
|
||||
log.debug(
|
||||
"search text='{}' topic={} scopes={} tokensBudget={} maxHits={}",
|
||||
request.text(),
|
||||
request.topic(),
|
||||
request.scope().size(),
|
||||
request.tokensBudget(),
|
||||
request.maxHits());
|
||||
SearchLibraryDocs.Result result = searchLibraryDocs.search(request.toQuery());
|
||||
return new SearchResponse(
|
||||
result.hits().stream().map(SearchHitDto::of).toList(),
|
||||
result.totalTokensReturned(),
|
||||
request.topic());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.trueref.adapter.in.rest;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
/**
|
||||
* SPA fallback: any unmatched request that looks like a client-side route (no file extension in
|
||||
* the final path segment) is served {@code index.html}. API, MCP, springdoc and actuator paths are
|
||||
* explicitly excluded — Spring routes those first anyway, but we exclude them defensively so the
|
||||
* resource resolver does not attempt a fallback for them.
|
||||
*/
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private static final Set<String> EXCLUDED_PREFIXES =
|
||||
Set.of("api/", "mcp", "swagger-ui/", "v3/api-docs", "actuator/");
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new SpaFallbackResolver());
|
||||
}
|
||||
|
||||
static final class SpaFallbackResolver extends PathResourceResolver {
|
||||
@Override
|
||||
protected @Nullable Resource getResource(String resourcePath, Resource location) throws IOException {
|
||||
for (String prefix : EXCLUDED_PREFIXES) {
|
||||
if (resourcePath.equals(prefix) || resourcePath.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Resource requested = location.createRelative(resourcePath);
|
||||
if (requested.exists() && requested.isReadable()) {
|
||||
return requested;
|
||||
}
|
||||
// Fallback to index.html only for client-side route-like paths (no extension on last segment).
|
||||
int lastSlash = resourcePath.lastIndexOf('/');
|
||||
String lastSegment = lastSlash < 0 ? resourcePath : resourcePath.substring(lastSlash + 1);
|
||||
if (!lastSegment.isEmpty() && lastSegment.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
Resource indexHtml = location.createRelative("index.html");
|
||||
return indexHtml.exists() && indexHtml.isReadable() ? indexHtml : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.model.IngestionJob;
|
||||
import com.trueref.domain.model.JobStatus;
|
||||
import com.trueref.domain.model.JobType;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "An orchestrated ingestion job with its stages.")
|
||||
public record JobDto(
|
||||
String id,
|
||||
String repoId,
|
||||
@Nullable String versionId,
|
||||
JobType type,
|
||||
JobStatus status,
|
||||
@Nullable Instant startedAt,
|
||||
@Nullable Instant finishedAt,
|
||||
List<JobStageDto> stages) {
|
||||
|
||||
public static JobDto of(IngestionJob j) {
|
||||
return new JobDto(
|
||||
j.id().toString(),
|
||||
j.repoId().toString(),
|
||||
j.versionId() == null ? null : j.versionId().toString(),
|
||||
j.type(),
|
||||
j.status(),
|
||||
j.startedAt(),
|
||||
j.finishedAt(),
|
||||
j.stages().stream().map(JobStageDto::of).toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.model.JobLogEvent;
|
||||
import com.trueref.domain.model.JobStage;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "A single log event emitted by an ingestion job.")
|
||||
public record JobLogEventDto(
|
||||
String jobId, Instant ts, JobLogEvent.Level level, JobStage.@Nullable StageName stage, String message) {
|
||||
|
||||
public static JobLogEventDto of(JobLogEvent e) {
|
||||
return new JobLogEventDto(e.jobId().toString(), e.ts(), e.level(), e.stage(), e.message());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.model.JobStage;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "A single stage within an ingestion job.")
|
||||
public record JobStageDto(
|
||||
String jobId,
|
||||
JobStage.StageName name,
|
||||
JobStage.StageStatus status,
|
||||
@Nullable Instant startedAt,
|
||||
@Nullable Instant finishedAt,
|
||||
long itemsProcessed,
|
||||
long itemsTotal,
|
||||
long bytesProcessed,
|
||||
@Nullable String errorMessage) {
|
||||
|
||||
public static JobStageDto of(JobStage s) {
|
||||
return new JobStageDto(
|
||||
s.jobId().toString(),
|
||||
s.name(),
|
||||
s.status(),
|
||||
s.startedAt(),
|
||||
s.finishedAt(),
|
||||
s.itemsProcessed(),
|
||||
s.itemsTotal(),
|
||||
s.bytesProcessed(),
|
||||
s.errorMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.model.TagPattern;
|
||||
import com.trueref.domain.port.in.RegisterRepository;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.time.Duration;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.List;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "Request payload to register a new repository.")
|
||||
public record RegisterRepositoryRequest(
|
||||
@NotBlank @Schema(description = "Human-readable display name, e.g. spring-projects/spring-boot") String name,
|
||||
@Schema(description = "Remote git URL (mutually exclusive with localPath)") @Nullable String remoteUrl,
|
||||
@Schema(description = "Absolute local path to an already-cloned repo") @Nullable String localPath,
|
||||
@Schema(description = "Per-repo ignore globs, ANDed with .gitignore") @Nullable List<String> ignoreGlobs,
|
||||
@Schema(description = "Max file size in bytes; default 1MiB") @Nullable Long maxFileSizeBytes,
|
||||
@Schema(description = "ISO-8601 duration (e.g. PT1H); 0 disables polling") @Nullable String pollInterval,
|
||||
@Schema(description = "Max most-recent tags auto-indexed") @Nullable Integer tagCap,
|
||||
@Schema(description = "Ordered tag-pattern rules for client version → tag mapping") @Valid @Nullable
|
||||
List<TagPatternDto> versionMappingRules) {
|
||||
|
||||
public RegisterRepository.Command toCommand() {
|
||||
Duration poll = parseDuration(pollInterval);
|
||||
List<String> globs = ignoreGlobs == null ? List.of() : List.copyOf(ignoreGlobs);
|
||||
List<TagPattern> rules = versionMappingRules == null
|
||||
? List.of()
|
||||
: versionMappingRules.stream().map(TagPatternDto::toModel).toList();
|
||||
return new RegisterRepository.Command(
|
||||
name, remoteUrl, localPath, globs, maxFileSizeBytes, poll, tagCap, rules);
|
||||
}
|
||||
|
||||
private static @Nullable Duration parseDuration(@Nullable String iso) {
|
||||
if (iso == null || iso.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Duration.parse(iso);
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new IllegalArgumentException("Invalid ISO-8601 duration: " + iso, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.model.Repository;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "A registered repository.")
|
||||
public record RepositoryDto(
|
||||
String id,
|
||||
String name,
|
||||
@Nullable String remoteUrl,
|
||||
String localPath,
|
||||
boolean managedClone,
|
||||
List<String> ignoreGlobs,
|
||||
long maxFileSizeBytes,
|
||||
@Schema(description = "ISO-8601 duration, e.g. PT1H") String pollInterval,
|
||||
int tagCap,
|
||||
List<TagPatternDto> versionMappingRules,
|
||||
Instant createdAt,
|
||||
Instant updatedAt) {
|
||||
|
||||
public static RepositoryDto of(Repository repo) {
|
||||
return new RepositoryDto(
|
||||
repo.id().toString(),
|
||||
repo.name(),
|
||||
repo.remoteUrl(),
|
||||
repo.localPath(),
|
||||
repo.managedClone(),
|
||||
List.copyOf(repo.ignoreGlobs()),
|
||||
repo.maxFileSizeBytes(),
|
||||
repo.pollInterval().toString(),
|
||||
repo.tagCap(),
|
||||
repo.versionMappingRules().stream().map(TagPatternDto::of).toList(),
|
||||
repo.createdAt(),
|
||||
repo.updatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.port.in.ResolveLibraryId;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "A single candidate library matching a resolve request.")
|
||||
public record ResolveMatchDto(
|
||||
String repoId,
|
||||
String libraryId,
|
||||
String name,
|
||||
@Nullable String description,
|
||||
int snippetCount,
|
||||
List<ResolveVersionRefDto> availableVersions,
|
||||
double score) {
|
||||
|
||||
public static ResolveMatchDto of(ResolveLibraryId.Match m) {
|
||||
return new ResolveMatchDto(
|
||||
m.repoId().toString(),
|
||||
m.libraryId(),
|
||||
m.name(),
|
||||
m.description(),
|
||||
m.snippetCount(),
|
||||
m.availableVersions().stream().map(ResolveVersionRefDto::of).toList(),
|
||||
m.score());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.port.in.ResolveLibraryId;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "Fuzzy library resolution request.")
|
||||
public record ResolveRequest(
|
||||
@NotBlank String libraryName,
|
||||
@Schema(description = "Optional hint to rerank candidates by relevance") @Nullable String query,
|
||||
@Nullable String version) {
|
||||
|
||||
public ResolveLibraryId.Query toQuery() {
|
||||
return new ResolveLibraryId.Query(libraryName, query, version);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "Ranked library matches for a resolve request.")
|
||||
public record ResolveResponse(List<ResolveMatchDto> matches) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.model.VersionStatus;
|
||||
import com.trueref.domain.port.in.ResolveLibraryId;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(description = "One available version of a resolved library.")
|
||||
public record ResolveVersionRefDto(String versionId, String tag, VersionStatus status) {
|
||||
|
||||
public static ResolveVersionRefDto of(ResolveLibraryId.VersionRef v) {
|
||||
return new ResolveVersionRefDto(v.versionId().toString(), v.tag(), v.status());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.model.SearchHit;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "A single ranked snippet returned by search.")
|
||||
public record SearchHitDto(
|
||||
String chunkId,
|
||||
String repoId,
|
||||
String versionId,
|
||||
String repoName,
|
||||
String tag,
|
||||
String filePath,
|
||||
int startLine,
|
||||
int endLine,
|
||||
String language,
|
||||
@Nullable String symbol,
|
||||
String content,
|
||||
double score) {
|
||||
|
||||
public static SearchHitDto of(SearchHit h) {
|
||||
return new SearchHitDto(
|
||||
h.chunkId().toString(),
|
||||
h.repoId().toString(),
|
||||
h.versionId().toString(),
|
||||
h.repoName(),
|
||||
h.tag(),
|
||||
h.filePath(),
|
||||
h.startLine(),
|
||||
h.endLine(),
|
||||
h.language(),
|
||||
h.symbol(),
|
||||
h.content(),
|
||||
h.score());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.model.RepositoryId;
|
||||
import com.trueref.domain.model.SearchScope;
|
||||
import com.trueref.domain.model.VersionId;
|
||||
import com.trueref.domain.port.in.SearchLibraryDocs;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import java.util.List;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "Hybrid search request scoped to one or more (repo, version) pairs.")
|
||||
public record SearchRequest(
|
||||
@NotBlank String text,
|
||||
@Nullable String topic,
|
||||
@NotEmpty @Valid List<ScopeRef> scope,
|
||||
@Schema(description = "Token budget; clamped by the service to [500, 50000]") @Positive @Nullable
|
||||
Integer tokensBudget,
|
||||
@Positive @Nullable Integer maxHits) {
|
||||
|
||||
public static final int DEFAULT_TOKENS_BUDGET = 5000;
|
||||
public static final int DEFAULT_MAX_HITS = 20;
|
||||
|
||||
public SearchLibraryDocs.Query toQuery() {
|
||||
List<SearchScope.RepoVersionRef> refs = scope.stream()
|
||||
.map(r -> new SearchScope.RepoVersionRef(RepositoryId.of(r.repoId()), VersionId.of(r.versionId())))
|
||||
.toList();
|
||||
return new SearchLibraryDocs.Query(
|
||||
text,
|
||||
topic,
|
||||
new SearchScope(refs),
|
||||
tokensBudget == null ? DEFAULT_TOKENS_BUDGET : tokensBudget,
|
||||
maxHits == null ? DEFAULT_MAX_HITS : maxHits);
|
||||
}
|
||||
|
||||
@Schema(description = "A (repo, version) pair to scope the search on.")
|
||||
public record ScopeRef(@NotBlank String repoId, @NotBlank String versionId) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "Response body for search.")
|
||||
public record SearchResponse(List<SearchHitDto> hits, int totalTokensReturned, @Nullable String topic) {}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.model.TagPattern;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/** DTO for {@link TagPattern}. The sealed hierarchy is flattened into a {@code type} + optional {@code template}. */
|
||||
@Schema(description = "Rule mapping a client-supplied version string to a git tag.")
|
||||
public record TagPatternDto(
|
||||
@Schema(
|
||||
description = "Pattern kind",
|
||||
allowableValues = {"EXACT", "V_PREFIX", "RELEASE_PREFIX", "SEMVER_FUZZY", "CUSTOM"})
|
||||
String type,
|
||||
@Schema(description = "Required when type=CUSTOM, e.g. release-{semver}") @Nullable String template) {
|
||||
|
||||
public static TagPatternDto of(TagPattern pattern) {
|
||||
return switch (pattern) {
|
||||
case TagPattern.Exact e -> new TagPatternDto("EXACT", null);
|
||||
case TagPattern.VPrefix v -> new TagPatternDto("V_PREFIX", null);
|
||||
case TagPattern.ReleasePrefix r -> new TagPatternDto("RELEASE_PREFIX", null);
|
||||
case TagPattern.SemverFuzzy s -> new TagPatternDto("SEMVER_FUZZY", null);
|
||||
case TagPattern.Custom c -> new TagPatternDto("CUSTOM", c.template());
|
||||
};
|
||||
}
|
||||
|
||||
public TagPattern toModel() {
|
||||
return switch (type) {
|
||||
case "EXACT" -> new TagPattern.Exact();
|
||||
case "V_PREFIX" -> new TagPattern.VPrefix();
|
||||
case "RELEASE_PREFIX" -> new TagPattern.ReleasePrefix();
|
||||
case "SEMVER_FUZZY" -> new TagPattern.SemverFuzzy();
|
||||
case "CUSTOM" -> {
|
||||
if (template == null || template.isBlank()) {
|
||||
throw new IllegalArgumentException("CUSTOM tag pattern requires a template");
|
||||
}
|
||||
yield new TagPattern.Custom(template);
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Unknown tag pattern type: " + type);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
|
||||
import com.trueref.domain.model.Version;
|
||||
import com.trueref.domain.model.VersionStatus;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Instant;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "A specific git tag (or branch) of a repository.")
|
||||
public record VersionDto(
|
||||
String id,
|
||||
String repoId,
|
||||
String tag,
|
||||
String commitSha,
|
||||
VersionStatus status,
|
||||
@Nullable Instant indexedAt,
|
||||
int chunkCount,
|
||||
@Nullable String errorMessage) {
|
||||
|
||||
public static VersionDto of(Version v) {
|
||||
return new VersionDto(
|
||||
v.id().toString(),
|
||||
v.repoId().toString(),
|
||||
v.tag(),
|
||||
v.commitSha(),
|
||||
v.status(),
|
||||
v.indexedAt(),
|
||||
v.chunkCount(),
|
||||
v.errorMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Transport-layer DTO records used by {@code com.trueref.adapter.in.rest} controllers. */
|
||||
@org.jspecify.annotations.NullMarked
|
||||
package com.trueref.adapter.in.rest.dto;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* REST driving adapter: controllers, DTOs, OpenAPI configuration, exception handling and SSE
|
||||
* streaming for the trueref HTTP API.
|
||||
*/
|
||||
@org.jspecify.annotations.NullMarked
|
||||
package com.trueref.adapter.in.rest;
|
||||
@@ -0,0 +1,67 @@
|
||||
-- trueref schema V1
|
||||
-- All UUIDs stored as CHAR(36) for H2 portability.
|
||||
|
||||
CREATE TABLE repositories (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(512) NOT NULL UNIQUE,
|
||||
remote_url VARCHAR(2048) NULL,
|
||||
local_path VARCHAR(2048) NOT NULL,
|
||||
managed_clone BOOLEAN NOT NULL,
|
||||
ignore_globs CLOB NOT NULL, -- JSON array of strings
|
||||
max_file_size_bytes BIGINT NOT NULL,
|
||||
poll_interval_seconds BIGINT NOT NULL,
|
||||
tag_cap INT NOT NULL,
|
||||
version_mapping_rules CLOB NOT NULL, -- JSON array of TagPattern
|
||||
created_at TIMESTAMP(9) WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(9) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE versions (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
repo_id CHAR(36) NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
||||
tag VARCHAR(512) NOT NULL,
|
||||
commit_sha CHAR(40) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
indexed_at TIMESTAMP(9) WITH TIME ZONE NULL,
|
||||
chunk_count INT NOT NULL DEFAULT 0,
|
||||
error_message CLOB NULL,
|
||||
UNIQUE (repo_id, tag)
|
||||
);
|
||||
CREATE INDEX idx_versions_repo_status ON versions(repo_id, status);
|
||||
|
||||
CREATE TABLE ingestion_jobs (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
repo_id CHAR(36) NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
||||
version_id CHAR(36) NULL REFERENCES versions(id) ON DELETE CASCADE,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
started_at TIMESTAMP(9) WITH TIME ZONE NULL,
|
||||
finished_at TIMESTAMP(9) WITH TIME ZONE NULL,
|
||||
created_at TIMESTAMP(9) WITH TIME ZONE NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_jobs_repo_status ON ingestion_jobs(repo_id, status);
|
||||
CREATE INDEX idx_jobs_status_created ON ingestion_jobs(status, created_at);
|
||||
|
||||
CREATE TABLE job_stages (
|
||||
job_id CHAR(36) NOT NULL REFERENCES ingestion_jobs(id) ON DELETE CASCADE,
|
||||
name VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
started_at TIMESTAMP(9) WITH TIME ZONE NULL,
|
||||
finished_at TIMESTAMP(9) WITH TIME ZONE NULL,
|
||||
items_processed BIGINT NOT NULL DEFAULT 0,
|
||||
items_total BIGINT NOT NULL DEFAULT 0,
|
||||
bytes_processed BIGINT NOT NULL DEFAULT 0,
|
||||
error_message CLOB NULL,
|
||||
PRIMARY KEY (job_id, name)
|
||||
);
|
||||
|
||||
-- Persisted log buffer (last N per job kept by application logic; SSE streams from in-memory bus).
|
||||
CREATE TABLE job_log_events (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
job_id CHAR(36) NOT NULL REFERENCES ingestion_jobs(id) ON DELETE CASCADE,
|
||||
ts TIMESTAMP(9) WITH TIME ZONE NOT NULL,
|
||||
level VARCHAR(8) NOT NULL,
|
||||
stage VARCHAR(32),
|
||||
message CLOB NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_job_log_job_ts ON job_log_events(job_id, ts);
|
||||
Reference in New Issue
Block a user