diff --git a/CLAUDE.md b/CLAUDE.md index 341e4a9..2899c49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 - `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 @@ -72,12 +72,24 @@ Five packages: - Entity (`ConnectionEntity`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns - `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()` -- `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` -- `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` @@ -97,7 +109,9 @@ React 19 + TypeScript + SCSS, Vite 6 build. Package-by-feature under `components ### Data & AI - 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 ### Testing Strategy diff --git a/backend/src/main/java/com/vaessl/app/ai/EmbeddingService.java b/backend/src/main/java/com/vaessl/app/ai/EmbeddingService.java new file mode 100644 index 0000000..21f3381 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/ai/EmbeddingService.java @@ -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 responses, ServiceType serviceType, + Long connectionId) { + + List mappedResponse = new ArrayList<>(); + + for (ServiceItem response : responses) { + + StringBuilder data = new StringBuilder(); + + if (response.extraData() != null) { + for (Map.Entry entry : response.extraData().entrySet()) { + if (!data.isEmpty()) { + data.append(" \n"); + } + data.append(entry.getKey()); + data.append(": "); + data.append(entry.getValue()); + } + } + + Map 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); + } + +} diff --git a/backend/src/main/java/com/vaessl/app/search/HomeboxAiSearchProvider.java b/backend/src/main/java/com/vaessl/app/search/HomeboxAiSearchProvider.java new file mode 100644 index 0000000..4aa5cd4 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/HomeboxAiSearchProvider.java @@ -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 getSearchResults(SearchRequest request, Pageable pageable) { + // TODO Auto-generated method stub + throw new UnsupportedOperationException("Unimplemented method 'getSearchResults'"); + } +} diff --git a/backend/src/main/java/com/vaessl/app/search/HomeboxSyncService.java b/backend/src/main/java/com/vaessl/app/search/HomeboxSyncService.java new file mode 100644 index 0000000..486b91f --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/search/HomeboxSyncService.java @@ -0,0 +1,8 @@ +package com.vaessl.app.search; + +import org.springframework.stereotype.Service; + +@Service +public class HomeboxSyncService { + +} diff --git a/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java b/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java index 9da5d55..f0b3bb1 100644 --- a/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java +++ b/backend/src/main/java/com/vaessl/app/search/PagedSearchResponse.java @@ -4,11 +4,11 @@ import java.util.List; import org.springframework.data.domain.Page; public record PagedSearchResponse(List content, int page, int pageSize, long totalElements, - boolean first, boolean last, String sort) { + boolean first, boolean last, String sort, String summary) { public static PagedSearchResponse from(Page pageResult) { return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(), pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(), - pageResult.isLast(), pageResult.getSort().toString()); + pageResult.isLast(), pageResult.getSort().toString(), null); } } diff --git a/backend/src/main/java/com/vaessl/app/search/SearchRequest.java b/backend/src/main/java/com/vaessl/app/search/SearchRequest.java index bc618e9..abd0b5f 100644 --- a/backend/src/main/java/com/vaessl/app/search/SearchRequest.java +++ b/backend/src/main/java/com/vaessl/app/search/SearchRequest.java @@ -5,5 +5,5 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query, - @NotNull ServiceType serviceType) { + @NotNull ServiceType serviceType, boolean aiSearch) { } diff --git a/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java b/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java index 63dd067..c81df9a 100644 --- a/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java +++ b/backend/src/test/java/com/vaessl/app/search/HomeboxSearchProviderTest.java @@ -29,7 +29,8 @@ class HomeboxSearchProviderTest { 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); assertThrows(ConnectionNotFoundException.class, () -> provider.getSearchResults(request, pageable)); diff --git a/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java b/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java index 6ba4d18..0fe5449 100644 --- a/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java +++ b/backend/src/test/java/com/vaessl/app/search/SearchControllerTest.java @@ -104,7 +104,8 @@ class SearchControllerTest { "appUrl": "http://irrelevant", "query": "Item", "serviceType": "HOMEBOX", - "username": "irrelevant" + "username": "irrelevant", + "aiSearch": false } """)).andExpect(status().isUnauthorized()); } @@ -117,22 +118,23 @@ class SearchControllerTest { } private String searchRequestBody(WireMockRuntimeInfo wm, String serviceType) { - return searchRequestBody(wm.getHttpBaseUrl(), serviceType); + return searchRequestBody(wm.getHttpBaseUrl(), serviceType, false); } 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 """ { "appUrl": "%s", "query": "Item", "serviceType": "%s", - "username": "%s" + "username": "%s", + "aiSearch": "%b" } - """.formatted(appUrl, serviceType, MOCK_USER); + """.formatted(appUrl, serviceType, MOCK_USER, aiSearch); } private String connectionRequestBody(WireMockRuntimeInfo wm) {