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

182 lines
8.4 KiB
Markdown

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("<Return>", commit)
1715- editor.bind("<FocusOut>", commit)
1716- editor.bind("<Escape>", 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])