Finally weight lookup results by language. #15
[zzz-pokedex.git] / pokedex / lookup.py
index 0bf1c18..0fa91cb 100644 (file)
@@ -10,12 +10,15 @@ import whoosh.filedb.filestore
 import whoosh.filedb.fileindex
 import whoosh.index
 from whoosh.qparser import QueryParser
 import whoosh.filedb.fileindex
 import whoosh.index
 from whoosh.qparser import QueryParser
+import whoosh.scoring
 import whoosh.spelling
 
 from pokedex.db import connect
 import pokedex.db.tables as tables
 from pokedex.roomaji import romanize
 
 import whoosh.spelling
 
 from pokedex.db import connect
 import pokedex.db.tables as tables
 from pokedex.roomaji import romanize
 
+__all__ = ['open_index', 'lookup']
+
 # Dictionary of table name => table class.
 # Need the table name so we can get the class from the table name after we
 # retrieve something from the index
 # Dictionary of table name => table class.
 # Need the table name so we can get the class from the table name after we
 # retrieve something from the index
@@ -29,17 +32,6 @@ for cls in [
     ]:
     indexed_tables[cls.__tablename__] = cls
 
     ]:
     indexed_tables[cls.__tablename__] = cls
 
-# Dictionary of extra keys to file types of objects under, e.g. Pokémon can
-# also be looked up purely by number
-extra_keys = {
-    tables.Move: [
-        lambda row: u"move %d" % row.id,
-    ],
-    tables.Pokemon: [
-        lambda row: unicode(row.id),
-    ],
-}
-
 def open_index(directory=None, session=None, recreate=False):
     """Opens the whoosh index stored in the named directory and returns (index,
     speller).  If the index doesn't already exist, it will be created.
 def open_index(directory=None, session=None, recreate=False):
     """Opens the whoosh index stored in the named directory and returns (index,
     speller).  If the index doesn't already exist, it will be created.
@@ -87,7 +79,7 @@ def open_index(directory=None, session=None, recreate=False):
     schema = whoosh.fields.Schema(
         name=whoosh.fields.ID(stored=True),
         table=whoosh.fields.STORED,
     schema = whoosh.fields.Schema(
         name=whoosh.fields.ID(stored=True),
         table=whoosh.fields.STORED,
-        row_id=whoosh.fields.STORED,
+        row_id=whoosh.fields.ID(stored=True),
         language=whoosh.fields.STORED,
     )
 
         language=whoosh.fields.STORED,
     )
 
@@ -95,6 +87,10 @@ def open_index(directory=None, session=None, recreate=False):
     writer = index.writer()
 
     # Index every name in all our tables of interest
     writer = index.writer()
 
     # Index every name in all our tables of interest
+    # speller_entries becomes a list of (word, score) tuples; the score is 2
+    # for English names, 1.5 for Roomaji, and 1 for everything else.  I think
+    # this biases the results in the direction most people expect, especially
+    # when e.g. German names are very similar to English names
     speller_entries = []
     for cls in indexed_tables.values():
         q = session.query(cls)
     speller_entries = []
     for cls in indexed_tables.values():
         q = session.query(cls)
@@ -104,32 +100,31 @@ def open_index(directory=None, session=None, recreate=False):
             q = q.filter_by(forme_base_pokemon_id=None)
 
         for row in q.yield_per(5):
             q = q.filter_by(forme_base_pokemon_id=None)
 
         for row in q.yield_per(5):
-            row_key = dict(table=cls.__tablename__, row_id=row.id)
+            row_key = dict(table=cls.__tablename__, row_id=unicode(row.id))
 
             name = row.name.lower()
             writer.add_document(name=name, **row_key)
 
             name = row.name.lower()
             writer.add_document(name=name, **row_key)
-            speller_entries.append(name)
-
-            for extra_key_func in extra_keys.get(cls, []):
-                extra_key = extra_key_func(row)
-                writer.add_document(name=extra_key, **row_key)
+            speller_entries.append((name, 1))
 
             # Pokemon also get other languages
 
             # Pokemon also get other languages
-            if cls == tables.Pokemon:
-                for foreign_name in row.foreign_names:
-                    name = foreign_name.name.lower()
-                    writer.add_document(name=name,
-                                        language=foreign_name.language.name,
+            for foreign_name in getattr(row, 'foreign_names', []):
+                moonspeak = foreign_name.name.lower()
+                if name == moonspeak:
+                    # Don't add the English name again as a different language;
+                    # no point and it makes spell results confusing
+                    continue
+
+                writer.add_document(name=moonspeak,
+                                    language=foreign_name.language.name,
+                                    **row_key)
+                speller_entries.append((moonspeak, 3))
+
+                # Add Roomaji too
+                if foreign_name.language.name == 'Japanese':
+                    roomaji = romanize(foreign_name.name).lower()
+                    writer.add_document(name=roomaji, language='Roomaji',
                                         **row_key)
                                         **row_key)
-                    speller_entries.append(name)
-
-                    if foreign_name.language.name == 'Japanese':
-                        # Add Roomaji too
-                        roomaji = romanize(foreign_name.name).lower()
-                        writer.add_document(name=roomaji,
-                                            language='Roomaji',
-                                            **row_key)
-                        speller_entries.append(roomaji)
+                    speller_entries.append((roomaji, 8))
 
 
     writer.commit()
 
 
     writer.commit()
@@ -138,18 +133,39 @@ def open_index(directory=None, session=None, recreate=False):
     # at once, as every call to add_* does a commit(), and those seem to be
     # expensive
     speller = whoosh.spelling.SpellChecker(index.storage)
     # at once, as every call to add_* does a commit(), and those seem to be
     # expensive
     speller = whoosh.spelling.SpellChecker(index.storage)
-    speller.add_words(speller_entries)
+    speller.add_scored_words(speller_entries)
 
     return index, speller
 
 
 
     return index, speller
 
 
-LookupResult = namedtuple('LookupResult', ['object', 'language', 'exact'])
-def lookup(name, session=None, indices=None, exact_only=False):
+class LanguageWeighting(whoosh.scoring.Weighting):
+    """A scoring class that forces otherwise-equal English results to come
+    before foreign results.
+    """
+
+    def score(self, searcher, fieldnum, text, docnum, weight, QTF=1):
+        doc = searcher.stored_fields(docnum)
+        if doc['language'] == None:
+            # English (well, "default"); leave it at 1
+            return weight
+        elif doc['language'] == u'Roomaji':
+            # Give Roomaji a bit of a boost, as it's most likely to be searched
+            return weight * 0.95
+        else:
+            # Everything else can drop down the totem pole
+            return weight * 0.9
+
+rx_is_number = re.compile('^\d+$')
+
+LookupResult = namedtuple('LookupResult',
+                          ['object', 'name', 'language', 'exact'])
+def lookup(input, session=None, indices=None, exact_only=False):
     """Attempts to find some sort of object, given a database session and name.
 
     """Attempts to find some sort of object, given a database session and name.
 
-    Returns a list of named (object, language, exact) tuples.  `object` is a
-    database object, `language` is the name of the language in which the name
-    was found, and `exact` is True iff this was an exact match.
+    Returns a list of named (object, name, language, exact) tuples.  `object`
+    is a database object, `name` is the name under which the object was found,
+    `language` is the name of the language in which the name was found, and
+    `exact` is True iff this was an exact match.
 
     This function currently ONLY does fuzzy matching if there are no exact
     matches.
 
     This function currently ONLY does fuzzy matching if there are no exact
     matches.
@@ -157,9 +173,13 @@ def lookup(name, session=None, indices=None, exact_only=False):
     Formes are not returned; "Shaymin" will return only grass Shaymin.
 
     Recognizes:
     Formes are not returned; "Shaymin" will return only grass Shaymin.
 
     Recognizes:
-    - Pokémon names: "Eevee"
+    - Names: "Eevee", "Surf", "Run Away", "Payapa Berry", etc.
+    - Foreign names: "Iibui", "Eivui"
+    - Fuzzy names in whatever language: "Evee", "Ibui"
+    - IDs: "pokemon 133", "move 192", "item 250"
+    - Dex numbers: "sinnoh 55", "133", "johto 180"
 
 
-    `name`
+    `input`
         Name of the thing to look for.
 
     `session`
         Name of the thing to look for.
 
     `session`
@@ -185,14 +205,25 @@ def lookup(name, session=None, indices=None, exact_only=False):
     else:
         index, speller = open_index()
 
     else:
         index, speller = open_index()
 
-    name = unicode(name)
-
+    name = unicode(input).lower()
     exact = True
 
     exact = True
 
-    # Look for exact name.  A Term object does an exact match, so we don't have
-    # to worry about a query parser tripping on weird characters in the input
+    # If the input provided is a number, match it as an id.  Otherwise, name.
+    # Term objects do an exact match, so we don't have to worry about a query
+    # parser tripping on weird characters in the input
+    if rx_is_number.match(name):
+        # Don't spell-check numbers!
+        exact_only = True
+        query = whoosh.query.Term(u'row_id', name)
+    else:
+        # Not an integer
+        query = whoosh.query.Term(u'name', name)
+
+    ### Actual searching
     searcher = index.searcher()
     searcher = index.searcher()
-    query = whoosh.query.Term('name', name.lower())
+    searcher.weighting = LanguageWeighting()  # XXX kosher?  docs say search()
+                                              # takes a weighting kw but it
+                                              # certainly does not
     results = searcher.search(query)
 
     # Look for some fuzzy matches if necessary
     results = searcher.search(query)
 
     # Look for some fuzzy matches if necessary
@@ -200,7 +231,7 @@ def lookup(name, session=None, indices=None, exact_only=False):
         exact = False
         results = []
 
         exact = False
         results = []
 
-        for suggestion in speller.suggest(name, 10):
+        for suggestion in speller.suggest(name, 25):
             query = whoosh.query.Term('name', suggestion)
             results.extend(searcher.search(query))
 
             query = whoosh.query.Term('name', suggestion)
             results.extend(searcher.search(query))
 
@@ -216,6 +247,14 @@ def lookup(name, session=None, indices=None, exact_only=False):
 
         cls = indexed_tables[result['table']]
         obj = session.query(cls).get(result['row_id'])
 
         cls = indexed_tables[result['table']]
         obj = session.query(cls).get(result['row_id'])
-        objects.append(LookupResult(obj, result['language'], exact))
-
-    return objects
+        objects.append(LookupResult(object=obj,
+                                    name=result['name'],
+                                    language=result['language'],
+                                    exact=exact))
+
+    # Only return up to 10 matches; beyond that, something is wrong.
+    # We strip out duplicate entries above, so it's remotely possible that we
+    # should have more than 10 here and lost a few.  The speller returns 25 to
+    # give us some padding, and should avoid that problem.  Not a big deal if
+    # we lose the 25th-most-likely match anyway.
+    return objects[:10]