document_sorter/results4.md
2026-09-16 13:10:15 +02:00

8.4 KiB

623- 624-def _fallback_subtitle(rec: Record, title: str, prefix: str) -> str: 625- blocked = {_semantic_key(title), _semantic_key(prefix), _semantic_key(rec.topic)} 626- blocked |= _prefix_alias_keys(rec.destination) 627- blocked |= CATEGORY_NOISE_LABELS 628- 629- for value in (rec.subtopic, rec.detail): 630- candidate = _component_for_record(value, rec) 631- key = _semantic_key(candidate) 632- if candidate and key and key not in blocked and key not in GENERIC_FILENAME_LABELS: 633- return candidate 634- 635- return _source_remainder(rec, title, prefix, rec.topic) 636- 637- 638:def build_structured_filename(rec: Record) -> str: 639- """Build YYYY-MM-DD_PREFIX_Title_Subtitle[_vN].ext deterministically.""" 640- prefix = _filename_component(_filename_prefix(rec.destination), title_case_first=True) 641- prefix_aliases = _prefix_alias_keys(rec.destination) 642- 643- raw_title = _component_for_record(rec.name_title, rec, title_case_first=True) 644- raw_subtitle = _component_for_record(rec.name_subtitle, rec) 645- title_key = _semantic_key(raw_title) 646- subtitle_key = _semantic_key(raw_subtitle) 647- 648- # Promote a useful subtitle when the model supplied a generic label like 649- # Info; this avoids names such as Allgemein_Info_SurvStat... In that 650- # special case, do not immediately add another model-derived subtitle: only 651- # recover a genuinely distinct remainder from the original filename. 652- promoted_generic_title = False 653- if title_key in GENERIC_FILENAME_LABELS and raw_subtitle:

1117- try: 1118- conf = float(result.get("confidence", 0)) 1119- except (TypeError, ValueError): 1120- conf = 0.0 1121- if dest not in rec.candidates: 1122- dest = rec.current_folder 1123- conf = min(conf, 0.4) 1124- rec.destination = dest 1125- rec.topic = str(result.get("topic", "")).strip() 1126- rec.subtopic = str(result.get("subtopic", "")).strip() 1127- rec.document_type = str(result.get("document_type", "")).strip() 1128- rec.detail = str(result.get("detail", "")).strip() 1129- rec.version = str(result.get("version", "")).strip() 1130- rec.name_title = str(result.get("title", "")).strip() 1131- rec.name_subtitle = str(result.get("subtitle", "")).strip() 1132: rec.proposed_name = build_structured_filename(rec) 1133- # If no usable content was extracted from a generic filename, the model 1134- # cannot reliably recover a hidden event/project name. Force human review. 1135- if is_generic_filename(rec.filename) and (not rec.excerpt.strip() or "no-text" in rec.content_status or rec.content_status.startswith("missing:")): 1136- conf = min(conf, 0.69) 1137- 1138- # Every V7 proposal follows the structured naming scheme. If its date 1139- # comes only from filesystem mtime, the date is useful but historically 1140- # unverified, so require explicit human review before apply. 1141- if rec.date_source == "mtime": 1142- conf = min(conf, 0.69) 1143: rec.confidence = max(0.0, min(1.0, conf)) 1144- rec.reason = str(result.get("reason", ""))[:120] 1145: rec.status = "suggested" if rec.confidence >= 0.70 else "review" 1146- 1147- 1148-# ---------------- planning + apply ---------------- 1149- 1150-_HASH_CACHE: dict[str, tuple[int, int, str]] = {} 1151- 1152- 1153-def sha256_file(path: Path) -> str: 1154- """Full-file SHA-256. Used only when two files compete for the same target. 1155- 1156- The cache is keyed by path + size + mtime_ns so routine re-planning in the GUI 1157- does not repeatedly hash unchanged files. 1158- """ 1159- st = path.stat() 1160- key = str(path)

1424- else: 1425- new.parent.mkdir(parents=True, exist_ok=True) 1426- if old.parent == new.parent: 1427- old.rename(new) 1428- else: 1429- shutil.move(str(old), str(new)) 1430- result = "moved" if row["result"] == "would move" else "moved as variant" 1431- if rec: 1432- rec.path = new 1433- try: 1434- rec.rel_path = str(new.relative_to(rec.root)) 1435- except ValueError: 1436- rec.rel_path = str(new.relative_to(rec.target_root)) 1437- rec.current_folder = str(new.parent.relative_to(rec.target_root)) 1438- rec.filename = new.name 1439: rec.proposed_name = new.name 1440- rec.collision = "" 1441- rec.status = "done" 1442- 1443- elif result == "EXACT duplicate -> would remove source": 1444- # Only reached after a full SHA-256 equality check in plan(). 1445- try: 1446- old.unlink() 1447- result = "removed exact duplicate" 1448- if rec: 1449- rec.collision = "" 1450- rec.status = "done" 1451- except OSError as e: 1452- result = f"SKIPPED - could not remove duplicate: {e}" 1453- 1454- out.append({"timestamp": timestamp, **row, "result": result})

1637- if rec in batch: 1638- self.refresh(iid) 1639- except queue.Empty: 1640- pass 1641- self.win.after(120, self.poll) 1642- 1643- def set_status(self, status): 1644- for iid, rec in self.selected(): 1645- rec.status = status 1646- self.refresh(iid) 1647- self.recompute_collisions() 1648- self._counts() 1649- 1650- def confirm_confident(self): 1651- for iid, rec in self.by_iid.items(): 1652: if rec.status == "suggested" and rec.confidence >= 0.85: 1653- rec.status = "confirmed" 1654- self.refresh(iid) 1655- self.recompute_collisions() 1656- self._counts() 1657- 1658- def set_folder(self): 1659- chosen = self.selected() 1660- if not chosen: 1661- return 1662- top = tk.Toplevel(self.win) 1663- top.title("Set destination") 1664- var = tk.StringVar() 1665- combo = ttk.Combobox(top, values=self.destinations, textvariable=var, width=55) 1666- combo.pack(padx=10, pady=10) 1667- combo.focus()

1684- col = int(self.tree.identify_column(event.x)[1:]) - 1 1685- if not iid or col not in (5, 7): # destination/name only 1686- return 1687- bbox = self.tree.bbox(iid, f"#{col + 1}") 1688- if not bbox: 1689- return 1690- x, y, w, h = bbox 1691- rec = self.by_iid[iid] 1692- if self.active_editor: 1693- self.active_editor.destroy() 1694- if col == 5: 1695- editor = ttk.Combobox(self.tree, values=self.destinations) 1696- editor.set(rec.destination) 1697- else: 1698- editor = ttk.Entry(self.tree) 1699: editor.insert(0, rec.proposed_name) 1700- editor.select_range(0, tk.END) 1701- editor.place(x=x, y=y, width=w, height=h) 1702- editor.focus() 1703- self.active_editor = editor 1704- def commit(_e=None): 1705- value = editor.get().strip() 1706- if col == 5 and value in self.destinations: 1707- rec.destination = value 1708- elif col == 7 and value: 1709: rec.proposed_name = Path(value).name 1710- rec.status = "review" 1711- editor.destroy() 1712- self.active_editor = None 1713- self.refresh(iid) 1714- editor.bind("", commit) 1715- editor.bind("", commit) 1716- editor.bind("", lambda _e: editor.destroy()) 1717- 1718- def preview(self): 1719- self.recompute_collisions() 1720- rows = plan(self.records) 1721- if not rows: 1722- messagebox.showinfo("Preview", "No confirmed rows.") 1723- return 1724- text = "\n".join(f"{r['result']}:\n {r['old_path']}\n → {r['new_path']}" for r in rows[:30])