Switch library hierarchy to GW webshop taxonomy
- warhammer_data.py: GW naming throughout - Space Marine chapters are
their own factions under a 'Space Marines' group, systems match the
shop (Age of Sigmar, The Horus Heresy, Other Games, The Old World,
Middle-Earth), classes are GW shop categories (Battleline, Infantry,
Vehicle, Walker, Flyer, Monster, Mounted, Hero, Titanic), group map
extended to AoS grand alliances / Other Games / Old World / Middle-Earth
- gwshop.py: hierarchy remap - GW depth-4 paths are system/group/faction/
class (the shop's last level is the class layer), depth-5 sources keep
a subfaction before it; catalog snapshot regenerated accordingly
- sorter.py: catalog entry is hierarchy-authoritative (group, class, and
faction when GW moved a unit or keyword tables are stale); generic GW
branches ('Unit Type') and a Battleline default never degrade a
confident keyword classification; whole-catalog retry when the faction
pool has no confident match (handles units GW moved between factions);
whole-catalog matching also runs when keyword classification found no
faction; invalid (HTML) items never fuzzy-match; default catalog is
now data/gwshop_catalog.json
- catalogue.py: --apply moves include the group layer, same structure
as the sorter
- docs/SORTING.md: hierarchy rewritten to GW naming with examples
- tests: expectations updated to GW naming (chapters, unit types)
- verified live: 4/4 real files in the test batch now catalog-match,
e.g. The Twin Lance -> 40k/Xenos Armies/T'au Empire/Epic Hero (GW's
current placement) instead of the stale Drukhari keyword
This commit is contained in:
+70
-31
@@ -22,6 +22,7 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from dlphelper.catalogue import _clean_name, _match_class, _match_faction
|
||||
from dlphelper.warhammer_data import GROUP_OF_FACTION
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -154,26 +155,7 @@ def classify_item(item: SortItem) -> None:
|
||||
item.group = GROUP_OF_FACTION.get(item.faction, UNSORTED)
|
||||
|
||||
|
||||
# faction group layer (miniset naming), extended as the taxonomy grows
|
||||
GROUP_OF_FACTION: dict[str, str] = {
|
||||
faction: group
|
||||
for group, factions in {
|
||||
"Armies of the Imperium": [
|
||||
"Adeptus Astartes", "Astra Militarum", "Adeptus Mechanicus",
|
||||
"Adepta Sororitas", "Adeptus Custodes", "Imperial Knights",
|
||||
"Inquisition & Officio Assassinorum",
|
||||
],
|
||||
"Armies of Chaos": [
|
||||
"Chaos Space Marines", "Death Guard", "Thousand Sons",
|
||||
"Chaos Daemons", "Chaos Knights",
|
||||
],
|
||||
"Xenos Armies": [
|
||||
"Orks", "T'au Empire", "Tyranids", "Genestealer Cults", "Necrons",
|
||||
"Aeldari", "Drukhari", "Harlequins", "Ynnari",
|
||||
],
|
||||
}.items()
|
||||
for faction in factions
|
||||
}
|
||||
# faction group layer (GW naming, shared with warhammer_data)
|
||||
|
||||
|
||||
def load_catalog(path: Path) -> list[dict]:
|
||||
@@ -192,12 +174,8 @@ def _best_ratio(clean: str, candidates: list[str]) -> float:
|
||||
)
|
||||
|
||||
|
||||
def match_catalog(item: SortItem, catalog: list[dict], threshold: float) -> None:
|
||||
"""Fuzzy-match against catalog entries in the same faction (or all)."""
|
||||
if item.faction == UNSORTED:
|
||||
return
|
||||
same_faction = [e for e in catalog if e["faction"] == item.faction]
|
||||
pool = same_faction or catalog # fall back to whole catalog if faction unknown
|
||||
def _best_in_pool(item: SortItem, pool: list[dict]) -> tuple[float, dict] | None:
|
||||
"""Best fuzzy score of `item` against all entries in `pool`."""
|
||||
best: tuple[float, dict] | None = None
|
||||
for entry in pool:
|
||||
cands = [entry["_clean"], *entry["_clean_aliases"]]
|
||||
@@ -208,22 +186,83 @@ def match_catalog(item: SortItem, catalog: list[dict], threshold: float) -> None
|
||||
score = max(score, _best_ratio(c, cands) * 0.95) # inner slightly discounted
|
||||
if best is None or score > best[0]:
|
||||
best = (score, entry)
|
||||
return best
|
||||
|
||||
|
||||
def match_catalog(item: SortItem, catalog: list[dict], threshold: float) -> None:
|
||||
"""Fuzzy-match against catalog entries in the same faction (or all).
|
||||
|
||||
The matched entry is hierarchy-authoritative: group, subfaction and
|
||||
class come from the catalog (GW shop categories), and when the keyword
|
||||
classification had no faction the entry's system/faction is adopted too.
|
||||
"""
|
||||
if item.faction == UNSORTED and not item.inner_names and not item.clean:
|
||||
return
|
||||
if item.match_kind == "invalid":
|
||||
return # HTML-page masquerading as an archive; never fuzzy-match it
|
||||
same_faction = [e for e in catalog if e.get("faction") == item.faction]
|
||||
pool = same_faction or catalog # fall back to whole catalog if faction unknown
|
||||
best = _best_in_pool(item, pool)
|
||||
if best is None or best[0] < threshold:
|
||||
# The keyword tables can lag behind GW's current categorization
|
||||
# (e.g. units moved between factions); if the faction pool has no
|
||||
# confident match, try the whole catalog before giving up.
|
||||
if same_faction:
|
||||
best = _best_in_pool(item, catalog)
|
||||
if best and best[0] >= threshold:
|
||||
entry = best[1]
|
||||
item.unit = entry["name"]
|
||||
item.subfaction = entry.get("subfaction")
|
||||
item.klass = item.klass or entry.get("class")
|
||||
if entry.get("group"):
|
||||
item.group = entry["group"]
|
||||
elif item.faction == UNSORTED:
|
||||
item.group = GROUP_OF_FACTION.get(entry["faction"], UNSORTED)
|
||||
if entry.get("subfaction"):
|
||||
item.subfaction = entry["subfaction"]
|
||||
# GW's class layer (shop category) beats the keyword guess, but do
|
||||
# not *narrow* it: "Battleline" is GW's bucket for core infantry,
|
||||
# a keyword-derived Hero/Elite is more specific for a proxy file.
|
||||
catalog_klass = entry.get("class")
|
||||
if catalog_klass and item.klass in (None, "", UNCLASSIFIED_CLASS):
|
||||
item.klass = catalog_klass
|
||||
item.match_score = best[0]
|
||||
item.match_kind = "catalog"
|
||||
# The catalog entry's faction is authoritative - but only adopt it
|
||||
# when it is a real army faction. Generic GW branches like
|
||||
# "Unit Type" or factionless entries would degrade a confident
|
||||
# keyword faction to a junk folder.
|
||||
entry_faction = entry.get("faction")
|
||||
generic_factions = {"Unit Type", "Heroes", "Warriors", "Character"}
|
||||
if item.faction == UNSORTED:
|
||||
if entry_faction and entry_faction not in generic_factions:
|
||||
item.system = entry["system"]
|
||||
item.faction = entry_faction
|
||||
if entry.get("group") and entry["group"] not in ("Unit Type",):
|
||||
item.group = entry["group"]
|
||||
else:
|
||||
item.group = GROUP_OF_FACTION.get(item.faction, UNSORTED)
|
||||
elif entry_faction and entry_faction not in generic_factions and entry_faction != item.faction:
|
||||
# GW moved this unit to another faction (or the keyword tables
|
||||
# are stale): trust the catalog.
|
||||
item.system = entry["system"]
|
||||
item.faction = entry["faction"]
|
||||
item.group = entry.get("group") or GROUP_OF_FACTION.get(entry["faction"], UNSORTED)
|
||||
item.faction = entry_faction
|
||||
if entry.get("group") and entry["group"] != "Unit Type":
|
||||
item.group = entry["group"]
|
||||
else:
|
||||
item.group = GROUP_OF_FACTION.get(entry_faction, UNSORTED)
|
||||
elif entry.get("group") and entry["group"] not in ("Unit Type",):
|
||||
item.group = entry["group"]
|
||||
else:
|
||||
if item.faction != UNSORTED:
|
||||
if item.faction == UNSORTED:
|
||||
item.unit = item.name # implicit entry: own (original) name
|
||||
item.match_score = best[0] if best else 0.0
|
||||
item.match_kind = "implicit"
|
||||
else:
|
||||
# Faction was known but nothing fuzzy-matched: keep the keyword
|
||||
# faction and use the file's own name as the unit, but adopt the
|
||||
# catalog's class layer when it adds information.
|
||||
item.unit = item.name
|
||||
item.match_score = best[0] if best else 0.0
|
||||
item.match_kind = "implicit"
|
||||
|
||||
|
||||
def build_target(item: SortItem, library: Path) -> Path:
|
||||
|
||||
Reference in New Issue
Block a user