Skip to content

Commit 244a6d8

Browse files
aborrusoclaude
andauthored
fix(search): relevance scoring, shared parser probe and accent-safe filters (#536)
* fix(scoring): score by share of terms, drop Italian stopwords, fix accented 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 * fix(search): same parser probe for find_relevant_datasets, accent-safe 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 * fix(search): acronyms survive stopwords, NFC matching, unbounded tag 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 * perf(tags): bound the tag facet, widen it only when the filter comes 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 * fix(tags): let an escalation failure surface, order imports - 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 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9399cde commit 244a6d8

6 files changed

Lines changed: 289 additions & 20 deletions

File tree

LOG.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,59 @@
22

33
## 2026-09-05
44

5+
### Relevance scoring: three defects that only became visible once search worked
6+
7+
Testing v0.4.122 through an MCP client, not curl, showed `ckan_find_relevant_datasets`
8+
answering its own documented example badly. `defibrillatori Comune di Lecce` on
9+
dati.gov.it used to return the catalog's only Lecce dataset — because the search
10+
returned exactly one result. With recall restored it returns 679, and the top three
11+
became Trento, Martina Franca and Desio while the Lecce dataset fell out of the window.
12+
13+
The wrapping fix did not cause this; it removed the cover. Three defects, all older:
14+
15+
- `scoreTextField` awarded the **whole** field weight when **any** query term matched.
16+
"Comune di Martina Franca" and "Comune di Lecce" both scored a full holder match, so
17+
the right dataset could not outrank the wrong ones. It now scores the share of terms
18+
the field carries.
19+
- the stopword list was English-only, so `di` counted as a term: "Provincia Autonoma
20+
**di** Trento" earned a full holder match on a query asking for Lecce. Italian
21+
stopwords added.
22+
- `\b` is ASCII-only in JavaScript, so a term ending in an accented letter never found
23+
its word boundary: `mobilità` scored 0 against "mobilità urbana", `qualità` against
24+
"qualità dell'aria". On a catalog that is mostly not in English this silently sank
25+
every accented query. Replaced with Unicode lookarounds.
26+
27+
Looking for more of the same family turned up three more, all cases of a local filter
28+
running over a truncated or wrongly-normalised set:
29+
30+
- `ckan_find_relevant_datasets` never called the parser probe. `portals.json` used to
31+
cover it; removing `force_text_field` left it sending boolean queries to the parser that
32+
ignores them. On dati.comune.milano.it `aria OR acqua` returned 0 there against 87 from
33+
`ckan_package_search`. Same probe now applies to both.
34+
- `ckan_organization_search` builds a Solr wildcard, which bypasses the analysis chain, so
35+
the pattern must be pre-normalised the way CKAN builds a name slug. It lowercased but did
36+
not fold accents: `città` returned 0 while `citta` matched 135 datasets.
37+
- `ckan_tag_list` applied `tag_query` after faceting, with `facet.limit` set to the
38+
caller's `limit`. On dati.gov.it 53 tags contain "citta" and none is in the top 100, so
39+
the filter answered "no tags" while they existed. The facet is now widened when a filter
40+
is given.
41+
42+
`openspec/specs/ckan-search/spec.md` described the parser as a property of
43+
`ckan_package_search` alone, which is what let the second caller go unnoticed — and after
44+
yesterday it was also wrong, still describing the per-portal default that was removed.
45+
Rewritten as a property of the query-building path, naming every tool that shares it.
46+
47+
Also raised the candidate window to at least 50: the local ranking only sees what Solr
48+
returns first, and `limit: 3` shrank it to 15 — enough when a search returned a handful
49+
of results, not enough now.
50+
51+
`defibrillatori Comune di Lecce` puts the Lecce dataset first again. 532 tests, 5 added.
52+
53+
How it was missed: yesterday's verification checked result **counts** through
54+
`ckan_package_search`, never the ranked output of `ckan_find_relevant_datasets` — the
55+
third most used tool in the telemetry, and the second caller of `resolveSearchQuery`.
56+
Counting results proves recall, not usefulness.
57+
558
### v0.4.122 - Solr parser fix
659

760
Ships #534: the `text:(...)` wrapper is reserved for the queries dismax cannot serve, the escaping preserves unary operators and balanced grouping, and the parser probe measures two terms taken from the catalog on every portal. `force_text_field` is gone from `portals.json`.

openspec/specs/ckan-search/spec.md

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,69 @@
11
# ckan-search Specification
22

33
## Purpose
4-
TBD - created by archiving change update-search-parser-config. Update Purpose after archive.
5-
## Requirements
6-
### Requirement: Package search parser override
7-
The system SHALL support a per-portal default and a per-request override to force package search queries through the `text` field when needed, and SHALL escape Solr/Lucene special characters when wrapping queries in `text:(...)`.
4+
How this server turns a caller's query into a CKAN `package_search` request: which Solr
5+
parser it reaches, how the query is escaped, and how results are ranked.
86

9-
#### Scenario: Portal default applies
10-
- **WHEN** a portal is configured to force the text-field parser
11-
- **THEN** `ckan_package_search` uses `text:(...)` for non-fielded queries by default with escaped query content
7+
## Requirements
8+
### Requirement: Solr parser selection
9+
10+
Every tool that builds a Solr query for `package_search` SHALL resolve the parser through
11+
`resolveSearchQuery`, and SHALL use the same portal probe when the query may need wrapping.
12+
This is a property of the query-building path, not of one tool: today the path is shared by
13+
`ckan_package_search` and `ckan_find_relevant_datasets`, any tool added later belongs on
14+
that list, and a change touching the parser SHALL be verified against every tool on it.
15+
16+
Background: CKAN sends a colon-free query to Solr's dismax parser with `q.op=AND`,
17+
`mm='2<-1 5<80%'` and `qf='name^4 title^4 tags^2 groups^2 text'`. dismax has no boolean
18+
syntax, so `A OR B` collapses into `A AND B`; a colon takes the query off dismax, which is
19+
what `text:(...)` exploits. The same switch discards the `qf` boosts and ANDs every term on
20+
one field, so the wrapper helps a boolean query and harms every other shape.
21+
22+
#### Scenario: Boolean query on a portal that ignores booleans
23+
- **WHEN** a query carries `AND`, `OR` or `NOT`, or punctuation inside a word, and the
24+
portal probe finds the default parser does not honour a disjunction
25+
- **THEN** the query is wrapped in `text:(...)` with its content escaped
26+
- **AND** the wrapper is applied identically by `ckan_package_search` and
27+
`ckan_find_relevant_datasets`
28+
29+
#### Scenario: Plain keyword query
30+
- **WHEN** a query carries no boolean operator, which is the shape an LLM client generates
31+
from a user's request
32+
- **THEN** the query reaches the portal's own parser unwrapped, keeping the `qf` boosts
33+
and `mm`, and no probe is issued
34+
35+
#### Scenario: Unary operator
36+
- **WHEN** a query carries a `+`, `-` or `!` in operator position
37+
- **THEN** the character survives the escaping, because dismax honours it natively and
38+
escaping it inverts the caller's intent
1239

1340
#### Scenario: Request override applies
1441
- **WHEN** a client explicitly requests the text-field parser
15-
- **THEN** `ckan_package_search` uses `text:(...)` regardless of portal defaults with escaped query content
42+
- **THEN** the wrapper is applied regardless of what the probe found
43+
44+
#### Scenario: Portal where the wrapper does not work
45+
- **WHEN** the probe finds the wrapped form returns fewer results than the plain one
46+
- **THEN** no wrapping is applied on that portal, and the verdict is cached only if it was
47+
actually measured
48+
49+
### Requirement: Relevance ranking
50+
51+
`ckan_find_relevant_datasets` ranks locally over the candidates `package_search` returns
52+
first, so its answer depends on both the recall of the query and the size of the candidate
53+
window. The tool SHALL score a field by the share of query terms it carries, SHALL match
54+
terms on Unicode word boundaries, and SHALL score at least 50 candidates whatever the
55+
requested limit.
56+
57+
#### Scenario: Field scoring
58+
- **WHEN** a field contains some of the query's terms
59+
- **THEN** it scores in proportion to the share it carries, never the full weight for a
60+
single term
61+
62+
#### Scenario: Non-English text
63+
- **WHEN** a query term ends in an accented letter, or is a stopword of the catalog's
64+
language
65+
- **THEN** term matching respects Unicode word boundaries, and the stopword does not
66+
contribute to any field's score
1667

1768
### Requirement: List Dataset Resources
1869

src/tools/organization.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ResponseFormat, ResponseFormatSchema, CkanOrganization } from "../types
77
import { makeCkanRequest, formatCkanError, CkanApiError } from "../utils/http.js";
88
import { truncateText, formatDate, addDemoFooter, wrapUntrusted, formatError, jsonToolResult, sanitizeInline } from "../utils/formatting.js";
99
import { getOrganizationViewUrl } from "../utils/url-generator.js";
10+
import { stripAccents } from "../utils/search.js";
1011
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1112

1213
type OrgFacetItem = { name: string; display_name?: string; count: number };
@@ -374,8 +375,11 @@ Typical workflow: ckan_organization_search → ckan_organization_show (get detai
374375
},
375376
async (params) => {
376377
try {
377-
// Build Solr query with wildcards (lowercase: Solr org names are always lowercase)
378-
const query = `organization:*${params.pattern.toLowerCase()}*`;
378+
// Build Solr query with wildcards. A wildcard term bypasses Solr's analysis
379+
// chain, so the pattern has to be pre-normalised the way CKAN builds the name
380+
// slug: lowercase and without accents. Otherwise `città` finds nothing while
381+
// `citta` finds 135 organizations, `citta-metropolitana-di-*` among them.
382+
const query = `organization:*${stripAccents(params.pattern.toLowerCase())}*`;
379383

380384
// Search using package_search with faceting
381385
const result = await makeCkanRequest<any>(

src/tools/package.ts

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ const DEFAULT_RELEVANCE_WEIGHTS: RelevanceWeights = {
175175
};
176176

177177
const QUERY_STOPWORDS = new Set([
178+
// Italian: without these, `defibrillatori Comune di Lecce` scored a full holder
179+
// match against "Provincia Autonoma di Trento" on the strength of "di" alone.
180+
"di", "del", "dello", "della", "dei", "degli", "delle",
181+
"il", "lo", "la", "i", "gli", "le", "un", "uno", "una",
182+
"e", "ed", "per", "con", "su", "da", "dal", "dalla", "nel", "nella", "al", "alla",
183+
"che", "non", "come", "dove", "sono",
178184
"a",
179185
"an",
180186
"the",
@@ -219,21 +225,62 @@ const QUERY_STOPWORDS = new Set([
219225
]);
220226

221227
export const extractQueryTerms = (query: string): string[] => {
222-
const matches = query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
223-
const terms = matches.filter((term) => term.length > 1 && !QUERY_STOPWORDS.has(term));
228+
const raw = query.normalize("NFC").match(/[\p{L}\p{N}]+/gu) ?? [];
229+
// An all-caps token is an acronym, not an article: the stopword list is there for
230+
// `defibrillatori Comune di Lecce`, and must not swallow the `UN` of `UN population`
231+
// on a catalog in another language.
232+
const terms = raw
233+
.filter((token) => {
234+
const term = token.toLowerCase();
235+
if (term.length <= 1) return false;
236+
if (!QUERY_STOPWORDS.has(term)) return true;
237+
return token.length > 1 && token === token.toUpperCase() && token !== term;
238+
})
239+
.map((token) => token.toLowerCase());
224240
return Array.from(new Set(terms));
225241
};
226242

227243
export const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
228244

245+
/**
246+
* Word-boundary matcher for a single term.
247+
*
248+
* `\b` is ASCII-only in JavaScript, so an accented letter counts as a non-word
249+
* character and a term ending in one never finds its boundary: `mobilità` failed to
250+
* match "mobilità urbana". Unicode lookarounds fix it, which matters on catalogs that
251+
* are mostly not in English.
252+
*/
253+
const termPattern = (term: string): RegExp =>
254+
new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(term.normalize("NFC"))}(?![\\p{L}\\p{N}])`, "iu");
255+
256+
/** Same Unicode form on both sides: `mobilità` written as NFD would not match NFC. */
257+
const normalizeForMatch = (text: string): string =>
258+
text.normalize("NFC").toLowerCase().replace(/_/g, " ");
259+
229260
export const textMatchesTerms = (text: string | undefined, terms: string[]): boolean => {
230261
if (!text || terms.length === 0) return false;
231-
const normalized = text.toLowerCase().replace(/_/g, " ");
232-
return terms.some((term) => new RegExp(`\\b${escapeRegExp(term)}\\b`, "i").test(normalized));
262+
const normalized = normalizeForMatch(text);
263+
return terms.some((term) => termPattern(term).test(normalized));
264+
};
265+
266+
/** How many of the query's terms this text contains. */
267+
export const countMatchingTerms = (text: string | undefined, terms: string[]): number => {
268+
if (!text || terms.length === 0) return 0;
269+
const normalized = normalizeForMatch(text);
270+
return terms.filter((term) => termPattern(term).test(normalized)).length;
233271
};
234272

273+
/**
274+
* Score a field by the share of query terms it carries, not by whether any of them
275+
* appears. With the all-or-nothing rule a single common word earned the whole field:
276+
* on `defibrillatori Comune di Lecce`, "Comune di Martina Franca" and "Comune di Lecce"
277+
* both scored a full holder match, so the catalog's only Lecce dataset could not
278+
* outrank the others and fell out of the top results.
279+
*/
235280
export const scoreTextField = (text: string | undefined, terms: string[], weight: number): number => {
236-
return textMatchesTerms(text, terms) ? weight : 0;
281+
const matched = countMatchingTerms(text, terms);
282+
if (matched === 0) return 0;
283+
return Math.round((weight * matched / terms.length) * 10) / 10;
237284
};
238285

239286
/**
@@ -1128,11 +1175,25 @@ Typical workflow: ckan_find_relevant_datasets → ckan_package_show (inspect top
11281175
...(params.weights ?? {})
11291176
};
11301177

1131-
const rows = Math.min(Math.max(params.limit * 5, params.limit), 100);
1178+
// At least 50 candidates to score: the local ranking only sees what Solr
1179+
// returns first, and a small limit used to shrink the window to 15 — enough
1180+
// when a search returned a handful of results, not enough now that it returns
1181+
// hundreds.
1182+
const rows = Math.min(Math.max(params.limit * 5, 50), 100);
1183+
1184+
// Same probe as ckan_package_search: without it this tool sends a boolean
1185+
// query to the parser that ignores booleans. On dati.comune.milano.it
1186+
// `aria OR acqua` returned 0 here against 87 there.
1187+
let parserOverride = params.query_parser;
1188+
if (!parserOverride && mayNeedTextWrapping(params.query)) {
1189+
const needsText = await probePortalParser(params.server_url);
1190+
if (needsText) parserOverride = "text";
1191+
}
1192+
11321193
const { effectiveQuery } = resolveSearchQuery(
11331194
params.server_url,
11341195
params.query,
1135-
params.query_parser
1196+
parserOverride
11361197
);
11371198

11381199
const searchResult = await makeCkanRequest<any>(

src/tools/tag.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { z } from "zod";
66
import { ResponseFormat, ResponseFormatSchema } from "../types.js";
77
import { makeCkanRequest } from "../utils/http.js";
88
import { truncateText, addDemoFooter, formatError, jsonToolResult, sanitizeInline } from "../utils/formatting.js";
9+
import { stripAccents } from "../utils/search.js";
910
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1011

1112
type TagItem = {
@@ -91,7 +92,13 @@ Typical workflow: ckan_tag_list → ckan_package_search with fq="tags:tag_name"
9192
q: params.q,
9293
rows: 0,
9394
'facet.field': JSON.stringify(['tags']),
94-
'facet.limit': params.limit
95+
// The filter runs on whatever the facet returned, so with a tag_query the
96+
// facet has to be wider than the caller's limit: on dati.gov.it 53 tags
97+
// contain "citta" and none is in the top 100, so asking for 100 answered
98+
// "no tags" while they existed. CKAN rejects Solr's own `facet.contains`
99+
// ("Invalid search parameters"), so the filtering cannot move server-side;
100+
// this window is the compromise, and WIDE_FACET below is the escape hatch.
101+
'facet.limit': params.tag_query ? Math.max(params.limit * 20, 500) : params.limit
95102
};
96103

97104
if (params.fq) apiParams.fq = params.fq;
@@ -105,8 +112,36 @@ Typical workflow: ckan_tag_list → ckan_package_search with fq="tags:tag_name"
105112
let tags = normalizeTagFacets(result);
106113

107114
if (params.tag_query) {
108-
const needle = params.tag_query.toLowerCase();
109-
tags = tags.filter(tag => tag.name.toLowerCase().includes(needle));
115+
// Both sides accent-folded: CKAN builds tag names as slugs, so `città`
116+
// would never match `citta-metropolitana` — while portals that do keep
117+
// accented tags still match either spelling.
118+
const needle = stripAccents(params.tag_query.toLowerCase());
119+
const matching = (candidates: TagItem[]) =>
120+
candidates.filter(tag => stripAccents(tag.name.toLowerCase()).includes(needle));
121+
122+
tags = matching(tags);
123+
124+
// Only a filter that came up short pays for the whole facet: on
125+
// dati.gov.it that is 14138 tags and 1.4 MB, against ~50 KB for the
126+
// bounded window above.
127+
if (tags.length < params.limit) {
128+
// No catch here on purpose: if the exhaustive request fails, returning
129+
// the bounded set would answer "these are the matching tags" while
130+
// hiding the ones it never looked at. The error goes to the tool's
131+
// normal path instead, and the caller can narrow with `fq` or `q`.
132+
const wide = await makeCkanRequest<any>(
133+
params.server_url,
134+
'package_search',
135+
{ ...apiParams, 'facet.limit': -1 }
136+
);
137+
tags = matching(normalizeTagFacets(wide));
138+
}
139+
140+
// Solr sorts by count only while facet.limit is positive: asked for -1 it
141+
// returns the tags in index order, so the widened set arrives alphabetical
142+
// ("zuglio", "zucs", ...). CKAN rejects `facet.sort` too, so the ordering
143+
// the tool documents has to be restored here.
144+
tags = tags.sort((a, b) => b.count - a.count).slice(0, params.limit);
110145
}
111146

112147
tags = tags

0 commit comments

Comments
 (0)