Find a knowledge base article by words from different fields
An article's searchable text lives in three places: its title, its keywords,
and the name of its topic. Nobody typing a search knows or cares which word
came from which, so 'CMM Community' means "the article tagged community, under
the CMM topic". Both search paths returned nothing for it.
The plugin's own listing, which is what the KB page calls, built a single
ilike('%CMM Community%'). That needs the whole phrase contiguous in ONE field,
and no article has it: 'CMM' is only in the topic's name, 'Community' only in
the keywords. Global search already split the query into words but only ever
looked at title and keywords, so a word that only the topic could satisfy
failed there too - the same bug wearing a different face.
Every word must now be found somewhere across all three fields, in any order.
_word_match gains an `extra` hook for a word that a RELATED row satisfies
rather than a column of this table, which is how the topic joins in without
colliding with the sort=='topic' join.
The retired-topic rule is unchanged and pinned on both paths: matching more
words is not a way past it.
Verified against the 235-article dev library, where all three of these
returned nothing before: 'Fieldglass Jefferson' (title word plus topic word),
'outage notification' (two keywords, not adjacent), 'compucom Jefferson'.
The same phrase-only pattern is repeated in about ten other list endpoints
(computers, machines, printers, network, printedparts, measuringtools, usb,
notifications). Left alone here - fixing them properly means promoting the
word-match helper onto the shopdb.api contract surface rather than copying it
per plugin, which is a contract bump.
This commit is contained in:
@@ -31,7 +31,7 @@ search_bp = Blueprint('search', __name__)
|
||||
# cannot reorder. Relevance is applied afterwards in Python, over a stable set.
|
||||
|
||||
|
||||
def _word_match(query, *columns):
|
||||
def _word_match(query, *columns, extra=None):
|
||||
"""SQL clause matching rows that contain EVERY word of the query, each word
|
||||
in any of the columns, in any order.
|
||||
|
||||
@@ -39,14 +39,25 @@ def _word_match(query, *columns):
|
||||
multi-word search returned nothing. This splits the query into words and ANDs
|
||||
them (OR across the columns per word): 'CSF Roles' matches a row with 'CSF'
|
||||
and 'Roles' anywhere in the searched fields.
|
||||
|
||||
`extra` is an optional callable taking one word and returning a further
|
||||
clause to OR in for that word. Use it where a word may be satisfied by a
|
||||
RELATED row rather than a column of this table - a knowledge base article
|
||||
matching on its topic's name, say. Splitting per word matters there: without
|
||||
it a word can only ever be matched by the columns, so a query naming the
|
||||
topic and a keyword ('CMM Community') matches neither side alone.
|
||||
"""
|
||||
words = [w for w in query.split() if w]
|
||||
if not words:
|
||||
words = ['']
|
||||
return db.and_(*[
|
||||
db.or_(*[c.ilike(f'%{w}%') for c in columns])
|
||||
for w in words
|
||||
])
|
||||
|
||||
def per_word(word):
|
||||
clauses = [c.ilike(f'%{word}%') for c in columns]
|
||||
if extra is not None:
|
||||
clauses.append(extra(word))
|
||||
return db.or_(*clauses)
|
||||
|
||||
return db.and_(*[per_word(w) for w in words])
|
||||
|
||||
|
||||
def _require_enabled(name):
|
||||
@@ -253,6 +264,10 @@ def _search_knowledgebase(query, search_term):
|
||||
subject, which reads as though it were still in service.
|
||||
|
||||
A null topic still matches. Not every article is about an application.
|
||||
|
||||
The topic's NAME is searched too, per word. An article tagged 'community'
|
||||
under the CMM topic has neither word in both of its own columns, so a search
|
||||
for 'CMM Community' found it in neither the plugin's listing nor here.
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
@@ -260,12 +275,24 @@ def _search_knowledgebase(query, search_term):
|
||||
from plugins.knowledgebase.models import KnowledgeBase
|
||||
retired = db.session.query(Application.appid).filter(
|
||||
Application.isactive.is_(False))
|
||||
|
||||
def topic_named(word):
|
||||
"""Articles whose topic is an ACTIVE application named like word.
|
||||
|
||||
Active only, for the same reason the retired filter above exists: a
|
||||
decommissioned application is not a topic anyone should be offered.
|
||||
"""
|
||||
return KnowledgeBase.appid.in_(
|
||||
db.session.query(Application.appid).filter(
|
||||
Application.appname.ilike(f'%{word}%'),
|
||||
Application.isactive.is_(True)))
|
||||
|
||||
kb_articles = KnowledgeBase.query.filter(
|
||||
KnowledgeBase.isactive == True,
|
||||
db.or_(KnowledgeBase.appid.is_(None),
|
||||
KnowledgeBase.appid.notin_(retired)),
|
||||
_word_match(query, KnowledgeBase.shortdescription,
|
||||
KnowledgeBase.keywords)
|
||||
KnowledgeBase.keywords, extra=topic_named)
|
||||
).order_by(KnowledgeBase.clicks.desc(),
|
||||
KnowledgeBase.linkid).limit(20).all()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user