Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions core/src/main/java/com/opencontext/config/EmbeddingConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.opencontext.config;

import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.ollama.OllamaEmbeddingModel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* Configuration for embedding model integration.
* Sets up Ollama embedding model for text vectorization.
*/
@Slf4j
@Configuration
public class EmbeddingConfig {

@Value("${app.ollama.api.url}")
private String ollamaBaseUrl;

@Value("${app.ollama.embedding.model}")
private String ollamaModel;

/**
* Creates and configures the Ollama embedding model bean.
*
* @return configured EmbeddingModel instance
*/
@Bean
public EmbeddingModel embeddingModel() {
log.info("Configuring Ollama embedding model: {} at {}", ollamaModel, ollamaBaseUrl);

return OllamaEmbeddingModel.builder()
.baseUrl(ollamaBaseUrl)
.modelName(ollamaModel)
.build();
}
}
38 changes: 38 additions & 0 deletions core/src/main/java/com/opencontext/config/OpenApiConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.opencontext.config;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* OpenAPI (Swagger) configuration for OpenContext API documentation
*/
@Configuration
public class OpenApiConfig {

@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("OpenContext API")
.description("Self-hosted AI Context Engine with hierarchical RAG search capabilities")
.version("1.0.0")
.contact(new Contact()
.name("OpenContext Team")
.url("https://github.com/OpenContextAI/open-context")))
.addServersItem(new Server()
.url("http://localhost:8080")
.description("Local development server"))
.components(new Components()
.addSecuritySchemes("ApiKeyAuth", new SecurityScheme()
.type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER)
.name("X-API-KEY")
.description("API Key for accessing admin endpoints")));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.opencontext.controller;

import com.opencontext.common.CommonResponse;
import com.opencontext.dto.GetContentRequest;
import com.opencontext.dto.GetContentResponse;
import com.opencontext.dto.SearchResultsResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Tag(name = "MCP Search", description = "APIs for exploratory and focused retrieval used by OpenContext MCP tools. No authentication required.")
public interface DocsSearchController {

@GetMapping("/search")
@Operation(
summary = "Exploratory search (find_knowledge)",
description = "Returns top-k structured chunk summaries based on a natural language query. Snippet policy: first 50 chars + '...'."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Search completed",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = SearchResultsResponse.class),
examples = @ExampleObject(name = "Sample Search Results", value = """
{
"success": true,
"data": {
"results": [
{
"chunkId": "c1d2e3f4-a5b6-7890-1234-567890abcdef",
"title": "5.8.2. Configuring the JWT Authentication Converter",
"snippet": "To customize the conversion from a JWT to an Auth...",
"relevanceScore": 0.92,
"breadcrumbs": ["Chapter 5", "Security", "JWT"]
}
]
},
"message": "Search completed successfully"
}
"""))
),
@ApiResponse(responseCode = "400", description = "Validation failed")
})
ResponseEntity<CommonResponse<SearchResultsResponse>> search(
@Parameter(description = "User search query", example = "Spring Security JWT filter configuration", required = true)
@RequestParam String query,
@Parameter(description = "Max number of results", example = "5")
@RequestParam(defaultValue = "5") Integer topK);

@PostMapping(value = "/get-content", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Focused retrieval (get_content)",
description = "Returns the full original text of a selected chunk. If token count exceeds maxTokens, text is truncated from the end."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Content retrieved",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = GetContentResponse.class),
examples = @ExampleObject(name = "Sample Content", value = """
{
"success": true,
"data": {
"content": "### 5.8.2. Configuring the JWT Authentication Converter...",
"tokenInfo": {"tokenizer": "tiktoken-cl100k_base", "actualTokens": 789}
},
"message": "Content retrieved successfully"
}
"""))
),
@ApiResponse(responseCode = "400", description = "Validation failed")
})
ResponseEntity<CommonResponse<GetContentResponse>> getContent(
@RequestBody(
required = true,
description = "Chunk selection and optional token limit",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = GetContentRequest.class),
examples = @ExampleObject(value = """
{
"chunkId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"maxTokens": 8000
}
"""))
) GetContentRequest request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
name = "Source Document Management",
description = "Admin APIs for document ingestion pipeline management. Requires X-API-KEY authentication."
)
@SecurityRequirement(name = "X-API-KEY")
@SecurityRequirement(name = "ApiKeyAuth")
public interface DocsSourceController {

/**
Expand Down Expand Up @@ -61,9 +61,8 @@ public interface DocsSourceController {
content = @Content(
mediaType = MediaType.MULTIPART_FORM_DATA_VALUE,
examples = @ExampleObject(
name = "File Upload",
description = "Upload a PDF document",
value = "file: [binary PDF content]"
name = "Upload a Markdown file",
value = "(Use the file picker UI to attach a .md file)"
)
)
)
Expand Down Expand Up @@ -263,7 +262,7 @@ ResponseEntity<CommonResponse<PageResponse<SourceDocumentDto>>> getAllSourceDocu
@ApiResponse(responseCode = "409", description = "Conflict - document is currently being processed")
})
ResponseEntity<CommonResponse<String>> resyncSourceDocument(
@Parameter(description = "Source document ID", required = true)
@Parameter(description = "Source document ID", required = true, example = "a1b2c3d4-e5f6-7890-1234-567890abcdef")
@PathVariable UUID sourceId
);

Expand Down Expand Up @@ -311,7 +310,7 @@ ResponseEntity<CommonResponse<String>> resyncSourceDocument(
@ApiResponse(responseCode = "409", description = "Conflict - document is currently being processed")
})
ResponseEntity<CommonResponse<String>> deleteSourceDocument(
@Parameter(description = "Source document ID", required = true)
@Parameter(description = "Source document ID", required = true, example = "a1b2c3d4-e5f6-7890-1234-567890abcdef")
@PathVariable UUID sourceId
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.opencontext.controller;

import com.opencontext.common.CommonResponse;
import com.opencontext.dto.GetContentResponse;
import com.opencontext.dto.GetContentRequest;
import com.opencontext.dto.SearchResultItem;
import com.opencontext.dto.SearchResultsResponse;
import com.opencontext.service.ContentRetrievalService;
import com.opencontext.service.SearchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
* MCP 검색 API 컨트롤러
* find_knowledge와 get_content MCP 도구를 위한 엔드포인트 제공
*/
@Slf4j
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class SearchController implements DocsSearchController {

private final SearchService searchService;
private final ContentRetrievalService contentRetrievalService;

/**
* 하이브리드 검색 수행 - find_knowledge MCP 도구
*/
@Override
@GetMapping("/search")
public ResponseEntity<CommonResponse<SearchResultsResponse>> search(
@RequestParam String query,
@RequestParam(defaultValue = "5") Integer topK) {

log.info("검색 요청: query='{}', topK={}", query, topK);

if (query == null || query.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(CommonResponse.error("Query cannot be empty", "VALIDATION_FAILED"));
}

List<SearchResultItem> results = searchService.search(query.trim(), topK);
SearchResultsResponse responseData = SearchResultsResponse.builder()
.results(results)
.build();
return ResponseEntity.ok(CommonResponse.success(responseData, "Search completed successfully"));
}

/**
* 청크 콘텐츠 조회 - get_content MCP 도구
*/
@Override
@PostMapping(value = "/get-content", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CommonResponse<GetContentResponse>> getContent(@jakarta.validation.Valid @org.springframework.web.bind.annotation.RequestBody GetContentRequest request) {
if (request == null || request.getChunkId() == null || request.getChunkId().isBlank()) {
return ResponseEntity.badRequest()
.body(CommonResponse.error("chunkId is required", "VALIDATION_FAILED"));
}
String chunkId = request.getChunkId();
Integer maxTokens = request.getMaxTokens();
if (maxTokens != null && maxTokens <= 0) {
return ResponseEntity.badRequest()
.body(CommonResponse.error("maxTokens must be positive", "VALIDATION_FAILED"));
}
log.info("콘텐츠 조회 요청: chunkId={}, maxTokens={}", chunkId, maxTokens);
GetContentResponse response = contentRetrievalService.getContent(chunkId, maxTokens);
return ResponseEntity.ok(CommonResponse.success(response, "Content retrieved successfully"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public class SourceController implements DocsSourceController{
* @return 업로드 결과 및 문서 정보
*/
@Override
@PostMapping("/upload")
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<CommonResponse<SourceUploadResponse>> uploadFile(
@RequestParam("file") MultipartFile file) {
log.info("File upload requested: filename={}, size={}", file.getOriginalFilename(), file.getSize());
Expand Down
32 changes: 32 additions & 0 deletions core/src/main/java/com/opencontext/dto/GetContentRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.opencontext.dto;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Schema(description = "DTO to request full content of a specific chunk")
@Getter
@lombok.Setter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class GetContentRequest {

@Schema(description = "Chunk ID to retrieve content for", requiredMode = Schema.RequiredMode.REQUIRED,
example = "a1b2c3d4-e5f6-7890-1234-567890abcdef-chunk-0")
@NotBlank(message = "chunkId is required")
@JsonAlias({"chunkID", "chunk_id", "id"})
@JsonProperty("chunkId")
private String chunkId;

@Schema(description = "Maximum number of tokens to return", defaultValue = "25000", example = "8000")
@Positive(message = "maxTokens must be positive")
@JsonProperty("maxTokens")
private Integer maxTokens;

// Use default constructor + setters for robust Jackson binding
}
10 changes: 5 additions & 5 deletions core/src/main/java/com/opencontext/dto/SearchResultItem.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
Expand All @@ -11,7 +12,6 @@
import lombok.NoArgsConstructor;

import java.util.List;
import java.util.UUID;

/**
* Individual search result item for exploratory knowledge discovery.
Expand All @@ -25,11 +25,11 @@
public class SearchResultItem {

/**
* Unique identifier of the chunk for subsequent get_content requests.
* Chunk identifier used by get_content (string: "<uuid>-chunk-<n>").
*/
@Schema(description = "Chunk identifier for content retrieval", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private UUID chunkId;
@Schema(description = "Chunk identifier for content retrieval (e.g., '<uuid>-chunk-0')", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
private String chunkId;

/**
* Title or heading of the chunk for user context.
Expand Down
21 changes: 21 additions & 0 deletions core/src/main/java/com/opencontext/dto/SearchResultsResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.opencontext.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;

@Schema(description = "Response DTO wrapping search results list")
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class SearchResultsResponse {

@Schema(description = "Search results ordered by relevance")
private List<SearchResultItem> results;
}
Loading