sorter: drop non-warhammer game systems (default: Middle-Earth)

The collection is warhammer-only; everything else stays in _unsorted
even when names match the catalog. The catalog snapshot keeps all
systems - the filter is a sorter default (DROPPED_SYSTEMS, applied in
load_catalog and after keyword classification), adjustable with
--drop-system/--keep-system.
This commit is contained in:
2026-10-07 08:03:05 +02:00
parent e13412a5fe
commit 0439071f70
3 changed files with 68 additions and 4 deletions
+26 -2
View File
@@ -32,9 +32,15 @@ UNSORTED = "_unsorted"
UNCLASSIFIED_CLASS = "Unclassified" UNCLASSIFIED_CLASS = "Unclassified"
# A file whose system the keyword tables could not identify only gets a # A file whose system the keyword tables could not identify only gets a
# catalog unit when the fuzzy hit is strong - otherwise it would wander # catalog unit when the fuzzy hit is strong - otherwise it would wander
# into other games' sections (Middle-earth, Blood Bowl, ...) on a guess. # into other games' sections (Blood Bowl, ...) on a guess.
CROSS_SYSTEM_MIN = 0.75 CROSS_SYSTEM_MIN = 0.75
# Game systems whose sections the sorter must never place into. The
# collection is warhammer-only: everything else (Middle-earth proxies,
# ...) stays in _unsorted even when a name matches the catalog. The
# catalog snapshot itself keeps all systems; the filter lives here.
DROPPED_SYSTEMS = frozenset({"Middle-Earth"})
# Weak acceptance tier: when nothing reached the normal threshold, a hit # Weak acceptance tier: when nothing reached the normal threshold, a hit
# at least this strong is accepted but flagged low confidence ("[w]" in # at least this strong is accepted but flagged low confidence ("[w]" in
# the plan, confidence field in the audit, listed in # the plan, confidence field in the audit, listed in
@@ -178,9 +184,14 @@ def classify_item(item: SortItem) -> None:
# faction group layer (GW naming, shared with warhammer_data) # faction group layer (GW naming, shared with warhammer_data)
def load_catalog(path: Path) -> list[dict]: def load_catalog(path: Path,
dropped: frozenset[str] | None = DROPPED_SYSTEMS) -> list[dict]:
"""Load catalog entries, dropping non-collected game systems by default
(pass dropped=frozenset() to keep everything)."""
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
entries = data["entries"] if isinstance(data, dict) else data entries = data["entries"] if isinstance(data, dict) else data
if dropped:
entries = [e for e in entries if e.get("system") not in dropped]
for e in entries: for e in entries:
e["_clean"] = _clean_name(e["name"]) e["_clean"] = _clean_name(e["name"])
e["_clean_aliases"] = [_clean_name(a) for a in e.get("aliases", [])] e["_clean_aliases"] = [_clean_name(a) for a in e.get("aliases", [])]
@@ -672,6 +683,17 @@ def _display_name(unit: str) -> str:
# plan / apply # plan / apply
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _drop_dropped_system(item: SortItem) -> None:
"""Dropped systems (see DROPPED_SYSTEMS) count as unclassified: the
collection does not cover them, so their names must not place files
into those sections. Called after keyword classification, before
matching - the item can still be re-homed by other signals."""
if item.system in DROPPED_SYSTEMS:
item.system = "Unknown"
item.group = item.faction = UNSORTED
item.subfaction = None
def sort_plan( def sort_plan(
files: list[Path], files: list[Path],
library: Path, library: Path,
@@ -716,6 +738,7 @@ def sort_plan(
if unit_name is None: if unit_name is None:
item = inspect(f) item = inspect(f)
classify_item(item) classify_item(item)
_drop_dropped_system(item)
match_catalog(item, catalog, threshold) match_catalog(item, catalog, threshold)
if root is not None: if root is not None:
# manual trees: let the real ancestor dir names compete # manual trees: let the real ancestor dir names compete
@@ -741,6 +764,7 @@ def sort_plan(
clean=_clean_name(unit_name), inner_names=listing, clean=_clean_name(unit_name), inner_names=listing,
) )
classify_item(probe) classify_item(probe)
_drop_dropped_system(probe)
match_catalog(probe, catalog, threshold) match_catalog(probe, catalog, threshold)
fields = {k: getattr(probe, k) for k in ( fields = {k: getattr(probe, k) for k in (
"system", "group", "faction", "subfaction", "klass", "system", "group", "faction", "subfaction", "klass",
+16 -1
View File
@@ -27,6 +27,7 @@ import sys
from pathlib import Path from pathlib import Path
from dlphelper.sorter import ( from dlphelper.sorter import (
DROPPED_SYSTEMS,
format_plan, format_plan,
load_catalog, load_catalog,
sort_plan, sort_plan,
@@ -57,8 +58,20 @@ def main() -> None:
"sort whole extraction directories as units") "sort whole extraction directories as units")
parser.add_argument("--json", action="store_true", parser.add_argument("--json", action="store_true",
help="print the plan as JSON instead of text") help="print the plan as JSON instead of text")
parser.add_argument("--drop-system", action="append", default=[],
metavar="NAME",
help="never place into this game system "
"(default-dropped: Middle-Earth)")
parser.add_argument("--keep-system", action="append", default=[],
metavar="NAME",
help="re-enable a default-dropped game system")
args = parser.parse_args() args = parser.parse_args()
dropped = frozenset(DROPPED_SYSTEMS - set(args.keep_system)
| set(args.drop_system))
if args.keep_system or args.drop_system:
log.info("System filter adjusted: dropping %s", ", ".join(sorted(dropped)) or "nothing")
logging.basicConfig(level=logging.INFO, logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s", format="%(asctime)s %(levelname)-7s %(message)s",
datefmt="%H:%M:%S") datefmt="%H:%M:%S")
@@ -69,7 +82,9 @@ def main() -> None:
# hidden trees like .broken/ must never reach the plan # hidden trees like .broken/ must never reach the plan
if not any(part.startswith(".") for part in p.relative_to(source).parts) if not any(part.startswith(".") for part in p.relative_to(source).parts)
) )
catalog = load_catalog(args.catalog) catalog = load_catalog(args.catalog, dropped=dropped)
if dropped:
log.info("Dropped systems: %s", ", ".join(sorted(dropped)))
audit = args.audit or (args.library / "sorter_applied.jsonl") audit = args.audit or (args.library / "sorter_applied.jsonl")
provenance = None provenance = None
+26 -1
View File
@@ -30,6 +30,9 @@ CATALOG = {
{"name": "Dark Reapers", "system": "Warhammer 40,000", {"name": "Dark Reapers", "system": "Warhammer 40,000",
"group": "Xenos Armies", "faction": "Aeldari", "group": "Xenos Armies", "faction": "Aeldari",
"class": "Aspect Warriors", "aliases": []}, "class": "Aspect Warriors", "aliases": []},
{"name": "Mordor Orcs", "system": "Middle-Earth",
"group": "The Lord of the Rings™ - Evil", "faction": "Mordor",
"class": "Warriors", "aliases": []},
] ]
} }
@@ -178,6 +181,27 @@ def test_tokenless_needs_bonus():
shutil.rmtree(root); shutil.rmtree(library) shutil.rmtree(root); shutil.rmtree(library)
def test_dropped_system_mordor():
"""Middle-earth is dropped: a 'Mordor' pack lands in _unsorted, and
its inner dirs cannot adopt the (filtered) Mordor Orcs entry."""
root = Path(tempfile.mkdtemp(prefix="sorter_test_src_"))
write(root / "Mordor pack" / "boy.stl")
library = make_lib()
items = sort_plan([root / "Mordor pack" / "boy.stl"], library,
get_catalog(), provenance={"Mordor pack": "Mordor pack.zip"},
root=root)
it = items[0]
assert "Middle-Earth" not in str(it.target), it.target
assert "_unsorted/Mordor pack" in str(it.target).replace("\\", "/")
shutil.rmtree(root); shutil.rmtree(library)
def test_load_catalog_filters_dropped():
cat = load_catalog_path()
assert not any(e["system"] == "Middle-Earth" for e in cat)
assert any(e["name"] == "Warp Spiders" for e in cat)
def test_nonprov_rehome(): def test_nonprov_rehome():
"""Loose noise file inside a named dir re-homes to the dir's unit.""" """Loose noise file inside a named dir re-homes to the dir's unit."""
root = Path(tempfile.mkdtemp(prefix="sorter_test_src_")) root = Path(tempfile.mkdtemp(prefix="sorter_test_src_"))
@@ -222,7 +246,8 @@ def main():
catalog = load_catalog_path() catalog = load_catalog_path()
for fn in (test_prov_dir_rehome, test_prov_weak_tier, test_nonprov_rehome, for fn in (test_prov_dir_rehome, test_prov_weak_tier, test_nonprov_rehome,
test_cross_system_needs_token_containment, test_apply_weak_doc, test_cross_system_needs_token_containment, test_apply_weak_doc,
test_weak_needs_shared_token, test_tokenless_needs_bonus): test_weak_needs_shared_token, test_tokenless_needs_bonus,
test_dropped_system_mordor, test_load_catalog_filters_dropped):
fn() fn()
print("OK", fn.__name__) print("OK", fn.__name__)