Skip to content

Commit 4ec8664

Browse files
Merge pull request #22 from OpenElementsLabs/feat/011-content-search-service
Content search service — multiSearch + highlighting facade
2 parents 7639990 + 8993e1e commit 4ec8664

9 files changed

Lines changed: 663 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ extraction, Meilisearch indexing with scheduled refresh, and four MCP tools
5555
│ ├── ContentIndexStore.java # index read/write seam (StoredDocument)
5656
│ ├── MeilisearchContentIndexStore.java # ContentIndexStore backed by MeilisearchClient
5757
│ ├── ContentBootstrapStep.java # SearchIndexBootstrapStep: startup full reindex
58-
│ └── ContentRefreshScheduler.java # @Scheduled incremental re-crawl (cron, guarded)
58+
│ ├── ContentRefreshScheduler.java # @Scheduled incremental re-crawl (cron, guarded)
59+
│ └── ContentSearchService.java # read facade: multiSearch + Highlighter (+ result records)
5960
├── src/main/resources/application.yaml # datasource, JPA, OAuth2, MCP, Meilisearch, content config
6061
├── src/test/java/com/openelements/content/ # behavior tests (context, MCP enabled/disabled, search-down, jsoup)
6162
└── docs/
@@ -113,7 +114,11 @@ stream (over all enabled sources, via `ContentIndexer.streamAllDocuments`) in ba
113114
and toggles `SearchReadinessState`. `ContentRefreshScheduler` is a `@Scheduled` bean (cron
114115
`open-elements.content.refresh-cron`, default hourly, from `@EnableScheduling` in spec 001) that
115116
re-runs `ContentIndexer.indexSource` over enabled sources — guarded to skip while disabled or
116-
bootstrapping and to never overlap, with per-source fault isolation.
117+
bootstrapping and to never overlap, with per-source fault isolation. On the read side,
118+
`ContentSearchService` is the facade the MCP tools (spec 012) call: it builds Meilisearch
119+
`multi-search` bodies (AND-combined `source`/`locale`/`categories`/`since` filters, `publishedDate:desc`
120+
tie-breaker, `Highlighter` boundary markers) and returns `SearchHit`s with HTML-safe snippets, plus
121+
`listPosts`, `getByUrlOrId`, and `categoryFacets`. Read-only; the scoped key comes in spec 013.
117122

118123
> **Key gotcha:** the library ships no Spring Boot auto-configuration and couples MCP to a JPA
119124
> datasource, so `@Import({ McpConfiguration, SearchConfig })` alone does **not** boot. See
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Implementation Steps: Content Search Service
2+
3+
## Step 1: Result/filter records
4+
5+
- [x] `ContentFilters(source, locale, category, since)` (+ `none()`)
6+
- [x] `SearchHit(title, url, publishedDate, snippet, score)`, `SearchHits(hits, estimatedTotal)`, `CategoryCount(category, count)`
7+
8+
**Related behaviors:** all (return/argument shapes)
9+
10+
---
11+
12+
## Step 2: `ContentSearchService`
13+
14+
- [x] `@Component` (gated on `meilisearch.enabled`) over `MeilisearchClient` + `MeilisearchProperties`
15+
- [x] `search`: builds a `multi-search` body — filter, `publishedDate:desc` sort, highlight tags = `Highlighter.PRE_MARK`/`POST_MARK`, crop `body`, `showRankingScore`, paging — and parses hits with `Highlighter.safeHighlight`
16+
- [x] `listPosts`: empty query, `publishedDate:desc`, paged
17+
- [x] `getByUrlOrId`: filter by `id` (preferred) or `url`; empty when both blank / not found
18+
- [x] `categoryFacets`: `facets:["categories"]` with source/locale filter, `limit 0`
19+
- [x] `buildFilter` AND-combines non-null filters (quote-escaped)
20+
21+
**Related behaviors:** all search/filter/list/get/facet scenarios
22+
23+
---
24+
25+
## Step 3: Tests
26+
27+
- [x] `ContentSearchServiceTest`: pure `buildFilter`/`parseHits`/`parseFacets` + public methods via a capturing `MeilisearchClient` subclass (request bodies + parsing + get + empty)
28+
29+
**Acceptance criteria:**
30+
- [x] All tests pass (`mvn test`); build green
31+
32+
---
33+
34+
## Behavior Coverage
35+
36+
| Scenario | Layer | Covered in Step |
37+
|----------|-------|-----------------|
38+
| Query returns relevant hits with snippet | Backend | Step 3 (`ResponseParsing.parsesHits` + `RequestBuilding.searchBuildsRequest`) |
39+
| Title matches rank above body matches | Backend | Governed by `IndexSettings` searchable order (spec 003) + Meilisearch; the service adds only the date tie-breaker (verified in `searchBuildsRequest`). Live ranking needs Meilisearch. |
40+
| Highlight markers become safe em tags | Backend | Step 3 (`highlightMarkersBecomeSafeEmTags`) |
41+
| Locale/Source/Category filter restricts results | Backend | Step 3 (`FilterBuilding.*` + `searchBuildsRequest`) |
42+
| since filters by published date | Backend | Step 3 (`FilterBuilding.since`) |
43+
| Combined filters are AND-combined | Backend | Step 3 (`FilterBuilding.combined`) |
44+
| listPosts sorts by published date descending | Backend | Step 3 (`listPostsBuildsRequest`) |
45+
| Empty query returns all (filtered) posts | Backend | Step 3 (`listPostsBuildsRequest`) |
46+
| Get by url / id returns full document | Backend | Step 3 (`GetByUrlOrId.returnsFullDocument`, `getById/UrlBuildsFilter`) |
47+
| Get for unknown url/id is empty | Backend | Step 3 (`emptyWhenNotFound`, `emptyWhenNoArguments`) |
48+
| Category facets return counts / respect filter | Backend | Step 3 (`parsesFacets` + `facetsBuildRequest`) |
49+
| No results / paging beyond the end | Backend | Step 3 (`parsesEmpty`; paging via `offset` in `searchBuildsRequest`) |
50+
51+
All scenarios are backend; there is no frontend in this spec. Live ranking/filtering is Meilisearch's
52+
responsibility (the service builds the correct request); tests verify the request and the parsing.

docs/specs/INDEX.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ roadmap step, to be implemented sequentially (each builds on the previous).
1616
| 008 | 008-content-indexer | Content indexer | backend, search, crawler | `ContentIndexer` — orchestration, diff, upsert/delete | #15 | done |
1717
| 009 | 009-content-bootstrap-step | Content bootstrap step | backend, search | `ContentBootstrapStep` — initial reindex via `SearchIndexBootstrapStep` | #17 | done |
1818
| 010 | 010-content-refresh-scheduler | Content refresh scheduler | backend, scheduling | `ContentRefreshScheduler``@Scheduled` incremental re-crawl | #19 | done |
19-
| 011 | 011-content-search-service | Content search service | backend, search | `ContentSearchService``multiSearch` + highlighting facade | | open |
19+
| 011 | 011-content-search-service | Content search service | backend, search | `ContentSearchService``multiSearch` + highlighting facade | #21 | done |
2020
| 012 | 012-content-mcp-tools | Content MCP tools | backend, mcp, api | `ContentMcpToolProvider` — the 4 MCP tools || open |
2121
| 013 | 013-scoped-search-key | Scoped search key | backend, search, security | Read-only scoped Meilisearch key for the content index || open |
2222
| 014 | 014-ops-robustness | Ops & robustness | backend, infrastructure, observability | robots.txt handling, fault tolerance, logging/metrics || open |
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.openelements.content;
2+
3+
/**
4+
* A category facet with its document count.
5+
*
6+
* @param category the category value
7+
* @param count the number of matching documents
8+
*/
9+
public record CategoryCount(String category, long count) {
10+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.openelements.content;
2+
3+
/**
4+
* Optional filters for a content search or listing. Any {@code null} field is not applied; non-null
5+
* fields are AND-combined.
6+
*
7+
* @param source restrict to a source id
8+
* @param locale restrict to a locale (e.g. {@code en}, {@code de})
9+
* @param category restrict to a category tag
10+
* @param since restrict to documents with {@code publishedDate >=} this ISO date
11+
*/
12+
public record ContentFilters(String source, String locale, String category, String since) {
13+
14+
/** @return filters with no restrictions */
15+
public static ContentFilters none() {
16+
return new ContentFilters(null, null, null, null);
17+
}
18+
}
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
package com.openelements.content;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.openelements.spring.base.services.search.Highlighter;
5+
import com.openelements.spring.base.services.search.MeilisearchClient;
6+
import com.openelements.spring.base.services.search.MeilisearchProperties;
7+
import java.util.ArrayList;
8+
import java.util.LinkedHashMap;
9+
import java.util.List;
10+
import java.util.Map;
11+
import java.util.Optional;
12+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
13+
import org.springframework.stereotype.Component;
14+
15+
/**
16+
* Read-only facade over the Meilisearch content index, backing the MCP tools (spec 012).
17+
*
18+
* <p>It builds {@code multi-search} request bodies with AND-combined filters, a
19+
* {@code publishedDate:desc} tie-breaker, and the library {@link Highlighter}'s boundary markers as
20+
* highlight tags, then turns the raw hits into {@link SearchHit}s with HTML-safe snippets. Searchable
21+
* ranking ({@code title > excerpt > body}) is governed by the {@code IndexSettings} (spec 003); this
22+
* service only adds the date tie-breaker and never writes.
23+
*
24+
* <p>Only created when the Meilisearch stack is enabled.
25+
*/
26+
@Component
27+
@ConditionalOnProperty(prefix = "openelements.meilisearch", name = "enabled", havingValue = "true")
28+
public class ContentSearchService {
29+
30+
/** Words of {@code body} kept around a match for the snippet preview. */
31+
static final int SNIPPET_CROP_LENGTH = 50;
32+
33+
private final MeilisearchClient client;
34+
private final String indexUid;
35+
36+
public ContentSearchService(MeilisearchClient client, MeilisearchProperties meilisearchProperties) {
37+
this.client = client;
38+
this.indexUid = meilisearchProperties.resolveIndex("content");
39+
}
40+
41+
/**
42+
* Full-text search with filters, highlighting, and a date tie-breaker.
43+
*
44+
* @param query the search terms (blank matches everything)
45+
* @param filters optional filters
46+
* @param page zero-based page index
47+
* @param size page size
48+
* @return the matching hits
49+
*/
50+
public SearchHits search(String query, ContentFilters filters, int page, int size) {
51+
Map<String, Object> query0 = baseQuery(filters, page, size);
52+
query0.put("q", query == null ? "" : query);
53+
query0.put("sort", List.of("publishedDate:desc"));
54+
query0.put("attributesToHighlight", List.of("title", "excerpt", "body"));
55+
query0.put("attributesToCrop", List.of("body"));
56+
query0.put("cropLength", SNIPPET_CROP_LENGTH);
57+
query0.put("highlightPreTag", Highlighter.PRE_MARK);
58+
query0.put("highlightPostTag", Highlighter.POST_MARK);
59+
query0.put("showRankingScore", true);
60+
return parseHits(client.multiSearch(wrap(query0)));
61+
}
62+
63+
/**
64+
* Lists documents (no query) sorted newest-first, with optional filters.
65+
*
66+
* @param filters optional filters
67+
* @param page zero-based page index
68+
* @param size page size
69+
* @return the matching documents as hits
70+
*/
71+
public SearchHits listPosts(ContentFilters filters, int page, int size) {
72+
Map<String, Object> query0 = baseQuery(filters, page, size);
73+
query0.put("q", "");
74+
query0.put("sort", List.of("publishedDate:desc"));
75+
return parseHits(client.multiSearch(wrap(query0)));
76+
}
77+
78+
/**
79+
* Fetches a full document by id (preferred) or url.
80+
*
81+
* @param url the document URL (used when {@code id} is blank)
82+
* @param id the document id
83+
* @return the document, or empty if none matches (or both arguments are blank)
84+
*/
85+
public Optional<ContentDocument> getByUrlOrId(String url, String id) {
86+
String filter;
87+
if (present(id)) {
88+
filter = "id = \"" + escape(id) + "\"";
89+
} else if (present(url)) {
90+
filter = "url = \"" + escape(url) + "\"";
91+
} else {
92+
return Optional.empty();
93+
}
94+
Map<String, Object> query0 = new LinkedHashMap<>();
95+
query0.put("indexUid", indexUid);
96+
query0.put("q", "");
97+
query0.put("filter", filter);
98+
query0.put("limit", 1);
99+
return firstDocument(client.multiSearch(wrap(query0)));
100+
}
101+
102+
/**
103+
* Category facet counts, optionally restricted by source/locale.
104+
*
105+
* @param source optional source filter
106+
* @param locale optional locale filter
107+
* @return the categories with their document counts
108+
*/
109+
public List<CategoryCount> categoryFacets(String source, String locale) {
110+
Map<String, Object> query0 = new LinkedHashMap<>();
111+
query0.put("indexUid", indexUid);
112+
query0.put("q", "");
113+
String filter = buildFilter(new ContentFilters(source, locale, null, null));
114+
if (filter != null) {
115+
query0.put("filter", filter);
116+
}
117+
query0.put("facets", List.of("categories"));
118+
query0.put("limit", 0);
119+
return parseFacets(client.multiSearch(wrap(query0)));
120+
}
121+
122+
// ---- request building ----
123+
124+
private Map<String, Object> baseQuery(ContentFilters filters, int page, int size) {
125+
Map<String, Object> query = new LinkedHashMap<>();
126+
query.put("indexUid", indexUid);
127+
String filter = buildFilter(filters);
128+
if (filter != null) {
129+
query.put("filter", filter);
130+
}
131+
int safeSize = Math.max(0, size);
132+
query.put("limit", safeSize);
133+
query.put("offset", Math.max(0, page) * safeSize);
134+
return query;
135+
}
136+
137+
private static Map<String, Object> wrap(Map<String, Object> query) {
138+
return Map.of("queries", List.of(query));
139+
}
140+
141+
/** Builds the AND-combined Meilisearch filter expression, or {@code null} when no filter applies. */
142+
static String buildFilter(ContentFilters filters) {
143+
if (filters == null) {
144+
return null;
145+
}
146+
List<String> clauses = new ArrayList<>();
147+
if (present(filters.source())) {
148+
clauses.add("source = \"" + escape(filters.source()) + "\"");
149+
}
150+
if (present(filters.locale())) {
151+
clauses.add("locale = \"" + escape(filters.locale()) + "\"");
152+
}
153+
if (present(filters.category())) {
154+
clauses.add("categories = \"" + escape(filters.category()) + "\"");
155+
}
156+
if (present(filters.since())) {
157+
clauses.add("publishedDate >= \"" + escape(filters.since()) + "\"");
158+
}
159+
return clauses.isEmpty() ? null : String.join(" AND ", clauses);
160+
}
161+
162+
// ---- response parsing ----
163+
164+
static SearchHits parseHits(JsonNode response) {
165+
JsonNode result = firstResult(response);
166+
if (result == null) {
167+
return SearchHits.empty();
168+
}
169+
List<SearchHit> hits = new ArrayList<>();
170+
for (JsonNode hit : result.path("hits")) {
171+
hits.add(toHit(hit));
172+
}
173+
return new SearchHits(hits, result.path("estimatedTotalHits").asLong(0));
174+
}
175+
176+
private static SearchHit toHit(JsonNode hit) {
177+
JsonNode formatted = hit.path("_formatted");
178+
String rawSnippet = firstNonBlank(text(formatted, "body"), text(formatted, "excerpt"), text(hit, "excerpt"));
179+
String snippet = rawSnippet == null ? "" : Highlighter.safeHighlight(rawSnippet);
180+
return new SearchHit(
181+
text(hit, "title"), text(hit, "url"), text(hit, "publishedDate"),
182+
snippet, hit.path("_rankingScore").asDouble(0.0));
183+
}
184+
185+
static List<CategoryCount> parseFacets(JsonNode response) {
186+
JsonNode result = firstResult(response);
187+
if (result == null) {
188+
return List.of();
189+
}
190+
List<CategoryCount> counts = new ArrayList<>();
191+
result.path("facetDistribution").path("categories").fields()
192+
.forEachRemaining(entry -> counts.add(new CategoryCount(entry.getKey(), entry.getValue().asLong(0))));
193+
return counts;
194+
}
195+
196+
private static Optional<ContentDocument> firstDocument(JsonNode response) {
197+
JsonNode result = firstResult(response);
198+
if (result == null) {
199+
return Optional.empty();
200+
}
201+
JsonNode hits = result.path("hits");
202+
if (!hits.isArray() || hits.isEmpty()) {
203+
return Optional.empty();
204+
}
205+
return Optional.of(toDocument(hits.get(0)));
206+
}
207+
208+
private static ContentDocument toDocument(JsonNode hit) {
209+
List<String> categories = new ArrayList<>();
210+
hit.path("categories").forEach(category -> categories.add(category.asText()));
211+
return new ContentDocument(
212+
text(hit, "id"), text(hit, "source"), text(hit, "locale"), text(hit, "url"),
213+
text(hit, "title"), text(hit, "excerpt"), text(hit, "body"), text(hit, "author"),
214+
categories, text(hit, "publishedDate"), text(hit, "lastmod"), text(hit, "previewImage"));
215+
}
216+
217+
// ---- small helpers ----
218+
219+
private static JsonNode firstResult(JsonNode response) {
220+
JsonNode results = response.path("results");
221+
return results.isArray() && !results.isEmpty() ? results.get(0) : null;
222+
}
223+
224+
private static String text(JsonNode node, String field) {
225+
JsonNode value = node.path(field);
226+
return value.isMissingNode() || value.isNull() ? null : value.asText();
227+
}
228+
229+
private static String firstNonBlank(String... values) {
230+
for (String value : values) {
231+
if (value != null && !value.isBlank()) {
232+
return value;
233+
}
234+
}
235+
return null;
236+
}
237+
238+
private static boolean present(String value) {
239+
return value != null && !value.isBlank();
240+
}
241+
242+
private static String escape(String value) {
243+
return value.replace("\"", "\\\"");
244+
}
245+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.openelements.content;
2+
3+
/**
4+
* One search or list result: the display fields plus a safe, highlighted snippet.
5+
*
6+
* @param title the document title
7+
* @param url the canonical URL
8+
* @param publishedDate the published date
9+
* @param snippet an HTML-safe snippet with {@code <em>} around matches (may be empty)
10+
* @param score the ranking score (0 when not available)
11+
*/
12+
public record SearchHit(String title, String url, String publishedDate, String snippet, double score) {
13+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.openelements.content;
2+
3+
import java.util.List;
4+
5+
/**
6+
* A page of search/list results.
7+
*
8+
* @param hits the results on this page
9+
* @param estimatedTotal Meilisearch's estimate of the total matching documents
10+
*/
11+
public record SearchHits(List<SearchHit> hits, long estimatedTotal) {
12+
13+
public SearchHits {
14+
hits = hits == null ? List.of() : List.copyOf(hits);
15+
}
16+
17+
static SearchHits empty() {
18+
return new SearchHits(List.of(), 0);
19+
}
20+
}

0 commit comments

Comments
 (0)