fix(search): relevance scoring, shared parser probe and accent-safe filters - #536
Merged
Conversation
…cented boundaries Testing v0.4.122 through an MCP client rather than curl showed ckan_find_relevant_datasets answering its own documented example badly. `defibrillatori Comune di Lecce` on dati.gov.it used to return the catalog's only Lecce dataset — because the search returned exactly one result. With recall restored it returns 679, and the top three became Trento, Martina Franca and Desio while the Lecce dataset fell out of the window. The wrapping fix did not cause this, it removed the cover. Three older defects: - scoreTextField awarded the whole field weight when any term matched, so "Comune di Martina Franca" and "Comune di Lecce" both scored a full holder match and the right dataset could not outrank the wrong ones. It now scores the share of terms the field carries. - the stopword list was English-only, so `di` counted as a term and "Provincia Autonoma di Trento" earned a full holder match on a query asking for Lecce. - `\b` is ASCII-only in JavaScript, so a term ending in an accented letter never found its word boundary: `mobilità` scored 0 against "mobilità urbana", `qualità` against "qualità dell'aria". On a mostly non-English catalog this sank every accented query. Replaced with Unicode lookarounds. The candidate window is now at least 50: the local ranking only sees what Solr returns first, and `limit: 3` shrank it to 15. Verified: the Lecce dataset is first again, `qualità dell'aria Milano` returns air quality monitoring datasets. 532 tests, 5 added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
|
| Filename | Overview |
|---|---|
| src/tools/package.ts | Improves multilingual term extraction, proportional relevance scoring, Unicode matching, parser resolution, and candidate selection. |
| src/tools/tag.ts | Adds accent-safe filtering, bounded-first facet retrieval, explicit fallback failure propagation, and deterministic count ordering. |
| src/tools/organization.ts | Normalizes accented organization search patterns before constructing Solr wildcard queries. |
| tests/unit/package-scoring.test.ts | Adds focused regressions for stopwords, acronyms, proportional scoring, boundaries, and Unicode normalization. |
| openspec/specs/ckan-search/spec.md | Defines shared parser-selection and relevance-ranking invariants for package-search tools. |
Reviews (5): Last reviewed commit: "fix(tags): let an escalation failure sur..." | Re-trigger Greptile
…e filters Sweeping for defects of the same family as the scoring dilution turned up three more, all local filters running over a truncated or wrongly-normalised set. - ckan_find_relevant_datasets never called the parser probe. portals.json used to cover it, and removing force_text_field left it sending boolean queries to the parser that ignores them: on dati.comune.milano.it `aria OR acqua` returned 0 there against 87 from ckan_package_search. Both tools now share the probe. - ckan_organization_search builds a Solr wildcard, which bypasses the analysis chain, so the pattern has to be pre-normalised the way CKAN builds a name slug. It lowercased but did not fold accents: `città` returned 0 organizations while `citta` matched 135 datasets' worth. - ckan_tag_list applied tag_query after faceting with facet.limit set to the caller's limit. On dati.gov.it 53 tags contain "citta" and none is in the top 100, so the filter answered "no tags" while they existed. openspec/specs/ckan-search/spec.md named ckan_package_search alone in both parser scenarios, which is what let the second caller go unnoticed, and after #534 it was also wrong — it still described the per-portal default that was removed. Rewritten as a property of the query-building path, naming every tool that shares it, with the invariant that a change touching the parser is verified against all of them. `openspec validate --specs --strict` passes. Verified e2e: find_relevant_datasets 87 and 7 on the two boolean queries, matching package_search; `città` finds 4 organizations and 5 tags; the Lecce dataset stays first. 532 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
…facet Three review findings, all confirmed: - the Italian stopwords were applied to every portal, so `UN population` lost the `UN`. Removing the list is not an option — without it the Lecce query ranks Martina Franca first, since `di` inflates its title match — so an all-caps token now survives the list: an acronym is not an article. - term matching compared raw Unicode, so an NFC query would miss NFD metadata. Both sides are normalised to NFC now. - widening the tag facet to 1000 moved the false-negative boundary instead of removing it. `facet.limit: -1` returns every tag when a filter is given: 14138 on dati.gov.it, against 3 from the tag_list action. 535 tests, 3 added. Verified e2e: the Lecce dataset stays first with `di` still dropped, `UN population` keeps its acronym, `città` matches across the full tag set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
…up short
`facet.limit: -1` made every filtered tag query download the whole facet — 1.4 MB
and 0.5s on dati.gov.it, more on a larger catalog — however few tags the caller
asked for.
The filtering cannot move server-side: CKAN's parameter whitelist rejects Solr's
`facet.contains` with "Invalid search parameters". So the facet starts bounded at
max(limit * 20, 500), roughly 50 KB, and only a filter that came up short pays for
the full set. `citta` is answered by the bounded window, `zzzqwerty` escalates and
still returns nothing, which is the honest answer.
The escalation exposed one more thing: Solr sorts a facet by count only while
facet.limit is positive. Asked for -1 it returns index order, so the widened set
arrived reverse-alphabetical ("zuglio", "zucs", ...) and the first three matches
were arbitrary. `facet.sort` is rejected by CKAN as well, so the ordering the tool
documents is restored locally: `acqua` now returns acqua(154),
impianti-agricoli-e-di-acquacoltura(66), acqua-dolce(13).
535 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
- the exhaustive facet request swallowed its error with `.catch(() => null)` and returned the bounded set, answering "these are the matching tags" while hiding the ones it never looked at — the same silent truncation this PR is about. The error now goes to the tool's normal path. - the stripAccents import sat after the type-only one, splitting the internal group. AGENTS.md orders imports external, internal, types. 535 tests. Verified: `acqua` still returns acqua(154), impianti-agricoli-e-di-acquacoltura(66), acqua-dolce(13); a filter matching nothing still returns nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
aborruso
added a commit
that referenced
this pull request
Sep 5, 2026
Ships #536, the relevance and parser-probe fixes. Version bumped in package.json, package-lock.json, manifest.json, server.json (both fields), src/server.ts and src/worker.ts. No tools added or removed, so /health stays at 20. 535 tests pass. Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 5, 2026
aborruso
added a commit
that referenced
this pull request
Sep 6, 2026
Review finding on #543: `"OR"` in double quotes is a literal search term to Solr, and dropping every OR token after tokenisation had discarded the quotes left such a query with no terms to score on. It had never scored — before #536 the `or` stopword took it — so this is a step past the regression, not just its repair. Quoted tokens are collected before filtering; an operator is dropped only when it is bare. Test added; 545 tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
aborruso
added a commit
that referenced
this pull request
Sep 6, 2026
* fix(scoring): a Solr operator is not an acronym The all-caps exception added in #536 so that `UN population` keeps its `UN` also let `OR`, `AND` and `NOT` through: `aria OR acqua` scored on three terms, and a title carrying one of them got 4 × 1/3 instead of 4 × 1/2. Found testing the local MCP client after the merge — the term list in the response read ["aria","or","acqua"]. Solr's boolean keywords are dropped before the acronym rule. Lowercase `or` was already a stopword and stays one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2 * fix(scoring): a quoted operator is a literal, and scores Review finding on #543: `"OR"` in double quotes is a literal search term to Solr, and dropping every OR token after tokenisation had discarded the quotes left such a query with no terms to score on. It had never scored — before #536 the `or` stopword took it — so this is a step past the regression, not just its repair. Quoted tokens are collected before filtering; an operator is dropped only when it is bare. Test added; 545 tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2 * fix(scoring): an escaped quote does not end a quoted phrase Review finding: the quoted-fragment regex stopped at a backslash-escaped quote, so in `"OR\" AND"` only `OR` counted as quoted and the literal `AND` was dropped as syntax. The regex now skips escaped characters inside the phrase. Test added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What happened
Testing v0.4.122 through an MCP client rather than curl showed
ckan_find_relevant_datasetsanswering its own documented example badly.defibrillatori Comune di Lecceis the example the tool's own description gives for holder weighting. It used to work for the wrong reason: the search returned exactly one result, so there was nothing to rank.The wrapping fix in #534 did not cause this. It removed the cover from defects that were already there — and looking for more of the same family turned up six in total.
Relevance scoring
Any term earned the whole field.
scoreTextFieldreturned the full weight if any query term matched, so "Comune di Martina Franca" and "Comune di Lecce" both scored a full holder match of 4 and the dataset actually held by Lecce could not outrank the others. It now scores the share of terms the field carries: 2 of 3 → 2.7, 1 of 3 → 1.3.The stopword list was English-only.
dicounted as a query term, so "Provincia Autonoma di Trento" earned a full holder match on a query asking for Lecce. That is why nearly every candidate scored 13.\bis ASCII-only in JavaScript. A term ending in an accented letter never found its word boundary:mobilitàscored 0 against "mobilità urbana",qualitàagainst "qualità dell'aria". On a catalog that is mostly not in English this silently sank every accented query. Replaced with Unicode lookarounds, which still rejectimmobilitàformobilità.The candidate window was too small. Now at least 50 (
Math.min(Math.max(limit * 5, 50), 100)): the local ranking only scores what Solr returns first, andlimit: 3shrank the window to 15.Three more of the same family
ckan_find_relevant_datasetsnever called the parser probe.portals.jsonused to cover it; removingforce_text_fieldin #534 left it sending boolean queries to the parser that ignores them.ckan_package_searchckan_find_relevant_datasetsbeforearia OR acqua(Milano)bandiera blu OR spiaggeckan_organization_searchdid not fold accents. It builds a Solr wildcard, which bypasses the analysis chain, so the pattern has to be pre-normalised the way CKAN builds a name slug. It lowercased but stopped there:cittàreturned 0 whilecittamatchedcitta-metropolitana-di-napoliand 134 others.ckan_tag_listfiltered a truncated list.tag_queryis applied after faceting, withfacet.limitset to the caller'slimit. On dati.gov.it 53 tags contain "citta" and none is in the top 100, so the filter answered "no tags" while they existed. The facet is widened when a filter is given, and the limit applied to the filtered list.The spec
openspec/specs/ckan-search/spec.mdnamedckan_package_searchalone in both parser scenarios. That is what let the second caller go unnoticed: it described the parser as a property of one tool, when it is a property of the query-building path. After #534 it was also wrong, still describing the per-portal default that was removed.Rewritten around the invariant — every tool that builds a Solr query for
package_searchresolves the parser throughresolveSearchQueryand the same probe, todayckan_package_searchandckan_find_relevant_datasets, and a change touching the parser is verified against every tool on that list. Scenarios added for plain, boolean and unary shapes, for the portal where the wrapper does not work, and for relevance ranking.openspec validate --specs --strictpasses.Verification
qualità dell'aria Milanoreturns air-quality monitoring datasets; the two search tools now agree on boolean queries;cittàfinds 4 organizations and 5 tagsHow it was missed
Yesterday's verification checked result counts through
ckan_package_search, never the ranked output ofckan_find_relevant_datasets— the third most used tool in the telemetry, and the second caller ofresolveSearchQuery, which I had listed and not exercised. Counting results proves recall, not usefulness.🤖 Generated with Claude Code
https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2