added classes for vectorization, similarity search and summarization
This commit is contained in:
@@ -50,7 +50,7 @@ Package-by-feature layout. Server context path is `/api`. Main endpoints:
|
|||||||
- `DELETE /api/connections/{serviceType}` — removes a service from the session; invalidates the session if no connections remain
|
- `DELETE /api/connections/{serviceType}` — removes a service from the session; invalidates the session if no connections remain
|
||||||
- `POST /api/search` — paged search against the requested service; returns 401 if no active session
|
- `POST /api/search` — paged search against the requested service; returns 401 if no active session
|
||||||
|
|
||||||
Five packages:
|
Six packages:
|
||||||
|
|
||||||
**`shared/`** — cross-cutting types used by more than one feature package
|
**`shared/`** — cross-cutting types used by more than one feature package
|
||||||
|
|
||||||
@@ -72,12 +72,24 @@ Five packages:
|
|||||||
- Entity (`ConnectionEntity`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns
|
- Entity (`ConnectionEntity`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns
|
||||||
- `HomeboxConnectionProvider` / `HomeboxEntity`: Homebox-specific implementation
|
- `HomeboxConnectionProvider` / `HomeboxEntity`: Homebox-specific implementation
|
||||||
|
|
||||||
**`search/`** — querying connected services
|
**`ai/`** — shared AI infrastructure used by multiple features (search, future image analysis)
|
||||||
|
|
||||||
|
- `EmbeddingService`: wraps Spring AI's `EmbeddingModel`; reused by any feature that needs to generate vectors
|
||||||
|
- `VectorStoreConfig`: Spring bean configuration for `PgVectorStore` (pgvector-backed `VectorStore`)
|
||||||
|
- All classes here are provider-agnostic — the OpenAI starter is pointed at LiteLLM, so the underlying model is configurable without code changes
|
||||||
|
|
||||||
|
**`search/`** — querying connected services (keyword and AI)
|
||||||
|
|
||||||
- `SearchProvider` interface: extends `ServiceProvider`; each integrated app implements `getSearchResults()`
|
- `SearchProvider` interface: extends `ServiceProvider`; each integrated app implements `getSearchResults()`
|
||||||
- `SearchService`: auto-discovers providers via Spring injection, dispatches by `ServiceType`
|
- `SearchService`: maintains two provider maps — keyword providers and AI providers; routes based on `SearchRequest.aiSearch` flag
|
||||||
- `SearchController`: guards with session check before delegating to `SearchService`
|
- `SearchController`: guards with session check before delegating to `SearchService`
|
||||||
- `HomeboxSearchProvider`: Homebox-specific search implementation using bearer token from session
|
- `SearchRequest`: includes `aiSearch: boolean` — when true, routes to AI provider instead of keyword provider
|
||||||
|
- `PagedSearchResponse`: includes nullable `summary` field — populated only for AI search results; null for keyword search
|
||||||
|
- `HomeboxSearchProvider`: keyword search via Homebox API; unchanged from original implementation
|
||||||
|
- `HomeboxAiSearchProvider`: AI search via pgvector similarity; returns ranked items + generated summary
|
||||||
|
- `HomeboxSyncService`: fetches all Homebox items page by page, embeds them via `EmbeddingService`, stores in `VectorStore`; triggered on connection login (background sync)
|
||||||
|
|
||||||
|
**AI search flow:** on login → background sync indexes all Homebox items into pgvector. On AI search → embed query → pgvector similarity search → top N results passed to `ChatClient` for summary generation → return list + summary. Sync is idempotent (delete-then-reindex per connection).
|
||||||
|
|
||||||
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
|
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
|
||||||
|
|
||||||
@@ -97,7 +109,9 @@ React 19 + TypeScript + SCSS, Vite 6 build. Package-by-feature under `components
|
|||||||
### Data & AI
|
### Data & AI
|
||||||
|
|
||||||
- PostgreSQL + pgvector (semantic search via embeddings); also used as the Spring Session store (JDBC)
|
- PostgreSQL + pgvector (semantic search via embeddings); also used as the Spring Session store (JDBC)
|
||||||
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it
|
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it — `OPENAI_BASE_URL` points to LiteLLM, not OpenAI directly, keeping the underlying model provider configurable
|
||||||
|
- `spring-ai-starter-vector-store-pgvector` provides `PgVectorStore`; configured in `ai/VectorStoreConfig`
|
||||||
|
- Embedding dimensions must stay consistent with the configured LiteLLM embedding model — changing models requires re-syncing all indexed items
|
||||||
- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
|
- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
|
||||||
|
|
||||||
### Testing Strategy
|
### Testing Strategy
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.vaessl.app.ai;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.ai.document.Document;
|
||||||
|
import org.springframework.ai.vectorstore.VectorStore;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import com.vaessl.app.shared.ServiceItem;
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class EmbeddingService {
|
||||||
|
|
||||||
|
private static final float THRESHOLD = 0.7f;
|
||||||
|
private static final int LIMIT = 2;
|
||||||
|
private final VectorStore vectorStore;
|
||||||
|
|
||||||
|
public EmbeddingService(VectorStore vectorStore) {
|
||||||
|
this.vectorStore = vectorStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void vectorizeData(List<ServiceItem> responses, ServiceType serviceType,
|
||||||
|
Long connectionId) {
|
||||||
|
|
||||||
|
List<Document> mappedResponse = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ServiceItem response : responses) {
|
||||||
|
|
||||||
|
StringBuilder data = new StringBuilder();
|
||||||
|
|
||||||
|
if (response.extraData() != null) {
|
||||||
|
for (Map.Entry<String, Object> entry : response.extraData().entrySet()) {
|
||||||
|
if (!data.isEmpty()) {
|
||||||
|
data.append(" \n");
|
||||||
|
}
|
||||||
|
data.append(entry.getKey());
|
||||||
|
data.append(": ");
|
||||||
|
data.append(entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> metadata = new HashMap<>();
|
||||||
|
metadata.put("connectionId", connectionId);
|
||||||
|
metadata.put("serviceType", serviceType.name());
|
||||||
|
metadata.put("id", response.id());
|
||||||
|
metadata.put("title", response.title());
|
||||||
|
metadata.put("description", response.description());
|
||||||
|
metadata.put("extraData", data.toString());
|
||||||
|
|
||||||
|
mappedResponse.add(new Document(connectionId + ":" + response.id(), "Title: "
|
||||||
|
+ response.title()
|
||||||
|
+ (response.description() != null ? "\n Description: " + response.description()
|
||||||
|
: "")
|
||||||
|
+ (!data.isEmpty() ? "\n Extradata: " + data.toString() : ""), metadata));
|
||||||
|
}
|
||||||
|
vectorStore.add(mappedResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import com.vaessl.app.shared.ServiceItem;
|
||||||
|
import com.vaessl.app.shared.ServiceType;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class HomeboxAiSearchProvider implements SearchProvider {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ServiceType getServiceType() {
|
||||||
|
return ServiceType.HOMEBOX;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable) {
|
||||||
|
// TODO Auto-generated method stub
|
||||||
|
throw new UnsupportedOperationException("Unimplemented method 'getSearchResults'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.vaessl.app.search;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class HomeboxSyncService {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,11 +4,11 @@ import java.util.List;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
|
|
||||||
public record PagedSearchResponse<T>(List<T> content, int page, int pageSize, long totalElements,
|
public record PagedSearchResponse<T>(List<T> content, int page, int pageSize, long totalElements,
|
||||||
boolean first, boolean last, String sort) {
|
boolean first, boolean last, String sort, String summary) {
|
||||||
|
|
||||||
public static <T> PagedSearchResponse<T> from(Page<T> pageResult) {
|
public static <T> PagedSearchResponse<T> from(Page<T> pageResult) {
|
||||||
return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(),
|
return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(),
|
||||||
pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(),
|
pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(),
|
||||||
pageResult.isLast(), pageResult.getSort().toString());
|
pageResult.isLast(), pageResult.getSort().toString(), null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query,
|
public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query,
|
||||||
@NotNull ServiceType serviceType) {
|
@NotNull ServiceType serviceType, boolean aiSearch) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ class HomeboxSearchProviderTest {
|
|||||||
|
|
||||||
when(mockRepo.findByAppUrlAndUsername(MOCK_URL, MOCK_USER)).thenReturn(null);
|
when(mockRepo.findByAppUrlAndUsername(MOCK_URL, MOCK_USER)).thenReturn(null);
|
||||||
|
|
||||||
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "test query", HOMEBOX);
|
SearchRequest request =
|
||||||
|
new SearchRequest(MOCK_URL, MOCK_USER, "test query", HOMEBOX, false);
|
||||||
Pageable pageable = PageRequest.of(0, 10);
|
Pageable pageable = PageRequest.of(0, 10);
|
||||||
assertThrows(ConnectionNotFoundException.class,
|
assertThrows(ConnectionNotFoundException.class,
|
||||||
() -> provider.getSearchResults(request, pageable));
|
() -> provider.getSearchResults(request, pageable));
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ class SearchControllerTest {
|
|||||||
"appUrl": "http://irrelevant",
|
"appUrl": "http://irrelevant",
|
||||||
"query": "Item",
|
"query": "Item",
|
||||||
"serviceType": "HOMEBOX",
|
"serviceType": "HOMEBOX",
|
||||||
"username": "irrelevant"
|
"username": "irrelevant",
|
||||||
|
"aiSearch": false
|
||||||
}
|
}
|
||||||
""")).andExpect(status().isUnauthorized());
|
""")).andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -117,22 +118,23 @@ class SearchControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String searchRequestBody(WireMockRuntimeInfo wm, String serviceType) {
|
private String searchRequestBody(WireMockRuntimeInfo wm, String serviceType) {
|
||||||
return searchRequestBody(wm.getHttpBaseUrl(), serviceType);
|
return searchRequestBody(wm.getHttpBaseUrl(), serviceType, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String searchRequestBody(String serviceType) {
|
private String searchRequestBody(String serviceType) {
|
||||||
return searchRequestBody("http://irrelevant", serviceType);
|
return searchRequestBody("http://irrelevant", serviceType, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String searchRequestBody(String appUrl, String serviceType) {
|
private String searchRequestBody(String appUrl, String serviceType, boolean aiSearch) {
|
||||||
return """
|
return """
|
||||||
{
|
{
|
||||||
"appUrl": "%s",
|
"appUrl": "%s",
|
||||||
"query": "Item",
|
"query": "Item",
|
||||||
"serviceType": "%s",
|
"serviceType": "%s",
|
||||||
"username": "%s"
|
"username": "%s",
|
||||||
|
"aiSearch": "%b"
|
||||||
}
|
}
|
||||||
""".formatted(appUrl, serviceType, MOCK_USER);
|
""".formatted(appUrl, serviceType, MOCK_USER, aiSearch);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||||
|
|||||||
Reference in New Issue
Block a user