splent-feature-search 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- splent_feature_search-0.3.1/MANIFEST.in +24 -0
- splent_feature_search-0.3.1/PKG-INFO +203 -0
- splent_feature_search-0.3.1/README.md +196 -0
- splent_feature_search-0.3.1/pyproject.toml +61 -0
- splent_feature_search-0.3.1/setup.cfg +4 -0
- splent_feature_search-0.3.1/src/splent_feature_search.egg-info/PKG-INFO +203 -0
- splent_feature_search-0.3.1/src/splent_feature_search.egg-info/SOURCES.txt +22 -0
- splent_feature_search-0.3.1/src/splent_feature_search.egg-info/dependency_links.txt +1 -0
- splent_feature_search-0.3.1/src/splent_feature_search.egg-info/requires.txt +1 -0
- splent_feature_search-0.3.1/src/splent_feature_search.egg-info/top_level.txt +1 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/__init__.py +29 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/assets/css/search.css +417 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/assets/js/search.js +585 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/commands.py +167 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/config.py +72 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/hooks.py +75 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/routes.py +140 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/services.py +657 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/templates/search/_box.html +85 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/templates/search/nav_box.html +31 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/templates/search/results.html +68 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/translations/es/LC_MESSAGES/messages.mo +0 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/translations/es/LC_MESSAGES/messages.po +100 -0
- splent_feature_search-0.3.1/src/splent_io/splent_feature_search/translations/messages.pot +97 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Frontend assets: the whole tree, not only the compiled bundles.
|
|
2
|
+
#
|
|
3
|
+
# A feature that registers a plain stylesheet with register_asset("css", …)
|
|
4
|
+
# serves it straight from assets/css, and a vendored library lives under
|
|
5
|
+
# assets/vendor. Shipping assets/dist alone put no stylesheet at all in the
|
|
6
|
+
# built package, so every production image came up without its theme, its
|
|
7
|
+
# skin and its editor, while development looked fine because that path
|
|
8
|
+
# installs the checkout in place and reads the files off disk.
|
|
9
|
+
recursive-include src/splent_io/splent_feature_search/assets *.css *.js *.map *.json
|
|
10
|
+
recursive-include src/splent_io/splent_feature_search/assets *.svg *.png *.jpg *.jpeg *.gif *.webp *.ico
|
|
11
|
+
recursive-include src/splent_io/splent_feature_search/assets *.woff *.woff2 *.ttf *.eot
|
|
12
|
+
|
|
13
|
+
# Build inputs, never shipped.
|
|
14
|
+
prune src/splent_io/splent_feature_search/assets/node_modules
|
|
15
|
+
|
|
16
|
+
# Jinja templates (feature views + hook fragments)
|
|
17
|
+
recursive-include src/splent_io/splent_feature_search/templates *.html
|
|
18
|
+
|
|
19
|
+
# Alembic migration config and scripts
|
|
20
|
+
recursive-include src/splent_io/splent_feature_search/migrations *.py *.ini *.mako
|
|
21
|
+
|
|
22
|
+
# Translations. The .mo is what gettext reads at runtime; the .po
|
|
23
|
+
# travels with it so the next translator has something to edit.
|
|
24
|
+
recursive-include src/splent_io/splent_feature_search/translations *.mo *.po *.pot
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: splent_feature_search
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Requires-Python: >=3.13
|
|
5
|
+
Description-Content-Type: text/markdown
|
|
6
|
+
Requires-Dist: splent_framework>=1.9.0
|
|
7
|
+
|
|
8
|
+
# splent_feature_search
|
|
9
|
+
|
|
10
|
+
Search across a whole product, over material this feature knows nothing about.
|
|
11
|
+
|
|
12
|
+
A SPLENT product is assembled from features, and the material worth searching
|
|
13
|
+
belongs to them: pages to courses, articles to post, people to team. This
|
|
14
|
+
feature owns the search page, the index and the ranking, and owns no content at
|
|
15
|
+
all. A content feature makes itself searchable by registering three callables
|
|
16
|
+
with the framework, and a search written today keeps working with a content
|
|
17
|
+
feature written next year. Neither imports the other.
|
|
18
|
+
|
|
19
|
+
## The seam
|
|
20
|
+
|
|
21
|
+
`splent_framework.search.search_registry` is the whole contract. A content
|
|
22
|
+
feature calls this once, from `init_feature`:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from splent_framework.search.search_registry import register_search_source
|
|
26
|
+
|
|
27
|
+
def init_feature(app):
|
|
28
|
+
register_search_source(
|
|
29
|
+
key="courses",
|
|
30
|
+
label="Pages",
|
|
31
|
+
fetch=fetch_pages,
|
|
32
|
+
resolve=resolve_page,
|
|
33
|
+
find=find_pages, # optional
|
|
34
|
+
scopes=course_scopes, # optional
|
|
35
|
+
current_scope=course_of_url, # optional
|
|
36
|
+
order=100,
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`fetch()` yields every document, for building an index. Each one is
|
|
41
|
+
`{"id": str, "title": str, "body": str}` and may carry `"url"`, `"scope"` and
|
|
42
|
+
any extra field the feature wants stored.
|
|
43
|
+
|
|
44
|
+
`resolve(doc_id, user)` is the authority. Given one candidate and the reader
|
|
45
|
+
asking, it returns a presentable result, `{"title": ..., "url": ...,
|
|
46
|
+
"snippet": ...}`, or `None` to drop it. It is called once per candidate on
|
|
47
|
+
every search, so it should be a primary key lookup and not a scan.
|
|
48
|
+
|
|
49
|
+
`find(term, user, scope=None)` is optional and is how the feature searches
|
|
50
|
+
itself when no engine is reachable. Same return shape as `resolve`. A source
|
|
51
|
+
that omits it contributes nothing while the engine is down, which is a decision
|
|
52
|
+
the feature gets to make. When a scope arrives it has to be honoured: without an
|
|
53
|
+
index there is nothing else between the reader and the whole site.
|
|
54
|
+
|
|
55
|
+
`order` sorts the groups on the results page. `label` names the group and is
|
|
56
|
+
translated at render time, so pass a plain or a lazy string, never one already
|
|
57
|
+
translated at startup.
|
|
58
|
+
|
|
59
|
+
## Narrowing
|
|
60
|
+
|
|
61
|
+
A wiki holding fourteen academic years has a page called "Lab 5" in every one of
|
|
62
|
+
them, so "everywhere" is the least useful answer the box can give. Two more
|
|
63
|
+
optional callables turn one box into "search this course", without this feature
|
|
64
|
+
ever learning what a course is.
|
|
65
|
+
|
|
66
|
+
`scopes(user)` returns the places this reader may narrow to, as
|
|
67
|
+
`[{"key": "egc-20252026", "label": "EGC 2025/2026"}, ...]` in display order,
|
|
68
|
+
already filtered to what they may be told exists. The list is rendered in a
|
|
69
|
+
selector, so a course that has not been announced is a course whose name the
|
|
70
|
+
selector would announce.
|
|
71
|
+
|
|
72
|
+
`current_scope()` returns where the reader is standing right now, read out of
|
|
73
|
+
the request, or `None`. It belongs to the feature that owns those URLs. Only
|
|
74
|
+
courses knows that the second segment of `/cursos/egc-20252026/paginas/lab-5`
|
|
75
|
+
names a course, and a search feature taking the URL apart itself would be
|
|
76
|
+
guessing at another feature's routing, would guess wrong the day the product
|
|
77
|
+
serves it under a different word, and would have to guess again for every
|
|
78
|
+
content feature ever written.
|
|
79
|
+
|
|
80
|
+
A document carries `"scope"` with the key of the place it belongs to. It is
|
|
81
|
+
indexed as a keyword, matched whole and never analysed, and a narrowed search
|
|
82
|
+
filters on it. A document with no scope belongs nowhere in particular and does
|
|
83
|
+
not appear in a narrowed search.
|
|
84
|
+
|
|
85
|
+
A scope is applied as it arrives and is never checked against the list first. A
|
|
86
|
+
name nobody offers matches no document and answers nothing, which is what a
|
|
87
|
+
stale bookmark or an edited address deserves; checking it and falling back would
|
|
88
|
+
turn that same request into a search over the whole site, which is the one
|
|
89
|
+
answer nobody asked for. Nothing leaks either way, because every candidate is
|
|
90
|
+
still resolved by the feature that owns it.
|
|
91
|
+
|
|
92
|
+
The box in the header defaults to `current_scope()` and lets the reader switch
|
|
93
|
+
to everything or to another place. On the search page, the scope the page
|
|
94
|
+
searched wins, so the two boxes never disagree. An empty `scope=` means
|
|
95
|
+
everything and is not the same as no `scope=` at all, which means wherever the
|
|
96
|
+
reader is.
|
|
97
|
+
|
|
98
|
+
## Why the split
|
|
99
|
+
|
|
100
|
+
An index is a cache and is never the authority on visibility.
|
|
101
|
+
|
|
102
|
+
It is a copy taken at some moment in the past, and material is withheld between
|
|
103
|
+
one reindex and the next. If a search answered out of the index, the first
|
|
104
|
+
reader to type "exam" after an exam was pulled would be shown its title, and a
|
|
105
|
+
title gives away as much as a body does. So Elasticsearch is only ever allowed
|
|
106
|
+
to propose candidates: the query asks for ids and switches `_source` off, and
|
|
107
|
+
every id goes back to the feature that owns it through
|
|
108
|
+
`resolve_search_hit(key, doc_id, user)`, at request time, with the reader in
|
|
109
|
+
hand. Nothing stored in the index is ever rendered, not even the title.
|
|
110
|
+
|
|
111
|
+
Deny by default, exactly like the file access registry. A source nobody
|
|
112
|
+
registered, a resolver that raises, a result with no URL: each one drops its
|
|
113
|
+
candidate and leaves the rest of the search alone.
|
|
114
|
+
|
|
115
|
+
The other half of the rule is what never goes into an index. Documents carry no
|
|
116
|
+
visibility, and the registry says so. Recording who may read what would be
|
|
117
|
+
wrong the moment somebody withheld a page, and it would be answering a question
|
|
118
|
+
about a reader who is not there yet and will be somebody else tomorrow.
|
|
119
|
+
|
|
120
|
+
## What a product gets
|
|
121
|
+
|
|
122
|
+
- `GET /<SEARCH_PATH>` (`search.index`), a server-rendered page. It is a plain
|
|
123
|
+
GET form and it works with JavaScript switched off. A term shorter than
|
|
124
|
+
`SEARCH_MIN_CHARS` renders the empty state, not an error.
|
|
125
|
+
- `GET /<SEARCH_PATH>/results.json` (`search.results`), the same answer as JSON,
|
|
126
|
+
for the script that makes the page feel immediate.
|
|
127
|
+
- A search box in `layout.nav`, unless `SEARCH_NAV` is off. It answers over
|
|
128
|
+
AJAX into a panel under the box, with the arrow keys moving, Enter opening,
|
|
129
|
+
Escape closing, and a plain form submit when there is no script. Nothing in
|
|
130
|
+
that panel is written with `innerHTML`.
|
|
131
|
+
- `SearchService` in the service locator, with `query()`, `scopes()`,
|
|
132
|
+
`reindex()` and `index_report()`.
|
|
133
|
+
- `splent feature:search reindex [--source KEY]` and
|
|
134
|
+
`splent feature:search status`.
|
|
135
|
+
|
|
136
|
+
The JSON answer:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"query": "grafo",
|
|
141
|
+
"engine": "elasticsearch",
|
|
142
|
+
"scope": "egc-20252026",
|
|
143
|
+
"scopes": [
|
|
144
|
+
{"key": "", "label": "Everything"},
|
|
145
|
+
{"key": "egc-20252026", "label": "EGC 2025/2026"}
|
|
146
|
+
],
|
|
147
|
+
"total": 12,
|
|
148
|
+
"groups": [
|
|
149
|
+
{"key": "courses", "label": "Pages",
|
|
150
|
+
"results": [{"title": "...", "url": "/cursos/...", "snippet": "..."}]}
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`engine` says how the answer was produced: `elasticsearch` from the index,
|
|
156
|
+
`fallback` from the sources' own `find()`, `none` when nothing produced it. The
|
|
157
|
+
page says so too, in a sentence, rather than letting a thin answer look like a
|
|
158
|
+
site with nothing in it.
|
|
159
|
+
|
|
160
|
+
`scope` is the narrowing that was applied, or `null`. `scopes` is what the
|
|
161
|
+
selector offers, always including the everything option under the empty key, and
|
|
162
|
+
it travels with every answer because the box that asked the question is redrawn
|
|
163
|
+
from it.
|
|
164
|
+
|
|
165
|
+
## The engine is optional
|
|
166
|
+
|
|
167
|
+
`splent_feature_elasticsearch` provides the transport. A product may install
|
|
168
|
+
search without it, and a product that installed it may be running while the
|
|
169
|
+
node is down. Both end up on the fallback, and the answer is labelled either
|
|
170
|
+
way. The contract names elasticsearch under `requires.features` because
|
|
171
|
+
`services.py` imports its exception types and the contract scanner reads
|
|
172
|
+
dependencies out of import statements; the import itself is guarded and the
|
|
173
|
+
transport is reached by name through the service locator.
|
|
174
|
+
|
|
175
|
+
One index per source, named `<ELASTICSEARCH_INDEX_PREFIX>_<source_key>`. The
|
|
176
|
+
prefix belongs to the engine feature, which keeps two products sharing a node
|
|
177
|
+
out of each other's documents, so everything here passes the bare source key
|
|
178
|
+
and lets the engine put it in the right namespace.
|
|
179
|
+
|
|
180
|
+
`reindex` drops each index and builds it again, because writing over it leaves
|
|
181
|
+
behind every document the feature no longer has. The window in which an index
|
|
182
|
+
is incomplete costs results and never privacy, which is the only direction that
|
|
183
|
+
is safe to be wrong in.
|
|
184
|
+
|
|
185
|
+
## Configuration
|
|
186
|
+
|
|
187
|
+
| Env var | Default | Purpose |
|
|
188
|
+
| --- | --- | --- |
|
|
189
|
+
| `SEARCH_PATH` | `search` | The word the search is served under. It ends up in links people send each other. |
|
|
190
|
+
| `SEARCH_LIMIT` | `20` | Candidates taken from each source, per source rather than in total. |
|
|
191
|
+
| `SEARCH_MIN_CHARS` | `2` | Below this a term is somebody still typing, not a question. |
|
|
192
|
+
| `SEARCH_NAV` | `true` | Whether the public header carries a search box. |
|
|
193
|
+
|
|
194
|
+
## Tests
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
splent feature:test splent_feature_search
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The sources in the tests are fakes registered through the framework registry,
|
|
201
|
+
so nothing here depends on a content feature being installed, and the engine is
|
|
202
|
+
a fake index that proposes exactly what a test put in it, including documents
|
|
203
|
+
the resolver then refuses.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# splent_feature_search
|
|
2
|
+
|
|
3
|
+
Search across a whole product, over material this feature knows nothing about.
|
|
4
|
+
|
|
5
|
+
A SPLENT product is assembled from features, and the material worth searching
|
|
6
|
+
belongs to them: pages to courses, articles to post, people to team. This
|
|
7
|
+
feature owns the search page, the index and the ranking, and owns no content at
|
|
8
|
+
all. A content feature makes itself searchable by registering three callables
|
|
9
|
+
with the framework, and a search written today keeps working with a content
|
|
10
|
+
feature written next year. Neither imports the other.
|
|
11
|
+
|
|
12
|
+
## The seam
|
|
13
|
+
|
|
14
|
+
`splent_framework.search.search_registry` is the whole contract. A content
|
|
15
|
+
feature calls this once, from `init_feature`:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from splent_framework.search.search_registry import register_search_source
|
|
19
|
+
|
|
20
|
+
def init_feature(app):
|
|
21
|
+
register_search_source(
|
|
22
|
+
key="courses",
|
|
23
|
+
label="Pages",
|
|
24
|
+
fetch=fetch_pages,
|
|
25
|
+
resolve=resolve_page,
|
|
26
|
+
find=find_pages, # optional
|
|
27
|
+
scopes=course_scopes, # optional
|
|
28
|
+
current_scope=course_of_url, # optional
|
|
29
|
+
order=100,
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`fetch()` yields every document, for building an index. Each one is
|
|
34
|
+
`{"id": str, "title": str, "body": str}` and may carry `"url"`, `"scope"` and
|
|
35
|
+
any extra field the feature wants stored.
|
|
36
|
+
|
|
37
|
+
`resolve(doc_id, user)` is the authority. Given one candidate and the reader
|
|
38
|
+
asking, it returns a presentable result, `{"title": ..., "url": ...,
|
|
39
|
+
"snippet": ...}`, or `None` to drop it. It is called once per candidate on
|
|
40
|
+
every search, so it should be a primary key lookup and not a scan.
|
|
41
|
+
|
|
42
|
+
`find(term, user, scope=None)` is optional and is how the feature searches
|
|
43
|
+
itself when no engine is reachable. Same return shape as `resolve`. A source
|
|
44
|
+
that omits it contributes nothing while the engine is down, which is a decision
|
|
45
|
+
the feature gets to make. When a scope arrives it has to be honoured: without an
|
|
46
|
+
index there is nothing else between the reader and the whole site.
|
|
47
|
+
|
|
48
|
+
`order` sorts the groups on the results page. `label` names the group and is
|
|
49
|
+
translated at render time, so pass a plain or a lazy string, never one already
|
|
50
|
+
translated at startup.
|
|
51
|
+
|
|
52
|
+
## Narrowing
|
|
53
|
+
|
|
54
|
+
A wiki holding fourteen academic years has a page called "Lab 5" in every one of
|
|
55
|
+
them, so "everywhere" is the least useful answer the box can give. Two more
|
|
56
|
+
optional callables turn one box into "search this course", without this feature
|
|
57
|
+
ever learning what a course is.
|
|
58
|
+
|
|
59
|
+
`scopes(user)` returns the places this reader may narrow to, as
|
|
60
|
+
`[{"key": "egc-20252026", "label": "EGC 2025/2026"}, ...]` in display order,
|
|
61
|
+
already filtered to what they may be told exists. The list is rendered in a
|
|
62
|
+
selector, so a course that has not been announced is a course whose name the
|
|
63
|
+
selector would announce.
|
|
64
|
+
|
|
65
|
+
`current_scope()` returns where the reader is standing right now, read out of
|
|
66
|
+
the request, or `None`. It belongs to the feature that owns those URLs. Only
|
|
67
|
+
courses knows that the second segment of `/cursos/egc-20252026/paginas/lab-5`
|
|
68
|
+
names a course, and a search feature taking the URL apart itself would be
|
|
69
|
+
guessing at another feature's routing, would guess wrong the day the product
|
|
70
|
+
serves it under a different word, and would have to guess again for every
|
|
71
|
+
content feature ever written.
|
|
72
|
+
|
|
73
|
+
A document carries `"scope"` with the key of the place it belongs to. It is
|
|
74
|
+
indexed as a keyword, matched whole and never analysed, and a narrowed search
|
|
75
|
+
filters on it. A document with no scope belongs nowhere in particular and does
|
|
76
|
+
not appear in a narrowed search.
|
|
77
|
+
|
|
78
|
+
A scope is applied as it arrives and is never checked against the list first. A
|
|
79
|
+
name nobody offers matches no document and answers nothing, which is what a
|
|
80
|
+
stale bookmark or an edited address deserves; checking it and falling back would
|
|
81
|
+
turn that same request into a search over the whole site, which is the one
|
|
82
|
+
answer nobody asked for. Nothing leaks either way, because every candidate is
|
|
83
|
+
still resolved by the feature that owns it.
|
|
84
|
+
|
|
85
|
+
The box in the header defaults to `current_scope()` and lets the reader switch
|
|
86
|
+
to everything or to another place. On the search page, the scope the page
|
|
87
|
+
searched wins, so the two boxes never disagree. An empty `scope=` means
|
|
88
|
+
everything and is not the same as no `scope=` at all, which means wherever the
|
|
89
|
+
reader is.
|
|
90
|
+
|
|
91
|
+
## Why the split
|
|
92
|
+
|
|
93
|
+
An index is a cache and is never the authority on visibility.
|
|
94
|
+
|
|
95
|
+
It is a copy taken at some moment in the past, and material is withheld between
|
|
96
|
+
one reindex and the next. If a search answered out of the index, the first
|
|
97
|
+
reader to type "exam" after an exam was pulled would be shown its title, and a
|
|
98
|
+
title gives away as much as a body does. So Elasticsearch is only ever allowed
|
|
99
|
+
to propose candidates: the query asks for ids and switches `_source` off, and
|
|
100
|
+
every id goes back to the feature that owns it through
|
|
101
|
+
`resolve_search_hit(key, doc_id, user)`, at request time, with the reader in
|
|
102
|
+
hand. Nothing stored in the index is ever rendered, not even the title.
|
|
103
|
+
|
|
104
|
+
Deny by default, exactly like the file access registry. A source nobody
|
|
105
|
+
registered, a resolver that raises, a result with no URL: each one drops its
|
|
106
|
+
candidate and leaves the rest of the search alone.
|
|
107
|
+
|
|
108
|
+
The other half of the rule is what never goes into an index. Documents carry no
|
|
109
|
+
visibility, and the registry says so. Recording who may read what would be
|
|
110
|
+
wrong the moment somebody withheld a page, and it would be answering a question
|
|
111
|
+
about a reader who is not there yet and will be somebody else tomorrow.
|
|
112
|
+
|
|
113
|
+
## What a product gets
|
|
114
|
+
|
|
115
|
+
- `GET /<SEARCH_PATH>` (`search.index`), a server-rendered page. It is a plain
|
|
116
|
+
GET form and it works with JavaScript switched off. A term shorter than
|
|
117
|
+
`SEARCH_MIN_CHARS` renders the empty state, not an error.
|
|
118
|
+
- `GET /<SEARCH_PATH>/results.json` (`search.results`), the same answer as JSON,
|
|
119
|
+
for the script that makes the page feel immediate.
|
|
120
|
+
- A search box in `layout.nav`, unless `SEARCH_NAV` is off. It answers over
|
|
121
|
+
AJAX into a panel under the box, with the arrow keys moving, Enter opening,
|
|
122
|
+
Escape closing, and a plain form submit when there is no script. Nothing in
|
|
123
|
+
that panel is written with `innerHTML`.
|
|
124
|
+
- `SearchService` in the service locator, with `query()`, `scopes()`,
|
|
125
|
+
`reindex()` and `index_report()`.
|
|
126
|
+
- `splent feature:search reindex [--source KEY]` and
|
|
127
|
+
`splent feature:search status`.
|
|
128
|
+
|
|
129
|
+
The JSON answer:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"query": "grafo",
|
|
134
|
+
"engine": "elasticsearch",
|
|
135
|
+
"scope": "egc-20252026",
|
|
136
|
+
"scopes": [
|
|
137
|
+
{"key": "", "label": "Everything"},
|
|
138
|
+
{"key": "egc-20252026", "label": "EGC 2025/2026"}
|
|
139
|
+
],
|
|
140
|
+
"total": 12,
|
|
141
|
+
"groups": [
|
|
142
|
+
{"key": "courses", "label": "Pages",
|
|
143
|
+
"results": [{"title": "...", "url": "/cursos/...", "snippet": "..."}]}
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`engine` says how the answer was produced: `elasticsearch` from the index,
|
|
149
|
+
`fallback` from the sources' own `find()`, `none` when nothing produced it. The
|
|
150
|
+
page says so too, in a sentence, rather than letting a thin answer look like a
|
|
151
|
+
site with nothing in it.
|
|
152
|
+
|
|
153
|
+
`scope` is the narrowing that was applied, or `null`. `scopes` is what the
|
|
154
|
+
selector offers, always including the everything option under the empty key, and
|
|
155
|
+
it travels with every answer because the box that asked the question is redrawn
|
|
156
|
+
from it.
|
|
157
|
+
|
|
158
|
+
## The engine is optional
|
|
159
|
+
|
|
160
|
+
`splent_feature_elasticsearch` provides the transport. A product may install
|
|
161
|
+
search without it, and a product that installed it may be running while the
|
|
162
|
+
node is down. Both end up on the fallback, and the answer is labelled either
|
|
163
|
+
way. The contract names elasticsearch under `requires.features` because
|
|
164
|
+
`services.py` imports its exception types and the contract scanner reads
|
|
165
|
+
dependencies out of import statements; the import itself is guarded and the
|
|
166
|
+
transport is reached by name through the service locator.
|
|
167
|
+
|
|
168
|
+
One index per source, named `<ELASTICSEARCH_INDEX_PREFIX>_<source_key>`. The
|
|
169
|
+
prefix belongs to the engine feature, which keeps two products sharing a node
|
|
170
|
+
out of each other's documents, so everything here passes the bare source key
|
|
171
|
+
and lets the engine put it in the right namespace.
|
|
172
|
+
|
|
173
|
+
`reindex` drops each index and builds it again, because writing over it leaves
|
|
174
|
+
behind every document the feature no longer has. The window in which an index
|
|
175
|
+
is incomplete costs results and never privacy, which is the only direction that
|
|
176
|
+
is safe to be wrong in.
|
|
177
|
+
|
|
178
|
+
## Configuration
|
|
179
|
+
|
|
180
|
+
| Env var | Default | Purpose |
|
|
181
|
+
| --- | --- | --- |
|
|
182
|
+
| `SEARCH_PATH` | `search` | The word the search is served under. It ends up in links people send each other. |
|
|
183
|
+
| `SEARCH_LIMIT` | `20` | Candidates taken from each source, per source rather than in total. |
|
|
184
|
+
| `SEARCH_MIN_CHARS` | `2` | Below this a term is somebody still typing, not a question. |
|
|
185
|
+
| `SEARCH_NAV` | `true` | Whether the public header carries a search box. |
|
|
186
|
+
|
|
187
|
+
## Tests
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
splent feature:test splent_feature_search
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The sources in the tests are fakes registered through the framework registry,
|
|
194
|
+
so nothing here depends on a content feature being installed, and the engine is
|
|
195
|
+
a fake index that proposes exactly what a test put in it, including documents
|
|
196
|
+
the resolver then refuses.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=80.3.1", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "splent_feature_search"
|
|
7
|
+
version = "0.3.1"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
|
|
11
|
+
# Nothing. The Elasticsearch client belongs to splent_feature_elasticsearch,
|
|
12
|
+
# which owns the transport; this feature reaches it through the service
|
|
13
|
+
# locator and works without it.
|
|
14
|
+
dependencies = [
|
|
15
|
+
"splent_framework>=1.9.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
package-dir = { "" = "src" }
|
|
20
|
+
include-package-data = true
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
exclude = ["*.tests", "*.tests.*"]
|
|
25
|
+
|
|
26
|
+
[tool.splent]
|
|
27
|
+
cli_version = "1.15.0"
|
|
28
|
+
|
|
29
|
+
# ── Feature Contract (auto-generated) ────────────────────────────────────────
|
|
30
|
+
# Do not edit manually — re-run `splent feature:contract --write` to refresh.
|
|
31
|
+
# Preserved across refreshes: description, category, tags, env,
|
|
32
|
+
# requires.features_manual (dependencies invisible to static analysis)
|
|
33
|
+
# and provides.routes_manual (routes built from configuration at
|
|
34
|
+
# registration time, which have no literal path to find in source).
|
|
35
|
+
[tool.splent.contract]
|
|
36
|
+
description = "Product-wide search over whatever the installed features declare searchable, with the owning feature deciding at request time who may see each hit"
|
|
37
|
+
archetype = "light"
|
|
38
|
+
|
|
39
|
+
[tool.splent.contract.provides]
|
|
40
|
+
routes = ["/<SEARCH_PATH>", "/<SEARCH_PATH>/results.json"]
|
|
41
|
+
routes_manual = ["/<SEARCH_PATH>", "/<SEARCH_PATH>/results.json"]
|
|
42
|
+
blueprints = []
|
|
43
|
+
models = []
|
|
44
|
+
commands = ["reindex", "status"]
|
|
45
|
+
hooks = ["layout.nav"]
|
|
46
|
+
services = ["SearchService"]
|
|
47
|
+
signals = []
|
|
48
|
+
translations = ["es"]
|
|
49
|
+
docker = []
|
|
50
|
+
|
|
51
|
+
[tool.splent.contract.requires]
|
|
52
|
+
features = ["elasticsearch"]
|
|
53
|
+
env_vars = ["SEARCH_LIMIT", "SEARCH_MIN_CHARS", "SEARCH_NAV", "SEARCH_PATH", "SEARCH_PLACEHOLDER"]
|
|
54
|
+
signals = []
|
|
55
|
+
|
|
56
|
+
[tool.splent.contract.extensible]
|
|
57
|
+
services = ["SearchService"]
|
|
58
|
+
templates = ["search/nav_box.html", "search/results.html"]
|
|
59
|
+
models = []
|
|
60
|
+
hooks = ["layout.nav"]
|
|
61
|
+
routes = false
|