Skip to content

Commit c7306a9

Browse files
committed
feat(search): add a page-content search
- wiki_tools.content_search wraps the MediaWiki list=search API - WtSite.content_search delegates to it, like prefix_search - new operation search_content, CLI `osw search content`, MCP tool - returns page titles in the same shape as the sibling searches
1 parent e57eb1f commit c7306a9

6 files changed

Lines changed: 160 additions & 2 deletions

File tree

‎docs/cli-and-mcp.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ Failures exit non-zero with a short message on stderr and no traceback.
8888

8989
`osw[mcp]` ships an [MCP](https://modelcontextprotocol.io) server that exposes a
9090
live OpenSemanticLab instance to MCP clients such as Claude Code. It wraps
91-
`OswExpress` and provides tools to search (semantic / SPARQL / page titles),
91+
`OswExpress` and provides tools to search (semantic / SPARQL / page titles /
92+
page content),
9293
introspect category schemas, read entities and every page slot, create/update
9394
and delete entities, and read and write file pages as text. The transport is
9495
stdio; SSE and HTTP are not supported.

‎src/osw/service/ops/search.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Search and query operations: semantic (SMW ask), titles, instances, SPARQL."""
1+
"""Search operations: semantic (SMW ask), titles, content, instances, SPARQL."""
22

33
from __future__ import annotations
44

@@ -63,6 +63,7 @@ def search_titles(ctx: Context, text: str, limit: Optional[int] = None) -> dict:
6363
``prefixsearch`` API. OSW pages are titled by OSW-ID, for example
6464
``Item:OSW7ec...``, so an entity's name is not part of its title and
6565
cannot be found here. Use ``osw search ask`` to search by name.
66+
Use ``osw search content`` to search the text of pages.
6667
6768
Useful for the titles that are readable: categories, properties,
6869
templates and other schema pages.
@@ -77,6 +78,31 @@ def search_titles(ctx: Context, text: str, limit: Optional[int] = None) -> dict:
7778
return {"titles": capped, "count": total, "truncated": truncated}
7879

7980

81+
@operation(
82+
group="search",
83+
cli_name="content",
84+
read_only_hint=True,
85+
idempotent_hint=True,
86+
)
87+
def search_content(ctx: Context, text: str, limit: Optional[int] = None) -> dict:
88+
"""Search the text content of pages for ``text``.
89+
90+
Uses the MediaWiki ``search`` API, which reads page wikitext. On an OSW
91+
instance an entity's data lives in JSON slots, so a value stored as
92+
structured data may not be reachable here; ``osw search ask`` queries
93+
that data directly and is the better tool for it.
94+
95+
Returns page titles, not the matching passages. ``limit`` defaults to
96+
``OSW_MAX_RESULTS`` (100 when that is unset). Returns
97+
``{titles, count, truncated}``, where ``titles`` are full page names
98+
and ``count`` is how many the wiki returned.
99+
"""
100+
lim = ctx.limit(limit)
101+
titles = ctx.osw.site.content_search(WtSite.SearchParam(query=text, limit=lim))
102+
capped, total, truncated = cap_list(titles, lim)
103+
return {"titles": capped, "count": total, "truncated": truncated}
104+
105+
80106
@operation(
81107
group="search",
82108
cli_name="instances",

‎src/osw/wiki_tools.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,70 @@ def prefix_search_(single_text) -> Union[List[str], dict]:
216216
# return page_list # original return
217217

218218

219+
def content_search(
220+
site: mwclient.client.Site, text: Union[str, List[str], SearchParam]
221+
) -> Union[List[str], List[dict]]:
222+
"""Searches the content (wikitext) of pages. Equivalent to the following
223+
mediawiki API call api.php?action=query&list=search&srsearch=Star Wars.
224+
225+
See https://www.mediawiki.org/wiki/API:Search for details.
226+
227+
Parameters
228+
----------
229+
site :
230+
Site object from mwclient lib
231+
text :
232+
Query text or instance of SearchParam
233+
234+
Returns
235+
-------
236+
result:
237+
With ``return_json=False`` (default): a flat list of page titles. With
238+
``return_json=True``: a list of raw MediaWiki ``search`` API response
239+
dicts, one per query (always a list, even for a single query).
240+
"""
241+
if not isinstance(text, SearchParam):
242+
query = SearchParam(query=text)
243+
else:
244+
query = text
245+
246+
def content_search_(single_text) -> Union[List[str], dict]:
247+
page_list = list()
248+
result = site.api(
249+
"query",
250+
list="search",
251+
srsearch=single_text,
252+
srlimit=query.limit,
253+
format="json",
254+
)
255+
if query.debug and len(result["query"]["search"]) == 0:
256+
print("No results")
257+
if query.return_json:
258+
return result
259+
260+
for page in result["query"]["search"]:
261+
title = page["title"]
262+
if query.debug:
263+
print(title)
264+
page_list.append(title)
265+
return page_list
266+
267+
if query.parallel:
268+
query_results = parallelize(
269+
func=content_search_, iterable=query.query, flush_at_end=query.debug
270+
)
271+
else:
272+
query_results = [content_search_(single_text=sq) for sq in query.query]
273+
274+
if query.return_json:
275+
# Each entry of query_results is the raw API response dict for one query.
276+
# Do not flatten dicts; always return the list of responses (one per query),
277+
# even when only a single query was passed.
278+
return query_results
279+
280+
return [item for sublist in query_results for item in sublist]
281+
282+
219283
def semantic_search(
220284
site: mwclient.client.Site, query: Union[str, List[str], SearchParam]
221285
) -> Union[List[str], List[dict]]:

‎src/osw/wtsite.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,21 @@ def prefix_search(self, text: Union[str, SearchParam]):
532532
"""
533533
return wt.prefix_search(self._site, text)
534534

535+
@try_and_renew_token
536+
def content_search(self, text: Union[str, SearchParam]):
537+
"""Send a content (full-text) search request to the site.
538+
539+
Parameters
540+
----------
541+
text
542+
The search text or a SearchParam object
543+
544+
Returns
545+
-------
546+
A list of page titles
547+
"""
548+
return wt.content_search(self._site, text)
549+
535550
@try_and_renew_token
536551
def semantic_search(self, query: Union[str, SearchParam]):
537552
"""Send a swm ask query to the site.

‎tests/test_service_ops_search.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ def test_search_titles_calls_prefix_search():
4444
osw.site.prefix_search.assert_called_once()
4545

4646

47+
def test_search_content_calls_content_search():
48+
osw = MagicMock()
49+
osw.site.content_search.return_value = ["Item:OSW1"]
50+
ctx = Context(_settings(), Policy(), osw=osw)
51+
52+
result = search.search_content(ctx, text="sensor")
53+
54+
assert result["titles"] == ["Item:OSW1"]
55+
assert result["count"] == 1
56+
assert result["truncated"] is False
57+
osw.site.content_search.assert_called_once()
58+
59+
4760
def test_list_instances_of_category_calls_query_instances():
4861
osw = MagicMock()
4962
osw.query_instances.return_value = ["Item:OSW1", "Item:OSW2"]

‎tests/test_wiki_tools.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,42 @@ def test_prefix_search_returns_flat_list_of_titles():
128128

129129
# return_json=False (the default) still yields a flat list of page titles
130130
assert out == ["Star Wars", "Star Trek"]
131+
132+
133+
def _search_result(*titles):
134+
"""Build a minimal MediaWiki ``search`` API result dict."""
135+
return {
136+
"batchcomplete": "",
137+
"query": {
138+
"search": [
139+
{"ns": 0, "title": title, "pageid": idx}
140+
for idx, title in enumerate(titles, start=1)
141+
]
142+
},
143+
}
144+
145+
146+
def test_content_search_returns_flat_list_of_titles():
147+
result = _search_result("Star Wars", "Star Trek")
148+
site = MagicMock()
149+
site.api.return_value = result
150+
151+
out = wt.content_search(site, "Star")
152+
153+
# return_json=False (the default) still yields a flat list of page titles
154+
assert out == ["Star Wars", "Star Trek"]
155+
156+
157+
def test_content_search_calls_the_search_api():
158+
site = MagicMock()
159+
site.api.return_value = _search_result("Star Wars", "Star Trek")
160+
161+
wt.content_search(site, wt.SearchParam(query="Star", limit=7))
162+
163+
# Pin the API contract: the content search must use list=search with its
164+
# sr* parameters, not list=prefixsearch
165+
args, kwargs = site.api.call_args
166+
assert args == ("query",)
167+
assert kwargs["list"] == "search"
168+
assert kwargs["srsearch"] == "Star"
169+
assert kwargs["srlimit"] == 7

0 commit comments

Comments
 (0)